From b7b68fb26c00bddb9eb1faf508c40f797a1af029 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 13 May 2026 16:46:31 -0400 Subject: [PATCH 01/38] Add benchmark suite for protovalidate-es Mirrors protovalidate-go's validator_bench_test.go in a new private packages/protovalidate-bench workspace so runtime cost can be tracked across changes and compared cross-language. Uses tinybench, hand-built deterministic fixtures, and writes JSON results to .tmp/bench/. Adds a checkbench script to diff two runs with a noise-aware regression threshold and non-zero exit on regression, suitable for gating PRs. --- .gitignore | 1 + package-lock.json | 29 + package.json | 1 + packages/protovalidate-bench/README.md | 133 + packages/protovalidate-bench/biome.json | 7 + packages/protovalidate-bench/buf.gen.yaml | 13 + packages/protovalidate-bench/buf.lock | 6 + packages/protovalidate-bench/buf.yaml | 17 + packages/protovalidate-bench/package.json | 27 + .../proto/bench/v1/bench.proto | 98 + .../proto/bench/v1/native.proto | 111 + .../protovalidate-bench/scripts/checkbench.js | 258 + packages/protovalidate-bench/src/bench.ts | 172 + packages/protovalidate-bench/src/fixtures.ts | 214 + .../src/gen/bench/v1/bench_pb.ts | 341 ++ .../src/gen/bench/v1/native_pb.ts | 260 + .../src/gen/buf/validate/validate_pb.ts | 5028 +++++++++++++++++ .../src/suites/byte-matching.bench.ts | 26 + .../src/suites/compile.bench.ts | 34 + .../src/suites/complex.bench.ts | 26 + .../src/suites/int32-gt.bench.ts | 26 + .../src/suites/map.bench.ts | 26 + .../src/suites/multirule.bench.ts | 31 + .../src/suites/repeated.bench.ts | 58 + .../src/suites/scalar.bench.ts | 27 + .../src/suites/standard-schema.bench.ts | 39 + .../src/suites/string-matching.bench.ts | 26 + .../src/suites/wrapper.bench.ts | 26 + packages/protovalidate-bench/tsconfig.json | 4 + packages/protovalidate-bench/turbo.json | 16 + 30 files changed, 7081 insertions(+) create mode 100644 packages/protovalidate-bench/README.md create mode 100644 packages/protovalidate-bench/biome.json create mode 100644 packages/protovalidate-bench/buf.gen.yaml create mode 100644 packages/protovalidate-bench/buf.lock create mode 100644 packages/protovalidate-bench/buf.yaml create mode 100644 packages/protovalidate-bench/package.json create mode 100644 packages/protovalidate-bench/proto/bench/v1/bench.proto create mode 100644 packages/protovalidate-bench/proto/bench/v1/native.proto create mode 100755 packages/protovalidate-bench/scripts/checkbench.js create mode 100644 packages/protovalidate-bench/src/bench.ts create mode 100644 packages/protovalidate-bench/src/fixtures.ts create mode 100644 packages/protovalidate-bench/src/gen/bench/v1/bench_pb.ts create mode 100644 packages/protovalidate-bench/src/gen/bench/v1/native_pb.ts create mode 100644 packages/protovalidate-bench/src/gen/buf/validate/validate_pb.ts create mode 100644 packages/protovalidate-bench/src/suites/byte-matching.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/compile.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/complex.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/int32-gt.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/map.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/multirule.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/repeated.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/scalar.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/standard-schema.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/string-matching.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/wrapper.bench.ts create mode 100644 packages/protovalidate-bench/tsconfig.json create mode 100644 packages/protovalidate-bench/turbo.json diff --git a/.gitignore b/.gitignore index 76d4562..22d4eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ node_modules .antlr/ .turbo packages/upstream/gobin +packages/protovalidate-bench/.tmp # Editor directories and files .vscode/* diff --git a/package-lock.json b/package-lock.json index 21b37f4..44d621a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "workspaces": [ "packages/protovalidate", "packages/protovalidate-testing", + "packages/protovalidate-bench", "packages/example", "packages/upstream" ], @@ -516,6 +517,10 @@ "resolved": "packages/protovalidate", "link": true }, + "node_modules/@bufbuild/protovalidate-bench": { + "resolved": "packages/protovalidate-bench", + "link": true + }, "node_modules/@bufbuild/protovalidate-testing": { "resolved": "packages/protovalidate-testing", "link": true @@ -1694,6 +1699,15 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-3.1.1.tgz", + "integrity": "sha512-74pmf47HY/bHqamcCMGris+1AtGGsqTZ3Hc/UK4QvSmRuf/9PIF9753+c8XBh7JfX2r9KeZtVjOYjd6vFpc0qQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1860,6 +1874,21 @@ "@bufbuild/protobuf": "^2.8.0" } }, + "packages/protovalidate-bench": { + "name": "@bufbuild/protovalidate-bench", + "version": "1.2.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.11.0", + "@bufbuild/protovalidate": "^1.2.0", + "tinybench": "^3.1.1" + }, + "devDependencies": { + "@bufbuild/buf": "^1.62.1", + "@bufbuild/protoc-gen-es": "^2.11.0", + "@standard-schema/spec": "^1.1.0" + } + }, "packages/protovalidate-testing": { "name": "@bufbuild/protovalidate-testing", "devDependencies": { diff --git a/package.json b/package.json index 6cb0488..812c0fd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "workspaces": [ "packages/protovalidate", "packages/protovalidate-testing", + "packages/protovalidate-bench", "packages/example", "packages/upstream" ], diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md new file mode 100644 index 0000000..a9dea47 --- /dev/null +++ b/packages/protovalidate-bench/README.md @@ -0,0 +1,133 @@ +# Protovalidate benchmarks + +Performance benchmarks for `@bufbuild/protovalidate`. This package is private and +mirrors the suite in [`protovalidate-go/validator_bench_test.go`](https://github.com/bufbuild/protovalidate-go/blob/main/validator_bench_test.go) +so that runtime cost can be tracked across changes and compared cross-language. + +The harness is [tinybench](https://github.com/tinylibs/tinybench). Fixtures are +hand-built (no faker dependency) and seeded with a deterministic PRNG so every +run validates the same messages. + +## Running + +From the repo root: + +```shell +npx turbo run bench --filter=@bufbuild/protovalidate-bench +``` + +Or from this directory: + +```shell +npm run bench +``` + +The runner prints a table of results and writes a JSON file to `.tmp/bench/` +(gitignored) named after the current timestamp. + +### Options + +| Flag | Default | Description | +| --------------------- | ----------- | --------------------------------------------------------------- | +| `--filter ` | _(none)_ | Only run tasks whose name contains `` | +| `--time ` | `1000` | Per-task wall-time budget | +| `--iterations ` | _(time)_ | Force fixed iteration count instead of the time budget | +| `--warmup ` | `16` | Warmup iterations per task | +| `--out ` | `.tmp/bench`| Output directory for JSON results | + +Examples: + +```shell +# Quick smoke run +npm run bench -- --time 200 --warmup 5 + +# Only validation benchmarks (skip the Compile/* tasks) +npm run bench -- --filter Scalar + +# Long, stable run +npm run bench -- --time 5000 --warmup 32 +``` + +## Benchmarks + +Each task mirrors the equivalent `Benchmark*` in `protovalidate-go` so deltas +between languages stay meaningful. + +| Task | What it measures | +| ------------------------------- | ------------------------------------------------------------------------------- | +| `Scalar` | One `int32` with `gt = 0`. Minimum-overhead baseline. | +| `Repeated/Scalar` | `repeated int32` with `max_items`. | +| `Repeated/Message` | `repeated` of nested messages. | +| `Repeated/Unique/Scalar` | `repeated float` with `unique = true` (hash-based dedup path). | +| `Repeated/Unique/Bytes` | `repeated bytes` with `unique = true`. | +| `Map` | `map` with `min_pairs`. | +| `ComplexSchema` | Broad message exercising scalars, repeated, maps, oneof, nested, self-ref. | +| `Int32GT` | Many numeric comparison rules (`gt`/`gte`/`lt`/`lte`/`const`/`in`/`not_in`). | +| `TestByteMatching` | `bytes.ip` / `bytes.ipv4` / `bytes.ipv6` / `bytes.uuid`. | +| `StringMatching` | `string.hostname` / `host_and_port` / `email` / `uuid`. | +| `WrapperTesting` | `google.protobuf.*Value` wrapper fields with rules. | +| `MultiRule/Error` | Multi-rule field that fails — drives violation accumulation. | +| `MultiRule/NoError` | Same schema, valid value — success path. | +| `Compile/ComplexSchema` | `createValidator()` + first validate on each iteration. Plan-build cost. | +| `Compile/Int32GT` | Same, simpler schema. | +| `StandardSchema/Scalar` | Standard Schema adapter, scalar message. TS-only — no Go analogue. | +| `StandardSchema/ComplexSchema` | Standard Schema adapter, complex message. | + +## Comparing runs + +Use `checkbench` to diff two result files and surface regressions: + +```shell +# After running on main, then on your branch: +node scripts/checkbench.js previous latest + +# Or pass explicit paths: +node scripts/checkbench.js .tmp/bench/baseline.json .tmp/bench/current.json +``` + +The shortcuts `latest` and `previous` resolve to the newest and second-newest +JSON files in `.tmp/bench/` (by mtime). Calling with only one argument +defaults the baseline to `previous`. + +Output is per task: baseline mean, current mean, `±%` delta, and a marker — +`REGRESS`, `faster`, or `(noise)`. A delta is treated as noise if it falls +inside the combined RME of the two runs, so jitter in low-RME benchmarks does +not trigger false alarms. + +### Options + +| Flag | Default | Description | +| --------------------- | ------- | ---------------------------------------------------------------- | +| `--threshold ` | `5` | Regression bar. Slowdowns above this AND outside noise fail. | +| `--dir ` | `.tmp/bench` | Directory the `latest` / `previous` shortcuts look in. | +| `--quiet`, `-q` | _(off)_ | Print summary line only. | + +The script exits **1** if any task regresses past `--threshold`, otherwise +**0** — drop it into a pre-commit hook or CI step to gate PRs on performance. + +### Typical workflow + +```shell +git checkout main +npm run bench # produces .tmp/bench/.json (baseline) + +git checkout my-optimization-branch +npm run bench # produces .tmp/bench/.json (current) + +node scripts/checkbench.js latest # diff vs previous +``` + +Heads up: bench-to-bench wall-time numbers are sensitive to other load on the +machine. For meaningful comparison, run baseline and current on the same +hardware, close other CPU-heavy apps, and prefer longer runs +(`--time 5000`) when the deltas you care about are within a few percent. + +## Regenerating proto code + +If the `.proto` files change: + +```shell +npm run generate +``` + +Generated code lives under `src/gen/` and is committed. diff --git a/packages/protovalidate-bench/biome.json b/packages/protovalidate-bench/biome.json new file mode 100644 index 0000000..b3d076a --- /dev/null +++ b/packages/protovalidate-bench/biome.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../../biome.base.json"], + "files": { + "ignore": ["src/gen", ".tmp"] + } +} diff --git a/packages/protovalidate-bench/buf.gen.yaml b/packages/protovalidate-bench/buf.gen.yaml new file mode 100644 index 0000000..90c90ca --- /dev/null +++ b/packages/protovalidate-bench/buf.gen.yaml @@ -0,0 +1,13 @@ +# buf.gen.yaml +version: v2 +clean: true +inputs: + - directory: proto +plugins: + - local: protoc-gen-es + out: src/gen + include_imports: true + opt: + - target=ts + - import_extension=.js + - ts_nocheck=false \ No newline at end of file diff --git a/packages/protovalidate-bench/buf.lock b/packages/protovalidate-bench/buf.lock new file mode 100644 index 0000000..709ae02 --- /dev/null +++ b/packages/protovalidate-bench/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 50325440f8f24053b047484a6bf60b76 + digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31 diff --git a/packages/protovalidate-bench/buf.yaml b/packages/protovalidate-bench/buf.yaml new file mode 100644 index 0000000..1190583 --- /dev/null +++ b/packages/protovalidate-bench/buf.yaml @@ -0,0 +1,17 @@ +# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml +version: v2 +modules: + - path: proto +deps: + - buf.build/bufbuild/protovalidate +lint: + use: + - STANDARD + # native.proto deliberately stacks redundant int32/int64 rules to exercise + # the multi-rule code path (mirrors protovalidate-go's BenchmarkMultiRule). + ignore_only: + PROTOVALIDATE: + - proto/bench/v1/native.proto +breaking: + use: + - FILE \ No newline at end of file diff --git a/packages/protovalidate-bench/package.json b/packages/protovalidate-bench/package.json new file mode 100644 index 0000000..60411f5 --- /dev/null +++ b/packages/protovalidate-bench/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bufbuild/protovalidate-bench", + "version": "1.2.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "generate": "buf generate", + "postgenerate": "license-header src/gen", + "bench": "tsx src/bench.ts", + "checkbench": "node scripts/checkbench.js", + "format": "biome format --write", + "lint": "biome lint --error-on-warnings && buf lint", + "license-header": "license-header" + }, + "type": "module", + "sideEffects": false, + "dependencies": { + "@bufbuild/protobuf": "^2.11.0", + "@bufbuild/protovalidate": "^1.2.0", + "tinybench": "^3.1.1" + }, + "devDependencies": { + "@bufbuild/buf": "^1.62.1", + "@bufbuild/protoc-gen-es": "^2.11.0", + "@standard-schema/spec": "^1.1.0" + } +} diff --git a/packages/protovalidate-bench/proto/bench/v1/bench.proto b/packages/protovalidate-bench/proto/bench/v1/bench.proto new file mode 100644 index 0000000..21ebb0c --- /dev/null +++ b/packages/protovalidate-bench/proto/bench/v1/bench.proto @@ -0,0 +1,98 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package bench.v1; + +import "buf/validate/validate.proto"; + +message BenchScalar { + int32 x = 1 [(buf.validate.field).int32.gt = 0]; +} + +message BenchRepeatedScalar { + repeated int32 x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedMessage { + repeated BenchScalar x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedScalarUnique { + repeated float x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchRepeatedBytesUnique { + repeated bytes x = 1 [(buf.validate.field).repeated.unique = true]; +} + +// Map validation benchmark. +message BenchMap { + map entries = 1 [(buf.validate.field).map.min_pairs = 1]; +} + +// Complex schema benchmark. +message BenchComplexSchema { + string s1 = 1 [(buf.validate.field).string.min_len = 1]; + string s2 = 2 [(buf.validate.field).string.max_len = 100]; + int32 i32 = 3 [(buf.validate.field).int32.gt = 0]; + int64 i64 = 4 [(buf.validate.field).int64.lt = 1000]; + uint32 u32 = 5 [(buf.validate.field).uint32.gte = 1]; + uint64 u64 = 6 [(buf.validate.field).uint64.lte = 1000]; + sint32 si32 = 7 [(buf.validate.field).sint32.gt = 0]; + sint64 si64 = 8 [(buf.validate.field).sint64.lt = 1000]; + fixed32 f32 = 9 [(buf.validate.field).fixed32.gte = 1]; + fixed64 f64 = 10 [(buf.validate.field).fixed64.lte = 1000]; + sfixed32 sf32 = 11 [(buf.validate.field).sfixed32.gt = 0]; + sfixed64 sf64 = 12 [(buf.validate.field).sfixed64.lt = 1000]; + float fl = 13 [(buf.validate.field).float.finite = true]; + double db = 14 [(buf.validate.field).double.finite = true]; + bool bl = 15; + bytes by = 16 [(buf.validate.field).bytes.min_len = 1]; + + BenchScalar nested = 17; + + BenchComplexSchema self_ref = 18; + + repeated string rep_str = 19 [(buf.validate.field).repeated.max_items = 10]; + repeated int32 rep_i32 = 20 [(buf.validate.field).repeated.min_items = 1]; + repeated bytes rep_bytes = 21 [(buf.validate.field).repeated.unique = true]; + + repeated BenchScalar rep_msg = 22 [(buf.validate.field).repeated.max_items = 5]; + + map map_str_str = 23 [(buf.validate.field).map.min_pairs = 1]; + map map_i32_i64 = 24 [(buf.validate.field).map.max_pairs = 10]; + map map_u64_bool = 25; + map map_str_bytes = 26 [(buf.validate.field).map.keys = { + string: {min_len: 1} + }]; + + map map_str_msg = 27 [(buf.validate.field).map.values = {required: true}]; + map map_i64_msg = 28; + + BenchEnum enum_field = 29 [(buf.validate.field).enum.defined_only = true]; + + oneof choice { + string oneof_str = 30 [(buf.validate.field).string.min_len = 1]; + int32 oneof_i32 = 31 [(buf.validate.field).int32.gt = 0]; + BenchScalar oneof_msg = 32; + } +} + +enum BenchEnum { + BENCH_ENUM_UNSPECIFIED = 0; + BENCH_ENUM_ONE = 1; + BENCH_ENUM_TWO = 2; +} diff --git a/packages/protovalidate-bench/proto/bench/v1/native.proto b/packages/protovalidate-bench/proto/bench/v1/native.proto new file mode 100644 index 0000000..936dba5 --- /dev/null +++ b/packages/protovalidate-bench/proto/bench/v1/native.proto @@ -0,0 +1,111 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package bench.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/wrappers.proto"; + +message BenchGT { + int32 gt = 1 [(buf.validate.field).int32.gt = 0]; + int32 gte = 2 [(buf.validate.field).int32.gte = 0]; + int32 lt = 3 [(buf.validate.field).int32.lt = 101]; + int32 lte = 4 [(buf.validate.field).int32.lte = 101]; + int32 gtltin = 5 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltein = 6 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltex = 7 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtlteex = 8 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 gteltin = 9 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltein = 10 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltex = 11 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtelteex = 12 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 const = 13 [(buf.validate.field).int32.const = 10]; + int32 constgt = 14 [ + (buf.validate.field).int32.const = 10, + (buf.validate.field).int32.gte = 0 + ]; + int32 in_test = 15 [(buf.validate.field).int32 = { + in: [ + 1, + 3, + 5 + ] + }]; + int32 not_in_test = 16 [(buf.validate.field).int32 = { + not_in: [ + 1, + 3, + 5 + ] + }]; +} + +message TestByteMatching { + bytes ip_addr = 1 [(buf.validate.field).bytes.ip = true]; + bytes ipv4_addr = 2 [(buf.validate.field).bytes.ipv4 = true]; + bytes ipv6_addr = 3 [(buf.validate.field).bytes.ipv6 = true]; + bytes uuid = 4 [(buf.validate.field).bytes.uuid = true]; +} + +message StringMatching { + string hostname = 1 [(buf.validate.field).string.hostname = true]; + string host_and_port = 2 [(buf.validate.field).string.host_and_port = true]; + string email = 3 [(buf.validate.field).string.email = true]; + string uuid = 4 [(buf.validate.field).string.uuid = true]; +} + +message WrapperTesting { + google.protobuf.Int32Value i32 = 1 [(buf.validate.field).int32.gt = 10]; + google.protobuf.DoubleValue d = 2 [(buf.validate.field).double.gt = 10]; + google.protobuf.FloatValue f = 3 [(buf.validate.field).float.gt = 10]; + google.protobuf.Int64Value i64 = 4 [(buf.validate.field).int64.gt = 10]; + google.protobuf.UInt64Value u64 = 5 [(buf.validate.field).uint64.gt = 10]; + google.protobuf.UInt32Value u32 = 6 [(buf.validate.field).uint32.gt = 10]; + google.protobuf.BoolValue b = 7 [(buf.validate.field).bool.const = true]; + google.protobuf.StringValue s = 8 [(buf.validate.field).string.const = "hello"]; + google.protobuf.BytesValue bs = 9 [(buf.validate.field).bytes.len = 5]; +} + +message MultiRule { + int64 many = 1 [ + (buf.validate.field).int64.const = 10, + (buf.validate.field).int64.gt = 5 + ]; +} diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/scripts/checkbench.js new file mode 100755 index 0000000..f7bbe6e --- /dev/null +++ b/packages/protovalidate-bench/scripts/checkbench.js @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Compare two bench JSON files written by src/bench.ts. +// +// Usage: +// node scripts/checkbench.js [--threshold 5] +// +// "latest" / "previous" shortcuts pick the most recent files in .tmp/bench/: +// node scripts/checkbench.js latest +// node scripts/checkbench.js previous latest +// +// Exits non-zero if any task regresses by more than --threshold percent +// (default 5%). A regression is defined as a slower mean latency where the +// delta exceeds both the threshold AND the combined RME of the two samples +// (so we don't flag noise as a regression). + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const BENCH_DIR = ".tmp/bench"; +const DEFAULT_THRESHOLD = 5; + +function parseArgs(argv) { + const positional = []; + let threshold = DEFAULT_THRESHOLD; + let dir = BENCH_DIR; + let quiet = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--threshold") { + threshold = Number(argv[++i]); + } else if (a === "--dir") { + dir = argv[++i]; + } else if (a === "--quiet" || a === "-q") { + quiet = true; + } else if (a === "-h" || a === "--help") { + usage(); + process.exit(0); + } else if (a.startsWith("--")) { + console.error(`unknown flag: ${a}`); + process.exit(2); + } else { + positional.push(a); + } + } + return { positional, threshold, dir, quiet }; +} + +function usage() { + process.stdout.write( + [ + "Usage: node scripts/checkbench.js [options]", + "", + "Arguments may be paths to JSON files or one of the shortcuts:", + " latest most recent file in .tmp/bench/", + " previous second-most recent file in .tmp/bench/", + "", + "Options:", + " --threshold regression threshold percent (default: 5)", + " --dir bench results directory (default: .tmp/bench)", + " --quiet, -q only print summary line", + "", + "Exit code: 0 if no regressions past threshold, 1 otherwise.", + "", + ].join("\n"), + ); +} + +function resolveFile(arg, dir) { + if (arg === "latest" || arg === "previous") { + const entries = readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + const idx = arg === "latest" ? 0 : 1; + if (entries.length <= idx) { + throw new Error(`not enough JSON files in ${dir} to resolve "${arg}"`); + } + return resolve(dir, entries[idx].f); + } + return resolve(arg); +} + +function load(path) { + const data = JSON.parse(readFileSync(path, "utf-8")); + const byName = new Map(); + for (const task of data.tasks) { + byName.set(task.name, task); + } + return { + meta: { + node: data.node, + platform: data.platform, + timestamp: data.timestamp, + path, + }, + byName, + }; +} + +function pad(s, n) { + return String(s).padEnd(n); +} + +function fmtNs(n) { + if (n < 1000) return `${n.toFixed(0)} ns`; + if (n < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; + return `${(n / 1_000_000).toFixed(2)} ms`; +} + +function color(s, code) { + if (!process.stdout.isTTY) return s; + return `\x1b[${code}m${s}\x1b[0m`; +} + +const args = parseArgs(process.argv.slice(2)); +if (args.positional.length === 0 || args.positional.length > 2) { + usage(); + process.exit(2); +} + +const baselineArg = + args.positional.length === 2 ? args.positional[0] : "previous"; +const currentArg = + args.positional.length === 2 ? args.positional[1] : args.positional[0]; + +const baselinePath = resolveFile(baselineArg, args.dir); +const currentPath = resolveFile(currentArg, args.dir); + +if (baselinePath === currentPath) { + console.error( + `baseline and current resolve to the same file: ${baselinePath}`, + ); + process.exit(2); +} + +const baseline = load(baselinePath); +const current = load(currentPath); + +console.log(`baseline: ${baseline.meta.path}`); +console.log( + ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform}`, +); +console.log(`current: ${current.meta.path}`); +console.log( + ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform}`, +); +console.log(""); + +if (baseline.meta.platform !== current.meta.platform) { + console.log( + color( + `! platform differs (${baseline.meta.platform} vs ${current.meta.platform}) — numbers may not be comparable`, + "33", + ), + ); +} +if (baseline.meta.node !== current.meta.node) { + console.log( + color( + `! node version differs (${baseline.meta.node} vs ${current.meta.node})`, + "33", + ), + ); +} + +const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); +let regressions = 0; +let improvements = 0; + +const rows = []; +for (const name of [...names].sort()) { + const b = baseline.byName.get(name); + const c = current.byName.get(name); + if (!b) { + rows.push({ + name, + kind: "new", + text: color("NEW", "36"), + bMean: undefined, + cMean: c.meanLatencyNs, + delta: undefined, + }); + continue; + } + if (!c) { + rows.push({ + name, + kind: "gone", + text: color("GONE", "90"), + bMean: b.meanLatencyNs, + cMean: undefined, + delta: undefined, + }); + continue; + } + const deltaPct = + ((c.meanLatencyNs - b.meanLatencyNs) / b.meanLatencyNs) * 100; + // Combined relative margin of error; deltas inside this are noise. + const noiseFloor = (b.rmePercent ?? 0) + (c.rmePercent ?? 0); + let kind = "ok"; + let text = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; + if (deltaPct > args.threshold && Math.abs(deltaPct) > noiseFloor) { + kind = "regress"; + text = color(`${text} REGRESS`, "31"); + regressions++; + } else if (deltaPct < -args.threshold && Math.abs(deltaPct) > noiseFloor) { + kind = "improve"; + text = color(`${text} faster`, "32"); + improvements++; + } else if (Math.abs(deltaPct) <= noiseFloor) { + text = color(`${text} (noise)`, "90"); + } + rows.push({ + name, + kind, + text, + bMean: b.meanLatencyNs, + cMean: c.meanLatencyNs, + delta: deltaPct, + }); +} + +if (!args.quiet) { + const nameW = Math.max(4, ...rows.map((r) => r.name.length)); + console.log( + `${pad("task", nameW)} ${pad("baseline", 12)} ${pad("current", 12)} delta`, + ); + console.log( + `${pad("", nameW).replaceAll(" ", "-")} ${"-".repeat(12)} ${"-".repeat(12)} ${"-".repeat(20)}`, + ); + for (const r of rows) { + const b = r.bMean !== undefined ? fmtNs(r.bMean) : "—"; + const c = r.cMean !== undefined ? fmtNs(r.cMean) : "—"; + console.log( + `${pad(r.name, nameW)} ${pad(b, 12)} ${pad(c, 12)} ${r.text}`, + ); + } + console.log(""); +} + +console.log( + `summary: ${regressions} regression(s), ${improvements} improvement(s), threshold ${args.threshold}%`, +); +process.exit(regressions > 0 ? 1 : 0); diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts new file mode 100644 index 0000000..6ef84be --- /dev/null +++ b/packages/protovalidate-bench/src/bench.ts @@ -0,0 +1,172 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Bench } from "tinybench"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { register as registerScalar } from "./suites/scalar.bench.js"; +import { register as registerRepeated } from "./suites/repeated.bench.js"; +import { register as registerMap } from "./suites/map.bench.js"; +import { register as registerComplex } from "./suites/complex.bench.js"; +import { register as registerInt32GT } from "./suites/int32-gt.bench.js"; +import { register as registerByteMatching } from "./suites/byte-matching.bench.js"; +import { register as registerStringMatching } from "./suites/string-matching.bench.js"; +import { register as registerWrapper } from "./suites/wrapper.bench.js"; +import { register as registerMultiRule } from "./suites/multirule.bench.js"; +import { register as registerCompile } from "./suites/compile.bench.js"; +import { register as registerStandardSchema } from "./suites/standard-schema.bench.js"; + +interface CliOptions { + filter: string | undefined; + iterations: number; + warmupIterations: number; + time: number; + outDir: string; +} + +function parseArgs(argv: readonly string[]): CliOptions { + let filter: string | undefined; + let iterations = 0; + let warmupIterations = 16; + let time = 1000; + let outDir = ".tmp/bench"; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + switch (a) { + case "--filter": + filter = argv[++i]; + break; + case "--iterations": + iterations = Number(argv[++i]); + break; + case "--warmup": + warmupIterations = Number(argv[++i]); + break; + case "--time": + time = Number(argv[++i]); + break; + case "--out": + outDir = String(argv[++i]); + break; + case "--help": + case "-h": + printUsage(); + process.exit(0); + break; + default: + if (a?.startsWith("--")) { + console.error(`unknown flag: ${a}`); + process.exit(2); + } + } + } + return { filter, iterations, warmupIterations, time, outDir }; +} + +function printUsage(): void { + process.stdout.write( + [ + "Usage: tsx src/bench.ts [options]", + "", + "Options:", + " --filter Only run benchmarks whose name contains ", + " --time Per-task wall time budget (default: 1000)", + " --iterations Force fixed iteration count instead of time budget", + " --warmup Warmup iterations per task (default: 16)", + " --out Output directory for JSON results (default: .tmp/bench)", + "", + ].join("\n"), + ); +} + +const opts = parseArgs(process.argv.slice(2)); + +const bench = new Bench({ + name: "protovalidate-es", + time: opts.iterations > 0 ? 0 : opts.time, + iterations: opts.iterations > 0 ? opts.iterations : 10, + warmupIterations: opts.warmupIterations, +}); + +registerScalar(bench); +registerRepeated(bench); +registerMap(bench); +registerComplex(bench); +registerInt32GT(bench); +registerByteMatching(bench); +registerStringMatching(bench); +registerWrapper(bench); +registerMultiRule(bench); +registerCompile(bench); +registerStandardSchema(bench); + +if (opts.filter !== undefined) { + const f = opts.filter; + for (const t of bench.tasks.slice()) { + if (!t.name.includes(f)) { + bench.remove(t.name); + } + } +} + +console.log(`# protovalidate-es bench`); +console.log(`# node ${process.version} ${process.platform}/${process.arch}`); +console.log(`# tasks: ${bench.tasks.length}`); +if (bench.tasks.length === 0) { + console.error("no tasks matched filter"); + process.exit(2); +} + +await bench.run(); + +const tableRows = bench.table((task) => { + const r = task.result; + if (!r) { + return { Task: task.name }; + } + return { + Task: task.name, + "ops/sec": Math.round(r.throughput.mean).toLocaleString(), + "avg (ns)": (r.latency.mean * 1e6).toFixed(0), + "p99 (ns)": ((r.latency.p99 ?? 0) * 1e6).toFixed(0), + rme: `±${r.latency.rme.toFixed(2)}%`, + samples: r.latency.samples.length, + }; +}); +console.table(tableRows); + +const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .replace(/T/, "_") + .replace(/Z$/, ""); +mkdirSync(opts.outDir, { recursive: true }); +const outPath = join(opts.outDir, `${stamp}.json`); +const payload = { + node: process.version, + platform: `${process.platform}/${process.arch}`, + timestamp: new Date().toISOString(), + tasks: bench.tasks + .filter((t) => t.result !== undefined) + .map((t) => ({ + name: t.name, + meanLatencyNs: (t.result?.latency.mean ?? 0) * 1e6, + p99LatencyNs: (t.result?.latency.p99 ?? 0) * 1e6, + throughputOpsPerSec: t.result?.throughput.mean ?? 0, + rmePercent: t.result?.latency.rme ?? 0, + samples: t.result?.latency.samples.length ?? 0, + })), +}; +writeFileSync(outPath, JSON.stringify(payload, null, 2)); +console.log(`wrote ${outPath}`); diff --git a/packages/protovalidate-bench/src/fixtures.ts b/packages/protovalidate-bench/src/fixtures.ts new file mode 100644 index 0000000..f0c9834 --- /dev/null +++ b/packages/protovalidate-bench/src/fixtures.ts @@ -0,0 +1,214 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { create } from "@bufbuild/protobuf"; +import { + BenchEnum, + BenchScalarSchema, + BenchRepeatedScalarSchema, + BenchRepeatedMessageSchema, + BenchRepeatedScalarUniqueSchema, + BenchRepeatedBytesUniqueSchema, + BenchMapSchema, + BenchComplexSchemaSchema, + type BenchComplexSchema, +} from "./gen/bench/v1/bench_pb.js"; +import { + BenchGTSchema, + TestByteMatchingSchema, + StringMatchingSchema, + WrapperTestingSchema, + MultiRuleSchema, +} from "./gen/bench/v1/native_pb.js"; + +// Seeded PRNG (mulberry32). Keeps fixture data deterministic across runs. +function rng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const rand = rng(1); + +function int(min: number, max: number): number { + return Math.floor(rand() * (max - min + 1)) + min; +} + +function pickWord(): string { + const words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + ]; + return words[int(0, words.length - 1)] ?? "alpha"; +} + +function bytes(n: number, salt: number): Uint8Array { + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) { + out[i] = (i + salt) & 0xff; + } + return out; +} + +export const benchScalar = create(BenchScalarSchema, { x: 42 }); + +export const benchRepeatedScalar = create(BenchRepeatedScalarSchema, { + x: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], +}); + +export const benchRepeatedMessage = create(BenchRepeatedMessageSchema, { + x: Array.from({ length: 10 }, (_, i) => + create(BenchScalarSchema, { x: i + 1 }), + ), +}); + +export const benchRepeatedScalarUnique = create( + BenchRepeatedScalarUniqueSchema, + { + x: [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8], + }, +); + +export const benchRepeatedBytesUnique = create(BenchRepeatedBytesUniqueSchema, { + x: Array.from({ length: 8 }, (_, i) => bytes(4, i + 1)), +}); + +export const benchMap = create(BenchMapSchema, { + entries: { + k1: "v1", + k2: "v2", + k3: "v3", + k4: "v4", + k5: "v5", + k6: "v6", + k7: "v7", + }, +}); + +function newComplex(depth: number): BenchComplexSchema { + const m = create(BenchComplexSchemaSchema, { + s1: pickWord(), + s2: pickWord(), + i32: int(1, 100), + i64: BigInt(int(1, 999)), + u32: int(1, 100), + u64: BigInt(int(1, 1000)), + si32: int(1, 100), + si64: BigInt(int(1, 999)), + f32: int(1, 100), + f64: BigInt(int(1, 1000)), + sf32: int(1, 100), + sf64: BigInt(int(1, 999)), + fl: int(1, 100), + db: int(1, 100), + bl: true, + by: bytes(8, 7), + nested: create(BenchScalarSchema, { x: int(1, 100) }), + repStr: [pickWord(), pickWord(), pickWord()], + repI32: [int(1, 100), int(1, 100)], + repBytes: [bytes(3, 1), bytes(3, 2), bytes(3, 3)], + repMsg: Array.from({ length: 2 }, () => + create(BenchScalarSchema, { x: int(1, 100) }), + ), + mapStrStr: { a: "1", b: "2", c: "3" }, + mapI32I64: { 1: 10n, 2: 20n, 3: 30n }, + mapU64Bool: { "1": true, "2": false }, + mapStrBytes: { k: bytes(2, 0) }, + mapStrMsg: { + a: create(BenchScalarSchema, { x: int(1, 100) }), + b: create(BenchScalarSchema, { x: int(1, 100) }), + }, + mapI64Msg: { + "1": create(BenchScalarSchema, { x: int(1, 100) }), + "2": create(BenchScalarSchema, { x: int(1, 100) }), + }, + enumField: BenchEnum.ONE, + choice: { case: "oneofStr", value: pickWord() }, + }); + if (depth > 0) { + m.selfRef = newComplex(depth - 1); + } + return m; +} + +export const benchComplexSchema = newComplex(1); + +export const benchGT = create(BenchGTSchema, { + gt: 50, + gte: 50, + lt: 50, + lte: 50, + gtltin: 50, + gtltein: 50, + // gtltex, gtlteex, gteltex, gtelteex have unsatisfiable rules (lt < gt); + // Go's bench leaves them at zero which is treated as unset for proto3 scalars + // by the rules engine — protovalidate skips fields with rules.required=false + // unset zero values. Keeping them at 0 mirrors Go's fixture. + gteltin: 50, + gteltein: 50, + const: 10, + constgt: 10, + inTest: 3, + notInTest: 4, +}); + +export const testByteMatching = create(TestByteMatchingSchema, { + // 16-byte buffers; bytes.ip accepts 4 or 16 bytes (v4/v6 raw), bytes.ipv4 + // requires 4 bytes, bytes.ipv6 requires 16, bytes.uuid requires 16. + ipAddr: bytes(16, 1), + ipv4Addr: bytes(4, 2), + ipv6Addr: bytes(16, 3), + uuid: bytes(16, 4), +}); + +export const stringMatching = create(StringMatchingSchema, { + hostname: "example.com", + hostAndPort: "example.com:8080", + email: "user@example.com", + uuid: "00112233-4455-6677-8899-aabbccddeeff", +}); + +// protobuf-es unboxes google.protobuf.*Value wrapper fields to their scalar +// types, so the field values are assigned directly. +export const wrapperTesting = create(WrapperTestingSchema, { + i32: 11, + d: 11, + f: 11, + i64: 11n, + u64: 11n, + u32: 11, + b: true, + s: "hello", + bs: bytes(5, 0), +}); + +// MultiRule with many=1 — fails int64.const=10 AND int64.gt=5 (drives the +// violation-accumulation path). +export const multiRuleError = create(MultiRuleSchema, { many: 1n }); + +// MultiRule with many=10 — satisfies both rules (drives the success path). +export const multiRuleNoError = create(MultiRuleSchema, { many: 10n }); diff --git a/packages/protovalidate-bench/src/gen/bench/v1/bench_pb.ts b/packages/protovalidate-bench/src/gen/bench/v1/bench_pb.ts new file mode 100644 index 0000000..b4a437e --- /dev/null +++ b/packages/protovalidate-bench/src/gen/bench/v1/bench_pb.ts @@ -0,0 +1,341 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=.js,ts_nocheck=false" +// @generated from file bench/v1/bench.proto (package bench.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file bench/v1/bench.proto. + */ +export const file_bench_v1_bench: GenFile = /*@__PURE__*/ + fileDesc("ChRiZW5jaC92MS9iZW5jaC5wcm90bxIIYmVuY2gudjEiIQoLQmVuY2hTY2FsYXISEgoBeBgBIAEoBUIHukgEGgIgACIqChNCZW5jaFJlcGVhdGVkU2NhbGFyEhMKAXgYASADKAVCCLpIBZIBAhAKIkIKFEJlbmNoUmVwZWF0ZWRNZXNzYWdlEioKAXgYASADKAsyFS5iZW5jaC52MS5CZW5jaFNjYWxhckIIukgFkgECEAoiMAoZQmVuY2hSZXBlYXRlZFNjYWxhclVuaXF1ZRITCgF4GAEgAygCQgi6SAWSAQIYASIvChhCZW5jaFJlcGVhdGVkQnl0ZXNVbmlxdWUSEwoBeBgBIAMoDEIIukgFkgECGAEidgoIQmVuY2hNYXASOgoHZW50cmllcxgBIAMoCzIfLmJlbmNoLnYxLkJlbmNoTWFwLkVudHJpZXNFbnRyeUIIukgFmgECCAEaLgoMRW50cmllc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEinwwKEkJlbmNoQ29tcGxleFNjaGVtYRITCgJzMRgBIAEoCUIHukgEcgIQARITCgJzMhgCIAEoCUIHukgEcgIYZBIUCgNpMzIYAyABKAVCB7pIBBoCIAASFQoDaTY0GAQgASgDQgi6SAUiAxDoBxIUCgN1MzIYBSABKA1CB7pIBCoCKAESFQoDdTY0GAYgASgEQgi6SAUyAxjoBxIVCgRzaTMyGAcgASgRQge6SAQ6AiAAEhYKBHNpNjQYCCABKBJCCLpIBUIDENAPEhcKA2YzMhgJIAEoB0IKukgHSgUtAQAAABIbCgNmNjQYCiABKAZCDrpIC1IJGegDAAAAAAAAEhgKBHNmMzIYCyABKA9CCrpIB1oFJQAAAAASHAoEc2Y2NBgMIAEoEEIOukgLYgkR6AMAAAAAAAASEwoCZmwYDSABKAJCB7pIBAoCQAESEwoCZGIYDiABKAFCB7pIBBICQAESCgoCYmwYDyABKAgSEwoCYnkYECABKAxCB7pIBHoCEAESJQoGbmVzdGVkGBEgASgLMhUuYmVuY2gudjEuQmVuY2hTY2FsYXISLgoIc2VsZl9yZWYYEiABKAsyHC5iZW5jaC52MS5CZW5jaENvbXBsZXhTY2hlbWESGQoHcmVwX3N0chgTIAMoCUIIukgFkgECEAoSGQoHcmVwX2kzMhgUIAMoBUIIukgFkgECCAESGwoJcmVwX2J5dGVzGBUgAygMQgi6SAWSAQIYARIwCgdyZXBfbXNnGBYgAygLMhUuYmVuY2gudjEuQmVuY2hTY2FsYXJCCLpIBZIBAhAFEkoKC21hcF9zdHJfc3RyGBcgAygLMisuYmVuY2gudjEuQmVuY2hDb21wbGV4U2NoZW1hLk1hcFN0clN0ckVudHJ5Qgi6SAWaAQIIARJKCgttYXBfaTMyX2k2NBgYIAMoCzIrLmJlbmNoLnYxLkJlbmNoQ29tcGxleFNjaGVtYS5NYXBJMzJJNjRFbnRyeUIIukgFmgECEAoSQgoMbWFwX3U2NF9ib29sGBkgAygLMiwuYmVuY2gudjEuQmVuY2hDb21wbGV4U2NoZW1hLk1hcFU2NEJvb2xFbnRyeRJSCg1tYXBfc3RyX2J5dGVzGBogAygLMi0uYmVuY2gudjEuQmVuY2hDb21wbGV4U2NoZW1hLk1hcFN0ckJ5dGVzRW50cnlCDLpICZoBBiIEcgIQARJNCgttYXBfc3RyX21zZxgbIAMoCzIrLmJlbmNoLnYxLkJlbmNoQ29tcGxleFNjaGVtYS5NYXBTdHJNc2dFbnRyeUILukgImgEFKgPIAQESQAoLbWFwX2k2NF9tc2cYHCADKAsyKy5iZW5jaC52MS5CZW5jaENvbXBsZXhTY2hlbWEuTWFwSTY0TXNnRW50cnkSMQoKZW51bV9maWVsZBgdIAEoDjITLmJlbmNoLnYxLkJlbmNoRW51bUIIukgFggECEAESHAoJb25lb2Zfc3RyGB4gASgJQge6SARyAhABSAASHAoJb25lb2ZfaTMyGB8gASgFQge6SAQaAiAASAASKgoJb25lb2ZfbXNnGCAgASgLMhUuYmVuY2gudjEuQmVuY2hTY2FsYXJIABowCg5NYXBTdHJTdHJFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBGjAKDk1hcEkzMkk2NEVudHJ5EgsKA2tleRgBIAEoBRINCgV2YWx1ZRgCIAEoAzoCOAEaMQoPTWFwVTY0Qm9vbEVudHJ5EgsKA2tleRgBIAEoBBINCgV2YWx1ZRgCIAEoCDoCOAEaMgoQTWFwU3RyQnl0ZXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAw6AjgBGkcKDk1hcFN0ck1zZ0VudHJ5EgsKA2tleRgBIAEoCRIkCgV2YWx1ZRgCIAEoCzIVLmJlbmNoLnYxLkJlbmNoU2NhbGFyOgI4ARpHCg5NYXBJNjRNc2dFbnRyeRILCgNrZXkYASABKAMSJAoFdmFsdWUYAiABKAsyFS5iZW5jaC52MS5CZW5jaFNjYWxhcjoCOAFCCAoGY2hvaWNlKk8KCUJlbmNoRW51bRIaChZCRU5DSF9FTlVNX1VOU1BFQ0lGSUVEEAASEgoOQkVOQ0hfRU5VTV9PTkUQARISCg5CRU5DSF9FTlVNX1RXTxACYgZwcm90bzM", [file_buf_validate_validate]); + +/** + * @generated from message bench.v1.BenchScalar + */ +export type BenchScalar = Message<"bench.v1.BenchScalar"> & { + /** + * @generated from field: int32 x = 1; + */ + x: number; +}; + +/** + * Describes the message bench.v1.BenchScalar. + * Use `create(BenchScalarSchema)` to create a new message. + */ +export const BenchScalarSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 0); + +/** + * @generated from message bench.v1.BenchRepeatedScalar + */ +export type BenchRepeatedScalar = Message<"bench.v1.BenchRepeatedScalar"> & { + /** + * @generated from field: repeated int32 x = 1; + */ + x: number[]; +}; + +/** + * Describes the message bench.v1.BenchRepeatedScalar. + * Use `create(BenchRepeatedScalarSchema)` to create a new message. + */ +export const BenchRepeatedScalarSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 1); + +/** + * @generated from message bench.v1.BenchRepeatedMessage + */ +export type BenchRepeatedMessage = Message<"bench.v1.BenchRepeatedMessage"> & { + /** + * @generated from field: repeated bench.v1.BenchScalar x = 1; + */ + x: BenchScalar[]; +}; + +/** + * Describes the message bench.v1.BenchRepeatedMessage. + * Use `create(BenchRepeatedMessageSchema)` to create a new message. + */ +export const BenchRepeatedMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 2); + +/** + * @generated from message bench.v1.BenchRepeatedScalarUnique + */ +export type BenchRepeatedScalarUnique = Message<"bench.v1.BenchRepeatedScalarUnique"> & { + /** + * @generated from field: repeated float x = 1; + */ + x: number[]; +}; + +/** + * Describes the message bench.v1.BenchRepeatedScalarUnique. + * Use `create(BenchRepeatedScalarUniqueSchema)` to create a new message. + */ +export const BenchRepeatedScalarUniqueSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 3); + +/** + * @generated from message bench.v1.BenchRepeatedBytesUnique + */ +export type BenchRepeatedBytesUnique = Message<"bench.v1.BenchRepeatedBytesUnique"> & { + /** + * @generated from field: repeated bytes x = 1; + */ + x: Uint8Array[]; +}; + +/** + * Describes the message bench.v1.BenchRepeatedBytesUnique. + * Use `create(BenchRepeatedBytesUniqueSchema)` to create a new message. + */ +export const BenchRepeatedBytesUniqueSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 4); + +/** + * Map validation benchmark. + * + * @generated from message bench.v1.BenchMap + */ +export type BenchMap = Message<"bench.v1.BenchMap"> & { + /** + * @generated from field: map entries = 1; + */ + entries: { [key: string]: string }; +}; + +/** + * Describes the message bench.v1.BenchMap. + * Use `create(BenchMapSchema)` to create a new message. + */ +export const BenchMapSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 5); + +/** + * Complex schema benchmark. + * + * @generated from message bench.v1.BenchComplexSchema + */ +export type BenchComplexSchema = Message<"bench.v1.BenchComplexSchema"> & { + /** + * @generated from field: string s1 = 1; + */ + s1: string; + + /** + * @generated from field: string s2 = 2; + */ + s2: string; + + /** + * @generated from field: int32 i32 = 3; + */ + i32: number; + + /** + * @generated from field: int64 i64 = 4; + */ + i64: bigint; + + /** + * @generated from field: uint32 u32 = 5; + */ + u32: number; + + /** + * @generated from field: uint64 u64 = 6; + */ + u64: bigint; + + /** + * @generated from field: sint32 si32 = 7; + */ + si32: number; + + /** + * @generated from field: sint64 si64 = 8; + */ + si64: bigint; + + /** + * @generated from field: fixed32 f32 = 9; + */ + f32: number; + + /** + * @generated from field: fixed64 f64 = 10; + */ + f64: bigint; + + /** + * @generated from field: sfixed32 sf32 = 11; + */ + sf32: number; + + /** + * @generated from field: sfixed64 sf64 = 12; + */ + sf64: bigint; + + /** + * @generated from field: float fl = 13; + */ + fl: number; + + /** + * @generated from field: double db = 14; + */ + db: number; + + /** + * @generated from field: bool bl = 15; + */ + bl: boolean; + + /** + * @generated from field: bytes by = 16; + */ + by: Uint8Array; + + /** + * @generated from field: bench.v1.BenchScalar nested = 17; + */ + nested?: BenchScalar; + + /** + * @generated from field: bench.v1.BenchComplexSchema self_ref = 18; + */ + selfRef?: BenchComplexSchema; + + /** + * @generated from field: repeated string rep_str = 19; + */ + repStr: string[]; + + /** + * @generated from field: repeated int32 rep_i32 = 20; + */ + repI32: number[]; + + /** + * @generated from field: repeated bytes rep_bytes = 21; + */ + repBytes: Uint8Array[]; + + /** + * @generated from field: repeated bench.v1.BenchScalar rep_msg = 22; + */ + repMsg: BenchScalar[]; + + /** + * @generated from field: map map_str_str = 23; + */ + mapStrStr: { [key: string]: string }; + + /** + * @generated from field: map map_i32_i64 = 24; + */ + mapI32I64: { [key: number]: bigint }; + + /** + * @generated from field: map map_u64_bool = 25; + */ + mapU64Bool: { [key: string]: boolean }; + + /** + * @generated from field: map map_str_bytes = 26; + */ + mapStrBytes: { [key: string]: Uint8Array }; + + /** + * @generated from field: map map_str_msg = 27; + */ + mapStrMsg: { [key: string]: BenchScalar }; + + /** + * @generated from field: map map_i64_msg = 28; + */ + mapI64Msg: { [key: string]: BenchScalar }; + + /** + * @generated from field: bench.v1.BenchEnum enum_field = 29; + */ + enumField: BenchEnum; + + /** + * @generated from oneof bench.v1.BenchComplexSchema.choice + */ + choice: { + /** + * @generated from field: string oneof_str = 30; + */ + value: string; + case: "oneofStr"; + } | { + /** + * @generated from field: int32 oneof_i32 = 31; + */ + value: number; + case: "oneofI32"; + } | { + /** + * @generated from field: bench.v1.BenchScalar oneof_msg = 32; + */ + value: BenchScalar; + case: "oneofMsg"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message bench.v1.BenchComplexSchema. + * Use `create(BenchComplexSchemaSchema)` to create a new message. + */ +export const BenchComplexSchemaSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_bench, 6); + +/** + * @generated from enum bench.v1.BenchEnum + */ +export enum BenchEnum { + /** + * @generated from enum value: BENCH_ENUM_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: BENCH_ENUM_ONE = 1; + */ + ONE = 1, + + /** + * @generated from enum value: BENCH_ENUM_TWO = 2; + */ + TWO = 2, +} + +/** + * Describes the enum bench.v1.BenchEnum. + */ +export const BenchEnumSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_bench_v1_bench, 0); + diff --git a/packages/protovalidate-bench/src/gen/bench/v1/native_pb.ts b/packages/protovalidate-bench/src/gen/bench/v1/native_pb.ts new file mode 100644 index 0000000..08da27c --- /dev/null +++ b/packages/protovalidate-bench/src/gen/bench/v1/native_pb.ts @@ -0,0 +1,260 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=.js,ts_nocheck=false" +// @generated from file bench/v1/native.proto (package bench.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb.js"; +import { file_google_protobuf_wrappers } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file bench/v1/native.proto. + */ +export const file_bench_v1_native: GenFile = /*@__PURE__*/ + fileDesc("ChViZW5jaC92MS9uYXRpdmUucHJvdG8SCGJlbmNoLnYxItcDCgdCZW5jaEdUEhMKAmd0GAEgASgFQge6SAQaAiAAEhQKA2d0ZRgCIAEoBUIHukgEGgIoABITCgJsdBgDIAEoBUIHukgEGgIQZRIUCgNsdGUYBCABKAVCB7pIBBoCGGUSGQoGZ3RsdGluGAUgASgFQgm6SAYaBBBlIAASGgoHZ3RsdGVpbhgGIAEoBUIJukgGGgQQZSAAEiIKBmd0bHRleBgHIAEoBUISukgPGg0Q7P//////////ASAAEiMKB2d0bHRlZXgYCCABKAVCErpIDxoNGOz//////////wEgABIaCgdndGVsdGluGAkgASgFQgm6SAYaBBBlKAASGwoIZ3RlbHRlaW4YCiABKAVCCbpIBhoEEGUoABIjCgdndGVsdGV4GAsgASgFQhK6SA8aDRDs//////////8BKAASJAoIZ3RlbHRlZXgYDCABKAVCErpIDxoNGOz//////////wEoABIWCgVjb25zdBgNIAEoBUIHukgEGgIIChIaCgdjb25zdGd0GA4gASgFQgm6SAYaBAgKKAASHAoHaW5fdGVzdBgPIAEoBUILukgIGgYwATADMAUSIAoLbm90X2luX3Rlc3QYECABKAVCC7pICBoGOAE4AzgFInsKEFRlc3RCeXRlTWF0Y2hpbmcSGAoHaXBfYWRkchgBIAEoDEIHukgEegJQARIaCglpcHY0X2FkZHIYAiABKAxCB7pIBHoCWAESGgoJaXB2Nl9hZGRyGAMgASgMQge6SAR6AmABEhUKBHV1aWQYBCABKAxCB7pIBHoCeAEifAoOU3RyaW5nTWF0Y2hpbmcSGQoIaG9zdG5hbWUYASABKAlCB7pIBHICaAESHwoNaG9zdF9hbmRfcG9ydBgCIAEoCUIIukgFcgOAAgESFgoFZW1haWwYAyABKAlCB7pIBHICYAESFgoEdXVpZBgEIAEoCUIIukgFcgOwAQEi5AMKDldyYXBwZXJUZXN0aW5nEjEKA2kzMhgBIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5JbnQzMlZhbHVlQge6SAQaAiAKEjcKAWQYAiABKAsyHC5nb29nbGUucHJvdG9idWYuRG91YmxlVmFsdWVCDrpICxIJIQAAAAAAACRAEjIKAWYYAyABKAsyGy5nb29nbGUucHJvdG9idWYuRmxvYXRWYWx1ZUIKukgHCgUlAAAgQRIxCgNpNjQYBCABKAsyGy5nb29nbGUucHJvdG9idWYuSW50NjRWYWx1ZUIHukgEIgIgChIyCgN1NjQYBSABKAsyHC5nb29nbGUucHJvdG9idWYuVUludDY0VmFsdWVCB7pIBDICIAoSMgoDdTMyGAYgASgLMhwuZ29vZ2xlLnByb3RvYnVmLlVJbnQzMlZhbHVlQge6SAQqAiAKEi4KAWIYByABKAsyGi5nb29nbGUucHJvdG9idWYuQm9vbFZhbHVlQge6SARqAggBEjUKAXMYCCABKAsyHC5nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWVCDLpICXIHCgVoZWxsbxIwCgJicxgJIAEoCzIbLmdvb2dsZS5wcm90b2J1Zi5CeXRlc1ZhbHVlQge6SAR6AmgFIiQKCU11bHRpUnVsZRIXCgRtYW55GAEgASgDQgm6SAYiBAgKIAViBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_wrappers]); + +/** + * @generated from message bench.v1.BenchGT + */ +export type BenchGT = Message<"bench.v1.BenchGT"> & { + /** + * @generated from field: int32 gt = 1; + */ + gt: number; + + /** + * @generated from field: int32 gte = 2; + */ + gte: number; + + /** + * @generated from field: int32 lt = 3; + */ + lt: number; + + /** + * @generated from field: int32 lte = 4; + */ + lte: number; + + /** + * @generated from field: int32 gtltin = 5; + */ + gtltin: number; + + /** + * @generated from field: int32 gtltein = 6; + */ + gtltein: number; + + /** + * @generated from field: int32 gtltex = 7; + */ + gtltex: number; + + /** + * @generated from field: int32 gtlteex = 8; + */ + gtlteex: number; + + /** + * @generated from field: int32 gteltin = 9; + */ + gteltin: number; + + /** + * @generated from field: int32 gteltein = 10; + */ + gteltein: number; + + /** + * @generated from field: int32 gteltex = 11; + */ + gteltex: number; + + /** + * @generated from field: int32 gtelteex = 12; + */ + gtelteex: number; + + /** + * @generated from field: int32 const = 13; + */ + const: number; + + /** + * @generated from field: int32 constgt = 14; + */ + constgt: number; + + /** + * @generated from field: int32 in_test = 15; + */ + inTest: number; + + /** + * @generated from field: int32 not_in_test = 16; + */ + notInTest: number; +}; + +/** + * Describes the message bench.v1.BenchGT. + * Use `create(BenchGTSchema)` to create a new message. + */ +export const BenchGTSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_native, 0); + +/** + * @generated from message bench.v1.TestByteMatching + */ +export type TestByteMatching = Message<"bench.v1.TestByteMatching"> & { + /** + * @generated from field: bytes ip_addr = 1; + */ + ipAddr: Uint8Array; + + /** + * @generated from field: bytes ipv4_addr = 2; + */ + ipv4Addr: Uint8Array; + + /** + * @generated from field: bytes ipv6_addr = 3; + */ + ipv6Addr: Uint8Array; + + /** + * @generated from field: bytes uuid = 4; + */ + uuid: Uint8Array; +}; + +/** + * Describes the message bench.v1.TestByteMatching. + * Use `create(TestByteMatchingSchema)` to create a new message. + */ +export const TestByteMatchingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_native, 1); + +/** + * @generated from message bench.v1.StringMatching + */ +export type StringMatching = Message<"bench.v1.StringMatching"> & { + /** + * @generated from field: string hostname = 1; + */ + hostname: string; + + /** + * @generated from field: string host_and_port = 2; + */ + hostAndPort: string; + + /** + * @generated from field: string email = 3; + */ + email: string; + + /** + * @generated from field: string uuid = 4; + */ + uuid: string; +}; + +/** + * Describes the message bench.v1.StringMatching. + * Use `create(StringMatchingSchema)` to create a new message. + */ +export const StringMatchingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_native, 2); + +/** + * @generated from message bench.v1.WrapperTesting + */ +export type WrapperTesting = Message<"bench.v1.WrapperTesting"> & { + /** + * @generated from field: google.protobuf.Int32Value i32 = 1; + */ + i32?: number; + + /** + * @generated from field: google.protobuf.DoubleValue d = 2; + */ + d?: number; + + /** + * @generated from field: google.protobuf.FloatValue f = 3; + */ + f?: number; + + /** + * @generated from field: google.protobuf.Int64Value i64 = 4; + */ + i64?: bigint; + + /** + * @generated from field: google.protobuf.UInt64Value u64 = 5; + */ + u64?: bigint; + + /** + * @generated from field: google.protobuf.UInt32Value u32 = 6; + */ + u32?: number; + + /** + * @generated from field: google.protobuf.BoolValue b = 7; + */ + b?: boolean; + + /** + * @generated from field: google.protobuf.StringValue s = 8; + */ + s?: string; + + /** + * @generated from field: google.protobuf.BytesValue bs = 9; + */ + bs?: Uint8Array; +}; + +/** + * Describes the message bench.v1.WrapperTesting. + * Use `create(WrapperTestingSchema)` to create a new message. + */ +export const WrapperTestingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_native, 3); + +/** + * @generated from message bench.v1.MultiRule + */ +export type MultiRule = Message<"bench.v1.MultiRule"> & { + /** + * @generated from field: int64 many = 1; + */ + many: bigint; +}; + +/** + * Describes the message bench.v1.MultiRule. + * Use `create(MultiRuleSchema)` to create a new message. + */ +export const MultiRuleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_bench_v1_native, 4); + diff --git a/packages/protovalidate-bench/src/gen/buf/validate/validate_pb.ts b/packages/protovalidate-bench/src/gen/buf/validate/validate_pb.ts new file mode 100644 index 0000000..99fbcd4 --- /dev/null +++ b/packages/protovalidate-bench/src/gen/buf/validate/validate_pb.ts @@ -0,0 +1,5028 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=.js,ts_nocheck=false" +// @generated from file buf/validate/validate.proto (package buf.validate, syntax proto2) +/* eslint-disable */ + +// [Protovalidate](https://protovalidate.com/) is the semantic validation library for Protobuf. +// It provides standard annotations to validate common rules on messages and fields, as well as the ability to use [CEL](https://cel.dev) to write custom rules. +// It's the next generation of [protoc-gen-validate](https://github.com/bufbuild/protoc-gen-validate). +// +// This package provides the options, messages, and enums that power Protovalidate. +// Apply its options to messages, fields, and oneofs in your Protobuf schemas to add validation rules: +// +// ```proto +// message User { +// string id = 1 [(buf.validate.field).string.uuid = true]; +// string first_name = 2 [(buf.validate.field).string.max_len = 64]; +// string last_name = 3 [(buf.validate.field).string.max_len = 64]; +// +// option (buf.validate.message).cel = { +// id: "first_name_requires_last_name" +// message: "last_name must be present if first_name is present" +// expression: "!has(this.first_name) || has(this.last_name)" +// }; +// } +// ``` +// +// These rules are enforced at runtime by language-specific libraries. +// See the [developer quickstart](https://protovalidate.com/quickstart/) to get started, or go directly to the runtime library for your language: +// [Go](https://github.com/bufbuild/protovalidate-go), +// [JavaScript/TypeScript](https://github.com/bufbuild/protovalidate-es), +// [Java](https://github.com/bufbuild/protovalidate-java), +// [Python](https://github.com/bufbuild/protovalidate-python), +// or [C++](https://github.com/bufbuild/protovalidate-cc). + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration, FieldDescriptorProto_Type, FieldMask, FieldOptions, MessageOptions, OneofOptions, Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_descriptor, file_google_protobuf_duration, file_google_protobuf_field_mask, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file buf/validate/validate.proto. + */ +export const file_buf_validate_validate: GenFile = /*@__PURE__*/ + fileDesc("ChtidWYvdmFsaWRhdGUvdmFsaWRhdGUucHJvdG8SDGJ1Zi52YWxpZGF0ZSI3CgRSdWxlEgoKAmlkGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSEgoKZXhwcmVzc2lvbhgDIAEoCSKGAQoMTWVzc2FnZVJ1bGVzEhYKDmNlbF9leHByZXNzaW9uGAUgAygJEh8KA2NlbBgDIAMoCzISLmJ1Zi52YWxpZGF0ZS5SdWxlEi0KBW9uZW9mGAQgAygLMh4uYnVmLnZhbGlkYXRlLk1lc3NhZ2VPbmVvZlJ1bGVKBAgBEAJSCGRpc2FibGVkIjQKEE1lc3NhZ2VPbmVvZlJ1bGUSDgoGZmllbGRzGAEgAygJEhAKCHJlcXVpcmVkGAIgASgIIh4KCk9uZW9mUnVsZXMSEAoIcmVxdWlyZWQYASABKAgiiwkKCkZpZWxkUnVsZXMSFgoOY2VsX2V4cHJlc3Npb24YHSADKAkSHwoDY2VsGBcgAygLMhIuYnVmLnZhbGlkYXRlLlJ1bGUSEAoIcmVxdWlyZWQYGSABKAgSJAoGaWdub3JlGBsgASgOMhQuYnVmLnZhbGlkYXRlLklnbm9yZRIpCgVmbG9hdBgBIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GbG9hdFJ1bGVzSAASKwoGZG91YmxlGAIgASgLMhkuYnVmLnZhbGlkYXRlLkRvdWJsZVJ1bGVzSAASKQoFaW50MzIYAyABKAsyGC5idWYudmFsaWRhdGUuSW50MzJSdWxlc0gAEikKBWludDY0GAQgASgLMhguYnVmLnZhbGlkYXRlLkludDY0UnVsZXNIABIrCgZ1aW50MzIYBSABKAsyGS5idWYudmFsaWRhdGUuVUludDMyUnVsZXNIABIrCgZ1aW50NjQYBiABKAsyGS5idWYudmFsaWRhdGUuVUludDY0UnVsZXNIABIrCgZzaW50MzIYByABKAsyGS5idWYudmFsaWRhdGUuU0ludDMyUnVsZXNIABIrCgZzaW50NjQYCCABKAsyGS5idWYudmFsaWRhdGUuU0ludDY0UnVsZXNIABItCgdmaXhlZDMyGAkgASgLMhouYnVmLnZhbGlkYXRlLkZpeGVkMzJSdWxlc0gAEi0KB2ZpeGVkNjQYCiABKAsyGi5idWYudmFsaWRhdGUuRml4ZWQ2NFJ1bGVzSAASLwoIc2ZpeGVkMzIYCyABKAsyGy5idWYudmFsaWRhdGUuU0ZpeGVkMzJSdWxlc0gAEi8KCHNmaXhlZDY0GAwgASgLMhsuYnVmLnZhbGlkYXRlLlNGaXhlZDY0UnVsZXNIABInCgRib29sGA0gASgLMhcuYnVmLnZhbGlkYXRlLkJvb2xSdWxlc0gAEisKBnN0cmluZxgOIAEoCzIZLmJ1Zi52YWxpZGF0ZS5TdHJpbmdSdWxlc0gAEikKBWJ5dGVzGA8gASgLMhguYnVmLnZhbGlkYXRlLkJ5dGVzUnVsZXNIABInCgRlbnVtGBAgASgLMhcuYnVmLnZhbGlkYXRlLkVudW1SdWxlc0gAEi8KCHJlcGVhdGVkGBIgASgLMhsuYnVmLnZhbGlkYXRlLlJlcGVhdGVkUnVsZXNIABIlCgNtYXAYEyABKAsyFi5idWYudmFsaWRhdGUuTWFwUnVsZXNIABIlCgNhbnkYFCABKAsyFi5idWYudmFsaWRhdGUuQW55UnVsZXNIABIvCghkdXJhdGlvbhgVIAEoCzIbLmJ1Zi52YWxpZGF0ZS5EdXJhdGlvblJ1bGVzSAASMgoKZmllbGRfbWFzaxgcIAEoCzIcLmJ1Zi52YWxpZGF0ZS5GaWVsZE1hc2tSdWxlc0gAEjEKCXRpbWVzdGFtcBgWIAEoCzIcLmJ1Zi52YWxpZGF0ZS5UaW1lc3RhbXBSdWxlc0gAQgYKBHR5cGVKBAgYEBlKBAgaEBtSB3NraXBwZWRSDGlnbm9yZV9lbXB0eSJVCg9QcmVkZWZpbmVkUnVsZXMSHwoDY2VsGAEgAygLMhIuYnVmLnZhbGlkYXRlLlJ1bGVKBAgYEBlKBAgaEBtSB3NraXBwZWRSDGlnbm9yZV9lbXB0eSL4FgoKRmxvYXRSdWxlcxJ9CgVjb25zdBgBIAEoAkJuwkhrCmkKC2Zsb2F0LmNvbnN0Glp0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICdtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSmQEKAmx0GAIgASgCQooBwkiGAQqDAQoIZmxvYXQubHQadyFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQpPyAnbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASqQEKA2x0ZRgDIAEoAkKZAcJIlQEKkgEKCWZsb2F0Lmx0ZRqEASFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPiBydWxlcy5sdGUpPyAnbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEtAHCgJndBgEIAEoAkLBB8JIvQcKhgEKCGZsb2F0Lmd0GnohaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq9AQoLZmxvYXQuZ3RfbHQarQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrHAQoVZmxvYXQuZ3RfbHRfZXhjbHVzaXZlGq0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKzQEKDGZsb2F0Lmd0X2x0ZRq8AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCtcBChZmbG9hdC5ndF9sdGVfZXhjbHVzaXZlGrwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARKcCAoDZ3RlGAUgASgCQowIwkiICAqVAQoJZmxvYXQuZ3RlGocBIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCswBCgxmbG9hdC5ndGVfbHQauwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCtYBChZmbG9hdC5ndGVfbHRfZXhjbHVzaXZlGrsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrcAQoNZmxvYXQuZ3RlX2x0ZRrKAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK5gEKF2Zsb2F0Lmd0ZV9sdGVfZXhjbHVzaXZlGsoBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSkpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEnkKAmluGAYgAygCQm3CSGoKaAoIZmxvYXQuaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEnAKBm5vdF9pbhgHIAMoAkJgwkhdClsKDGZsb2F0Lm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEm8KBmZpbml0ZRgIIAEoCEJfwkhcCloKDGZsb2F0LmZpbml0ZRpKcnVsZXMuZmluaXRlID8gKHRoaXMuaXNOYW4oKSB8fCB0aGlzLmlzSW5mKCkgPyAnbXVzdCBiZSBmaW5pdGUnIDogJycpIDogJycSKwoHZXhhbXBsZRgJIAMoAkIawkgXChUKDWZsb2F0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIooXCgtEb3VibGVSdWxlcxJ+CgVjb25zdBgBIAEoAUJvwkhsCmoKDGRvdWJsZS5jb25zdBpadGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAnbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEpoBCgJsdBgCIAEoAUKLAcJIhwEKhAEKCWRvdWJsZS5sdBp3IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+PSBydWxlcy5sdCk/ICdtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKqAQoDbHRlGAMgASgBQpoBwkiWAQqTAQoKZG91YmxlLmx0ZRqEASFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPiBydWxlcy5sdGUpPyAnbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEtUHCgJndBgEIAEoAULGB8JIwgcKhwEKCWRvdWJsZS5ndBp6IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKvgEKDGRvdWJsZS5ndF9sdBqtAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsgBChZkb3VibGUuZ3RfbHRfZXhjbHVzaXZlGq0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKzgEKDWRvdWJsZS5ndF9sdGUavAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrYAQoXZG91YmxlLmd0X2x0ZV9leGNsdXNpdmUavAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEqEICgNndGUYBSABKAFCkQjCSI0ICpYBCgpkb3VibGUuZ3RlGocBIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCs0BCg1kb3VibGUuZ3RlX2x0GrsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrXAQoXZG91YmxlLmd0ZV9sdF9leGNsdXNpdmUauwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSkpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCt0BCg5kb3VibGUuZ3RlX2x0ZRrKAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK5wEKGGRvdWJsZS5ndGVfbHRlX2V4Y2x1c2l2ZRrKAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ6CgJpbhgGIAMoAUJuwkhrCmkKCWRvdWJsZS5pbhpcISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAnbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycScQoGbm90X2luGAcgAygBQmHCSF4KXAoNZG91YmxlLm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEnAKBmZpbml0ZRgIIAEoCEJgwkhdClsKDWRvdWJsZS5maW5pdGUaSnJ1bGVzLmZpbml0ZSA/ICh0aGlzLmlzTmFuKCkgfHwgdGhpcy5pc0luZigpID8gJ211c3QgYmUgZmluaXRlJyA6ICcnKSA6ICcnEiwKB2V4YW1wbGUYCSADKAFCG8JIGAoWCg5kb3VibGUuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4isBQKCkludDMyUnVsZXMSfQoFY29uc3QYASABKAVCbsJIawppCgtpbnQzMi5jb25zdBpadGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAnbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEoQBCgJsdBgCIAEoBUJ2wkhzCnEKCGludDMyLmx0GmUhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ211c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEpUBCgNsdGUYAyABKAVChQHCSIEBCn8KCWludDMyLmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAAS+QYKAmd0GAQgASgFQuoGwkjmBgp0CghpbnQzMi5ndBpoIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKrQEKC2ludDMyLmd0X2x0Gp0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq1AQoVaW50MzIuZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvQEKDGludDMyLmd0X2x0ZRqsAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKxQEKFmludDMyLmd0X2x0ZV9leGNsdXNpdmUaqgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEsUHCgNndGUYBSABKAVCtQfCSLEHCoIBCglpbnQzMi5ndGUadSFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwq8AQoMaW50MzIuZ3RlX2x0GqsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsQBChZpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGqkBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrMAQoNaW50MzIuZ3RlX2x0ZRq6AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrUAQoXaW50MzIuZ3RlX2x0ZV9leGNsdXNpdmUauAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESeQoCaW4YBiADKAVCbcJIagpoCghpbnQzMi5pbhpcISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAnbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycScAoGbm90X2luGAcgAygFQmDCSF0KWwoMaW50MzIubm90X2luGkt0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICdtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKwoHZXhhbXBsZRgIIAMoBUIawkgXChUKDWludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIrAUCgpJbnQ2NFJ1bGVzEn0KBWNvbnN0GAEgASgDQm7CSGsKaQoLaW50NjQuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKEAQoCbHQYAiABKANCdsJIcwpxCghpbnQ2NC5sdBplIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICdtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKVAQoDbHRlGAMgASgDQoUBwkiBAQp/CglpbnQ2NC5sdGUaciFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAnbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEvkGCgJndBgEIAEoA0LqBsJI5gYKdAoIaW50NjQuZ3QaaCFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCq0BCgtpbnQ2NC5ndF9sdBqdAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKtQEKFWludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqbAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr0BCgxpbnQ2NC5ndF9sdGUarAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCsUBChZpbnQ2NC5ndF9sdGVfZXhjbHVzaXZlGqoBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLFBwoDZ3RlGAUgASgDQrUHwkixBwqCAQoJaW50NjQuZ3RlGnUhaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKvAEKDGludDY0Lmd0ZV9sdBqrAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrEAQoWaW50NjQuZ3RlX2x0X2V4Y2x1c2l2ZRqpAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzAEKDWludDY0Lmd0ZV9sdGUaugFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK1AEKF2ludDY0Lmd0ZV9sdGVfZXhjbHVzaXZlGrgBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEnkKAmluGAYgAygDQm3CSGoKaAoIaW50NjQuaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEnAKBm5vdF9pbhgHIAMoA0JgwkhdClsKDGludDY0Lm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEisKB2V4YW1wbGUYCSADKANCGsJIFwoVCg1pbnQ2NC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLCFAoLVUludDMyUnVsZXMSfgoFY29uc3QYASABKA1Cb8JIbApqCgx1aW50MzIuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKFAQoCbHQYAiABKA1Cd8JIdApyCgl1aW50MzIubHQaZSFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAnbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASlwEKA2x0ZRgDIAEoDUKHAcJIgwEKgAEKCnVpbnQzMi5sdGUaciFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAnbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEv4GCgJndBgEIAEoDULvBsJI6wYKdQoJdWludDMyLmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwquAQoMdWludDMyLmd0X2x0Gp0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq2AQoWdWludDMyLmd0X2x0X2V4Y2x1c2l2ZRqbAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr4BCg11aW50MzIuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrGAQoXdWludDMyLmd0X2x0ZV9leGNsdXNpdmUaqgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEsoHCgNndGUYBSABKA1CugfCSLYHCoMBCgp1aW50MzIuZ3RlGnUhaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKvQEKDXVpbnQzMi5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxQEKF3VpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGqkBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrNAQoOdWludDMyLmd0ZV9sdGUaugFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK1QEKGHVpbnQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ6CgJpbhgGIAMoDUJuwkhrCmkKCXVpbnQzMi5pbhpcISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAnbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycScQoGbm90X2luGAcgAygNQmHCSF4KXAoNdWludDMyLm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEiwKB2V4YW1wbGUYCCADKA1CG8JIGAoWCg51aW50MzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iwhQKC1VJbnQ2NFJ1bGVzEn4KBWNvbnN0GAEgASgEQm/CSGwKagoMdWludDY0LmNvbnN0Glp0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICdtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycShQEKAmx0GAIgASgEQnfCSHQKcgoJdWludDY0Lmx0GmUhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ211c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEpcBCgNsdGUYAyABKARChwHCSIMBCoABCgp1aW50NjQubHRlGnIhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ211c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABL+BgoCZ3QYBCABKARC7wbCSOsGCnUKCXVpbnQ2NC5ndBpoIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKrgEKDHVpbnQ2NC5ndF9sdBqdAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKtgEKFnVpbnQ2NC5ndF9sdF9leGNsdXNpdmUamwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq+AQoNdWludDY0Lmd0X2x0ZRqsAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKxgEKF3VpbnQ2NC5ndF9sdGVfZXhjbHVzaXZlGqoBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLKBwoDZ3RlGAUgASgEQroHwki2BwqDAQoKdWludDY0Lmd0ZRp1IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCr0BCg11aW50NjQuZ3RlX2x0GqsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsUBChd1aW50NjQuZ3RlX2x0X2V4Y2x1c2l2ZRqpAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzQEKDnVpbnQ2NC5ndGVfbHRlGroBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCtUBChh1aW50NjQuZ3RlX2x0ZV9leGNsdXNpdmUauAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESegoCaW4YBiADKARCbsJIawppCgl1aW50NjQuaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEnEKBm5vdF9pbhgHIAMoBEJhwkheClwKDXVpbnQ2NC5ub3RfaW4aS3RoaXMgaW4gcnVsZXMubm90X2luID8gJ211c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygEQhvCSBgKFgoOdWludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIsIUCgtTSW50MzJSdWxlcxJ+CgVjb25zdBgBIAEoEUJvwkhsCmoKDHNpbnQzMi5jb25zdBpadGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAnbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEoUBCgJsdBgCIAEoEUJ3wkh0CnIKCXNpbnQzMi5sdBplIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICdtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKXAQoDbHRlGAMgASgRQocBwkiDAQqAAQoKc2ludDMyLmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAAS/gYKAmd0GAQgASgRQu8GwkjrBgp1CglzaW50MzIuZ3QaaCFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCq4BCgxzaW50MzIuZ3RfbHQanQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCrYBChZzaW50MzIuZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvgEKDXNpbnQzMi5ndF9sdGUarAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCsYBChdzaW50MzIuZ3RfbHRlX2V4Y2x1c2l2ZRqqAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAESygcKA2d0ZRgFIAEoEUK6B8JItgcKgwEKCnNpbnQzMi5ndGUadSFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwq9AQoNc2ludDMyLmd0ZV9sdBqrAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrFAQoXc2ludDMyLmd0ZV9sdF9leGNsdXNpdmUaqQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs0BCg5zaW50MzIuZ3RlX2x0ZRq6AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrVAQoYc2ludDMyLmd0ZV9sdGVfZXhjbHVzaXZlGrgBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEnoKAmluGAYgAygRQm7CSGsKaQoJc2ludDMyLmluGlwhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICdtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJxCgZub3RfaW4YByADKBFCYcJIXgpcCg1zaW50MzIubm90X2luGkt0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICdtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLAoHZXhhbXBsZRgIIAMoEUIbwkgYChYKDnNpbnQzMi5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLCFAoLU0ludDY0UnVsZXMSfgoFY29uc3QYASABKBJCb8JIbApqCgxzaW50NjQuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKFAQoCbHQYAiABKBJCd8JIdApyCglzaW50NjQubHQaZSFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAnbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASlwEKA2x0ZRgDIAEoEkKHAcJIgwEKgAEKCnNpbnQ2NC5sdGUaciFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAnbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEv4GCgJndBgEIAEoEkLvBsJI6wYKdQoJc2ludDY0Lmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwquAQoMc2ludDY0Lmd0X2x0Gp0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq2AQoWc2ludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqbAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr4BCg1zaW50NjQuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrGAQoXc2ludDY0Lmd0X2x0ZV9leGNsdXNpdmUaqgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEsoHCgNndGUYBSABKBJCugfCSLYHCoMBCgpzaW50NjQuZ3RlGnUhaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKvQEKDXNpbnQ2NC5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxQEKF3NpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGqkBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrNAQoOc2ludDY0Lmd0ZV9sdGUaugFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK1QEKGHNpbnQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ6CgJpbhgGIAMoEkJuwkhrCmkKCXNpbnQ2NC5pbhpcISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAnbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycScQoGbm90X2luGAcgAygSQmHCSF4KXAoNc2ludDY0Lm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEiwKB2V4YW1wbGUYCCADKBJCG8JIGAoWCg5zaW50NjQuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4i0xQKDEZpeGVkMzJSdWxlcxJ/CgVjb25zdBgBIAEoB0JwwkhtCmsKDWZpeGVkMzIuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKGAQoCbHQYAiABKAdCeMJIdQpzCgpmaXhlZDMyLmx0GmUhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ211c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEpgBCgNsdGUYAyABKAdCiAHCSIQBCoEBCgtmaXhlZDMyLmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASgwcKAmd0GAQgASgHQvQGwkjwBgp2CgpmaXhlZDMyLmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwqvAQoNZml4ZWQzMi5ndF9sdBqdAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKtwEKF2ZpeGVkMzIuZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvwEKDmZpeGVkMzIuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrHAQoYZml4ZWQzMi5ndF9sdGVfZXhjbHVzaXZlGqoBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLPBwoDZ3RlGAUgASgHQr8Hwki7BwqEAQoLZml4ZWQzMi5ndGUadSFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwq+AQoOZml4ZWQzMi5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxgEKGGZpeGVkMzIuZ3RlX2x0X2V4Y2x1c2l2ZRqpAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzgEKD2ZpeGVkMzIuZ3RlX2x0ZRq6AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrWAQoZZml4ZWQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ7CgJpbhgGIAMoB0JvwkhsCmoKCmZpeGVkMzIuaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEnIKBm5vdF9pbhgHIAMoB0JiwkhfCl0KDmZpeGVkMzIubm90X2luGkt0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICdtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLQoHZXhhbXBsZRgIIAMoB0IcwkgZChcKD2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4i0xQKDEZpeGVkNjRSdWxlcxJ/CgVjb25zdBgBIAEoBkJwwkhtCmsKDWZpeGVkNjQuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKGAQoCbHQYAiABKAZCeMJIdQpzCgpmaXhlZDY0Lmx0GmUhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ211c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEpgBCgNsdGUYAyABKAZCiAHCSIQBCoEBCgtmaXhlZDY0Lmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASgwcKAmd0GAQgASgGQvQGwkjwBgp2CgpmaXhlZDY0Lmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwqvAQoNZml4ZWQ2NC5ndF9sdBqdAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKtwEKF2ZpeGVkNjQuZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvwEKDmZpeGVkNjQuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrHAQoYZml4ZWQ2NC5ndF9sdGVfZXhjbHVzaXZlGqoBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLPBwoDZ3RlGAUgASgGQr8Hwki7BwqEAQoLZml4ZWQ2NC5ndGUadSFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwq+AQoOZml4ZWQ2NC5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxgEKGGZpeGVkNjQuZ3RlX2x0X2V4Y2x1c2l2ZRqpAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzgEKD2ZpeGVkNjQuZ3RlX2x0ZRq6AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrWAQoZZml4ZWQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ7CgJpbhgGIAMoBkJvwkhsCmoKCmZpeGVkNjQuaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEnIKBm5vdF9pbhgHIAMoBkJiwkhfCl0KDmZpeGVkNjQubm90X2luGkt0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICdtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLQoHZXhhbXBsZRgIIAMoBkIcwkgZChcKD2ZpeGVkNjQuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4i5RQKDVNGaXhlZDMyUnVsZXMSgAEKBWNvbnN0GAEgASgPQnHCSG4KbAoOc2ZpeGVkMzIuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKHAQoCbHQYAiABKA9CecJIdgp0CgtzZml4ZWQzMi5sdBplIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICdtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKZAQoDbHRlGAMgASgPQokBwkiFAQqCAQoMc2ZpeGVkMzIubHRlGnIhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ211c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKIBwoCZ3QYBCABKA9C+QbCSPUGCncKC3NmaXhlZDMyLmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwqwAQoOc2ZpeGVkMzIuZ3RfbHQanQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCrgBChhzZml4ZWQzMi5ndF9sdF9leGNsdXNpdmUamwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrAAQoPc2ZpeGVkMzIuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrIAQoZc2ZpeGVkMzIuZ3RfbHRlX2V4Y2x1c2l2ZRqqAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES1AcKA2d0ZRgFIAEoD0LEB8JIwAcKhQEKDHNmaXhlZDMyLmd0ZRp1IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCr8BCg9zZml4ZWQzMi5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxwEKGXNmaXhlZDMyLmd0ZV9sdF9leGNsdXNpdmUaqQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs8BChBzZml4ZWQzMi5ndGVfbHRlGroBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCtcBChpzZml4ZWQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ8CgJpbhgGIAMoD0JwwkhtCmsKC3NmaXhlZDMyLmluGlwhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICdtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJzCgZub3RfaW4YByADKA9CY8JIYApeCg9zZml4ZWQzMi5ub3RfaW4aS3RoaXMgaW4gcnVsZXMubm90X2luID8gJ211c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIuCgdleGFtcGxlGAggAygPQh3CSBoKGAoQc2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4i5RQKDVNGaXhlZDY0UnVsZXMSgAEKBWNvbnN0GAEgASgQQnHCSG4KbAoOc2ZpeGVkNjQuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKHAQoCbHQYAiABKBBCecJIdgp0CgtzZml4ZWQ2NC5sdBplIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICdtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKZAQoDbHRlGAMgASgQQokBwkiFAQqCAQoMc2ZpeGVkNjQubHRlGnIhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ211c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKIBwoCZ3QYBCABKBBC+QbCSPUGCncKC3NmaXhlZDY0Lmd0GmghaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwqwAQoOc2ZpeGVkNjQuZ3RfbHQanQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCrgBChhzZml4ZWQ2NC5ndF9sdF9leGNsdXNpdmUamwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrAAQoPc2ZpeGVkNjQuZ3RfbHRlGqwBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrIAQoZc2ZpeGVkNjQuZ3RfbHRlX2V4Y2x1c2l2ZRqqAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES1AcKA2d0ZRgFIAEoEELEB8JIwAcKhQEKDHNmaXhlZDY0Lmd0ZRp1IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCr8BCg9zZml4ZWQ2NC5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKxwEKGXNmaXhlZDY0Lmd0ZV9sdF9leGNsdXNpdmUaqQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs8BChBzZml4ZWQ2NC5ndGVfbHRlGroBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCtcBChpzZml4ZWQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ8CgJpbhgGIAMoEEJwwkhtCmsKC3NmaXhlZDY0LmluGlwhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICdtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJzCgZub3RfaW4YByADKBBCY8JIYApeCg9zZml4ZWQ2NC5ub3RfaW4aS3RoaXMgaW4gcnVsZXMubm90X2luID8gJ211c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIuCgdleGFtcGxlGAggAygQQh3CSBoKGAoQc2ZpeGVkNjQuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iwAEKCUJvb2xSdWxlcxJ8CgVjb25zdBgBIAEoCEJtwkhqCmgKCmJvb2wuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxIqCgdleGFtcGxlGAIgAygIQhnCSBYKFAoMYm9vbC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAIi6jwKC1N0cmluZ1J1bGVzEoABCgVjb25zdBgBIAEoCUJxwkhuCmwKDHN0cmluZy5jb25zdBpcdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAnbXVzdCBlcXVhbCBgJXNgJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycScQoDbGVuGBMgASgEQmTCSGEKXwoKc3RyaW5nLmxlbhpRdWludCh0aGlzLnNpemUoKSkgIT0gcnVsZXMubGVuID8gJ211c3QgYmUgJXMgY2hhcmFjdGVycycuZm9ybWF0KFtydWxlcy5sZW5dKSA6ICcnEokBCgdtaW5fbGVuGAIgASgEQnjCSHUKcwoOc3RyaW5nLm1pbl9sZW4aYXVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX2xlbiA/ICdtdXN0IGJlIGF0IGxlYXN0ICVzIGNoYXJhY3RlcnMnLmZvcm1hdChbcnVsZXMubWluX2xlbl0pIDogJycSiAEKB21heF9sZW4YAyABKARCd8JIdApyCg5zdHJpbmcubWF4X2xlbhpgdWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfbGVuID8gJ211c3QgYmUgYXQgbW9zdCAlcyBjaGFyYWN0ZXJzJy5mb3JtYXQoW3J1bGVzLm1heF9sZW5dKSA6ICcnEosBCglsZW5fYnl0ZXMYFCABKARCeMJIdQpzChBzdHJpbmcubGVuX2J5dGVzGl91aW50KGJ5dGVzKHRoaXMpLnNpemUoKSkgIT0gcnVsZXMubGVuX2J5dGVzID8gJ211c3QgYmUgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubGVuX2J5dGVzXSkgOiAnJxKUAQoJbWluX2J5dGVzGAQgASgEQoABwkh9CnsKEHN0cmluZy5taW5fYnl0ZXMaZ3VpbnQoYnl0ZXModGhpcykuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9ieXRlcyA/ICdtdXN0IGJlIGF0IGxlYXN0ICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLm1pbl9ieXRlc10pIDogJycSkgEKCW1heF9ieXRlcxgFIAEoBEJ/wkh8CnoKEHN0cmluZy5tYXhfYnl0ZXMaZnVpbnQoYnl0ZXModGhpcykuc2l6ZSgpKSA+IHJ1bGVzLm1heF9ieXRlcyA/ICdtdXN0IGJlIGF0IG1vc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWF4X2J5dGVzXSkgOiAnJxKHAQoHcGF0dGVybhgGIAEoCUJ2wkhzCnEKDnN0cmluZy5wYXR0ZXJuGl8hdGhpcy5tYXRjaGVzKHJ1bGVzLnBhdHRlcm4pID8gJ2RvZXMgbm90IG1hdGNoIHJlZ2V4IHBhdHRlcm4gYCVzYCcuZm9ybWF0KFtydWxlcy5wYXR0ZXJuXSkgOiAnJxJ+CgZwcmVmaXgYByABKAlCbsJIawppCg1zdHJpbmcucHJlZml4GlghdGhpcy5zdGFydHNXaXRoKHJ1bGVzLnByZWZpeCkgPyAnZG9lcyBub3QgaGF2ZSBwcmVmaXggYCVzYCcuZm9ybWF0KFtydWxlcy5wcmVmaXhdKSA6ICcnEnwKBnN1ZmZpeBgIIAEoCUJswkhpCmcKDXN0cmluZy5zdWZmaXgaViF0aGlzLmVuZHNXaXRoKHJ1bGVzLnN1ZmZpeCkgPyAnZG9lcyBub3QgaGF2ZSBzdWZmaXggYCVzYCcuZm9ybWF0KFtydWxlcy5zdWZmaXhdKSA6ICcnEooBCghjb250YWlucxgJIAEoCUJ4wkh1CnMKD3N0cmluZy5jb250YWlucxpgIXRoaXMuY29udGFpbnMocnVsZXMuY29udGFpbnMpID8gJ2RvZXMgbm90IGNvbnRhaW4gc3Vic3RyaW5nIGAlc2AnLmZvcm1hdChbcnVsZXMuY29udGFpbnNdKSA6ICcnEpEBCgxub3RfY29udGFpbnMYFyABKAlCe8JIeAp2ChNzdHJpbmcubm90X2NvbnRhaW5zGl90aGlzLmNvbnRhaW5zKHJ1bGVzLm5vdF9jb250YWlucykgPyAnY29udGFpbnMgc3Vic3RyaW5nIGAlc2AnLmZvcm1hdChbcnVsZXMubm90X2NvbnRhaW5zXSkgOiAnJxJ6CgJpbhgKIAMoCUJuwkhrCmkKCXN0cmluZy5pbhpcISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAnbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycScQoGbm90X2luGAsgAygJQmHCSF4KXAoNc3RyaW5nLm5vdF9pbhpLdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAnbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEtkBCgVlbWFpbBgMIAEoCELHAcJIwwEKWwoMc3RyaW5nLmVtYWlsEh1tdXN0IGJlIGEgdmFsaWQgZW1haWwgYWRkcmVzcxosIXJ1bGVzLmVtYWlsIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0VtYWlsKCkKZAoSc3RyaW5nLmVtYWlsX2VtcHR5EjJ2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgZW1haWwgYWRkcmVzcxoaIXJ1bGVzLmVtYWlsIHx8IHRoaXMgIT0gJydIABLhAQoIaG9zdG5hbWUYDSABKAhCzAHCSMgBCl8KD3N0cmluZy5ob3N0bmFtZRIYbXVzdCBiZSBhIHZhbGlkIGhvc3RuYW1lGjIhcnVsZXMuaG9zdG5hbWUgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSG9zdG5hbWUoKQplChVzdHJpbmcuaG9zdG5hbWVfZW1wdHkSLXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBob3N0bmFtZRodIXJ1bGVzLmhvc3RuYW1lIHx8IHRoaXMgIT0gJydIABLBAQoCaXAYDiABKAhCsgHCSK4BCk8KCXN0cmluZy5pcBIabXVzdCBiZSBhIHZhbGlkIElQIGFkZHJlc3MaJiFydWxlcy5pcCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcCgpClsKD3N0cmluZy5pcF9lbXB0eRIvdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQIGFkZHJlc3MaFyFydWxlcy5pcCB8fCB0aGlzICE9ICcnSAAS0AEKBGlwdjQYDyABKAhCvwHCSLsBClYKC3N0cmluZy5pcHY0EhxtdXN0IGJlIGEgdmFsaWQgSVB2NCBhZGRyZXNzGikhcnVsZXMuaXB2NCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcCg0KQphChFzdHJpbmcuaXB2NF9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjQgYWRkcmVzcxoZIXJ1bGVzLmlwdjQgfHwgdGhpcyAhPSAnJ0gAEtABCgRpcHY2GBAgASgIQr8Bwki7AQpWCgtzdHJpbmcuaXB2NhIcbXVzdCBiZSBhIHZhbGlkIElQdjYgYWRkcmVzcxopIXJ1bGVzLmlwdjYgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXAoNikKYQoRc3RyaW5nLmlwdjZfZW1wdHkSMXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY2IGFkZHJlc3MaGSFydWxlcy5pcHY2IHx8IHRoaXMgIT0gJydIABK5AQoDdXJpGBEgASgIQqkBwkilAQpLCgpzdHJpbmcudXJpEhNtdXN0IGJlIGEgdmFsaWQgVVJJGighcnVsZXMudXJpIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc1VyaSgpClYKEHN0cmluZy51cmlfZW1wdHkSKHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBVUkkaGCFydWxlcy51cmkgfHwgdGhpcyAhPSAnJ0gAEmoKB3VyaV9yZWYYEiABKAhCV8JIVApSCg5zdHJpbmcudXJpX3JlZhIdbXVzdCBiZSBhIHZhbGlkIFVSSSBSZWZlcmVuY2UaISFydWxlcy51cmlfcmVmIHx8IHRoaXMuaXNVcmlSZWYoKUgAEokCCgdhZGRyZXNzGBUgASgIQvUBwkjxAQp7Cg5zdHJpbmcuYWRkcmVzcxInbXVzdCBiZSBhIHZhbGlkIGhvc3RuYW1lLCBvciBpcCBhZGRyZXNzGkAhcnVsZXMuYWRkcmVzcyB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNIb3N0bmFtZSgpIHx8IHRoaXMuaXNJcCgpCnIKFHN0cmluZy5hZGRyZXNzX2VtcHR5Ejx2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgaG9zdG5hbWUsIG9yIGlwIGFkZHJlc3MaHCFydWxlcy5hZGRyZXNzIHx8IHRoaXMgIT0gJydIABKSAgoEdXVpZBgWIAEoCEKBAsJI/QEKnwEKC3N0cmluZy51dWlkEhRtdXN0IGJlIGEgdmFsaWQgVVVJRBp6IXJ1bGVzLnV1aWQgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLm1hdGNoZXMoJ15bMC05YS1mQS1GXXs4fS1bMC05YS1mQS1GXXs0fS1bMC05YS1mQS1GXXs0fS1bMC05YS1mQS1GXXs0fS1bMC05YS1mQS1GXXsxMn0kJykKWQoRc3RyaW5nLnV1aWRfZW1wdHkSKXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBVVUlEGhkhcnVsZXMudXVpZCB8fCB0aGlzICE9ICcnSAAS6gEKBXR1dWlkGCEgASgIQtgBwkjUAQptCgxzdHJpbmcudHV1aWQSHG11c3QgYmUgYSB2YWxpZCB0cmltbWVkIFVVSUQaPyFydWxlcy50dXVpZCB8fCB0aGlzID09ICcnIHx8IHRoaXMubWF0Y2hlcygnXlswLTlhLWZBLUZdezMyfSQnKQpjChJzdHJpbmcudHV1aWRfZW1wdHkSMXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCB0cmltbWVkIFVVSUQaGiFydWxlcy50dXVpZCB8fCB0aGlzICE9ICcnSAASkAIKEWlwX3dpdGhfcHJlZml4bGVuGBogASgIQvIBwkjuAQpyChhzdHJpbmcuaXBfd2l0aF9wcmVmaXhsZW4SGW11c3QgYmUgYSB2YWxpZCBJUCBwcmVmaXgaOyFydWxlcy5pcF93aXRoX3ByZWZpeGxlbiB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCgpCngKHnN0cmluZy5pcF93aXRoX3ByZWZpeGxlbl9lbXB0eRIudmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQIHByZWZpeBomIXJ1bGVzLmlwX3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgIT0gJydIABLJAgoTaXB2NF93aXRoX3ByZWZpeGxlbhgbIAEoCEKpAsJIpQIKjQEKGnN0cmluZy5pcHY0X3dpdGhfcHJlZml4bGVuEi9tdXN0IGJlIGEgdmFsaWQgSVB2NCBhZGRyZXNzIHdpdGggcHJlZml4IGxlbmd0aBo+IXJ1bGVzLmlwdjRfd2l0aF9wcmVmaXhsZW4gfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXBQcmVmaXgoNCkKkgEKIHN0cmluZy5pcHY0X3dpdGhfcHJlZml4bGVuX2VtcHR5EkR2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NCBhZGRyZXNzIHdpdGggcHJlZml4IGxlbmd0aBooIXJ1bGVzLmlwdjRfd2l0aF9wcmVmaXhsZW4gfHwgdGhpcyAhPSAnJ0gAEskCChNpcHY2X3dpdGhfcHJlZml4bGVuGBwgASgIQqkCwkilAgqNAQoac3RyaW5nLmlwdjZfd2l0aF9wcmVmaXhsZW4SL211c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGj4hcnVsZXMuaXB2Nl93aXRoX3ByZWZpeGxlbiB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCg2KQqSAQogc3RyaW5nLmlwdjZfd2l0aF9wcmVmaXhsZW5fZW1wdHkSRHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY2IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGighcnVsZXMuaXB2Nl93aXRoX3ByZWZpeGxlbiB8fCB0aGlzICE9ICcnSAAS7AEKCWlwX3ByZWZpeBgdIAEoCELWAcJI0gEKZgoQc3RyaW5nLmlwX3ByZWZpeBIZbXVzdCBiZSBhIHZhbGlkIElQIHByZWZpeBo3IXJ1bGVzLmlwX3ByZWZpeCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCh0cnVlKQpoChZzdHJpbmcuaXBfcHJlZml4X2VtcHR5Ei52YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVAgcHJlZml4Gh4hcnVsZXMuaXBfcHJlZml4IHx8IHRoaXMgIT0gJydIABL9AQoLaXB2NF9wcmVmaXgYHiABKAhC5QHCSOEBCm8KEnN0cmluZy5pcHY0X3ByZWZpeBIbbXVzdCBiZSBhIHZhbGlkIElQdjQgcHJlZml4GjwhcnVsZXMuaXB2NF9wcmVmaXggfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXBQcmVmaXgoNCwgdHJ1ZSkKbgoYc3RyaW5nLmlwdjRfcHJlZml4X2VtcHR5EjB2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NCBwcmVmaXgaICFydWxlcy5pcHY0X3ByZWZpeCB8fCB0aGlzICE9ICcnSAAS/QEKC2lwdjZfcHJlZml4GB8gASgIQuUBwkjhAQpvChJzdHJpbmcuaXB2Nl9wcmVmaXgSG211c3QgYmUgYSB2YWxpZCBJUHY2IHByZWZpeBo8IXJ1bGVzLmlwdjZfcHJlZml4IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KDYsIHRydWUpCm4KGHN0cmluZy5pcHY2X3ByZWZpeF9lbXB0eRIwdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgcHJlZml4GiAhcnVsZXMuaXB2Nl9wcmVmaXggfHwgdGhpcyAhPSAnJ0gAEq8CCg1ob3N0X2FuZF9wb3J0GCAgASgIQpUCwkiRAgqTAQoUc3RyaW5nLmhvc3RfYW5kX3BvcnQSO211c3QgYmUgYSB2YWxpZCBob3N0IChob3N0bmFtZSBvciBJUCBhZGRyZXNzKSBhbmQgcG9ydCBwYWlyGj4hcnVsZXMuaG9zdF9hbmRfcG9ydCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNIb3N0QW5kUG9ydCh0cnVlKQp5ChpzdHJpbmcuaG9zdF9hbmRfcG9ydF9lbXB0eRI3dmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIGhvc3QgYW5kIHBvcnQgcGFpchoiIXJ1bGVzLmhvc3RfYW5kX3BvcnQgfHwgdGhpcyAhPSAnJ0gAEu4BCgR1bGlkGCMgASgIQt0BwkjZAQp8CgtzdHJpbmcudWxpZBIUbXVzdCBiZSBhIHZhbGlkIFVMSUQaVyFydWxlcy51bGlkIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5tYXRjaGVzKCdeWzAtN11bMC05QS1ISktNTlAtVFYtWmEtaGprbW5wLXR2LXpdezI1fSQnKQpZChFzdHJpbmcudWxpZF9lbXB0eRIpdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIFVMSUQaGSFydWxlcy51bGlkIHx8IHRoaXMgIT0gJydIABLUAgoMcHJvdG9idWZfZnFuGCUgASgIQrsCwki3AgqvAQoTc3RyaW5nLnByb3RvYnVmX2ZxbhItbXVzdCBiZSBhIHZhbGlkIGZ1bGx5LXF1YWxpZmllZCBQcm90b2J1ZiBuYW1lGmkhcnVsZXMucHJvdG9idWZfZnFuIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5tYXRjaGVzKCdeW0EtWmEtel9dW0EtWmEtel8wLTldKihcXC5bQS1aYS16X11bQS1aYS16XzAtOV0qKSokJykKggEKGXN0cmluZy5wcm90b2J1Zl9mcW5fZW1wdHkSQnZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBmdWxseS1xdWFsaWZpZWQgUHJvdG9idWYgbmFtZRohIXJ1bGVzLnByb3RvYnVmX2ZxbiB8fCB0aGlzICE9ICcnSAASkQMKEHByb3RvYnVmX2RvdF9mcW4YJiABKAhC9ALCSPACCs0BChdzdHJpbmcucHJvdG9idWZfZG90X2ZxbhJAbXVzdCBiZSBhIHZhbGlkIGZ1bGx5LXF1YWxpZmllZCBQcm90b2J1ZiBuYW1lIHdpdGggYSBsZWFkaW5nIGRvdBpwIXJ1bGVzLnByb3RvYnVmX2RvdF9mcW4gfHwgdGhpcyA9PSAnJyB8fCB0aGlzLm1hdGNoZXMoJ15cXC5bQS1aYS16X11bQS1aYS16XzAtOV0qKFxcLltBLVphLXpfXVtBLVphLXpfMC05XSopKiQnKQqdAQodc3RyaW5nLnByb3RvYnVmX2RvdF9mcW5fZW1wdHkSVXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBmdWxseS1xdWFsaWZpZWQgUHJvdG9idWYgbmFtZSB3aXRoIGEgbGVhZGluZyBkb3QaJSFydWxlcy5wcm90b2J1Zl9kb3RfZnFuIHx8IHRoaXMgIT0gJydIABKcBQoQd2VsbF9rbm93bl9yZWdleBgYIAEoDjIYLmJ1Zi52YWxpZGF0ZS5Lbm93blJlZ2V4QuUEwkjhBArqAQojc3RyaW5nLndlbGxfa25vd25fcmVnZXguaGVhZGVyX25hbWUSIG11c3QgYmUgYSB2YWxpZCBIVFRQIGhlYWRlciBuYW1lGqABcnVsZXMud2VsbF9rbm93bl9yZWdleCAhPSAxIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5tYXRjaGVzKCFoYXMocnVsZXMuc3RyaWN0KSB8fCBydWxlcy5zdHJpY3QgPydeOj9bMC05YS16QS1aISMkJSZcJyorLS5eX3x+XHg2MF0rJCcgOideW15cdTAwMDBcdTAwMEFcdTAwMERdKyQnKQqNAQopc3RyaW5nLndlbGxfa25vd25fcmVnZXguaGVhZGVyX25hbWVfZW1wdHkSNXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBIVFRQIGhlYWRlciBuYW1lGilydWxlcy53ZWxsX2tub3duX3JlZ2V4ICE9IDEgfHwgdGhpcyAhPSAnJwrhAQokc3RyaW5nLndlbGxfa25vd25fcmVnZXguaGVhZGVyX3ZhbHVlEiFtdXN0IGJlIGEgdmFsaWQgSFRUUCBoZWFkZXIgdmFsdWUalQFydWxlcy53ZWxsX2tub3duX3JlZ2V4ICE9IDIgfHwgdGhpcy5tYXRjaGVzKCFoYXMocnVsZXMuc3RyaWN0KSB8fCBydWxlcy5zdHJpY3QgPydeW15cdTAwMDAtXHUwMDA4XHUwMDBBLVx1MDAxRlx1MDA3Rl0qJCcgOideW15cdTAwMDBcdTAwMEFcdTAwMERdKiQnKUgAEg4KBnN0cmljdBgZIAEoCBIsCgdleGFtcGxlGCIgAygJQhvCSBgKFgoOc3RyaW5nLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkIMCgp3ZWxsX2tub3duIt0RCgpCeXRlc1J1bGVzEnoKBWNvbnN0GAEgASgMQmvCSGgKZgoLYnl0ZXMuY29uc3QaV3RoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgYmUgJXgnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxJrCgNsZW4YDSABKARCXsJIWwpZCglieXRlcy5sZW4aTHVpbnQodGhpcy5zaXplKCkpICE9IHJ1bGVzLmxlbiA/ICdtdXN0IGJlICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLmxlbl0pIDogJycSgwEKB21pbl9sZW4YAiABKARCcsJIbwptCg1ieXRlcy5taW5fbGVuGlx1aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9sZW4gPyAnbXVzdCBiZSBhdCBsZWFzdCAlcyBieXRlcycuZm9ybWF0KFtydWxlcy5taW5fbGVuXSkgOiAnJxKCAQoHbWF4X2xlbhgDIAEoBEJxwkhuCmwKDWJ5dGVzLm1heF9sZW4aW3VpbnQodGhpcy5zaXplKCkpID4gcnVsZXMubWF4X2xlbiA/ICdtdXN0IGJlIGF0IG1vc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWF4X2xlbl0pIDogJycSigEKB3BhdHRlcm4YBCABKAlCecJIdgp0Cg1ieXRlcy5wYXR0ZXJuGmMhc3RyaW5nKHRoaXMpLm1hdGNoZXMocnVsZXMucGF0dGVybikgPyAnbXVzdCBtYXRjaCByZWdleCBwYXR0ZXJuIGAlc2AnLmZvcm1hdChbcnVsZXMucGF0dGVybl0pIDogJycSewoGcHJlZml4GAUgASgMQmvCSGgKZgoMYnl0ZXMucHJlZml4GlYhdGhpcy5zdGFydHNXaXRoKHJ1bGVzLnByZWZpeCkgPyAnZG9lcyBub3QgaGF2ZSBwcmVmaXggJXgnLmZvcm1hdChbcnVsZXMucHJlZml4XSkgOiAnJxJ5CgZzdWZmaXgYBiABKAxCacJIZgpkCgxieXRlcy5zdWZmaXgaVCF0aGlzLmVuZHNXaXRoKHJ1bGVzLnN1ZmZpeCkgPyAnZG9lcyBub3QgaGF2ZSBzdWZmaXggJXgnLmZvcm1hdChbcnVsZXMuc3VmZml4XSkgOiAnJxJ9Cghjb250YWlucxgHIAEoDEJrwkhoCmYKDmJ5dGVzLmNvbnRhaW5zGlQhdGhpcy5jb250YWlucyhydWxlcy5jb250YWlucykgPyAnZG9lcyBub3QgY29udGFpbiAleCcuZm9ybWF0KFtydWxlcy5jb250YWluc10pIDogJycSoQEKAmluGAggAygMQpQBwkiQAQqNAQoIYnl0ZXMuaW4agAFnZXRGaWVsZChydWxlcywgJ2luJykuc2l6ZSgpID4gMCAmJiAhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICdtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJwCgZub3RfaW4YCSADKAxCYMJIXQpbCgxieXRlcy5ub3RfaW4aS3RoaXMgaW4gcnVsZXMubm90X2luID8gJ211c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxLlAQoCaXAYCiABKAhC1gHCSNIBCm4KCGJ5dGVzLmlwEhptdXN0IGJlIGEgdmFsaWQgSVAgYWRkcmVzcxpGIXJ1bGVzLmlwIHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNCB8fCB0aGlzLnNpemUoKSA9PSAxNgpgCg5ieXRlcy5pcF9lbXB0eRIvdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQIGFkZHJlc3MaHSFydWxlcy5pcCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS3gEKBGlwdjQYCyABKAhCzQHCSMkBCl8KCmJ5dGVzLmlwdjQSHG11c3QgYmUgYSB2YWxpZCBJUHY0IGFkZHJlc3MaMyFydWxlcy5pcHY0IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNApmChBieXRlcy5pcHY0X2VtcHR5EjF2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NCBhZGRyZXNzGh8hcnVsZXMuaXB2NCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS3wEKBGlwdjYYDCABKAhCzgHCSMoBCmAKCmJ5dGVzLmlwdjYSHG11c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3MaNCFydWxlcy5pcHY2IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gMTYKZgoQYnl0ZXMuaXB2Nl9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgYWRkcmVzcxofIXJ1bGVzLmlwdjYgfHwgdGhpcy5zaXplKCkgIT0gMEgAEs8BCgR1dWlkGA8gASgIQr4Bwki6AQpYCgpieXRlcy51dWlkEhRtdXN0IGJlIGEgdmFsaWQgVVVJRBo0IXJ1bGVzLnV1aWQgfHwgdGhpcy5zaXplKCkgPT0gMCB8fCB0aGlzLnNpemUoKSA9PSAxNgpeChBieXRlcy51dWlkX2VtcHR5Eil2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgVVVJRBofIXJ1bGVzLnV1aWQgfHwgdGhpcy5zaXplKCkgIT0gMEgAEisKB2V4YW1wbGUYDiADKAxCGsJIFwoVCg1ieXRlcy5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCDAoKd2VsbF9rbm93biLBAwoJRW51bVJ1bGVzEnwKBWNvbnN0GAEgASgFQm3CSGoKaAoKZW51bS5jb25zdBpadGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAnbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEhQKDGRlZmluZWRfb25seRgCIAEoCBJ4CgJpbhgDIAMoBUJswkhpCmcKB2VudW0uaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEm8KBm5vdF9pbhgEIAMoBUJfwkhcCloKC2VudW0ubm90X2luGkt0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICdtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKgoHZXhhbXBsZRgFIAMoBUIZwkgWChQKDGVudW0uZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACIu0DCg1SZXBlYXRlZFJ1bGVzEpYBCgltaW5faXRlbXMYASABKARCggHCSH8KfQoScmVwZWF0ZWQubWluX2l0ZW1zGmd1aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9pdGVtcyA/ICdtdXN0IGNvbnRhaW4gYXQgbGVhc3QgJWQgaXRlbShzKScuZm9ybWF0KFtydWxlcy5taW5faXRlbXNdKSA6ICcnEpwBCgltYXhfaXRlbXMYAiABKARCiAHCSIQBCoEBChJyZXBlYXRlZC5tYXhfaXRlbXMaa3VpbnQodGhpcy5zaXplKCkpID4gcnVsZXMubWF4X2l0ZW1zID8gJ211c3QgY29udGFpbiBubyBtb3JlIHRoYW4gJXMgaXRlbShzKScuZm9ybWF0KFtydWxlcy5tYXhfaXRlbXNdKSA6ICcnEnAKBnVuaXF1ZRgDIAEoCEJgwkhdClsKD3JlcGVhdGVkLnVuaXF1ZRIocmVwZWF0ZWQgdmFsdWUgbXVzdCBjb250YWluIHVuaXF1ZSBpdGVtcxoeIXJ1bGVzLnVuaXF1ZSB8fCB0aGlzLnVuaXF1ZSgpEicKBWl0ZW1zGAQgASgLMhguYnVmLnZhbGlkYXRlLkZpZWxkUnVsZXMqCQjoBxCAgICAAiKKAwoITWFwUnVsZXMSjwEKCW1pbl9wYWlycxgBIAEoBEJ8wkh5CncKDW1hcC5taW5fcGFpcnMaZnVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX3BhaXJzID8gJ21hcCBtdXN0IGJlIGF0IGxlYXN0ICVkIGVudHJpZXMnLmZvcm1hdChbcnVsZXMubWluX3BhaXJzXSkgOiAnJxKOAQoJbWF4X3BhaXJzGAIgASgEQnvCSHgKdgoNbWFwLm1heF9wYWlycxpldWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfcGFpcnMgPyAnbWFwIG11c3QgYmUgYXQgbW9zdCAlZCBlbnRyaWVzJy5mb3JtYXQoW3J1bGVzLm1heF9wYWlyc10pIDogJycSJgoEa2V5cxgEIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzEigKBnZhbHVlcxgFIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzKgkI6AcQgICAgAIiJgoIQW55UnVsZXMSCgoCaW4YAiADKAkSDgoGbm90X2luGAMgAygJIr8WCg1EdXJhdGlvblJ1bGVzEpsBCgVjb25zdBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkJxwkhuCmwKDmR1cmF0aW9uLmNvbnN0Glp0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICdtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSogEKAmx0GAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQnnCSHYKdAoLZHVyYXRpb24ubHQaZSFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAnbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAAStAEKA2x0ZRgEIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkKJAcJIhQEKggEKDGR1cmF0aW9uLmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASowcKAmd0GAUgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQvkGwkj1Bgp3CgtkdXJhdGlvbi5ndBpoIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKsAEKDmR1cmF0aW9uLmd0X2x0Gp0BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq4AQoYZHVyYXRpb24uZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwAEKD2R1cmF0aW9uLmd0X2x0ZRqsAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKyAEKGWR1cmF0aW9uLmd0X2x0ZV9leGNsdXNpdmUaqgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEu8HCgNndGUYBiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CxAfCSMAHCoUBCgxkdXJhdGlvbi5ndGUadSFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwq/AQoPZHVyYXRpb24uZ3RlX2x0GqsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCscBChlkdXJhdGlvbi5ndGVfbHRfZXhjbHVzaXZlGqkBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrPAQoQZHVyYXRpb24uZ3RlX2x0ZRq6AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrXAQoaZHVyYXRpb24uZ3RlX2x0ZV9leGNsdXNpdmUauAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESlwEKAmluGAcgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQnDCSG0KawoLZHVyYXRpb24uaW4aXCEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ211c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEo4BCgZub3RfaW4YCCADKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CY8JIYApeCg9kdXJhdGlvbi5ub3RfaW4aS3RoaXMgaW4gcnVsZXMubm90X2luID8gJ211c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJJCgdleGFtcGxlGAkgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQh3CSBoKGAoQZHVyYXRpb24uZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4i6wUKDkZpZWxkTWFza1J1bGVzErkBCgVjb25zdBgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCjQHCSIkBCoYBChBmaWVsZF9tYXNrLmNvbnN0GnJ0aGlzLnBhdGhzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKS5wYXRocyA/ICdtdXN0IGVxdWFsIHBhdGhzICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKS5wYXRoc10pIDogJycS0wEKAmluGAIgAygJQsYBwkjCAQq/AQoNZmllbGRfbWFzay5pbhqtASF0aGlzLnBhdGhzLmFsbChwLCBwIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSB8fCBnZXRGaWVsZChydWxlcywgJ2luJykuZXhpc3RzKGYsIHAuc3RhcnRzV2l0aChmKycuJykpKSA/ICdtdXN0IG9ubHkgY29udGFpbiBwYXRocyBpbiAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEu0BCgZub3RfaW4YAyADKAlC3AHCSNgBCtUBChFmaWVsZF9tYXNrLm5vdF9pbhq/ASF0aGlzLnBhdGhzLmFsbChwLCAhKHAgaW4gZ2V0RmllbGQocnVsZXMsICdub3RfaW4nKSB8fCBnZXRGaWVsZChydWxlcywgJ25vdF9pbicpLmV4aXN0cyhmLCBwLnN0YXJ0c1dpdGgoZisnLicpKSkpID8gJ211c3Qgbm90IGNvbnRhaW4gYW55IHBhdGhzIGluICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnbm90X2luJyldKSA6ICcnEkwKB2V4YW1wbGUYBCADKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQh/CSBwKGgoSZmllbGRfbWFzay5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAIisBcKDlRpbWVzdGFtcFJ1bGVzEp0BCgVjb25zdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCcsJIbwptCg90aW1lc3RhbXAuY29uc3QaWnRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ211c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKkAQoCbHQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQnrCSHcKdQoMdGltZXN0YW1wLmx0GmUhaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ211c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAErYBCgNsdGUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQooBwkiGAQqDAQoNdGltZXN0YW1wLmx0ZRpyIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICdtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASZgoGbHRfbm93GAcgASgIQlTCSFEKTwoQdGltZXN0YW1wLmx0X25vdxo7KHJ1bGVzLmx0X25vdyAmJiB0aGlzID4gbm93KSA/ICdtdXN0IGJlIGxlc3MgdGhhbiBub3cnIDogJydIABKpBwoCZ3QYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQv4Gwkj6Bgp4Cgx0aW1lc3RhbXAuZ3QaaCFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCrEBCg90aW1lc3RhbXAuZ3RfbHQanQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCrkBChl0aW1lc3RhbXAuZ3RfbHRfZXhjbHVzaXZlGpsBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwQEKEHRpbWVzdGFtcC5ndF9sdGUarAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICdtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCskBChp0aW1lc3RhbXAuZ3RfbHRlX2V4Y2x1c2l2ZRqqAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES9QcKA2d0ZRgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCyQfCSMUHCoYBCg10aW1lc3RhbXAuZ3RlGnUhaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwAEKEHRpbWVzdGFtcC5ndGVfbHQaqwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKyAEKGnRpbWVzdGFtcC5ndGVfbHRfZXhjbHVzaXZlGqkBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrQAQoRdGltZXN0YW1wLmd0ZV9sdGUaugFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAnbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2AEKG3RpbWVzdGFtcC5ndGVfbHRlX2V4Y2x1c2l2ZRq4AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJpCgZndF9ub3cYCCABKAhCV8JIVApSChB0aW1lc3RhbXAuZ3Rfbm93Gj4ocnVsZXMuZ3Rfbm93ICYmIHRoaXMgPCBub3cpID8gJ211c3QgYmUgZ3JlYXRlciB0aGFuIG5vdycgOiAnJ0gBErEBCgZ3aXRoaW4YCSABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25ChQHCSIEBCn8KEHRpbWVzdGFtcC53aXRoaW4aa3RoaXMgPCBub3ctcnVsZXMud2l0aGluIHx8IHRoaXMgPiBub3crcnVsZXMud2l0aGluID8gJ211c3QgYmUgd2l0aGluICVzIG9mIG5vdycuZm9ybWF0KFtydWxlcy53aXRoaW5dKSA6ICcnEksKB2V4YW1wbGUYCiADKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQh7CSBsKGQoRdGltZXN0YW1wLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIjkKClZpb2xhdGlvbnMSKwoKdmlvbGF0aW9ucxgBIAMoCzIXLmJ1Zi52YWxpZGF0ZS5WaW9sYXRpb24inwEKCVZpb2xhdGlvbhImCgVmaWVsZBgFIAEoCzIXLmJ1Zi52YWxpZGF0ZS5GaWVsZFBhdGgSJQoEcnVsZRgGIAEoCzIXLmJ1Zi52YWxpZGF0ZS5GaWVsZFBhdGgSDwoHcnVsZV9pZBgCIAEoCRIPCgdtZXNzYWdlGAMgASgJEg8KB2Zvcl9rZXkYBCABKAhKBAgBEAJSCmZpZWxkX3BhdGgiPQoJRmllbGRQYXRoEjAKCGVsZW1lbnRzGAEgAygLMh4uYnVmLnZhbGlkYXRlLkZpZWxkUGF0aEVsZW1lbnQi6QIKEEZpZWxkUGF0aEVsZW1lbnQSFAoMZmllbGRfbnVtYmVyGAEgASgFEhIKCmZpZWxkX25hbWUYAiABKAkSPgoKZmllbGRfdHlwZRgDIAEoDjIqLmdvb2dsZS5wcm90b2J1Zi5GaWVsZERlc2NyaXB0b3JQcm90by5UeXBlEjwKCGtleV90eXBlGAQgASgOMiouZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLlR5cGUSPgoKdmFsdWVfdHlwZRgFIAEoDjIqLmdvb2dsZS5wcm90b2J1Zi5GaWVsZERlc2NyaXB0b3JQcm90by5UeXBlEg8KBWluZGV4GAYgASgESAASEgoIYm9vbF9rZXkYByABKAhIABIRCgdpbnRfa2V5GAggASgDSAASEgoIdWludF9rZXkYCSABKARIABIUCgpzdHJpbmdfa2V5GAogASgJSABCCwoJc3Vic2NyaXB0KqEBCgZJZ25vcmUSFgoSSUdOT1JFX1VOU1BFQ0lGSUVEEAASGAoUSUdOT1JFX0lGX1pFUk9fVkFMVUUQARIRCg1JR05PUkVfQUxXQVlTEAMiBAgCEAIqDElHTk9SRV9FTVBUWSoOSUdOT1JFX0RFRkFVTFQqF0lHTk9SRV9JRl9ERUZBVUxUX1ZBTFVFKhVJR05PUkVfSUZfVU5QT1BVTEFURUQqbgoKS25vd25SZWdleBIbChdLTk9XTl9SRUdFWF9VTlNQRUNJRklFRBAAEiAKHEtOT1dOX1JFR0VYX0hUVFBfSEVBREVSX05BTUUQARIhCh1LTk9XTl9SRUdFWF9IVFRQX0hFQURFUl9WQUxVRRACOlYKB21lc3NhZ2USHy5nb29nbGUucHJvdG9idWYuTWVzc2FnZU9wdGlvbnMYhwkgASgLMhouYnVmLnZhbGlkYXRlLk1lc3NhZ2VSdWxlc1IHbWVzc2FnZTpOCgVvbmVvZhIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnMYhwkgASgLMhguYnVmLnZhbGlkYXRlLk9uZW9mUnVsZXNSBW9uZW9mOk4KBWZpZWxkEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxiHCSABKAsyGC5idWYudmFsaWRhdGUuRmllbGRSdWxlc1IFZmllbGQ6XQoKcHJlZGVmaW5lZBIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMYiAkgASgLMh0uYnVmLnZhbGlkYXRlLlByZWRlZmluZWRSdWxlc1IKcHJlZGVmaW5lZEJuChJidWlsZC5idWYudmFsaWRhdGVCDVZhbGlkYXRlUHJvdG9QAVpHYnVmLmJ1aWxkL2dlbi9nby9idWZidWlsZC9wcm90b3ZhbGlkYXRlL3Byb3RvY29sYnVmZmVycy9nby9idWYvdmFsaWRhdGU", [file_google_protobuf_descriptor, file_google_protobuf_duration, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + +/** + * `Rule` represents a validation rule written in the Common Expression + * Language (CEL) syntax. Each Rule includes a unique identifier, an + * optional error message, and the CEL expression to evaluate. For more + * information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + * + * ```proto + * message Foo { + * option (buf.validate.message).cel = { + * id: "foo.bar" + * message: "bar must be greater than 0" + * expression: "this.bar > 0" + * }; + * int32 bar = 1; + * } + * ``` + * + * @generated from message buf.validate.Rule + */ +export type Rule = Message<"buf.validate.Rule"> & { + /** + * `id` is a string that serves as a machine-readable name for this Rule. + * It should be unique within its scope, which could be either a message or a field. + * + * @generated from field: optional string id = 1; + */ + id: string; + + /** + * `message` is an optional field that provides a human-readable error message + * for this Rule when the CEL expression evaluates to false. If a + * non-empty message is provided, any strings resulting from the CEL + * expression evaluation are ignored. + * + * @generated from field: optional string message = 2; + */ + message: string; + + /** + * `expression` is the actual CEL expression that will be evaluated for + * validation. This string must resolve to either a boolean or a string + * value. If the expression evaluates to false or a non-empty string, the + * validation is considered failed, and the message is rejected. + * + * @generated from field: optional string expression = 3; + */ + expression: string; +}; + +/** + * Describes the message buf.validate.Rule. + * Use `create(RuleSchema)` to create a new message. + */ +export const RuleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 0); + +/** + * MessageRules represents validation rules that are applied to the entire message. + * It includes disabling options and a list of Rule messages representing Common Expression Language (CEL) validation rules. + * + * @generated from message buf.validate.MessageRules + */ +export type MessageRules = Message<"buf.validate.MessageRules"> & { + /** + * `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + * rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + * + * This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + * simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + * be same as the `expression`. + * + * For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + * + * ```proto + * message MyMessage { + * // The field `foo` must be greater than 42. + * option (buf.validate.message).cel_expression = "this.foo > 42"; + * // The field `foo` must be less than 84. + * option (buf.validate.message).cel_expression = "this.foo < 84"; + * optional int32 foo = 1; + * } + * ``` + * + * @generated from field: repeated string cel_expression = 5; + */ + celExpression: string[]; + + /** + * `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message. + * These rules are written in Common Expression Language (CEL) syntax. For more information, + * [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + * + * + * ```proto + * message MyMessage { + * // The field `foo` must be greater than 42. + * option (buf.validate.message).cel = { + * id: "my_message.value", + * message: "must be greater than 42", + * expression: "this.foo > 42", + * }; + * optional int32 foo = 1; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 3; + */ + cel: Rule[]; + + /** + * `oneof` is a repeated field of type MessageOneofRule that specifies a list of fields + * of which at most one can be present. If `required` is also specified, then exactly one + * of the specified fields _must_ be present. + * + * This will enforce oneof-like constraints with a few features not provided by + * actual Protobuf oneof declarations: + * 1. Repeated and map fields are allowed in this validation. In a Protobuf oneof, + * only scalar fields are allowed. + * 2. Fields with implicit presence are allowed. In a Protobuf oneof, all member + * fields have explicit presence. This means that, for the purpose of determining + * how many fields are set, explicitly setting such a field to its zero value is + * effectively the same as not setting it at all. + * 3. This will always generate validation errors for a message unmarshalled from + * serialized data that sets more than one field. With a Protobuf oneof, when + * multiple fields are present in the serialized form, earlier values are usually + * silently ignored when unmarshalling, with only the last field being set when + * unmarshalling completes. + * + * Note that adding a field to a `oneof` will also set the IGNORE_IF_ZERO_VALUE on the fields. This means + * only the field that is set will be validated and the unset fields are not validated according to the field rules. + * This behavior can be overridden by setting `ignore` against a field. + * + * ```proto + * message MyMessage { + * // Only one of `field1` or `field2` _can_ be present in this message. + * option (buf.validate.message).oneof = { fields: ["field1", "field2"] }; + * // Exactly one of `field3` or `field4` _must_ be present in this message. + * option (buf.validate.message).oneof = { fields: ["field3", "field4"], required: true }; + * string field1 = 1; + * bytes field2 = 2; + * bool field3 = 3; + * int32 field4 = 4; + * } + * ``` + * + * @generated from field: repeated buf.validate.MessageOneofRule oneof = 4; + */ + oneof: MessageOneofRule[]; +}; + +/** + * Describes the message buf.validate.MessageRules. + * Use `create(MessageRulesSchema)` to create a new message. + */ +export const MessageRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 1); + +/** + * @generated from message buf.validate.MessageOneofRule + */ +export type MessageOneofRule = Message<"buf.validate.MessageOneofRule"> & { + /** + * A list of field names to include in the oneof. All field names must be + * defined in the message. At least one field must be specified, and + * duplicates are not permitted. + * + * @generated from field: repeated string fields = 1; + */ + fields: string[]; + + /** + * If true, one of the fields specified _must_ be set. + * + * @generated from field: optional bool required = 2; + */ + required: boolean; +}; + +/** + * Describes the message buf.validate.MessageOneofRule. + * Use `create(MessageOneofRuleSchema)` to create a new message. + */ +export const MessageOneofRuleSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 2); + +/** + * The `OneofRules` message type enables you to manage rules for + * oneof fields in your protobuf messages. + * + * @generated from message buf.validate.OneofRules + */ +export type OneofRules = Message<"buf.validate.OneofRules"> & { + /** + * If `required` is true, exactly one field of the oneof must be set. A + * validation error is returned if no fields in the oneof are set. Further rules + * should be placed on the fields themselves to ensure they are valid values, + * such as `min_len` or `gt`. + * + * ```proto + * message MyMessage { + * oneof value { + * // Either `a` or `b` must be set. If `a` is set, it must also be + * // non-empty; whereas if `b` is set, it can still be an empty string. + * option (buf.validate.oneof).required = true; + * string a = 1 [(buf.validate.field).string.min_len = 1]; + * string b = 2; + * } + * } + * ``` + * + * @generated from field: optional bool required = 1; + */ + required: boolean; +}; + +/** + * Describes the message buf.validate.OneofRules. + * Use `create(OneofRulesSchema)` to create a new message. + */ +export const OneofRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 3); + +/** + * FieldRules encapsulates the rules for each type of field. Depending on + * the field, the correct set should be used to ensure proper validations. + * + * @generated from message buf.validate.FieldRules + */ +export type FieldRules = Message<"buf.validate.FieldRules"> & { + /** + * `cel_expression` is a repeated field CEL expressions. Each expression specifies a validation + * rule to be applied to this message. These rules are written in Common Expression Language (CEL) syntax. + * + * This is a simplified form of the `cel` Rule field, where only `expression` is set. This allows for + * simpler syntax when defining CEL Rules where `id` and `message` derived from the `expression`. `id` will + * be same as the `expression`. + * + * For more information, [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.field).cel_expression = "this > 42"]; + * } + * ``` + * + * @generated from field: repeated string cel_expression = 29; + */ + celExpression: string[]; + + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information, + * [see our documentation](https://buf.build/docs/protovalidate/schemas/custom-rules/). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.field).cel = { + * id: "my_message.value", + * message: "must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 23; + */ + cel: Rule[]; + + /** + * If `required` is true, the field must be set. A validation error is returned + * if the field is not set. + * + * ```proto + * syntax="proto3"; + * + * message FieldsWithPresence { + * // Requires any string to be set, including the empty string. + * optional string link = 1 [ + * (buf.validate.field).required = true + * ]; + * // Requires true or false to be set. + * optional bool disabled = 2 [ + * (buf.validate.field).required = true + * ]; + * // Requires a message to be set, including the empty message. + * SomeMessage msg = 4 [ + * (buf.validate.field).required = true + * ]; + * } + * ``` + * + * All fields in the example above track presence. By default, Protovalidate + * ignores rules on those fields if no value is set. `required` ensures that + * the fields are set and valid. + * + * Fields that don't track presence are always validated by Protovalidate, + * whether they are set or not. It is not necessary to add `required`. It + * can be added to indicate that the field cannot be the zero value. + * + * ```proto + * syntax="proto3"; + * + * message FieldsWithoutPresence { + * // `string.email` always applies, even to an empty string. + * string link = 1 [ + * (buf.validate.field).string.email = true + * ]; + * // `repeated.min_items` always applies, even to an empty list. + * repeated string labels = 2 [ + * (buf.validate.field).repeated.min_items = 1 + * ]; + * // `required`, for fields that don't track presence, indicates + * // the value of the field can't be the zero value. + * int32 zero_value_not_allowed = 3 [ + * (buf.validate.field).required = true + * ]; + * } + * ``` + * + * To learn which fields track presence, see the + * [Field Presence cheat sheet](https://protobuf.dev/programming-guides/field_presence/#cheat). + * + * Note: While field rules can be applied to repeated items, map keys, and map + * values, the elements are always considered to be set. Consequently, + * specifying `repeated.items.required` is redundant. + * + * @generated from field: optional bool required = 25; + */ + required: boolean; + + /** + * Ignore validation rules on the field if its value matches the specified + * criteria. See the `Ignore` enum for details. + * + * ```proto + * message UpdateRequest { + * // The uri rule only applies if the field is not an empty string. + * string url = 1 [ + * (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + * (buf.validate.field).string.uri = true + * ]; + * } + * ``` + * + * @generated from field: optional buf.validate.Ignore ignore = 27; + */ + ignore: Ignore; + + /** + * @generated from oneof buf.validate.FieldRules.type + */ + type: { + /** + * Scalar Field Types + * + * @generated from field: buf.validate.FloatRules float = 1; + */ + value: FloatRules; + case: "float"; + } | { + /** + * @generated from field: buf.validate.DoubleRules double = 2; + */ + value: DoubleRules; + case: "double"; + } | { + /** + * @generated from field: buf.validate.Int32Rules int32 = 3; + */ + value: Int32Rules; + case: "int32"; + } | { + /** + * @generated from field: buf.validate.Int64Rules int64 = 4; + */ + value: Int64Rules; + case: "int64"; + } | { + /** + * @generated from field: buf.validate.UInt32Rules uint32 = 5; + */ + value: UInt32Rules; + case: "uint32"; + } | { + /** + * @generated from field: buf.validate.UInt64Rules uint64 = 6; + */ + value: UInt64Rules; + case: "uint64"; + } | { + /** + * @generated from field: buf.validate.SInt32Rules sint32 = 7; + */ + value: SInt32Rules; + case: "sint32"; + } | { + /** + * @generated from field: buf.validate.SInt64Rules sint64 = 8; + */ + value: SInt64Rules; + case: "sint64"; + } | { + /** + * @generated from field: buf.validate.Fixed32Rules fixed32 = 9; + */ + value: Fixed32Rules; + case: "fixed32"; + } | { + /** + * @generated from field: buf.validate.Fixed64Rules fixed64 = 10; + */ + value: Fixed64Rules; + case: "fixed64"; + } | { + /** + * @generated from field: buf.validate.SFixed32Rules sfixed32 = 11; + */ + value: SFixed32Rules; + case: "sfixed32"; + } | { + /** + * @generated from field: buf.validate.SFixed64Rules sfixed64 = 12; + */ + value: SFixed64Rules; + case: "sfixed64"; + } | { + /** + * @generated from field: buf.validate.BoolRules bool = 13; + */ + value: BoolRules; + case: "bool"; + } | { + /** + * @generated from field: buf.validate.StringRules string = 14; + */ + value: StringRules; + case: "string"; + } | { + /** + * @generated from field: buf.validate.BytesRules bytes = 15; + */ + value: BytesRules; + case: "bytes"; + } | { + /** + * Complex Field Types + * + * @generated from field: buf.validate.EnumRules enum = 16; + */ + value: EnumRules; + case: "enum"; + } | { + /** + * @generated from field: buf.validate.RepeatedRules repeated = 18; + */ + value: RepeatedRules; + case: "repeated"; + } | { + /** + * @generated from field: buf.validate.MapRules map = 19; + */ + value: MapRules; + case: "map"; + } | { + /** + * Well-Known Field Types + * + * @generated from field: buf.validate.AnyRules any = 20; + */ + value: AnyRules; + case: "any"; + } | { + /** + * @generated from field: buf.validate.DurationRules duration = 21; + */ + value: DurationRules; + case: "duration"; + } | { + /** + * @generated from field: buf.validate.FieldMaskRules field_mask = 28; + */ + value: FieldMaskRules; + case: "fieldMask"; + } | { + /** + * @generated from field: buf.validate.TimestampRules timestamp = 22; + */ + value: TimestampRules; + case: "timestamp"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldRules. + * Use `create(FieldRulesSchema)` to create a new message. + */ +export const FieldRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 4); + +/** + * PredefinedRules are custom rules that can be re-used with + * multiple fields. + * + * @generated from message buf.validate.PredefinedRules + */ +export type PredefinedRules = Message<"buf.validate.PredefinedRules"> & { + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information, + * [see our documentation](https://buf.build/docs/protovalidate/schemas/predefined-rules/). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.predefined).cel = { + * id: "my_message.value", + * message: "must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 1; + */ + cel: Rule[]; +}; + +/** + * Describes the message buf.validate.PredefinedRules. + * Use `create(PredefinedRulesSchema)` to create a new message. + */ +export const PredefinedRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 5); + +/** + * FloatRules describes the rules applied to `float` values. These + * rules may also be applied to the `google.protobuf.FloatValue` Well-Known-Type. + * + * @generated from message buf.validate.FloatRules + */ +export type FloatRules = Message<"buf.validate.FloatRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFloat { + * // value must equal 42.0 + * float value = 1 [(buf.validate.field).float.const = 42.0]; + * } + * ``` + * + * @generated from field: optional float const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.FloatRules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // must be less than 10.0 + * float value = 1 [(buf.validate.field).float.lt = 10.0]; + * } + * ``` + * + * @generated from field: float lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFloat { + * // must be less than or equal to 10.0 + * float value = 1 [(buf.validate.field).float.lte = 10.0]; + * } + * ``` + * + * @generated from field: float lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.FloatRules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // must be greater than 5.0 [float.gt] + * float value = 1 [(buf.validate.field).float.gt = 5.0]; + * + * // must be greater than 5 and less than 10.0 [float.gt_lt] + * float other_value = 2 [(buf.validate.field).float = { gt: 5.0, lt: 10.0 }]; + * + * // must be greater than 10 or less than 5.0 [float.gt_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // must be greater than or equal to 5.0 [float.gte] + * float value = 1 [(buf.validate.field).float.gte = 5.0]; + * + * // must be greater than or equal to 5.0 and less than 10.0 [float.gte_lt] + * float other_value = 2 [(buf.validate.field).float = { gte: 5.0, lt: 10.0 }]; + * + * // must be greater than or equal to 10.0 or less than 5.0 [float.gte_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFloat { + * // must be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFloat { + * // value must not be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFloat { + * float value = 1 [ + * (buf.validate.field).float.example = 1.0, + * (buf.validate.field).float.example = inf + * ]; + * } + * ``` + * + * @generated from field: repeated float example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.FloatRules. + * Use `create(FloatRulesSchema)` to create a new message. + */ +export const FloatRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 6); + +/** + * DoubleRules describes the rules applied to `double` values. These + * rules may also be applied to the `google.protobuf.DoubleValue` Well-Known-Type. + * + * @generated from message buf.validate.DoubleRules + */ +export type DoubleRules = Message<"buf.validate.DoubleRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyDouble { + * // value must equal 42.0 + * double value = 1 [(buf.validate.field).double.const = 42.0]; + * } + * ``` + * + * @generated from field: optional double const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.DoubleRules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyDouble { + * // must be less than 10.0 + * double value = 1 [(buf.validate.field).double.lt = 10.0]; + * } + * ``` + * + * @generated from field: double lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified value + * (field <= value). If the field value is greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // must be less than or equal to 10.0 + * double value = 1 [(buf.validate.field).double.lte = 10.0]; + * } + * ``` + * + * @generated from field: double lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DoubleRules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or `lte`, + * the range is reversed, and the field value must be outside the specified + * range. If the field value doesn't meet the required conditions, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // must be greater than 5.0 [double.gt] + * double value = 1 [(buf.validate.field).double.gt = 5.0]; + * + * // must be greater than 5 and less than 10.0 [double.gt_lt] + * double other_value = 2 [(buf.validate.field).double = { gt: 5.0, lt: 10.0 }]; + * + * // must be greater than 10 or less than 5.0 [double.gt_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // must be greater than or equal to 5.0 [double.gte] + * double value = 1 [(buf.validate.field).double.gte = 5.0]; + * + * // must be greater than or equal to 5.0 and less than 10.0 [double.gte_lt] + * double other_value = 2 [(buf.validate.field).double = { gte: 5.0, lt: 10.0 }]; + * + * // must be greater than or equal to 10.0 or less than 5.0 [double.gte_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyDouble { + * // must be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // value must not be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDouble { + * double value = 1 [ + * (buf.validate.field).double.example = 1.0, + * (buf.validate.field).double.example = inf + * ]; + * } + * ``` + * + * @generated from field: repeated double example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.DoubleRules. + * Use `create(DoubleRulesSchema)` to create a new message. + */ +export const DoubleRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 7); + +/** + * Int32Rules describes the rules applied to `int32` values. These + * rules may also be applied to the `google.protobuf.Int32Value` Well-Known-Type. + * + * @generated from message buf.validate.Int32Rules + */ +export type Int32Rules = Message<"buf.validate.Int32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must equal 42 + * int32 value = 1 [(buf.validate.field).int32.const = 42]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Int32Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // must be less than 10 + * int32 value = 1 [(buf.validate.field).int32.lt = 10]; + * } + * ``` + * + * @generated from field: int32 lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // must be less than or equal to 10 + * int32 value = 1 [(buf.validate.field).int32.lte = 10]; + * } + * ``` + * + * @generated from field: int32 lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int32Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // must be greater than 5 [int32.gt] + * int32 value = 1 [(buf.validate.field).int32.gt = 5]; + * + * // must be greater than 5 and less than 10 [int32.gt_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [int32.gt_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified value + * (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // must be greater than or equal to 5 [int32.gte] + * int32 value = 1 [(buf.validate.field).int32.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [int32.gte_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [int32.gte_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt32 { + * // must be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error message + * is generated. + * + * ```proto + * message MyInt32 { + * // value must not be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt32 { + * int32 value = 1 [ + * (buf.validate.field).int32.example = 1, + * (buf.validate.field).int32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Int32Rules. + * Use `create(Int32RulesSchema)` to create a new message. + */ +export const Int32RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 8); + +/** + * Int64Rules describes the rules applied to `int64` values. These + * rules may also be applied to the `google.protobuf.Int64Value` Well-Known-Type. + * + * @generated from message buf.validate.Int64Rules + */ +export type Int64Rules = Message<"buf.validate.Int64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must equal 42 + * int64 value = 1 [(buf.validate.field).int64.const = 42]; + * } + * ``` + * + * @generated from field: optional int64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Int64Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // must be less than 10 + * int64 value = 1 [(buf.validate.field).int64.lt = 10]; + * } + * ``` + * + * @generated from field: int64 lt = 2; + */ + value: bigint; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt64 { + * // must be less than or equal to 10 + * int64 value = 1 [(buf.validate.field).int64.lte = 10]; + * } + * ``` + * + * @generated from field: int64 lte = 3; + */ + value: bigint; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int64Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // must be greater than 5 [int64.gt] + * int64 value = 1 [(buf.validate.field).int64.gt = 5]; + * + * // must be greater than 5 and less than 10 [int64.gt_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [int64.gt_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gt = 4; + */ + value: bigint; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // must be greater than or equal to 5 [int64.gte] + * int64 value = 1 [(buf.validate.field).int64.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [int64.gte_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [int64.gte_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gte = 5; + */ + value: bigint; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt64 { + * // must be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyInt64 { + * // value must not be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt64 { + * int64 value = 1 [ + * (buf.validate.field).int64.example = 1, + * (buf.validate.field).int64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int64 example = 9; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Int64Rules. + * Use `create(Int64RulesSchema)` to create a new message. + */ +export const Int64RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 9); + +/** + * UInt32Rules describes the rules applied to `uint32` values. These + * rules may also be applied to the `google.protobuf.UInt32Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt32Rules + */ +export type UInt32Rules = Message<"buf.validate.UInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must equal 42 + * uint32 value = 1 [(buf.validate.field).uint32.const = 42]; + * } + * ``` + * + * @generated from field: optional uint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.UInt32Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // must be less than 10 + * uint32 value = 1 [(buf.validate.field).uint32.lt = 10]; + * } + * ``` + * + * @generated from field: uint32 lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // must be less than or equal to 10 + * uint32 value = 1 [(buf.validate.field).uint32.lte = 10]; + * } + * ``` + * + * @generated from field: uint32 lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt32Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // must be greater than 5 [uint32.gt] + * uint32 value = 1 [(buf.validate.field).uint32.gt = 5]; + * + * // must be greater than 5 and less than 10 [uint32.gt_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [uint32.gt_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // must be greater than or equal to 5 [uint32.gte] + * uint32 value = 1 [(buf.validate.field).uint32.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [uint32.gte_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [uint32.gte_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt32 { + * // must be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt32 { + * // value must not be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt32 { + * uint32 value = 1 [ + * (buf.validate.field).uint32.example = 1, + * (buf.validate.field).uint32.example = 10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.UInt32Rules. + * Use `create(UInt32RulesSchema)` to create a new message. + */ +export const UInt32RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 10); + +/** + * UInt64Rules describes the rules applied to `uint64` values. These + * rules may also be applied to the `google.protobuf.UInt64Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt64Rules + */ +export type UInt64Rules = Message<"buf.validate.UInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must equal 42 + * uint64 value = 1 [(buf.validate.field).uint64.const = 42]; + * } + * ``` + * + * @generated from field: optional uint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.UInt64Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // must be less than 10 + * uint64 value = 1 [(buf.validate.field).uint64.lt = 10]; + * } + * ``` + * + * @generated from field: uint64 lt = 2; + */ + value: bigint; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // must be less than or equal to 10 + * uint64 value = 1 [(buf.validate.field).uint64.lte = 10]; + * } + * ``` + * + * @generated from field: uint64 lte = 3; + */ + value: bigint; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt64Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // must be greater than 5 [uint64.gt] + * uint64 value = 1 [(buf.validate.field).uint64.gt = 5]; + * + * // must be greater than 5 and less than 10 [uint64.gt_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [uint64.gt_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gt = 4; + */ + value: bigint; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // must be greater than or equal to 5 [uint64.gte] + * uint64 value = 1 [(buf.validate.field).uint64.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [uint64.gte_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [uint64.gte_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gte = 5; + */ + value: bigint; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt64 { + * // must be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt64 { + * // value must not be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt64 { + * uint64 value = 1 [ + * (buf.validate.field).uint64.example = 1, + * (buf.validate.field).uint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.UInt64Rules. + * Use `create(UInt64RulesSchema)` to create a new message. + */ +export const UInt64RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 11); + +/** + * SInt32Rules describes the rules applied to `sint32` values. + * + * @generated from message buf.validate.SInt32Rules + */ +export type SInt32Rules = Message<"buf.validate.SInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must equal 42 + * sint32 value = 1 [(buf.validate.field).sint32.const = 42]; + * } + * ``` + * + * @generated from field: optional sint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SInt32Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // must be less than 10 + * sint32 value = 1 [(buf.validate.field).sint32.lt = 10]; + * } + * ``` + * + * @generated from field: sint32 lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // must be less than or equal to 10 + * sint32 value = 1 [(buf.validate.field).sint32.lte = 10]; + * } + * ``` + * + * @generated from field: sint32 lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt32Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // must be greater than 5 [sint32.gt] + * sint32 value = 1 [(buf.validate.field).sint32.gt = 5]; + * + * // must be greater than 5 and less than 10 [sint32.gt_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [sint32.gt_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // must be greater than or equal to 5 [sint32.gte] + * sint32 value = 1 [(buf.validate.field).sint32.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [sint32.gte_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [sint32.gte_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySInt32 { + * // must be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt32 { + * // value must not be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt32 { + * sint32 value = 1 [ + * (buf.validate.field).sint32.example = 1, + * (buf.validate.field).sint32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SInt32Rules. + * Use `create(SInt32RulesSchema)` to create a new message. + */ +export const SInt32RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 12); + +/** + * SInt64Rules describes the rules applied to `sint64` values. + * + * @generated from message buf.validate.SInt64Rules + */ +export type SInt64Rules = Message<"buf.validate.SInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must equal 42 + * sint64 value = 1 [(buf.validate.field).sint64.const = 42]; + * } + * ``` + * + * @generated from field: optional sint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SInt64Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // must be less than 10 + * sint64 value = 1 [(buf.validate.field).sint64.lt = 10]; + * } + * ``` + * + * @generated from field: sint64 lt = 2; + */ + value: bigint; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // must be less than or equal to 10 + * sint64 value = 1 [(buf.validate.field).sint64.lte = 10]; + * } + * ``` + * + * @generated from field: sint64 lte = 3; + */ + value: bigint; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt64Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // must be greater than 5 [sint64.gt] + * sint64 value = 1 [(buf.validate.field).sint64.gt = 5]; + * + * // must be greater than 5 and less than 10 [sint64.gt_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [sint64.gt_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gt = 4; + */ + value: bigint; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // must be greater than or equal to 5 [sint64.gte] + * sint64 value = 1 [(buf.validate.field).sint64.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [sint64.gte_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [sint64.gte_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gte = 5; + */ + value: bigint; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MySInt64 { + * // must be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt64 { + * // value must not be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt64 { + * sint64 value = 1 [ + * (buf.validate.field).sint64.example = 1, + * (buf.validate.field).sint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SInt64Rules. + * Use `create(SInt64RulesSchema)` to create a new message. + */ +export const SInt64RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 13); + +/** + * Fixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.Fixed32Rules + */ +export type Fixed32Rules = Message<"buf.validate.Fixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must equal 42 + * fixed32 value = 1 [(buf.validate.field).fixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Fixed32Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // must be less than 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lt = 10]; + * } + * ``` + * + * @generated from field: fixed32 lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // must be less than or equal to 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lte = 10]; + * } + * ``` + * + * @generated from field: fixed32 lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed32Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // must be greater than 5 [fixed32.gt] + * fixed32 value = 1 [(buf.validate.field).fixed32.gt = 5]; + * + * // must be greater than 5 and less than 10 [fixed32.gt_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [fixed32.gt_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // must be greater than or equal to 5 [fixed32.gte] + * fixed32 value = 1 [(buf.validate.field).fixed32.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [fixed32.gte_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [fixed32.gte_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFixed32 { + * // must be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed32 { + * // value must not be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed32 { + * fixed32 value = 1 [ + * (buf.validate.field).fixed32.example = 1, + * (buf.validate.field).fixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Fixed32Rules. + * Use `create(Fixed32RulesSchema)` to create a new message. + */ +export const Fixed32RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 14); + +/** + * Fixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.Fixed64Rules + */ +export type Fixed64Rules = Message<"buf.validate.Fixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must equal 42 + * fixed64 value = 1 [(buf.validate.field).fixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Fixed64Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // must be less than 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lt = 10]; + * } + * ``` + * + * @generated from field: fixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // must be less than or equal to 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lte = 10]; + * } + * ``` + * + * @generated from field: fixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed64Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // must be greater than 5 [fixed64.gt] + * fixed64 value = 1 [(buf.validate.field).fixed64.gt = 5]; + * + * // must be greater than 5 and less than 10 [fixed64.gt_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [fixed64.gt_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // must be greater than or equal to 5 [fixed64.gte] + * fixed64 value = 1 [(buf.validate.field).fixed64.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [fixed64.gte_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [fixed64.gte_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyFixed64 { + * // must be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed64 { + * // value must not be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed64 { + * fixed64 value = 1 [ + * (buf.validate.field).fixed64.example = 1, + * (buf.validate.field).fixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Fixed64Rules. + * Use `create(Fixed64RulesSchema)` to create a new message. + */ +export const Fixed64RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 15); + +/** + * SFixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.SFixed32Rules + */ +export type SFixed32Rules = Message<"buf.validate.SFixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must equal 42 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SFixed32Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // must be less than 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lt = 2; + */ + value: number; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // must be less than or equal to 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lte = 3; + */ + value: number; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed32Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // must be greater than 5 [sfixed32.gt] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gt = 5]; + * + * // must be greater than 5 and less than 10 [sfixed32.gt_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [sfixed32.gt_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gt = 4; + */ + value: number; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // must be greater than or equal to 5 [sfixed32.gte] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [sfixed32.gte_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [sfixed32.gte_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gte = 5; + */ + value: number; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed32 { + * // must be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed32 { + * // value must not be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed32 { + * sfixed32 value = 1 [ + * (buf.validate.field).sfixed32.example = 1, + * (buf.validate.field).sfixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SFixed32Rules. + * Use `create(SFixed32RulesSchema)` to create a new message. + */ +export const SFixed32RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 16); + +/** + * SFixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.SFixed64Rules + */ +export type SFixed64Rules = Message<"buf.validate.SFixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must equal 42 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SFixed64Rules.less_than + */ + lessThan: { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // must be less than 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // must be less than or equal to 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed64Rules.greater_than + */ + greaterThan: { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // must be greater than 5 [sfixed64.gt] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gt = 5]; + * + * // must be greater than 5 and less than 10 [sfixed64.gt_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gt: 5, lt: 10 }]; + * + * // must be greater than 10 or less than 5 [sfixed64.gt_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // must be greater than or equal to 5 [sfixed64.gte] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gte = 5]; + * + * // must be greater than or equal to 5 and less than 10 [sfixed64.gte_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gte: 5, lt: 10 }]; + * + * // must be greater than or equal to 10 or less than 5 [sfixed64.gte_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed64 { + * // must be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed64 { + * // value must not be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed64 { + * sfixed64 value = 1 [ + * (buf.validate.field).sfixed64.example = 1, + * (buf.validate.field).sfixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SFixed64Rules. + * Use `create(SFixed64RulesSchema)` to create a new message. + */ +export const SFixed64RulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 17); + +/** + * BoolRules describes the rules applied to `bool` values. These rules + * may also be applied to the `google.protobuf.BoolValue` Well-Known-Type. + * + * @generated from message buf.validate.BoolRules + */ +export type BoolRules = Message<"buf.validate.BoolRules"> & { + /** + * `const` requires the field value to exactly match the specified boolean value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBool { + * // value must equal true + * bool value = 1 [(buf.validate.field).bool.const = true]; + * } + * ``` + * + * @generated from field: optional bool const = 1; + */ + const: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBool { + * bool value = 1 [ + * (buf.validate.field).bool.example = 1, + * (buf.validate.field).bool.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated bool example = 2; + */ + example: boolean[]; +}; + +/** + * Describes the message buf.validate.BoolRules. + * Use `create(BoolRulesSchema)` to create a new message. + */ +export const BoolRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 18); + +/** + * StringRules describes the rules applied to `string` values These + * rules may also be applied to the `google.protobuf.StringValue` Well-Known-Type. + * + * @generated from message buf.validate.StringRules + */ +export type StringRules = Message<"buf.validate.StringRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyString { + * // value must equal `hello` + * string value = 1 [(buf.validate.field).string.const = "hello"]; + * } + * ``` + * + * @generated from field: optional string const = 1; + */ + const: string; + + /** + * `len` dictates that the field value must have the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value does not meet the specified + * length, an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 5 characters + * string value = 1 [(buf.validate.field).string.len = 5]; + * } + * ``` + * + * @generated from field: optional uint64 len = 19; + */ + len: bigint; + + /** + * `min_len` specifies that the field value must have at least the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value contains fewer characters, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 3 characters + * string value = 1 [(buf.validate.field).string.min_len = 3]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` specifies that the field value must have no more than the specified + * number of characters (Unicode code points), which may differ from the + * number of bytes in the string. If the field value contains more characters, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 10 characters + * string value = 1 [(buf.validate.field).string.max_len = 10]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `len_bytes` dictates that the field value must have the specified number of + * bytes. If the field value does not match the specified length in bytes, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 6 bytes + * string value = 1 [(buf.validate.field).string.len_bytes = 6]; + * } + * ``` + * + * @generated from field: optional uint64 len_bytes = 20; + */ + lenBytes: bigint; + + /** + * `min_bytes` specifies that the field value must have at least the specified + * number of bytes. If the field value contains fewer bytes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 4 bytes + * string value = 1 [(buf.validate.field).string.min_bytes = 4]; + * } + * + * ``` + * + * @generated from field: optional uint64 min_bytes = 4; + */ + minBytes: bigint; + + /** + * `max_bytes` specifies that the field value must have no more than the + * specified number of bytes. If the field value contains more bytes, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 8 bytes + * string value = 1 [(buf.validate.field).string.max_bytes = 8]; + * } + * ``` + * + * @generated from field: optional uint64 max_bytes = 5; + */ + maxBytes: bigint; + + /** + * `pattern` specifies that the field value must match the specified + * regular expression (RE2 syntax), with the expression provided without any + * delimiters. If the field value doesn't match the regular expression, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value does not match regex pattern `^[a-zA-Z]//$` + * string value = 1 [(buf.validate.field).string.pattern = "^[a-zA-Z]//$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 6; + */ + pattern: string; + + /** + * `prefix` specifies that the field value must have the + * specified substring at the beginning of the string. If the field value + * doesn't start with the specified prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value does not have prefix `pre` + * string value = 1 [(buf.validate.field).string.prefix = "pre"]; + * } + * ``` + * + * @generated from field: optional string prefix = 7; + */ + prefix: string; + + /** + * `suffix` specifies that the field value must have the + * specified substring at the end of the string. If the field value doesn't + * end with the specified suffix, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not have suffix `post` + * string value = 1 [(buf.validate.field).string.suffix = "post"]; + * } + * ``` + * + * @generated from field: optional string suffix = 8; + */ + suffix: string; + + /** + * `contains` specifies that the field value must have the + * specified substring anywhere in the string. If the field value doesn't + * contain the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not contain substring `inside`. + * string value = 1 [(buf.validate.field).string.contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string contains = 9; + */ + contains: string; + + /** + * `not_contains` specifies that the field value must not have the + * specified substring anywhere in the string. If the field value contains + * the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value contains substring `inside`. + * string value = 1 [(buf.validate.field).string.not_contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string not_contains = 23; + */ + notContains: string; + + /** + * `in` specifies that the field value must be equal to one of the specified + * values. If the field value isn't one of the specified values, an error + * message will be generated. + * + * ```proto + * message MyString { + * // must be in list ["apple", "banana"] + * string value = 1 [(buf.validate.field).string.in = "apple", (buf.validate.field).string.in = "banana"]; + * } + * ``` + * + * @generated from field: repeated string in = 10; + */ + in: string[]; + + /** + * `not_in` specifies that the field value cannot be equal to any + * of the specified values. If the field value is one of the specified values, + * an error message will be generated. + * ```proto + * message MyString { + * // value must not be in list ["orange", "grape"] + * string value = 1 [(buf.validate.field).string.not_in = "orange", (buf.validate.field).string.not_in = "grape"]; + * } + * ``` + * + * @generated from field: repeated string not_in = 11; + */ + notIn: string[]; + + /** + * `WellKnown` rules provide advanced rules against common string + * patterns. + * + * @generated from oneof buf.validate.StringRules.well_known + */ + wellKnown: { + /** + * `email` specifies that the field value must be a valid email address, for + * example "foo@example.com". + * + * Conforms to the definition for a valid email address from the [HTML standard](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address). + * Note that this standard willfully deviates from [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322), + * which allows many unexpected forms of email addresses and will easily match + * a typographical error. + * + * If the field value isn't a valid email address, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid email address + * string value = 1 [(buf.validate.field).string.email = true]; + * } + * ``` + * + * @generated from field: bool email = 12; + */ + value: boolean; + case: "email"; + } | { + /** + * `hostname` specifies that the field value must be a valid hostname, for + * example "foo.example.com". + * + * A valid hostname follows the rules below: + * - The name consists of one or more labels, separated by a dot ("."). + * - Each label can be 1 to 63 alphanumeric characters. + * - A label can contain hyphens ("-"), but must not start or end with a hyphen. + * - The right-most label must not be digits only. + * - The name can have a trailing dot—for example, "foo.example.com.". + * - The name can be 253 characters at most, excluding the optional trailing dot. + * + * If the field value isn't a valid hostname, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid hostname + * string value = 1 [(buf.validate.field).string.hostname = true]; + * } + * ``` + * + * @generated from field: bool hostname = 13; + */ + value: boolean; + case: "hostname"; + } | { + /** + * `ip` specifies that the field value must be a valid IP (v4 or v6) address. + * + * IPv4 addresses are expected in the dotted decimal format—for example, "192.168.5.21". + * IPv6 addresses are expected in their text representation—for example, "::1", + * or "2001:0DB8:ABCD:0012::0". + * + * Both formats are well-defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone identifiers for IPv6 addresses (for example, "fe80::a%en1") are supported. + * + * If the field value isn't a valid IP address, an error message will be + * generated. + * + * ```proto + * message MyString { + * // must be a valid IP address + * string value = 1 [(buf.validate.field).string.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 14; + */ + value: boolean; + case: "ip"; + } | { + /** + * `ipv4` specifies that the field value must be a valid IPv4 address—for + * example "192.168.5.21". If the field value isn't a valid IPv4 address, an + * error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid IPv4 address + * string value = 1 [(buf.validate.field).string.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 15; + */ + value: boolean; + case: "ipv4"; + } | { + /** + * `ipv6` specifies that the field value must be a valid IPv6 address—for + * example "::1", or "d7a:115c:a1e0:ab12:4843:cd96:626b:430b". If the field + * value is not a valid IPv6 address, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid IPv6 address + * string value = 1 [(buf.validate.field).string.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 16; + */ + value: boolean; + case: "ipv6"; + } | { + /** + * `uri` specifies that the field value must be a valid URI, for example + * "https://example.com/foo/bar?baz=quux#frag". + * + * URI is defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid URI + * string value = 1 [(buf.validate.field).string.uri = true]; + * } + * ``` + * + * @generated from field: bool uri = 17; + */ + value: boolean; + case: "uri"; + } | { + /** + * `uri_ref` specifies that the field value must be a valid URI Reference—either + * a URI such as "https://example.com/foo/bar?baz=quux#frag", or a Relative + * Reference such as "./foo/bar?query". + * + * URI, URI Reference, and Relative Reference are defined in the internet + * standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). Zone + * Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI Reference, an error message will be + * generated. + * + * ```proto + * message MyString { + * // must be a valid URI Reference + * string value = 1 [(buf.validate.field).string.uri_ref = true]; + * } + * ``` + * + * @generated from field: bool uri_ref = 18; + */ + value: boolean; + case: "uriRef"; + } | { + /** + * `address` specifies that the field value must be either a valid hostname + * (for example, "example.com"), or a valid IP (v4 or v6) address (for example, + * "192.168.0.1", or "::1"). If the field value isn't a valid hostname or IP, + * an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid hostname, or ip address + * string value = 1 [(buf.validate.field).string.address = true]; + * } + * ``` + * + * @generated from field: bool address = 21; + */ + value: boolean; + case: "address"; + } | { + /** + * `uuid` specifies that the field value must be a valid UUID as defined by + * [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). If the + * field value isn't a valid UUID, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid UUID + * string value = 1 [(buf.validate.field).string.uuid = true]; + * } + * ``` + * + * @generated from field: bool uuid = 22; + */ + value: boolean; + case: "uuid"; + } | { + /** + * `tuuid` (trimmed UUID) specifies that the field value must be a valid UUID as + * defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2) with all dashes + * omitted. If the field value isn't a valid UUID without dashes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // must be a valid trimmed UUID + * string value = 1 [(buf.validate.field).string.tuuid = true]; + * } + * ``` + * + * @generated from field: bool tuuid = 33; + */ + value: boolean; + case: "tuuid"; + } | { + /** + * `ip_with_prefixlen` specifies that the field value must be a valid IP + * (v4 or v6) address with prefix length—for example, "192.168.5.21/16" or + * "2001:0DB8:ABCD:0012::F1/64". If the field value isn't a valid IP with + * prefix length, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid IP with prefix length + * string value = 1 [(buf.validate.field).string.ip_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ip_with_prefixlen = 26; + */ + value: boolean; + case: "ipWithPrefixlen"; + } | { + /** + * `ipv4_with_prefixlen` specifies that the field value must be a valid + * IPv4 address with prefix length—for example, "192.168.5.21/16". If the + * field value isn't a valid IPv4 address with prefix length, an error + * message will be generated. + * + * ```proto + * message MyString { + * // must be a valid IPv4 address with prefix length + * string value = 1 [(buf.validate.field).string.ipv4_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv4_with_prefixlen = 27; + */ + value: boolean; + case: "ipv4WithPrefixlen"; + } | { + /** + * `ipv6_with_prefixlen` specifies that the field value must be a valid + * IPv6 address with prefix length—for example, "2001:0DB8:ABCD:0012::F1/64". + * If the field value is not a valid IPv6 address with prefix length, + * an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid IPv6 address prefix length + * string value = 1 [(buf.validate.field).string.ipv6_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv6_with_prefixlen = 28; + */ + value: boolean; + case: "ipv6WithPrefixlen"; + } | { + /** + * `ip_prefix` specifies that the field value must be a valid IP (v4 or v6) + * prefix—for example, "192.168.0.0/16" or "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value isn't a valid IP prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // must be a valid IP prefix + * string value = 1 [(buf.validate.field).string.ip_prefix = true]; + * } + * ``` + * + * @generated from field: bool ip_prefix = 29; + */ + value: boolean; + case: "ipPrefix"; + } | { + /** + * `ipv4_prefix` specifies that the field value must be a valid IPv4 + * prefix, for example "192.168.0.0/16". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "192.168.0.0/16" designates the left-most 16 bits for the prefix, + * and the remaining 16 bits must be zero. + * + * If the field value isn't a valid IPv4 prefix, an error message + * will be generated. + * + * ```proto + * message MyString { + * // must be a valid IPv4 prefix + * string value = 1 [(buf.validate.field).string.ipv4_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv4_prefix = 30; + */ + value: boolean; + case: "ipv4Prefix"; + } | { + /** + * `ipv6_prefix` specifies that the field value must be a valid IPv6 prefix—for + * example, "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value is not a valid IPv6 prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // must be a valid IPv6 prefix + * string value = 1 [(buf.validate.field).string.ipv6_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv6_prefix = 31; + */ + value: boolean; + case: "ipv6Prefix"; + } | { + /** + * `host_and_port` specifies that the field value must be a valid host/port + * pair—for example, "example.com:8080". + * + * The host can be one of: + * - An IPv4 address in dotted decimal format—for example, "192.168.5.21". + * - An IPv6 address enclosed in square brackets—for example, "[2001:0DB8:ABCD:0012::F1]". + * - A hostname—for example, "example.com". + * + * The port is separated by a colon. It must be non-empty, with a decimal number + * in the range of 0-65535, inclusive. + * + * @generated from field: bool host_and_port = 32; + */ + value: boolean; + case: "hostAndPort"; + } | { + /** + * `ulid` specifies that the field value must be a valid ULID (Universally Unique + * Lexicographically Sortable Identifier) as defined by the [ULID specification](https://github.com/ulid/spec). + * If the field value isn't a valid ULID, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid ULID + * string value = 1 [(buf.validate.field).string.ulid = true]; + * } + * ``` + * + * @generated from field: bool ulid = 35; + */ + value: boolean; + case: "ulid"; + } | { + /** + * `protobuf_fqn` specifies that the field value must be a valid fully-qualified + * Protobuf name as defined by the [Protobuf Language Specification](https://protobuf.com/docs/language-spec). + * + * A fully-qualified Protobuf name is a dot-separated list of Protobuf identifiers, + * where each identifier starts with a letter or underscore and is followed by zero or + * more letters, underscores, or digits. + * + * Examples: "buf.validate", "google.protobuf.Timestamp", "my_package.MyMessage". + * + * Note: historically, fully-qualified Protobuf names were represented with a leading + * dot (for example, ".buf.validate.StringRules"). Modern Protobuf does not use the + * leading dot, and most fully-qualified names are represented without it. Use + * `protobuf_dot_fqn` if a leading dot is required. + * + * If the field value isn't a valid fully-qualified Protobuf name, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value must be a valid fully-qualified Protobuf name + * string value = 1 [(buf.validate.field).string.protobuf_fqn = true]; + * } + * ``` + * + * @generated from field: bool protobuf_fqn = 37; + */ + value: boolean; + case: "protobufFqn"; + } | { + /** + * `protobuf_dot_fqn` specifies that the field value must be a valid fully-qualified + * Protobuf name with a leading dot, as defined by the + * [Protobuf Language Specification](https://protobuf.com/docs/language-spec). + * + * A fully-qualified Protobuf name with a leading dot is a dot followed by a + * dot-separated list of Protobuf identifiers, where each identifier starts with a + * letter or underscore and is followed by zero or more letters, underscores, or + * digits. + * + * Examples: ".buf.validate", ".google.protobuf.Timestamp", ".my_package.MyMessage". + * + * Note: this is the historical representation of fully-qualified Protobuf names, + * where a leading dot denotes an absolute reference. Modern Protobuf does not use + * the leading dot, and most fully-qualified names are represented without it. Most + * users will want to use `protobuf_fqn` instead. + * + * If the field value isn't a valid fully-qualified Protobuf name with a leading dot, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid fully-qualified Protobuf name with a leading dot + * string value = 1 [(buf.validate.field).string.protobuf_dot_fqn = true]; + * } + * ``` + * + * @generated from field: bool protobuf_dot_fqn = 38; + */ + value: boolean; + case: "protobufDotFqn"; + } | { + /** + * `well_known_regex` specifies a common well-known pattern + * defined as a regex. If the field value doesn't match the well-known + * regex, an error message will be generated. + * + * ```proto + * message MyString { + * // must be a valid HTTP header value + * string value = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; + * } + * ``` + * + * #### KnownRegex + * + * `well_known_regex` contains some well-known patterns. + * + * | Name | Number | Description | + * |-------------------------------|--------|-------------------------------------------| + * | KNOWN_REGEX_UNSPECIFIED | 0 | | + * | KNOWN_REGEX_HTTP_HEADER_NAME | 1 | HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2) | + * | KNOWN_REGEX_HTTP_HEADER_VALUE | 2 | HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4) | + * + * @generated from field: buf.validate.KnownRegex well_known_regex = 24; + */ + value: KnownRegex; + case: "wellKnownRegex"; + } | { case: undefined; value?: undefined }; + + /** + * This applies to regexes `HTTP_HEADER_NAME` and `HTTP_HEADER_VALUE` to + * enable strict header validation. By default, this is true, and HTTP header + * validations are [RFC-compliant](https://datatracker.ietf.org/doc/html/rfc7230#section-3). Setting to false will enable looser + * validations that only disallow `\r\n\0` characters, which can be used to + * bypass header matching rules. + * + * ```proto + * message MyString { + * // The field `value` must have be a valid HTTP headers, but not enforced with strict rules. + * string value = 1 [(buf.validate.field).string.strict = false]; + * } + * ``` + * + * @generated from field: optional bool strict = 25; + */ + strict: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyString { + * string value = 1 [ + * (buf.validate.field).string.example = "hello", + * (buf.validate.field).string.example = "world" + * ]; + * } + * ``` + * + * @generated from field: repeated string example = 34; + */ + example: string[]; +}; + +/** + * Describes the message buf.validate.StringRules. + * Use `create(StringRulesSchema)` to create a new message. + */ +export const StringRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 19); + +/** + * BytesRules describe the rules applied to `bytes` values. These rules + * may also be applied to the `google.protobuf.BytesValue` Well-Known-Type. + * + * @generated from message buf.validate.BytesRules + */ +export type BytesRules = Message<"buf.validate.BytesRules"> & { + /** + * `const` requires the field value to exactly match the specified bytes + * value. If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // must be "\x01\x02\x03\x04" + * bytes value = 1 [(buf.validate.field).bytes.const = "\x01\x02\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes const = 1; + */ + const: Uint8Array; + + /** + * `len` requires the field value to have the specified length in bytes. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be 4 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.len = 4]; + * } + * ``` + * + * @generated from field: optional uint64 len = 13; + */ + len: bigint; + + /** + * `min_len` requires the field value to have at least the specified minimum + * length in bytes. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be at least 2 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.min_len = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` requires the field value to have at most the specified maximum + * length in bytes. + * If the field value exceeds the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // must be at most 6 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.max_len = 6]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `pattern` requires the field value to match the specified regular + * expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)). + * The value of the field must be valid UTF-8 or validation will fail with a + * runtime error. + * If the field value doesn't match the pattern, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must match regex pattern "^[a-zA-Z0-9]+$". + * optional bytes value = 1 [(buf.validate.field).bytes.pattern = "^[a-zA-Z0-9]+$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 4; + */ + pattern: string; + + /** + * `prefix` requires the field value to have the specified bytes at the + * beginning of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have prefix \x01\x02 + * optional bytes value = 1 [(buf.validate.field).bytes.prefix = "\x01\x02"]; + * } + * ``` + * + * @generated from field: optional bytes prefix = 5; + */ + prefix: Uint8Array; + + /** + * `suffix` requires the field value to have the specified bytes at the end + * of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have suffix \x03\x04 + * optional bytes value = 1 [(buf.validate.field).bytes.suffix = "\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes suffix = 6; + */ + suffix: Uint8Array; + + /** + * `contains` requires the field value to have the specified bytes anywhere in + * the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not contain \x02\x03 + * optional bytes value = 1 [(buf.validate.field).bytes.contains = "\x02\x03"]; + * } + * ``` + * + * @generated from field: optional bytes contains = 7; + */ + contains: Uint8Array; + + /** + * `in` requires the field value to be equal to one of the specified + * values. If the field value doesn't match any of the specified values, an + * error message is generated. + * + * ```proto + * message MyBytes { + * // value must in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes in = 8; + */ + in: Uint8Array[]; + + /** + * `not_in` requires the field value to be not equal to any of the specified + * values. + * If the field value matches any of the specified values, an error message is + * generated. + * + * ```proto + * message MyBytes { + * // value must not in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.not_in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes not_in = 9; + */ + notIn: Uint8Array[]; + + /** + * WellKnown rules provide advanced rules against common byte + * patterns + * + * @generated from oneof buf.validate.BytesRules.well_known + */ + wellKnown: { + /** + * `ip` ensures that the field `value` is a valid IP address (v4 or v6) in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // must be a valid IP address + * optional bytes value = 1 [(buf.validate.field).bytes.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 10; + */ + value: boolean; + case: "ip"; + } | { + /** + * `ipv4` ensures that the field `value` is a valid IPv4 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // must be a valid IPv4 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 11; + */ + value: boolean; + case: "ipv4"; + } | { + /** + * `ipv6` ensures that the field `value` is a valid IPv6 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * ```proto + * message MyBytes { + * // must be a valid IPv6 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 12; + */ + value: boolean; + case: "ipv6"; + } | { + /** + * `uuid` ensures that the field value encodes 128-bit UUID data as defined + * by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). + * The field must contain exactly 16 bytes representing the UUID. If the + * field value isn't a valid UUID, an error message will be generated. + * + * ```proto + * message MyBytes { + * // must be a valid UUID + * optional bytes value = 1 [(buf.validate.field).bytes.uuid = true]; + * } + * ``` + * + * @generated from field: bool uuid = 15; + */ + value: boolean; + case: "uuid"; + } | { case: undefined; value?: undefined }; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBytes { + * bytes value = 1 [ + * (buf.validate.field).bytes.example = "\x01\x02", + * (buf.validate.field).bytes.example = "\x02\x03" + * ]; + * } + * ``` + * + * @generated from field: repeated bytes example = 14; + */ + example: Uint8Array[]; +}; + +/** + * Describes the message buf.validate.BytesRules. + * Use `create(BytesRulesSchema)` to create a new message. + */ +export const BytesRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 20); + +/** + * EnumRules describe the rules applied to `enum` values. + * + * @generated from message buf.validate.EnumRules + */ +export type EnumRules = Message<"buf.validate.EnumRules"> & { + /** + * `const` requires the field value to exactly match the specified enum value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be exactly MY_ENUM_VALUE1. + * MyEnum value = 1 [(buf.validate.field).enum.const = 1]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * `defined_only` requires the field value to be one of the defined values for + * this enum, failing on any undefined value. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be a defined value of MyEnum. + * MyEnum value = 1 [(buf.validate.field).enum.defined_only = true]; + * } + * ``` + * + * @generated from field: optional bool defined_only = 2; + */ + definedOnly: boolean; + + /** + * `in` requires the field value to be equal to one of the + * specified enum values. If the field value doesn't match any of the + * specified values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be equal to one of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 in = 3; + */ + in: number[]; + + /** + * `not_in` requires the field value to be not equal to any of the + * specified enum values. If the field value matches one of the specified + * values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must not be equal to any of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { not_in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 4; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * (buf.validate.field).enum.example = 1, + * (buf.validate.field).enum.example = 2 + * } + * ``` + * + * @generated from field: repeated int32 example = 5; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.EnumRules. + * Use `create(EnumRulesSchema)` to create a new message. + */ +export const EnumRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 21); + +/** + * RepeatedRules describe the rules applied to `repeated` values. + * + * @generated from message buf.validate.RepeatedRules + */ +export type RepeatedRules = Message<"buf.validate.RepeatedRules"> & { + /** + * `min_items` requires that this field must contain at least the specified + * minimum number of items. + * + * Note that `min_items = 1` is equivalent to setting a field as `required`. + * + * ```proto + * message MyRepeated { + * // value must contain at least 2 items + * repeated string value = 1 [(buf.validate.field).repeated.min_items = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_items = 1; + */ + minItems: bigint; + + /** + * `max_items` denotes that this field must not exceed a + * certain number of items as the upper limit. If the field contains more + * items than specified, an error message will be generated, requiring the + * field to maintain no more than the specified number of items. + * + * ```proto + * message MyRepeated { + * // value must contain no more than 3 item(s) + * repeated string value = 1 [(buf.validate.field).repeated.max_items = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_items = 2; + */ + maxItems: bigint; + + /** + * `unique` indicates that all elements in this field must + * be unique. This rule is strictly applicable to scalar and enum + * types, with message types not being supported. + * + * ```proto + * message MyRepeated { + * // repeated value must contain unique items + * repeated string value = 1 [(buf.validate.field).repeated.unique = true]; + * } + * ``` + * + * @generated from field: optional bool unique = 3; + */ + unique: boolean; + + /** + * `items` details the rules to be applied to each item + * in the field. Even for repeated message fields, validation is executed + * against each item unless `ignore` is specified. + * + * ```proto + * message MyRepeated { + * // The items in the field `value` must follow the specified rules. + * repeated string value = 1 [(buf.validate.field).repeated.items = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * Note that the `required` rule does not apply. Repeated items + * cannot be unset. + * + * @generated from field: optional buf.validate.FieldRules items = 4; + */ + items?: FieldRules; +}; + +/** + * Describes the message buf.validate.RepeatedRules. + * Use `create(RepeatedRulesSchema)` to create a new message. + */ +export const RepeatedRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 22); + +/** + * MapRules describe the rules applied to `map` values. + * + * @generated from message buf.validate.MapRules + */ +export type MapRules = Message<"buf.validate.MapRules"> & { + /** + * Specifies the minimum number of key-value pairs allowed. If the field has + * fewer key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at least 2 key-value pairs. + * map value = 1 [(buf.validate.field).map.min_pairs = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_pairs = 1; + */ + minPairs: bigint; + + /** + * Specifies the maximum number of key-value pairs allowed. If the field has + * more key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at most 3 key-value pairs. + * map value = 1 [(buf.validate.field).map.max_pairs = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_pairs = 2; + */ + maxPairs: bigint; + + /** + * Specifies the rules to be applied to each key in the field. + * + * ```proto + * message MyMap { + * // The keys in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.keys = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * Note that the `required` rule does not apply. Map keys cannot be unset. + * + * @generated from field: optional buf.validate.FieldRules keys = 4; + */ + keys?: FieldRules; + + /** + * Specifies the rules to be applied to the value of each key in the + * field. Message values will still have their validations evaluated unless + * `ignore` is specified. + * + * ```proto + * message MyMap { + * // The values in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.values = { + * string: { + * min_len: 5 + * max_len: 20 + * } + * }]; + * } + * ``` + * Note that the `required` rule does not apply. Map values cannot be unset. + * + * @generated from field: optional buf.validate.FieldRules values = 5; + */ + values?: FieldRules; +}; + +/** + * Describes the message buf.validate.MapRules. + * Use `create(MapRulesSchema)` to create a new message. + */ +export const MapRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 23); + +/** + * AnyRules describe rules applied exclusively to the `google.protobuf.Any` well-known type. + * + * @generated from message buf.validate.AnyRules + */ +export type AnyRules = Message<"buf.validate.AnyRules"> & { + /** + * `in` requires the field's `type_url` to be equal to one of the + * specified values. If it doesn't match any of the specified values, an error + * message is generated. + * + * ```proto + * message MyAny { + * // The `value` field must have a `type_url` equal to one of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any = { + * in: ["type.googleapis.com/MyType1", "type.googleapis.com/MyType2"] + * }]; + * } + * ``` + * + * @generated from field: repeated string in = 2; + */ + in: string[]; + + /** + * `not_in` requires the field's type_url to be not equal to any of the specified values. If it matches any of the specified values, an error message is generated. + * + * ```proto + * message MyAny { + * // The `value` field must not have a `type_url` equal to any of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any = { + * not_in: ["type.googleapis.com/ForbiddenType1", "type.googleapis.com/ForbiddenType2"] + * }]; + * } + * ``` + * + * @generated from field: repeated string not_in = 3; + */ + notIn: string[]; +}; + +/** + * Describes the message buf.validate.AnyRules. + * Use `create(AnyRulesSchema)` to create a new message. + */ +export const AnyRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 24); + +/** + * DurationRules describe the rules applied exclusively to the `google.protobuf.Duration` well-known type. + * + * @generated from message buf.validate.DurationRules + */ +export type DurationRules = Message<"buf.validate.DurationRules"> & { + /** + * `const` dictates that the field must match the specified value of the `google.protobuf.Duration` type exactly. + * If the field's value deviates from the specified value, an error message + * will be generated. + * + * ```proto + * message MyDuration { + * // value must equal 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.const = "5s"]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration const = 2; + */ + const?: Duration; + + /** + * @generated from oneof buf.validate.DurationRules.less_than + */ + lessThan: { + /** + * `lt` stipulates that the field must be less than the specified value of the `google.protobuf.Duration` type, + * exclusive. If the field's value is greater than or equal to the specified + * value, an error message will be generated. + * + * ```proto + * message MyDuration { + * // must be less than 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = "5s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lt = 3; + */ + value: Duration; + case: "lt"; + } | { + /** + * `lte` indicates that the field must be less than or equal to the specified + * value of the `google.protobuf.Duration` type, inclusive. If the field's value is greater than the specified value, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // must be less than or equal to 10s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lte = "10s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lte = 4; + */ + value: Duration; + case: "lte"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DurationRules.greater_than + */ + greaterThan: { + /** + * `gt` requires the duration field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than 5s [duration.gt] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gt = { seconds: 5 }]; + * + * // duration must be greater than 5s and less than 10s [duration.gt_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gt: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than 10s or less than 5s [duration.gt_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gt: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gt = 5; + */ + value: Duration; + case: "gt"; + } | { + /** + * `gte` requires the duration field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value must + * be outside the specified range. If the field value doesn't meet the + * required conditions, an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than or equal to 5s [duration.gte] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gte = { seconds: 5 }]; + * + * // duration must be greater than or equal to 5s and less than 10s [duration.gte_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gte: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than or equal to 10s or less than 5s [duration.gte_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gte: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gte = 6; + */ + value: Duration; + case: "gte"; + } | { case: undefined; value?: undefined }; + + /** + * `in` asserts that the field must be equal to one of the specified values of the `google.protobuf.Duration` type. + * If the field's value doesn't correspond to any of the specified values, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // must be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration in = 7; + */ + in: Duration[]; + + /** + * `not_in` denotes that the field must not be equal to + * any of the specified values of the `google.protobuf.Duration` type. + * If the field's value matches any of these values, an error message will be + * generated. + * + * ```proto + * message MyDuration { + * // value must not be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.not_in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration not_in = 8; + */ + notIn: Duration[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDuration { + * google.protobuf.Duration value = 1 [ + * (buf.validate.field).duration.example = { seconds: 1 }, + * (buf.validate.field).duration.example = { seconds: 2 }, + * ]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration example = 9; + */ + example: Duration[]; +}; + +/** + * Describes the message buf.validate.DurationRules. + * Use `create(DurationRulesSchema)` to create a new message. + */ +export const DurationRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 25); + +/** + * FieldMaskRules describe rules applied exclusively to the `google.protobuf.FieldMask` well-known type. + * + * @generated from message buf.validate.FieldMaskRules + */ +export type FieldMaskRules = Message<"buf.validate.FieldMaskRules"> & { + /** + * `const` dictates that the field must match the specified value of the `google.protobuf.FieldMask` type exactly. + * If the field's value deviates from the specified value, an error message + * will be generated. + * + * ```proto + * message MyFieldMask { + * // value must equal ["a"] + * google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask.const = { + * paths: ["a"] + * }]; + * } + * ``` + * + * @generated from field: optional google.protobuf.FieldMask const = 1; + */ + const?: FieldMask; + + /** + * `in` requires the field value to only contain paths matching specified + * values or their subpaths. + * If any of the field value's paths doesn't match the rule, + * an error message is generated. + * See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + * + * ```proto + * message MyFieldMask { + * // The `value` FieldMask must only contain paths listed in `in`. + * google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + * in: ["a", "b", "c.a"] + * }]; + * } + * ``` + * + * @generated from field: repeated string in = 2; + */ + in: string[]; + + /** + * `not_in` requires the field value to not contain paths matching specified + * values or their subpaths. + * If any of the field value's paths matches the rule, + * an error message is generated. + * See: https://protobuf.dev/reference/protobuf/google.protobuf/#field-mask + * + * ```proto + * message MyFieldMask { + * // The `value` FieldMask shall not contain paths listed in `not_in`. + * google.protobuf.FieldMask value = 1 [(buf.validate.field).field_mask = { + * not_in: ["forbidden", "immutable", "c.a"] + * }]; + * } + * ``` + * + * @generated from field: repeated string not_in = 3; + */ + notIn: string[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFieldMask { + * google.protobuf.FieldMask value = 1 [ + * (buf.validate.field).field_mask.example = { paths: ["a", "b"] }, + * (buf.validate.field).field_mask.example = { paths: ["c.a", "d"] }, + * ]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.FieldMask example = 4; + */ + example: FieldMask[]; +}; + +/** + * Describes the message buf.validate.FieldMaskRules. + * Use `create(FieldMaskRulesSchema)` to create a new message. + */ +export const FieldMaskRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 26); + +/** + * TimestampRules describe the rules applied exclusively to the `google.protobuf.Timestamp` well-known type. + * + * @generated from message buf.validate.TimestampRules + */ +export type TimestampRules = Message<"buf.validate.TimestampRules"> & { + /** + * `const` dictates that this field, of the `google.protobuf.Timestamp` type, must exactly match the specified value. If the field value doesn't correspond to the specified timestamp, an error message will be generated. + * + * ```proto + * message MyTimestamp { + * // value must equal 2023-05-03T10:00:00Z + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.const = {seconds: 1727998800}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Timestamp const = 2; + */ + const?: Timestamp; + + /** + * @generated from oneof buf.validate.TimestampRules.less_than + */ + lessThan: { + /** + * `lt` requires the timestamp field value to be less than the specified value (field < value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be less than '2023-01-01T00:00:00Z' [timestamp.lt] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lt = { seconds: 1672444800 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lt = 3; + */ + value: Timestamp; + case: "lt"; + } | { + /** + * `lte` requires the timestamp field value to be less than or equal to the specified value (field <= value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be less than or equal to '2023-05-14T00:00:00Z' [timestamp.lte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lte = { seconds: 1678867200 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lte = 4; + */ + value: Timestamp; + case: "lte"; + } | { + /** + * `lt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be less than the current time. `lt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // must be less than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true]; + * } + * ``` + * + * @generated from field: bool lt_now = 7; + */ + value: boolean; + case: "ltNow"; + } | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.TimestampRules.greater_than + */ + greaterThan: { + /** + * `gt` requires the timestamp field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than '2023-01-01T00:00:00Z' [timestamp.gt] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gt = { seconds: 1672444800 }]; + * + * // timestamp must be greater than '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gt_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gt: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gt_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gt: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gt = 5; + */ + value: Timestamp; + case: "gt"; + } | { + /** + * `gte` requires the timestamp field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value + * must be outside the specified range. If the field value doesn't meet + * the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' [timestamp.gte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gte = { seconds: 1672444800 }]; + * + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gte_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gte: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than or equal to '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gte_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gte: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gte = 6; + */ + value: Timestamp; + case: "gte"; + } | { + /** + * `gt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be greater than the current time. `gt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // must be greater than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.gt_now = true]; + * } + * ``` + * + * @generated from field: bool gt_now = 8; + */ + value: boolean; + case: "gtNow"; + } | { case: undefined; value?: undefined }; + + /** + * `within` specifies that this field, of the `google.protobuf.Timestamp` type, must be within the specified duration of the current time. If the field value isn't within the duration, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // must be within 1 hour of now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.within = {seconds: 3600}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration within = 9; + */ + within?: Duration; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyTimestamp { + * google.protobuf.Timestamp value = 1 [ + * (buf.validate.field).timestamp.example = { seconds: 1672444800 }, + * (buf.validate.field).timestamp.example = { seconds: 1672531200 }, + * ]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Timestamp example = 10; + */ + example: Timestamp[]; +}; + +/** + * Describes the message buf.validate.TimestampRules. + * Use `create(TimestampRulesSchema)` to create a new message. + */ +export const TimestampRulesSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 27); + +/** + * `Violations` is a collection of `Violation` messages. This message type is returned by + * Protovalidate when a proto message fails to meet the requirements set by the `Rule` validation rules. + * Each individual violation is represented by a `Violation` message. + * + * @generated from message buf.validate.Violations + */ +export type Violations = Message<"buf.validate.Violations"> & { + /** + * `violations` is a repeated field that contains all the `Violation` messages corresponding to the violations detected. + * + * @generated from field: repeated buf.validate.Violation violations = 1; + */ + violations: Violation[]; +}; + +/** + * Describes the message buf.validate.Violations. + * Use `create(ViolationsSchema)` to create a new message. + */ +export const ViolationsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 28); + +/** + * `Violation` represents a single instance where a validation rule, expressed + * as a `Rule`, was not met. It provides information about the field that + * caused the violation, the specific rule that wasn't fulfilled, and a + * human-readable error message. + * + * For example, consider the following message: + * + * ```proto + * message User { + * int32 age = 1 [(buf.validate.field).cel = { + * id: "user.age", + * expression: "this < 18 ? 'User must be at least 18 years old' : ''", + * }]; + * } + * ``` + * + * It could produce the following violation: + * + * ```json + * { + * "ruleId": "user.age", + * "message": "User must be at least 18 years old", + * "field": { + * "elements": [ + * { + * "fieldNumber": 1, + * "fieldName": "age", + * "fieldType": "TYPE_INT32" + * } + * ] + * }, + * "rule": { + * "elements": [ + * { + * "fieldNumber": 23, + * "fieldName": "cel", + * "fieldType": "TYPE_MESSAGE", + * "index": "0" + * } + * ] + * } + * } + * ``` + * + * @generated from message buf.validate.Violation + */ +export type Violation = Message<"buf.validate.Violation"> & { + /** + * `field` is a machine-readable path to the field that failed validation. + * This could be a nested field, in which case the path will include all the parent fields leading to the actual field that caused the violation. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * } + * ``` + * + * It could produce the following violation: + * + * ```textproto + * violation { + * field { element { field_number: 1, field_name: "a", field_type: 8 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath field = 5; + */ + field?: FieldPath; + + /** + * `rule` is a machine-readable path that points to the specific rule that failed validation. + * This will be a nested field starting from the FieldRules of the field that failed validation. + * For custom rules, this will provide the path of the rule, e.g. `cel[0]`. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * bool b = 2 [(buf.validate.field).cel = { + * id: "custom_rule", + * expression: "!this ? 'b must be true': ''" + * }] + * } + * ``` + * + * It could produce the following violations: + * + * ```textproto + * violation { + * rule { element { field_number: 25, field_name: "required", field_type: 8 } } + * ... + * } + * violation { + * rule { element { field_number: 23, field_name: "cel", field_type: 11, index: 0 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath rule = 6; + */ + rule?: FieldPath; + + /** + * `rule_id` is the unique identifier of the `Rule` that was not fulfilled. + * This is the same `id` that was specified in the `Rule` message, allowing easy tracing of which rule was violated. + * + * @generated from field: optional string rule_id = 2; + */ + ruleId: string; + + /** + * `message` is a human-readable error message that describes the nature of the violation. + * This can be the default error message from the violated `Rule`, or it can be a custom message that gives more context about the violation. + * + * @generated from field: optional string message = 3; + */ + message: string; + + /** + * `for_key` indicates whether the violation was caused by a map key, rather than a value. + * + * @generated from field: optional bool for_key = 4; + */ + forKey: boolean; +}; + +/** + * Describes the message buf.validate.Violation. + * Use `create(ViolationSchema)` to create a new message. + */ +export const ViolationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 29); + +/** + * `FieldPath` provides a path to a nested protobuf field. + * + * This message provides enough information to render a dotted field path even without protobuf descriptors. + * It also provides enough information to resolve a nested field through unknown wire data. + * + * @generated from message buf.validate.FieldPath + */ +export type FieldPath = Message<"buf.validate.FieldPath"> & { + /** + * `elements` contains each element of the path, starting from the root and recursing downward. + * + * @generated from field: repeated buf.validate.FieldPathElement elements = 1; + */ + elements: FieldPathElement[]; +}; + +/** + * Describes the message buf.validate.FieldPath. + * Use `create(FieldPathSchema)` to create a new message. + */ +export const FieldPathSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 30); + +/** + * `FieldPathElement` provides enough information to nest through a single protobuf field. + * + * If the selected field is a map or repeated field, the `subscript` value selects a specific element from it. + * A path that refers to a value nested under a map key or repeated field index will have a `subscript` value. + * The `field_type` field allows unambiguous resolution of a field even if descriptors are not available. + * + * @generated from message buf.validate.FieldPathElement + */ +export type FieldPathElement = Message<"buf.validate.FieldPathElement"> & { + /** + * `field_number` is the field number this path element refers to. + * + * @generated from field: optional int32 field_number = 1; + */ + fieldNumber: number; + + /** + * `field_name` contains the field name this path element refers to. + * This can be used to display a human-readable path even if the field number is unknown. + * + * @generated from field: optional string field_name = 2; + */ + fieldName: string; + + /** + * `field_type` specifies the type of this field. When using reflection, this value is not needed. + * + * This value is provided to make it possible to traverse unknown fields through wire data. + * When traversing wire data, be mindful of both packed[1] and delimited[2] encoding schemes. + * + * [1]: https://protobuf.dev/programming-guides/encoding/#packed + * [2]: https://protobuf.dev/programming-guides/encoding/#groups + * + * N.B.: Although groups are deprecated, the corresponding delimited encoding scheme is not, and + * can be explicitly used in Protocol Buffers 2023 Edition. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type field_type = 3; + */ + fieldType: FieldDescriptorProto_Type; + + /** + * `key_type` specifies the map key type of this field. This value is useful when traversing + * unknown fields through wire data: specifically, it allows handling the differences between + * different integer encodings. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type key_type = 4; + */ + keyType: FieldDescriptorProto_Type; + + /** + * `value_type` specifies map value type of this field. This is useful if you want to display a + * value inside unknown fields through wire data. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type value_type = 5; + */ + valueType: FieldDescriptorProto_Type; + + /** + * `subscript` contains a repeated index or map key, if this path element nests into a repeated or map field. + * + * @generated from oneof buf.validate.FieldPathElement.subscript + */ + subscript: { + /** + * `index` specifies a 0-based index into a repeated field. + * + * @generated from field: uint64 index = 6; + */ + value: bigint; + case: "index"; + } | { + /** + * `bool_key` specifies a map key of type bool. + * + * @generated from field: bool bool_key = 7; + */ + value: boolean; + case: "boolKey"; + } | { + /** + * `int_key` specifies a map key of type int32, int64, sint32, sint64, sfixed32 or sfixed64. + * + * @generated from field: int64 int_key = 8; + */ + value: bigint; + case: "intKey"; + } | { + /** + * `uint_key` specifies a map key of type uint32, uint64, fixed32 or fixed64. + * + * @generated from field: uint64 uint_key = 9; + */ + value: bigint; + case: "uintKey"; + } | { + /** + * `string_key` specifies a map key of type string. + * + * @generated from field: string string_key = 10; + */ + value: string; + case: "stringKey"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldPathElement. + * Use `create(FieldPathElementSchema)` to create a new message. + */ +export const FieldPathElementSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 31); + +/** + * Specifies how `FieldRules.ignore` behaves, depending on the field's value, and + * whether the field tracks presence. + * + * @generated from enum buf.validate.Ignore + */ +export enum Ignore { + /** + * Ignore rules if the field tracks presence and is unset. This is the default + * behavior. + * + * In proto3, only message fields, members of a Protobuf `oneof`, and fields + * with the `optional` label track presence. Consequently, the following fields + * are always validated, whether a value is set or not: + * + * ```proto + * syntax="proto3"; + * + * message RulesApply { + * string email = 1 [ + * (buf.validate.field).string.email = true + * ]; + * int32 age = 2 [ + * (buf.validate.field).int32.gt = 0 + * ]; + * repeated string labels = 3 [ + * (buf.validate.field).repeated.min_items = 1 + * ]; + * } + * ``` + * + * In contrast, the following fields track presence, and are only validated if + * a value is set: + * + * ```proto + * syntax="proto3"; + * + * message RulesApplyIfSet { + * optional string email = 1 [ + * (buf.validate.field).string.email = true + * ]; + * oneof ref { + * string reference = 2 [ + * (buf.validate.field).string.uuid = true + * ]; + * string name = 3 [ + * (buf.validate.field).string.min_len = 4 + * ]; + * } + * SomeMessage msg = 4 [ + * (buf.validate.field).cel = {/* ... *\/} + * ]; + * } + * ``` + * + * To ensure that such a field is set, add the `required` rule. + * + * To learn which fields track presence, see the + * [Field Presence cheat sheet](https://protobuf.dev/programming-guides/field_presence/#cheat). + * + * @generated from enum value: IGNORE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Ignore rules if the field is unset, or set to the zero value. + * + * The zero value depends on the field type: + * - For strings, the zero value is the empty string. + * - For bytes, the zero value is empty bytes. + * - For bool, the zero value is false. + * - For numeric types, the zero value is zero. + * - For enums, the zero value is the first defined enum value. + * - For repeated fields, the zero is an empty list. + * - For map fields, the zero is an empty map. + * - For message fields, absence of the message (typically a null-value) is considered zero value. + * + * For fields that track presence (e.g. adding the `optional` label in proto3), + * this a no-op and behavior is the same as the default `IGNORE_UNSPECIFIED`. + * + * @generated from enum value: IGNORE_IF_ZERO_VALUE = 1; + */ + IF_ZERO_VALUE = 1, + + /** + * Always ignore rules, including the `required` rule. + * + * This is useful for ignoring the rules of a referenced message, or to + * temporarily ignore rules during development. + * + * ```proto + * message MyMessage { + * // The field's rules will always be ignored, including any validations + * // on value's fields. + * MyOtherMessage value = 1 [ + * (buf.validate.field).ignore = IGNORE_ALWAYS + * ]; + * } + * ``` + * + * @generated from enum value: IGNORE_ALWAYS = 3; + */ + ALWAYS = 3, +} + +/** + * Describes the enum buf.validate.Ignore. + */ +export const IgnoreSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_buf_validate_validate, 0); + +/** + * KnownRegex contains some well-known patterns. + * + * @generated from enum buf.validate.KnownRegex + */ +export enum KnownRegex { + /** + * @generated from enum value: KNOWN_REGEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_NAME = 1; + */ + HTTP_HEADER_NAME = 1, + + /** + * HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_VALUE = 2; + */ + HTTP_HEADER_VALUE = 2, +} + +/** + * Describes the enum buf.validate.KnownRegex. + */ +export const KnownRegexSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this message. By default, + * no validation is performed against a message. + * + * @generated from extension: optional buf.validate.MessageRules message = 1159; + */ +export const message: GenExtension = /*@__PURE__*/ + extDesc(file_buf_validate_validate, 0); + +/** + * Rules specify the validations to be performed on this oneof. By default, + * no validation is performed against a oneof. + * + * @generated from extension: optional buf.validate.OneofRules oneof = 1159; + */ +export const oneof: GenExtension = /*@__PURE__*/ + extDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this field. By default, + * no validation is performed against a field. + * + * @generated from extension: optional buf.validate.FieldRules field = 1159; + */ +export const field: GenExtension = /*@__PURE__*/ + extDesc(file_buf_validate_validate, 2); + +/** + * Specifies predefined rules. When extending a standard rule message, + * this adds additional CEL expressions that apply when the extension is used. + * + * ```proto + * extend buf.validate.Int32Rules { + * bool is_zero [(buf.validate.predefined).cel = { + * id: "int32.is_zero", + * message: "must be zero", + * expression: "!rule || this == 0", + * }]; + * } + * + * message Foo { + * int32 reserved = 1 [(buf.validate.field).int32.(is_zero) = true]; + * } + * ``` + * + * @generated from extension: optional buf.validate.PredefinedRules predefined = 1160; + */ +export const predefined: GenExtension = /*@__PURE__*/ + extDesc(file_buf_validate_validate, 3); + diff --git a/packages/protovalidate-bench/src/suites/byte-matching.bench.ts b/packages/protovalidate-bench/src/suites/byte-matching.bench.ts new file mode 100644 index 0000000..bb455e9 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/byte-matching.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { TestByteMatchingSchema } from "../gen/bench/v1/native_pb.js"; +import { testByteMatching } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(TestByteMatchingSchema, testByteMatching); + bench.add("TestByteMatching", () => { + validator.validate(TestByteMatchingSchema, testByteMatching); + }); +} diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts new file mode 100644 index 0000000..43fcf83 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -0,0 +1,34 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; +import { BenchGTSchema } from "../gen/bench/v1/native_pb.js"; +import { benchComplexSchema, benchGT } from "../fixtures.js"; + +// Compile-time benchmarks: build a fresh validator on each iteration and run +// one validate() call so the plan is forced. Mirrors Go's BenchmarkCompile, +// which calls New() in the hot loop. + +export function register(bench: Bench): void { + bench.add("Compile/ComplexSchema", () => { + const v = createValidator(); + v.validate(BenchComplexSchemaSchema, benchComplexSchema); + }); + bench.add("Compile/Int32GT", () => { + const v = createValidator(); + v.validate(BenchGTSchema, benchGT); + }); +} diff --git a/packages/protovalidate-bench/src/suites/complex.bench.ts b/packages/protovalidate-bench/src/suites/complex.bench.ts new file mode 100644 index 0000000..c85c858 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/complex.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; +import { benchComplexSchema } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(BenchComplexSchemaSchema, benchComplexSchema); + bench.add("ComplexSchema", () => { + validator.validate(BenchComplexSchemaSchema, benchComplexSchema); + }); +} diff --git a/packages/protovalidate-bench/src/suites/int32-gt.bench.ts b/packages/protovalidate-bench/src/suites/int32-gt.bench.ts new file mode 100644 index 0000000..e5dcd65 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/int32-gt.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { BenchGTSchema } from "../gen/bench/v1/native_pb.js"; +import { benchGT } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(BenchGTSchema, benchGT); + bench.add("Int32GT", () => { + validator.validate(BenchGTSchema, benchGT); + }); +} diff --git a/packages/protovalidate-bench/src/suites/map.bench.ts b/packages/protovalidate-bench/src/suites/map.bench.ts new file mode 100644 index 0000000..18d6e20 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/map.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { BenchMapSchema } from "../gen/bench/v1/bench_pb.js"; +import { benchMap } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(BenchMapSchema, benchMap); + bench.add("Map", () => { + validator.validate(BenchMapSchema, benchMap); + }); +} diff --git a/packages/protovalidate-bench/src/suites/multirule.bench.ts b/packages/protovalidate-bench/src/suites/multirule.bench.ts new file mode 100644 index 0000000..2070618 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/multirule.bench.ts @@ -0,0 +1,31 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { MultiRuleSchema } from "../gen/bench/v1/native_pb.js"; +import { multiRuleError, multiRuleNoError } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(MultiRuleSchema, multiRuleError); + validator.validate(MultiRuleSchema, multiRuleNoError); + + bench.add("MultiRule/Error", () => { + validator.validate(MultiRuleSchema, multiRuleError); + }); + bench.add("MultiRule/NoError", () => { + validator.validate(MultiRuleSchema, multiRuleNoError); + }); +} diff --git a/packages/protovalidate-bench/src/suites/repeated.bench.ts b/packages/protovalidate-bench/src/suites/repeated.bench.ts new file mode 100644 index 0000000..dae453a --- /dev/null +++ b/packages/protovalidate-bench/src/suites/repeated.bench.ts @@ -0,0 +1,58 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { + BenchRepeatedBytesUniqueSchema, + BenchRepeatedMessageSchema, + BenchRepeatedScalarSchema, + BenchRepeatedScalarUniqueSchema, +} from "../gen/bench/v1/bench_pb.js"; +import { + benchRepeatedBytesUnique, + benchRepeatedMessage, + benchRepeatedScalar, + benchRepeatedScalarUnique, +} from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(BenchRepeatedScalarSchema, benchRepeatedScalar); + validator.validate(BenchRepeatedMessageSchema, benchRepeatedMessage); + validator.validate( + BenchRepeatedScalarUniqueSchema, + benchRepeatedScalarUnique, + ); + validator.validate(BenchRepeatedBytesUniqueSchema, benchRepeatedBytesUnique); + + bench.add("Repeated/Scalar", () => { + validator.validate(BenchRepeatedScalarSchema, benchRepeatedScalar); + }); + bench.add("Repeated/Message", () => { + validator.validate(BenchRepeatedMessageSchema, benchRepeatedMessage); + }); + bench.add("Repeated/Unique/Scalar", () => { + validator.validate( + BenchRepeatedScalarUniqueSchema, + benchRepeatedScalarUnique, + ); + }); + bench.add("Repeated/Unique/Bytes", () => { + validator.validate( + BenchRepeatedBytesUniqueSchema, + benchRepeatedBytesUnique, + ); + }); +} diff --git a/packages/protovalidate-bench/src/suites/scalar.bench.ts b/packages/protovalidate-bench/src/suites/scalar.bench.ts new file mode 100644 index 0000000..e71f254 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/scalar.bench.ts @@ -0,0 +1,27 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { BenchScalarSchema } from "../gen/bench/v1/bench_pb.js"; +import { benchScalar } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + // Warm the planner cache once; equivalent to Go's WithMessages eager compile. + validator.validate(BenchScalarSchema, benchScalar); + bench.add("Scalar", () => { + validator.validate(BenchScalarSchema, benchScalar); + }); +} diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts new file mode 100644 index 0000000..c5ca9ff --- /dev/null +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -0,0 +1,39 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createStandardSchema } from "@bufbuild/protovalidate"; +import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; +import { BenchScalarSchema } from "../gen/bench/v1/bench_pb.js"; +import { benchComplexSchema, benchScalar } from "../fixtures.js"; + +// Standard Schema adapter overhead — TS-only surface, no Go analogue. Compares +// directly with the Scalar and ComplexSchema benches to surface the cost of +// the adapter's path→Issue translation and unknown→typed narrowing. + +export function register(bench: Bench): void { + const scalarSchema = createStandardSchema(BenchScalarSchema); + const complexSchema = createStandardSchema(BenchComplexSchemaSchema); + + // Warm planner. + scalarSchema["~standard"].validate(benchScalar); + complexSchema["~standard"].validate(benchComplexSchema); + + bench.add("StandardSchema/Scalar", () => { + scalarSchema["~standard"].validate(benchScalar); + }); + bench.add("StandardSchema/ComplexSchema", () => { + complexSchema["~standard"].validate(benchComplexSchema); + }); +} diff --git a/packages/protovalidate-bench/src/suites/string-matching.bench.ts b/packages/protovalidate-bench/src/suites/string-matching.bench.ts new file mode 100644 index 0000000..0820e68 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/string-matching.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { StringMatchingSchema } from "../gen/bench/v1/native_pb.js"; +import { stringMatching } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(StringMatchingSchema, stringMatching); + bench.add("StringMatching", () => { + validator.validate(StringMatchingSchema, stringMatching); + }); +} diff --git a/packages/protovalidate-bench/src/suites/wrapper.bench.ts b/packages/protovalidate-bench/src/suites/wrapper.bench.ts new file mode 100644 index 0000000..12a1234 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/wrapper.bench.ts @@ -0,0 +1,26 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Bench } from "tinybench"; +import { createValidator } from "@bufbuild/protovalidate"; +import { WrapperTestingSchema } from "../gen/bench/v1/native_pb.js"; +import { wrapperTesting } from "../fixtures.js"; + +export function register(bench: Bench): void { + const validator = createValidator(); + validator.validate(WrapperTestingSchema, wrapperTesting); + bench.add("WrapperTesting", () => { + validator.validate(WrapperTestingSchema, wrapperTesting); + }); +} diff --git a/packages/protovalidate-bench/tsconfig.json b/packages/protovalidate-bench/tsconfig.json new file mode 100644 index 0000000..335a258 --- /dev/null +++ b/packages/protovalidate-bench/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": ["src/**/*"], + "extends": "../../tsconfig.base.json" +} diff --git a/packages/protovalidate-bench/turbo.json b/packages/protovalidate-bench/turbo.json new file mode 100644 index 0000000..c2fb12e --- /dev/null +++ b/packages/protovalidate-bench/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "bench": { + "dependsOn": ["^build", "generate"], + "cache": false + }, + "generate": { + "dependsOn": ["^build"], + "inputs": ["proto", "buf.gen.yaml", "buf.yaml", "package.json"], + "outputs": ["src/gen/**"], + "outputLogs": "new-only" + } + } +} From 9a6d8b436eea0824047f72ebcbf66dff80362f16 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 13 May 2026 17:20:52 -0400 Subject: [PATCH 02/38] Add disableNativeRules option and native rule dispatcher seam Phase 0 of porting protovalidate-go's native rule evaluation. This change introduces the surface and plumbing without yet replacing any CEL rule. - Adds `disableNativeRules?: boolean` to `ValidatorOptions` (default false) - Threads the resolved `regexMatch` and the new flag from `createValidator` through `Planner` so future phases have a single source of truth for both - Scaffolds `src/native/`: dispatcher stub returning `{kind:"none"}`, plus `codepointLength` and `printFloat` helpers used by upcoming handlers - Wires `Planner.rules()` to consult the dispatcher and skip CEL enrollment for any field it claims, appending the native eval to the resulting `EvalMany`. With the stub this is behavior-preserving. Unit tests cover the new format helpers and assert that the native-on and native-off paths produce identical violations. Conformance stays green (2870 passed, 2 expected skips). Benchmark deltas vs `jbodner/add-benchmarks` sit within the 5% threshold across all 17 suites. --- .../protovalidate/src/native/dispatcher.ts | 68 +++++++++++++++++++ .../protovalidate/src/native/format.test.ts | 66 ++++++++++++++++++ packages/protovalidate/src/native/format.ts | 47 +++++++++++++ packages/protovalidate/src/native/index.ts | 19 ++++++ packages/protovalidate/src/planner.ts | 25 ++++++- packages/protovalidate/src/validator.test.ts | 39 +++++++++++ packages/protovalidate/src/validator.ts | 21 +++++- 7 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 packages/protovalidate/src/native/dispatcher.ts create mode 100644 packages/protovalidate/src/native/format.test.ts create mode 100644 packages/protovalidate/src/native/format.ts create mode 100644 packages/protovalidate/src/native/index.ts diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts new file mode 100644 index 0000000..041fed3 --- /dev/null +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -0,0 +1,68 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DescField } from "@bufbuild/protobuf"; +import type { + PathBuilder, + ReflectMessageGet, +} from "@bufbuild/protobuf/reflect"; +import type { FieldRules } from "../gen/buf/validate/validate_pb.js"; +import type { Eval } from "../eval.js"; +import type { RegexMatcher } from "../func.js"; + +/** + * Result of {@link tryBuildNative}. + * + * - "none": no native handler applies; the planner enrolls every set field in + * the CEL evaluator as it does today. + * - "partial" / "full": at least one field is handled natively. The planner + * skips CEL enrollment for fields in `handledFields` and appends `eval` to + * the rule's `EvalMany`. "full" indicates every set field on the rules + * message was handled natively, so the trailing `EvalStandardRulesCel` will + * be empty and pruned. + */ +export type NativeDispatchResult = + | { kind: "none" } + | { + kind: "partial" | "full"; + eval: Eval; + handledFields: ReadonlySet; + }; + +/** + * Inputs to the native rule dispatcher. + * + * Future phases add per-field-type evaluators here. Phase 0 wires the seam + * but always returns `{ kind: "none" }`. + */ +export type NativeDispatchInput = { + rules: Exclude; + rulePath: PathBuilder; + forMapKey: boolean; + regexMatch: RegexMatcher | undefined; +}; + +/** + * Decide whether the given rules submessage can be evaluated natively, and + * return an `Eval` for the handled subset plus the set of rule fields that + * have been claimed (so the planner skips them on the CEL path). + * + * Phase 0: stub. Returns `{ kind: "none" }` so the CEL path handles every + * rule exactly as before. + */ +export function tryBuildNative( + _input: NativeDispatchInput, +): NativeDispatchResult { + return { kind: "none" }; +} diff --git a/packages/protovalidate/src/native/format.test.ts b/packages/protovalidate/src/native/format.test.ts new file mode 100644 index 0000000..4f71d6a --- /dev/null +++ b/packages/protovalidate/src/native/format.test.ts @@ -0,0 +1,66 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { codepointLength, printFloat } from "./format.js"; + +void suite("codepointLength", () => { + void test("counts ASCII as one per char", () => { + assert.strictEqual(codepointLength(""), 0); + assert.strictEqual(codepointLength("a"), 1); + assert.strictEqual(codepointLength("abc"), 3); + }); + + void test("counts surrogate pair as one code point", () => { + // U+1D44E MATHEMATICAL ITALIC SMALL A → surrogate pair "𝑎" + const s = "𝑎"; + assert.strictEqual(s.length, 2); + assert.strictEqual(codepointLength(s), 1); + }); + + void test("counts combining marks separately", () => { + // "é" as e + combining acute accent: 2 code points. + const s = "é"; + assert.strictEqual(codepointLength(s), 2); + }); + + void test("matches CEL size() spread-based count", () => { + const cases = ["", "x", "𝑎b", "🇺🇸", "héllo"]; + for (const s of cases) { + assert.strictEqual(codepointLength(s), [...s].length); + } + }); +}); + +void suite("printFloat", () => { + void test("formats finite numbers via toString", () => { + assert.strictEqual(printFloat(0), "0"); + assert.strictEqual(printFloat(1), "1"); + assert.strictEqual(printFloat(-1.5), "-1.5"); + assert.strictEqual(printFloat(1e20), "100000000000000000000"); + }); + + void test("formats NaN", () => { + assert.strictEqual(printFloat(Number.NaN), "NaN"); + }); + + void test("formats +Infinity", () => { + assert.strictEqual(printFloat(Number.POSITIVE_INFINITY), "Infinity"); + }); + + void test("formats -Infinity", () => { + assert.strictEqual(printFloat(Number.NEGATIVE_INFINITY), "-Infinity"); + }); +}); diff --git a/packages/protovalidate/src/native/format.ts b/packages/protovalidate/src/native/format.ts new file mode 100644 index 0000000..370407b --- /dev/null +++ b/packages/protovalidate/src/native/format.ts @@ -0,0 +1,47 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Number of Unicode code points in a string. + * + * Matches CEL's `size(string)` semantics — counting code points rather than + * UTF-16 code units. A surrogate pair like "𝑎" counts as 1. + */ +export function codepointLength(s: string): number { + // String iteration yields one element per code point. + let n = 0; + for (const _ of s) { + n++; + } + return n; +} + +/** + * Format a number for inclusion in a violation message. + * + * Mirrors protovalidate-go's `printFloat` so error messages match the CEL + * implementation byte-for-byte. + */ +export function printFloat(n: number): string { + if (Number.isNaN(n)) { + return "NaN"; + } + if (n === Number.POSITIVE_INFINITY) { + return "Infinity"; + } + if (n === Number.NEGATIVE_INFINITY) { + return "-Infinity"; + } + return n.toString(); +} diff --git a/packages/protovalidate/src/native/index.ts b/packages/protovalidate/src/native/index.ts new file mode 100644 index 0000000..8117899 --- /dev/null +++ b/packages/protovalidate/src/native/index.ts @@ -0,0 +1,19 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { + tryBuildNative, + type NativeDispatchInput, + type NativeDispatchResult, +} from "./dispatcher.js"; diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index af68d42..020e36f 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -79,6 +79,8 @@ import { EvalStandardRulesCel, } from "./cel.js"; import { CompilationError } from "./error.js"; +import { tryBuildNative } from "./native/index.js"; +import type { RegexMatcher } from "./func.js"; export class Planner { private readonly messageCache = new Map>(); @@ -86,6 +88,8 @@ export class Planner { constructor( private readonly celMan: CelManager, private readonly legacyRequired: boolean, + private readonly disableNativeRules: boolean, + private readonly regexMatch: RegexMatcher | undefined, ) {} plan(message: DescMessage): Eval { @@ -431,15 +435,27 @@ export class Planner { ) { const ruleDesc = getRuleDescriptor(rules.$typeName); const prepared = this.celMan.compileRules(ruleDesc); + const native = this.disableNativeRules + ? ({ kind: "none" } as const) + : tryBuildNative({ + rules, + rulePath, + forMapKey, + regexMatch: this.regexMatch, + }); const evalStandard = new EvalStandardRulesCel( this.celMan, rules, forMapKey, ); + const handled = native.kind === "none" ? undefined : native.handledFields; for (const plan of prepared.standard) { if (!isFieldSet(rules, plan.field)) { continue; } + if (handled?.has(plan.field)) { + continue; + } evalStandard.add( plan.compiled, rulePath.clone().field(plan.field).toPath(), @@ -468,7 +484,14 @@ export class Planner { } } } - return new EvalMany(evalStandard, evalExtended); + const combined = new EvalMany( + evalStandard, + evalExtended, + ); + if (native.kind !== "none") { + combined.add(native.eval); + } + return combined; } private messageCel(messageRules: MessageRules): Eval { diff --git a/packages/protovalidate/src/validator.test.ts b/packages/protovalidate/src/validator.test.ts index aeb6fcc..fa52188 100644 --- a/packages/protovalidate/src/validator.test.ts +++ b/packages/protovalidate/src/validator.test.ts @@ -296,6 +296,45 @@ void suite("Validator", () => { assert.equal(result.kind, "valid"); }); }); + void suite("option disableNativeRules", () => { + const schema = compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + message Example { + int32 n = 1 [(buf.validate.field).int32.gt = 0]; + string s = 2 [(buf.validate.field).string.min_len = 3]; + } + `, + bufCompileOptions, + ); + const invalid = create(schema, { n: 0, s: "ab" }); + const valid = create(schema, { n: 1, s: "abc" }); + void test("createValidator accepts the option", () => { + const v = createValidator({ disableNativeRules: true }); + assert.ok(typeof v.validate == "function"); + }); + void test("default and disabled paths agree on a valid message", () => { + const def = createValidator().validate(schema, valid); + const off = createValidator({ disableNativeRules: true }).validate( + schema, + valid, + ); + assert.equal(def.kind, "valid"); + assert.equal(off.kind, "valid"); + }); + void test("default and disabled paths produce identical violations", () => { + const def = createValidator().validate(schema, invalid); + const off = createValidator({ disableNativeRules: true }).validate( + schema, + invalid, + ); + assert.equal(def.kind, "invalid"); + assert.equal(off.kind, "invalid"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(def.violations?.map(fmt), off.violations?.map(fmt)); + }); + }); void suite("predefined rules", () => { const descFile = compileFile( ` diff --git a/packages/protovalidate/src/validator.ts b/packages/protovalidate/src/validator.ts index 4e2abea..33c2a94 100644 --- a/packages/protovalidate/src/validator.ts +++ b/packages/protovalidate/src/validator.ts @@ -77,6 +77,17 @@ export type ValidatorOptions = { * By default, legacy required field are not validated. */ legacyRequired?: boolean; + + /** + * Disable the native (non-CEL) implementation of standard rules. + * + * By default, validation uses hand-written checks for most standard + * buf.validate.field rules. Setting this option to true forces every + * standard rule through CEL instead. Behavior is identical either way; + * the flag exists to isolate the native path during debugging or to + * compare conformance. + */ + disableNativeRules?: boolean; }; /** @@ -136,8 +147,14 @@ export function createValidator(opt?: ValidatorOptions): Validator { ? createMutableRegistry(opt.registry, file_buf_validate_validate) : createMutableRegistry(file_buf_validate_validate); const failFast = opt?.failFast ?? false; - const celMan = new CelManager(registry, opt?.regexMatch); - const planner = new Planner(celMan, opt?.legacyRequired ?? false); + const regexMatch = opt?.regexMatch; + const celMan = new CelManager(registry, regexMatch); + const planner = new Planner( + celMan, + opt?.legacyRequired ?? false, + opt?.disableNativeRules ?? false, + regexMatch, + ); return { validate< Desc extends DescMessage, From dacf5c0e0c3b9799624e04a6afa71700ed7151df Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 10:30:58 -0400 Subject: [PATCH 03/38] Consolidate bench suites into a single case registry Eleven near-identical .bench.ts files have collapsed to four: cases.ts lists every (name, schema, fixture) triple in one place, validate.bench.ts iterates it for the per-case validate-time benches, and compile.bench.ts plus standard-schema.bench.ts look up curated subsets by name. Adding a benchmark is now a one-row append to cases.ts plus a fixture in fixtures.ts instead of new-file + import + register call in bench.ts. Bench output is byte-identical: same 17 tasks, same names, same ordering, deltas within the noise floor. --- packages/protovalidate-bench/src/bench.ts | 20 +-- .../src/suites/byte-matching.bench.ts | 26 ---- .../protovalidate-bench/src/suites/cases.ts | 130 ++++++++++++++++++ .../src/suites/compile.bench.ts | 21 ++- .../src/suites/complex.bench.ts | 26 ---- .../src/suites/int32-gt.bench.ts | 26 ---- .../src/suites/map.bench.ts | 26 ---- .../src/suites/multirule.bench.ts | 31 ----- .../src/suites/repeated.bench.ts | 58 -------- .../src/suites/scalar.bench.ts | 27 ---- .../src/suites/standard-schema.bench.ts | 31 ++--- .../src/suites/string-matching.bench.ts | 26 ---- .../{wrapper.bench.ts => validate.bench.ts} | 17 ++- 13 files changed, 166 insertions(+), 299 deletions(-) delete mode 100644 packages/protovalidate-bench/src/suites/byte-matching.bench.ts create mode 100644 packages/protovalidate-bench/src/suites/cases.ts delete mode 100644 packages/protovalidate-bench/src/suites/complex.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/int32-gt.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/map.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/multirule.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/repeated.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/scalar.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/string-matching.bench.ts rename packages/protovalidate-bench/src/suites/{wrapper.bench.ts => validate.bench.ts} (67%) diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index 6ef84be..9f562a5 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -15,15 +15,7 @@ import { Bench } from "tinybench"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { register as registerScalar } from "./suites/scalar.bench.js"; -import { register as registerRepeated } from "./suites/repeated.bench.js"; -import { register as registerMap } from "./suites/map.bench.js"; -import { register as registerComplex } from "./suites/complex.bench.js"; -import { register as registerInt32GT } from "./suites/int32-gt.bench.js"; -import { register as registerByteMatching } from "./suites/byte-matching.bench.js"; -import { register as registerStringMatching } from "./suites/string-matching.bench.js"; -import { register as registerWrapper } from "./suites/wrapper.bench.js"; -import { register as registerMultiRule } from "./suites/multirule.bench.js"; +import { register as registerValidate } from "./suites/validate.bench.js"; import { register as registerCompile } from "./suites/compile.bench.js"; import { register as registerStandardSchema } from "./suites/standard-schema.bench.js"; @@ -99,15 +91,7 @@ const bench = new Bench({ warmupIterations: opts.warmupIterations, }); -registerScalar(bench); -registerRepeated(bench); -registerMap(bench); -registerComplex(bench); -registerInt32GT(bench); -registerByteMatching(bench); -registerStringMatching(bench); -registerWrapper(bench); -registerMultiRule(bench); +registerValidate(bench); registerCompile(bench); registerStandardSchema(bench); diff --git a/packages/protovalidate-bench/src/suites/byte-matching.bench.ts b/packages/protovalidate-bench/src/suites/byte-matching.bench.ts deleted file mode 100644 index bb455e9..0000000 --- a/packages/protovalidate-bench/src/suites/byte-matching.bench.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { TestByteMatchingSchema } from "../gen/bench/v1/native_pb.js"; -import { testByteMatching } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(TestByteMatchingSchema, testByteMatching); - bench.add("TestByteMatching", () => { - validator.validate(TestByteMatchingSchema, testByteMatching); - }); -} diff --git a/packages/protovalidate-bench/src/suites/cases.ts b/packages/protovalidate-bench/src/suites/cases.ts new file mode 100644 index 0000000..830bdc9 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/cases.ts @@ -0,0 +1,130 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DescMessage, Message } from "@bufbuild/protobuf"; +import { + BenchComplexSchemaSchema, + BenchMapSchema, + BenchRepeatedBytesUniqueSchema, + BenchRepeatedMessageSchema, + BenchRepeatedScalarSchema, + BenchRepeatedScalarUniqueSchema, + BenchScalarSchema, +} from "../gen/bench/v1/bench_pb.js"; +import { + BenchGTSchema, + MultiRuleSchema, + StringMatchingSchema, + TestByteMatchingSchema, + WrapperTestingSchema, +} from "../gen/bench/v1/native_pb.js"; +import { + benchComplexSchema, + benchGT, + benchMap, + benchRepeatedBytesUnique, + benchRepeatedMessage, + benchRepeatedScalar, + benchRepeatedScalarUnique, + benchScalar, + multiRuleError, + multiRuleNoError, + stringMatching, + testByteMatching, + wrapperTesting, +} from "../fixtures.js"; + +/** + * One bench case: a schema, a fixture, and the name to record under. + */ +export type BenchCase = { + name: string; + schema: DescMessage; + fixture: Message; +}; + +/** + * Every (schema, fixture) pair used by the validate-time benches. + * + * To add a benchmark, add the fixture to fixtures.ts and append a row here. + * `validate.bench.ts` iterates this list; `compile.bench.ts` and + * `standard-schema.bench.ts` reference individual entries by name. + */ +export const cases: readonly BenchCase[] = [ + { name: "Scalar", schema: BenchScalarSchema, fixture: benchScalar }, + { + name: "Repeated/Scalar", + schema: BenchRepeatedScalarSchema, + fixture: benchRepeatedScalar, + }, + { + name: "Repeated/Message", + schema: BenchRepeatedMessageSchema, + fixture: benchRepeatedMessage, + }, + { + name: "Repeated/Unique/Scalar", + schema: BenchRepeatedScalarUniqueSchema, + fixture: benchRepeatedScalarUnique, + }, + { + name: "Repeated/Unique/Bytes", + schema: BenchRepeatedBytesUniqueSchema, + fixture: benchRepeatedBytesUnique, + }, + { name: "Map", schema: BenchMapSchema, fixture: benchMap }, + { + name: "ComplexSchema", + schema: BenchComplexSchemaSchema, + fixture: benchComplexSchema, + }, + { name: "Int32GT", schema: BenchGTSchema, fixture: benchGT }, + { + name: "TestByteMatching", + schema: TestByteMatchingSchema, + fixture: testByteMatching, + }, + { + name: "StringMatching", + schema: StringMatchingSchema, + fixture: stringMatching, + }, + { + name: "WrapperTesting", + schema: WrapperTestingSchema, + fixture: wrapperTesting, + }, + { + name: "MultiRule/Error", + schema: MultiRuleSchema, + fixture: multiRuleError, + }, + { + name: "MultiRule/NoError", + schema: MultiRuleSchema, + fixture: multiRuleNoError, + }, +]; + +/** + * Look up a single case by name. Throws if no case matches — used by suites + * that pick a curated subset (e.g. compile, standard-schema benches). + */ +export function caseByName(name: string): BenchCase { + const c = cases.find((c) => c.name === name); + if (!c) { + throw new Error(`no bench case named "${name}"`); + } + return c; +} diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts index 43fcf83..401783c 100644 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -14,21 +14,20 @@ import type { Bench } from "tinybench"; import { createValidator } from "@bufbuild/protovalidate"; -import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; -import { BenchGTSchema } from "../gen/bench/v1/native_pb.js"; -import { benchComplexSchema, benchGT } from "../fixtures.js"; +import { caseByName } from "./cases.js"; // Compile-time benchmarks: build a fresh validator on each iteration and run // one validate() call so the plan is forced. Mirrors Go's BenchmarkCompile, // which calls New() in the hot loop. +const compileTargets = ["ComplexSchema", "Int32GT"] as const; + export function register(bench: Bench): void { - bench.add("Compile/ComplexSchema", () => { - const v = createValidator(); - v.validate(BenchComplexSchemaSchema, benchComplexSchema); - }); - bench.add("Compile/Int32GT", () => { - const v = createValidator(); - v.validate(BenchGTSchema, benchGT); - }); + for (const name of compileTargets) { + const c = caseByName(name); + bench.add(`Compile/${c.name}`, () => { + const v = createValidator(); + v.validate(c.schema, c.fixture); + }); + } } diff --git a/packages/protovalidate-bench/src/suites/complex.bench.ts b/packages/protovalidate-bench/src/suites/complex.bench.ts deleted file mode 100644 index c85c858..0000000 --- a/packages/protovalidate-bench/src/suites/complex.bench.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; -import { benchComplexSchema } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(BenchComplexSchemaSchema, benchComplexSchema); - bench.add("ComplexSchema", () => { - validator.validate(BenchComplexSchemaSchema, benchComplexSchema); - }); -} diff --git a/packages/protovalidate-bench/src/suites/int32-gt.bench.ts b/packages/protovalidate-bench/src/suites/int32-gt.bench.ts deleted file mode 100644 index e5dcd65..0000000 --- a/packages/protovalidate-bench/src/suites/int32-gt.bench.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { BenchGTSchema } from "../gen/bench/v1/native_pb.js"; -import { benchGT } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(BenchGTSchema, benchGT); - bench.add("Int32GT", () => { - validator.validate(BenchGTSchema, benchGT); - }); -} diff --git a/packages/protovalidate-bench/src/suites/map.bench.ts b/packages/protovalidate-bench/src/suites/map.bench.ts deleted file mode 100644 index 18d6e20..0000000 --- a/packages/protovalidate-bench/src/suites/map.bench.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { BenchMapSchema } from "../gen/bench/v1/bench_pb.js"; -import { benchMap } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(BenchMapSchema, benchMap); - bench.add("Map", () => { - validator.validate(BenchMapSchema, benchMap); - }); -} diff --git a/packages/protovalidate-bench/src/suites/multirule.bench.ts b/packages/protovalidate-bench/src/suites/multirule.bench.ts deleted file mode 100644 index 2070618..0000000 --- a/packages/protovalidate-bench/src/suites/multirule.bench.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { MultiRuleSchema } from "../gen/bench/v1/native_pb.js"; -import { multiRuleError, multiRuleNoError } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(MultiRuleSchema, multiRuleError); - validator.validate(MultiRuleSchema, multiRuleNoError); - - bench.add("MultiRule/Error", () => { - validator.validate(MultiRuleSchema, multiRuleError); - }); - bench.add("MultiRule/NoError", () => { - validator.validate(MultiRuleSchema, multiRuleNoError); - }); -} diff --git a/packages/protovalidate-bench/src/suites/repeated.bench.ts b/packages/protovalidate-bench/src/suites/repeated.bench.ts deleted file mode 100644 index dae453a..0000000 --- a/packages/protovalidate-bench/src/suites/repeated.bench.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { - BenchRepeatedBytesUniqueSchema, - BenchRepeatedMessageSchema, - BenchRepeatedScalarSchema, - BenchRepeatedScalarUniqueSchema, -} from "../gen/bench/v1/bench_pb.js"; -import { - benchRepeatedBytesUnique, - benchRepeatedMessage, - benchRepeatedScalar, - benchRepeatedScalarUnique, -} from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(BenchRepeatedScalarSchema, benchRepeatedScalar); - validator.validate(BenchRepeatedMessageSchema, benchRepeatedMessage); - validator.validate( - BenchRepeatedScalarUniqueSchema, - benchRepeatedScalarUnique, - ); - validator.validate(BenchRepeatedBytesUniqueSchema, benchRepeatedBytesUnique); - - bench.add("Repeated/Scalar", () => { - validator.validate(BenchRepeatedScalarSchema, benchRepeatedScalar); - }); - bench.add("Repeated/Message", () => { - validator.validate(BenchRepeatedMessageSchema, benchRepeatedMessage); - }); - bench.add("Repeated/Unique/Scalar", () => { - validator.validate( - BenchRepeatedScalarUniqueSchema, - benchRepeatedScalarUnique, - ); - }); - bench.add("Repeated/Unique/Bytes", () => { - validator.validate( - BenchRepeatedBytesUniqueSchema, - benchRepeatedBytesUnique, - ); - }); -} diff --git a/packages/protovalidate-bench/src/suites/scalar.bench.ts b/packages/protovalidate-bench/src/suites/scalar.bench.ts deleted file mode 100644 index e71f254..0000000 --- a/packages/protovalidate-bench/src/suites/scalar.bench.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { BenchScalarSchema } from "../gen/bench/v1/bench_pb.js"; -import { benchScalar } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - // Warm the planner cache once; equivalent to Go's WithMessages eager compile. - validator.validate(BenchScalarSchema, benchScalar); - bench.add("Scalar", () => { - validator.validate(BenchScalarSchema, benchScalar); - }); -} diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index c5ca9ff..ee14308 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -14,26 +14,21 @@ import type { Bench } from "tinybench"; import { createStandardSchema } from "@bufbuild/protovalidate"; -import { BenchComplexSchemaSchema } from "../gen/bench/v1/bench_pb.js"; -import { BenchScalarSchema } from "../gen/bench/v1/bench_pb.js"; -import { benchComplexSchema, benchScalar } from "../fixtures.js"; +import { caseByName } from "./cases.js"; // Standard Schema adapter overhead — TS-only surface, no Go analogue. Compares -// directly with the Scalar and ComplexSchema benches to surface the cost of -// the adapter's path→Issue translation and unknown→typed narrowing. +// directly with the matching Scalar/ComplexSchema validate benches to surface +// the cost of the adapter's path→Issue translation and unknown→typed narrowing. -export function register(bench: Bench): void { - const scalarSchema = createStandardSchema(BenchScalarSchema); - const complexSchema = createStandardSchema(BenchComplexSchemaSchema); - - // Warm planner. - scalarSchema["~standard"].validate(benchScalar); - complexSchema["~standard"].validate(benchComplexSchema); +const adapterTargets = ["Scalar", "ComplexSchema"] as const; - bench.add("StandardSchema/Scalar", () => { - scalarSchema["~standard"].validate(benchScalar); - }); - bench.add("StandardSchema/ComplexSchema", () => { - complexSchema["~standard"].validate(benchComplexSchema); - }); +export function register(bench: Bench): void { + for (const name of adapterTargets) { + const c = caseByName(name); + const adapter = createStandardSchema(c.schema); + adapter["~standard"].validate(c.fixture); // warm + bench.add(`StandardSchema/${c.name}`, () => { + adapter["~standard"].validate(c.fixture); + }); + } } diff --git a/packages/protovalidate-bench/src/suites/string-matching.bench.ts b/packages/protovalidate-bench/src/suites/string-matching.bench.ts deleted file mode 100644 index 0820e68..0000000 --- a/packages/protovalidate-bench/src/suites/string-matching.bench.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { StringMatchingSchema } from "../gen/bench/v1/native_pb.js"; -import { stringMatching } from "../fixtures.js"; - -export function register(bench: Bench): void { - const validator = createValidator(); - validator.validate(StringMatchingSchema, stringMatching); - bench.add("StringMatching", () => { - validator.validate(StringMatchingSchema, stringMatching); - }); -} diff --git a/packages/protovalidate-bench/src/suites/wrapper.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts similarity index 67% rename from packages/protovalidate-bench/src/suites/wrapper.bench.ts rename to packages/protovalidate-bench/src/suites/validate.bench.ts index 12a1234..5c25266 100644 --- a/packages/protovalidate-bench/src/suites/wrapper.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -14,13 +14,18 @@ import type { Bench } from "tinybench"; import { createValidator } from "@bufbuild/protovalidate"; -import { WrapperTestingSchema } from "../gen/bench/v1/native_pb.js"; -import { wrapperTesting } from "../fixtures.js"; +import { cases } from "./cases.js"; + +// Validate-time benches: a single validator is warmed once per case and then +// reused across iterations, matching Go's BenchmarkValidate*. The set of +// cases lives in cases.ts — add a row there to add a benchmark. export function register(bench: Bench): void { const validator = createValidator(); - validator.validate(WrapperTestingSchema, wrapperTesting); - bench.add("WrapperTesting", () => { - validator.validate(WrapperTestingSchema, wrapperTesting); - }); + for (const c of cases) { + validator.validate(c.schema, c.fixture); // warm the planner cache + bench.add(c.name, () => { + validator.validate(c.schema, c.fixture); + }); + } } From a37e50aeb3b7fc55d925122335d600c59ba7404c Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 10:41:04 -0400 Subject: [PATCH 04/38] fix license header in checkbench.js --- packages/protovalidate-bench/scripts/checkbench.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/scripts/checkbench.js index f7bbe6e..c1cb840 100755 --- a/packages/protovalidate-bench/scripts/checkbench.js +++ b/packages/protovalidate-bench/scripts/checkbench.js @@ -1,4 +1,5 @@ #!/usr/bin/env node + // Copyright 2024-2026 Buf Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); From fcb9a0c7217335f1277a921e00a776515a0284e5 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 11:27:53 -0400 Subject: [PATCH 05/38] Add native rule handlers for bool and the 12 numeric scalar types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the protovalidate-go native rules port. Every standard rule on bool, int32/int64/uint32/uint64/sint32/sint64/fixed32/fixed64/sfixed32/ sfixed64/float/double — const, gt, gte, lt, lte, in, not_in, and finite for float/double — now runs through a hand-written TS evaluator instead of CEL. A WrappedValueEval adapter handles the matching `google.protobuf.*Value` wrapper messages so wrapper fields validated against scalar rules go through the same native path. The dispatcher in planner.ts skips CEL enrollment for fields the native path claims and falls through to CEL for fields it doesn't (unknown extensions, NaN-bound rules, anything not yet ported). Rule paths, rule IDs (including compound ones like int32.gt_lt_exclusive), and violation messages are byte-identical to the CEL output. Conformance: 2870 pass / 2 expected skips (matches baseline). Unit tests: 857 pass — 44 new diff-based tests assert native and CEL violation arrays match for every rule on every scalar type plus every wrapper type. Benchmark deltas vs phase 1 baseline (mean latency): Scalar -85.7% Int32GT -90.0% MultiRule/NoError -88.4% MultiRule/Error -58.5% Repeated/Message -85.0% WrapperTesting -78.7% ComplexSchema -63.9% StandardSchema/Scalar -85.3% StandardSchema/ComplexSchema -62.4% String/bytes/map suites unchanged within noise; 0 regressions past 5%. Updates the protovalidate test script to walk src/**/*.test.ts so the new suites under src/native/ are picked up by `npm test`. --- packages/protovalidate/package.json | 2 +- .../protovalidate/src/native/bool.test.ts | 87 +++ packages/protovalidate/src/native/bool.ts | 77 +++ .../protovalidate/src/native/dispatcher.ts | 166 ++++- .../protovalidate/src/native/numeric.test.ts | 336 ++++++++++ packages/protovalidate/src/native/numeric.ts | 625 ++++++++++++++++++ packages/protovalidate/src/native/sites.ts | 108 +++ packages/protovalidate/src/native/wrapper.ts | 58 ++ packages/protovalidate/src/planner.ts | 12 +- 9 files changed, 1459 insertions(+), 12 deletions(-) create mode 100644 packages/protovalidate/src/native/bool.test.ts create mode 100644 packages/protovalidate/src/native/bool.ts create mode 100644 packages/protovalidate/src/native/numeric.test.ts create mode 100644 packages/protovalidate/src/native/numeric.ts create mode 100644 packages/protovalidate/src/native/sites.ts create mode 100644 packages/protovalidate/src/native/wrapper.ts diff --git a/packages/protovalidate/package.json b/packages/protovalidate/package.json index a213c95..6483a86 100644 --- a/packages/protovalidate/package.json +++ b/packages/protovalidate/package.json @@ -20,7 +20,7 @@ "postfetch-proto": "license-header proto", "generate": "buf generate", "postgenerate": "license-header src/gen", - "test": "npx tsx --test ./src/*.test.ts", + "test": "npx tsx --test ./src/*.test.ts ./src/**/*.test.ts", "prebuild": "rm -rf ./dist/*", "build": "npm run build:cjs && npm run build:esm", "build:cjs": "tsc --project tsconfig.json --module commonjs --verbatimModuleSyntax false --moduleResolution node10 --outDir ./dist/cjs && echo >./dist/cjs/package.json '{\"type\":\"commonjs\"}'", diff --git a/packages/protovalidate/src/native/bool.test.ts b/packages/protovalidate/src/native/bool.test.ts new file mode 100644 index 0000000..74bde8a --- /dev/null +++ b/packages/protovalidate/src/native/bool.test.ts @@ -0,0 +1,87 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { compileMessage } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; + +const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +const native = createValidator(); +const cel = createValidator({ disableNativeRules: true }); + +/** + * Validate a fixture under both the native and CEL paths and assert their + * Violation arrays are byte-identical (message + ruleId + rule path + field + * path, via Violation.toString()). + */ +function diff(schema: DescMessage, msg: object): void { + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const a = native.validate(schema, msg as any); + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const b = cel.validate(schema, msg as any); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +void suite("native bool rules", () => { + void suite("bool.const", () => { + const schema = compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + message M { + bool b = 1 [(buf.validate.field).bool.const = true]; + }`, + bufCompileOptions, + ); + void test("matches: valid", () => { + diff(schema, create(schema, { b: true })); + }); + void test("mismatches: invalid", () => { + diff(schema, create(schema, { b: false })); + }); + }); + + void suite("BoolValue wrapper", () => { + const schema = compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + import "google/protobuf/wrappers.proto"; + message M { + google.protobuf.BoolValue b = 1 [(buf.validate.field).bool.const = true]; + }`, + bufCompileOptions, + ); + void test("inner value matches: valid", () => { + diff(schema, create(schema, { b: true })); + }); + void test("inner value mismatches: invalid", () => { + diff(schema, create(schema, { b: false })); + }); + }); +}); diff --git a/packages/protovalidate/src/native/bool.ts b/packages/protovalidate/src/native/bool.ts new file mode 100644 index 0000000..cea1e5a --- /dev/null +++ b/packages/protovalidate/src/native/bool.ts @@ -0,0 +1,77 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { isFieldSet } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import type { BoolRules } from "../gen/buf/validate/validate_pb.js"; +import { boolConstDesc } from "./sites.js"; +import type { ScalarNativeResult } from "./dispatcher.js"; + +/** + * Native evaluator for `bool.const`. + * + * Bool only supports the `const` rule. Anything else on a BoolRules instance + * falls through to CEL via the dispatcher. + */ +class EvalNativeBoolRules implements Eval { + constructor( + private readonly forMapKey: boolean, + private readonly constVal: boolean, + private readonly rulePath: Path, + ) {} + + eval(val: ScalarValue, cursor: Cursor): void { + if ((val as boolean) !== this.constVal) { + cursor.violate( + `must equal ${this.constVal}`, + "bool.const", + this.rulePath, + this.forMapKey, + ); + } + } + + prune(): boolean { + return false; + } +} + +/** + * Try to build a native evaluator for BoolRules. Returns kind:"none" if no + * native handler applies (no const set, or unknown extensions present). + */ +export function tryBuildNativeBoolRules( + rules: BoolRules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + if (rules.$unknown && rules.$unknown.length > 0) { + return { kind: "none" }; + } + if (!isFieldSet(rules, boolConstDesc)) { + return { kind: "none" }; + } + const path = rulePath.clone().field(boolConstDesc).toPath(); + return { + kind: "full", + eval: new EvalNativeBoolRules(forMapKey, rules.const, path), + handledFields: new Set([boolConstDesc]), + }; +} diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index 041fed3..a4862a8 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -16,10 +16,57 @@ import type { DescField } from "@bufbuild/protobuf"; import type { PathBuilder, ReflectMessageGet, + ScalarValue, } from "@bufbuild/protobuf/reflect"; -import type { FieldRules } from "../gen/buf/validate/validate_pb.js"; +import type { + BoolRules, + DoubleRules, + Fixed32Rules, + Fixed64Rules, + FieldRules, + FloatRules, + Int32Rules, + Int64Rules, + SFixed32Rules, + SFixed64Rules, + SInt32Rules, + SInt64Rules, + UInt32Rules, + UInt64Rules, +} from "../gen/buf/validate/validate_pb.js"; +import { + BoolRulesSchema, + DoubleRulesSchema, + Fixed32RulesSchema, + Fixed64RulesSchema, + FloatRulesSchema, + Int32RulesSchema, + Int64RulesSchema, + SFixed32RulesSchema, + SFixed64RulesSchema, + SInt32RulesSchema, + SInt64RulesSchema, + UInt32RulesSchema, + UInt64RulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { Eval } from "../eval.js"; import type { RegexMatcher } from "../func.js"; +import { tryBuildNativeBoolRules } from "./bool.js"; +import { + tryBuildNativeDoubleRules, + tryBuildNativeFixed32Rules, + tryBuildNativeFixed64Rules, + tryBuildNativeFloatRules, + tryBuildNativeInt32Rules, + tryBuildNativeInt64Rules, + tryBuildNativeSfixed32Rules, + tryBuildNativeSfixed64Rules, + tryBuildNativeSint32Rules, + tryBuildNativeSint64Rules, + tryBuildNativeUint32Rules, + tryBuildNativeUint64Rules, +} from "./numeric.js"; +import { WrappedValueEval, asReflectGet } from "./wrapper.js"; /** * Result of {@link tryBuildNative}. @@ -40,29 +87,130 @@ export type NativeDispatchResult = handledFields: ReadonlySet; }; +/** + * Internal dispatch result used by the per-rules-type builders. They produce + * a scalar-typed eval; {@link tryBuildNative} either lifts it directly into + * `Eval` (the scalar case) or wraps it in a + * `WrappedValueEval` for WKT wrapper messages. + */ +export type ScalarNativeResult = + | { kind: "none" } + | { + kind: "partial" | "full"; + eval: Eval; + handledFields: ReadonlySet; + }; + /** * Inputs to the native rule dispatcher. - * - * Future phases add per-field-type evaluators here. Phase 0 wires the seam - * but always returns `{ kind: "none" }`. */ export type NativeDispatchInput = { rules: Exclude; rulePath: PathBuilder; forMapKey: boolean; regexMatch: RegexMatcher | undefined; + /** + * When the rules are being applied to a `google.protobuf.*Value` wrapper + * field, this is the descriptor of the wrapper's inner `value` field. The + * dispatcher wraps the native scalar evaluator in an unwrap adapter so the + * runtime can read the inner scalar before delegating. Undefined for + * direct scalar fields. + */ + wrappedValueField: DescField | undefined; }; /** * Decide whether the given rules submessage can be evaluated natively, and * return an `Eval` for the handled subset plus the set of rule fields that * have been claimed (so the planner skips them on the CEL path). - * - * Phase 0: stub. Returns `{ kind: "none" }` so the CEL path handles every - * rule exactly as before. */ export function tryBuildNative( - _input: NativeDispatchInput, + input: NativeDispatchInput, ): NativeDispatchResult { - return { kind: "none" }; + const inner = buildScalarNative(input); + if (inner.kind === "none") return inner; + if (input.wrappedValueField === undefined) { + return { + kind: inner.kind, + eval: asReflectGet(inner.eval), + handledFields: inner.handledFields, + }; + } + return { + kind: inner.kind, + eval: asReflectGet( + new WrappedValueEval(input.wrappedValueField, inner.eval), + ), + handledFields: inner.handledFields, + }; +} + +function buildScalarNative(input: NativeDispatchInput): ScalarNativeResult { + const { rules, rulePath, forMapKey } = input; + switch (rules.$typeName) { + case BoolRulesSchema.typeName: + return tryBuildNativeBoolRules(rules as BoolRules, rulePath, forMapKey); + case Int32RulesSchema.typeName: + return tryBuildNativeInt32Rules(rules as Int32Rules, rulePath, forMapKey); + case Int64RulesSchema.typeName: + return tryBuildNativeInt64Rules(rules as Int64Rules, rulePath, forMapKey); + case UInt32RulesSchema.typeName: + return tryBuildNativeUint32Rules( + rules as UInt32Rules, + rulePath, + forMapKey, + ); + case UInt64RulesSchema.typeName: + return tryBuildNativeUint64Rules( + rules as UInt64Rules, + rulePath, + forMapKey, + ); + case SInt32RulesSchema.typeName: + return tryBuildNativeSint32Rules( + rules as SInt32Rules, + rulePath, + forMapKey, + ); + case SInt64RulesSchema.typeName: + return tryBuildNativeSint64Rules( + rules as SInt64Rules, + rulePath, + forMapKey, + ); + case Fixed32RulesSchema.typeName: + return tryBuildNativeFixed32Rules( + rules as Fixed32Rules, + rulePath, + forMapKey, + ); + case Fixed64RulesSchema.typeName: + return tryBuildNativeFixed64Rules( + rules as Fixed64Rules, + rulePath, + forMapKey, + ); + case SFixed32RulesSchema.typeName: + return tryBuildNativeSfixed32Rules( + rules as SFixed32Rules, + rulePath, + forMapKey, + ); + case SFixed64RulesSchema.typeName: + return tryBuildNativeSfixed64Rules( + rules as SFixed64Rules, + rulePath, + forMapKey, + ); + case FloatRulesSchema.typeName: + return tryBuildNativeFloatRules(rules as FloatRules, rulePath, forMapKey); + case DoubleRulesSchema.typeName: + return tryBuildNativeDoubleRules( + rules as DoubleRules, + rulePath, + forMapKey, + ); + default: + return { kind: "none" }; + } } diff --git a/packages/protovalidate/src/native/numeric.test.ts b/packages/protovalidate/src/native/numeric.test.ts new file mode 100644 index 0000000..c20ee26 --- /dev/null +++ b/packages/protovalidate/src/native/numeric.test.ts @@ -0,0 +1,336 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { compileMessage } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; + +const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +const native = createValidator(); +const cel = createValidator({ disableNativeRules: true }); + +function diff(schema: DescMessage, msg: object): void { + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const a = native.validate(schema, msg as any); + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const b = cel.validate(schema, msg as any); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +function compile(proto: string): DescMessage { + return compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + import "google/protobuf/wrappers.proto"; + ${proto}`, + bufCompileOptions, + ); +} + +void suite("native numeric rules", () => { + void suite("int32", () => { + void test("const passes and fails", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32.const = 42]; }`, + ); + diff(s, create(s, { n: 42 })); + diff(s, create(s, { n: 41 })); + }); + + void test("gt passes and fails", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32.gt = 5]; }`, + ); + diff(s, create(s, { n: 6 })); + diff(s, create(s, { n: 5 })); + diff(s, create(s, { n: 4 })); + }); + + void test("gte boundary", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32.gte = 5]; }`, + ); + diff(s, create(s, { n: 5 })); + diff(s, create(s, { n: 4 })); + }); + + void test("lt and lte", () => { + const lt = compile( + `message M { int32 n = 1 [(buf.validate.field).int32.lt = 10]; }`, + ); + diff(lt, create(lt, { n: 9 })); + diff(lt, create(lt, { n: 10 })); + const lte = compile( + `message M { int32 n = 1 [(buf.validate.field).int32.lte = 10]; }`, + ); + diff(lte, create(lte, { n: 10 })); + diff(lte, create(lte, { n: 11 })); + }); + + void test("gt + lt normal range", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32 = { gt: 0, lt: 10 }]; }`, + ); + diff(s, create(s, { n: 5 })); + diff(s, create(s, { n: 0 })); + diff(s, create(s, { n: 10 })); + diff(s, create(s, { n: -1 })); + }); + + void test("gt + lt exclusive range (lt < gt)", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; }`, + ); + // Inside the gap [5..10] is the rejected zone for exclusive ranges + diff(s, create(s, { n: 7 })); + // Outside: passes + diff(s, create(s, { n: 4 })); + diff(s, create(s, { n: 11 })); + }); + + void test("gte + lte normal range", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32 = { gte: 1, lte: 3 }]; }`, + ); + diff(s, create(s, { n: 0 })); + diff(s, create(s, { n: 1 })); + diff(s, create(s, { n: 4 })); + }); + + void test("in and not_in", () => { + const s = compile( + `message M { int32 n = 1 [(buf.validate.field).int32 = { in: [1, 2, 3], not_in: [4, 5] }]; }`, + ); + diff(s, create(s, { n: 2 })); + diff(s, create(s, { n: 4 })); + diff(s, create(s, { n: 9 })); + }); + }); + + void suite("uint32 / sint32 / fixed32 / sfixed32", () => { + void test("uint32.gt", () => { + const s = compile( + `message M { uint32 n = 1 [(buf.validate.field).uint32.gt = 5]; }`, + ); + diff(s, create(s, { n: 6 })); + diff(s, create(s, { n: 5 })); + }); + void test("sint32.const", () => { + const s = compile( + `message M { sint32 n = 1 [(buf.validate.field).sint32.const = -7]; }`, + ); + diff(s, create(s, { n: -7 })); + diff(s, create(s, { n: -8 })); + }); + void test("fixed32.lte", () => { + const s = compile( + `message M { fixed32 n = 1 [(buf.validate.field).fixed32.lte = 100]; }`, + ); + diff(s, create(s, { n: 100 })); + diff(s, create(s, { n: 101 })); + }); + void test("sfixed32.in", () => { + const s = compile( + `message M { sfixed32 n = 1 [(buf.validate.field).sfixed32 = { in: [-1, 0, 1] }]; }`, + ); + diff(s, create(s, { n: 0 })); + diff(s, create(s, { n: 2 })); + }); + }); + + void suite("int64 / uint64 / sint64 / fixed64 / sfixed64 (bigint)", () => { + void test("int64.gt", () => { + const s = compile( + `message M { int64 n = 1 [(buf.validate.field).int64.gt = 5]; }`, + ); + diff(s, create(s, { n: 6n })); + diff(s, create(s, { n: 5n })); + }); + void test("uint64.const", () => { + const s = compile( + `message M { uint64 n = 1 [(buf.validate.field).uint64.const = 42]; }`, + ); + diff(s, create(s, { n: 42n })); + diff(s, create(s, { n: 41n })); + }); + void test("sint64.lt", () => { + const s = compile( + `message M { sint64 n = 1 [(buf.validate.field).sint64.lt = 0]; }`, + ); + diff(s, create(s, { n: -1n })); + diff(s, create(s, { n: 0n })); + }); + void test("fixed64.not_in", () => { + const s = compile( + `message M { fixed64 n = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2] }]; }`, + ); + diff(s, create(s, { n: 3n })); + diff(s, create(s, { n: 1n })); + }); + void test("sfixed64 range", () => { + const s = compile( + `message M { sfixed64 n = 1 [(buf.validate.field).sfixed64 = { gt: -10, lt: 10 }]; }`, + ); + diff(s, create(s, { n: 0n })); + diff(s, create(s, { n: -10n })); + diff(s, create(s, { n: 10n })); + }); + }); + + void suite("float / double", () => { + void test("float.const", () => { + const s = compile( + `message M { float x = 1 [(buf.validate.field).float.const = 1.5]; }`, + ); + diff(s, create(s, { x: 1.5 })); + diff(s, create(s, { x: 1.25 })); + }); + void test("double.gt", () => { + const s = compile( + `message M { double x = 1 [(buf.validate.field).double.gt = 0.5]; }`, + ); + diff(s, create(s, { x: 1 })); + diff(s, create(s, { x: 0.5 })); + }); + void test("float.finite passes finite", () => { + const s = compile( + `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`, + ); + diff(s, create(s, { x: 1.5 })); + }); + void test("float.finite fails NaN", () => { + const s = compile( + `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`, + ); + diff(s, create(s, { x: Number.NaN })); + }); + void test("float.finite fails Infinity", () => { + const s = compile( + `message M { float x = 1 [(buf.validate.field).float.finite = true]; }`, + ); + diff(s, create(s, { x: Number.POSITIVE_INFINITY })); + diff(s, create(s, { x: Number.NEGATIVE_INFINITY })); + }); + void test("double.lte NaN fails range (nanFailsRange)", () => { + const s = compile( + `message M { double x = 1 [(buf.validate.field).double.lte = 10]; }`, + ); + diff(s, create(s, { x: Number.NaN })); + }); + void test("double range NaN fails", () => { + const s = compile( + `message M { double x = 1 [(buf.validate.field).double = { gt: 0, lt: 10 }]; }`, + ); + diff(s, create(s, { x: Number.NaN })); + }); + }); + + void suite("wrapper types", () => { + void test("Int32Value with int32.gte", () => { + const s = compile( + `message M { + google.protobuf.Int32Value n = 1 [(buf.validate.field).int32.gte = 5]; + }`, + ); + diff(s, create(s, { n: 5 })); + diff(s, create(s, { n: 4 })); + }); + void test("Int64Value with int64.const", () => { + const s = compile( + `message M { + google.protobuf.Int64Value n = 1 [(buf.validate.field).int64.const = 42]; + }`, + ); + diff(s, create(s, { n: 42n })); + diff(s, create(s, { n: 41n })); + }); + void test("UInt32Value with uint32.lt", () => { + const s = compile( + `message M { + google.protobuf.UInt32Value n = 1 [(buf.validate.field).uint32.lt = 100]; + }`, + ); + diff(s, create(s, { n: 50 })); + diff(s, create(s, { n: 100 })); + }); + void test("UInt64Value with uint64.in", () => { + const s = compile( + `message M { + google.protobuf.UInt64Value n = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }]; + }`, + ); + diff(s, create(s, { n: 2n })); + diff(s, create(s, { n: 4n })); + }); + void test("FloatValue with float.finite", () => { + const s = compile( + `message M { + google.protobuf.FloatValue n = 1 [(buf.validate.field).float.finite = true]; + }`, + ); + diff(s, create(s, { n: 1.5 })); + diff(s, create(s, { n: Number.NaN })); + }); + void test("DoubleValue with double range", () => { + const s = compile( + `message M { + google.protobuf.DoubleValue n = 1 [(buf.validate.field).double = { gte: 0, lte: 1 }]; + }`, + ); + diff(s, create(s, { n: 0.5 })); + diff(s, create(s, { n: 1.5 })); + }); + }); + + void suite("repeated scalar items", () => { + void test("repeated int32 with item rules", () => { + const s = compile( + `message M { + repeated int32 n = 1 [(buf.validate.field).repeated.items.int32.gt = 0]; + }`, + ); + diff(s, create(s, { n: [1, 2, 3] })); + diff(s, create(s, { n: [1, 0, 3] })); + diff(s, create(s, { n: [-1, -2] })); + }); + }); + + void suite("fallthrough cases", () => { + void test("NaN gt bound on float falls through to CEL with same error", () => { + // Compile-time CEL would normally throw. To exercise the fallthrough path + // we'd need a NaN-bound rule message — protovalidate-conformance covers + // this; here we just confirm the native+CEL paths produce the same + // result on a non-NaN rule with NaN input value. + const s = compile( + `message M { float x = 1 [(buf.validate.field).float.gt = 0]; }`, + ); + diff(s, create(s, { x: Number.NaN })); + }); + }); +}); diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts new file mode 100644 index 0000000..6ed80f5 --- /dev/null +++ b/packages/protovalidate/src/native/numeric.ts @@ -0,0 +1,625 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet, type Message } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import type { + DoubleRules, + FloatRules, + Fixed32Rules, + Fixed64Rules, + Int32Rules, + Int64Rules, + SFixed32Rules, + SFixed64Rules, + SInt32Rules, + SInt64Rules, + UInt32Rules, + UInt64Rules, +} from "../gen/buf/validate/validate_pb.js"; +import type { ScalarNativeResult } from "./dispatcher.js"; +import { printFloat } from "./format.js"; +import { + doubleDescs, + fixed32Descs, + fixed64Descs, + floatDescs, + int32Descs, + int64Descs, + type NumericRulesDescs, + sfixed32Descs, + sfixed64Descs, + sint32Descs, + sint64Descs, + uint32Descs, + uint64Descs, +} from "./sites.js"; + +/** + * Per-scalar configuration for the numeric native evaluator. + * + * `T` is `number` for 32-bit ints + float/double, `bigint` for 64-bit ints. + */ +type NumericConfig = { + readonly typeName: string; + readonly descs: NumericRulesDescs; + /** Format a value into the user-facing error string. */ + readonly format: (v: T) => string; + /** True only for float/double: a NaN field value fails every range check. */ + readonly nanFailsRange: boolean; +}; + +const stringFormat = (v: number | bigint): string => v.toString(); +const floatFormat = (v: number): string => printFloat(v); + +export const int32Config: NumericConfig = { + typeName: "int32", + descs: int32Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const int64Config: NumericConfig = { + typeName: "int64", + descs: int64Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const uint32Config: NumericConfig = { + typeName: "uint32", + descs: uint32Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const uint64Config: NumericConfig = { + typeName: "uint64", + descs: uint64Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const sint32Config: NumericConfig = { + typeName: "sint32", + descs: sint32Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const sint64Config: NumericConfig = { + typeName: "sint64", + descs: sint64Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const fixed32Config: NumericConfig = { + typeName: "fixed32", + descs: fixed32Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const fixed64Config: NumericConfig = { + typeName: "fixed64", + descs: fixed64Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const sfixed32Config: NumericConfig = { + typeName: "sfixed32", + descs: sfixed32Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const sfixed64Config: NumericConfig = { + typeName: "sfixed64", + descs: sfixed64Descs, + format: stringFormat, + nanFailsRange: false, +}; +export const floatConfig: NumericConfig = { + typeName: "float", + descs: floatDescs, + format: floatFormat, + nanFailsRange: true, +}; +export const doubleConfig: NumericConfig = { + typeName: "double", + descs: doubleDescs, + format: floatFormat, + nanFailsRange: true, +}; + +/** + * The shape all 12 numeric rules messages share (after accounting for the + * `greater_than` / `less_than` oneofs). + */ +type NumericRulesShape = Message & { + const: T; + in: readonly T[]; + notIn: readonly T[]; + greaterThan: + | { case: "gt"; value: T } + | { case: "gte"; value: T } + | { case: undefined; value?: undefined }; + lessThan: + | { case: "lt"; value: T } + | { case: "lte"; value: T } + | { case: undefined; value?: undefined }; +}; + +/** Float and Double additionally carry the `finite` flag. */ +type NumericRulesWithFinite = NumericRulesShape & { + finite: boolean; +}; + +type LowerBound = "none" | "gt" | "gte"; +type UpperBound = "none" | "lt" | "lte"; + +class EvalNativeNumericRules + implements Eval +{ + constructor( + private readonly config: NumericConfig, + private readonly forMapKey: boolean, + private readonly constVal: T | undefined, + private readonly inVals: readonly T[], + private readonly notInVals: readonly T[], + private readonly lower: LowerBound, + private readonly lo: T, + private readonly upper: UpperBound, + private readonly hi: T, + private readonly finite: boolean, + private readonly paths: { + const: Path | undefined; + in: Path | undefined; + notIn: Path | undefined; + lo: Path | undefined; + hi: Path | undefined; + finite: Path | undefined; + }, + ) {} + + eval(val: ScalarValue, cursor: Cursor): void { + const v = val as T; + + if (this.constVal !== undefined && v !== this.constVal) { + cursor.violate( + `must equal ${this.config.format(this.constVal)}`, + `${this.config.typeName}.const`, + // biome-ignore lint/style/noNonNullAssertion: path is set whenever constVal is set + this.paths.const!, + this.forMapKey, + ); + } + + if (this.inVals.length > 0 && !contains(this.inVals, v)) { + cursor.violate( + `must be in list ${this.formatList(this.inVals)}`, + `${this.config.typeName}.in`, + // biome-ignore lint/style/noNonNullAssertion: path is set whenever inVals is non-empty + this.paths.in!, + this.forMapKey, + ); + } + + if (this.notInVals.length > 0 && contains(this.notInVals, v)) { + cursor.violate( + `must not be in list ${this.formatList(this.notInVals)}`, + `${this.config.typeName}.not_in`, + // biome-ignore lint/style/noNonNullAssertion: path is set whenever notInVals is non-empty + this.paths.notIn!, + this.forMapKey, + ); + } + + if ( + this.finite && + typeof v === "number" && + (Number.isNaN(v) || !Number.isFinite(v)) + ) { + cursor.violate( + "must be finite", + `${this.config.typeName}.finite`, + // biome-ignore lint/style/noNonNullAssertion: path is set whenever finite=true + this.paths.finite!, + this.forMapKey, + ); + } + + this.evalRange(v, cursor); + } + + prune(): boolean { + return false; + } + + private evalRange(v: T, cursor: Cursor): void { + if (this.lower === "none" && this.upper === "none") { + return; + } + const isNaNVal = + this.config.nanFailsRange && typeof v === "number" && Number.isNaN(v); + + if (this.lower === "none") { + if (isNaNVal || this.aboveHi(v)) { + cursor.violate( + `must be ${this.hiMessage()}`, + this.rangeRuleId(), + // biome-ignore lint/style/noNonNullAssertion: path set when upper != none + this.paths.hi!, + this.forMapKey, + ); + } + return; + } + if (this.upper === "none") { + if (isNaNVal || this.belowLo(v)) { + cursor.violate( + `must be ${this.loMessage()}`, + this.rangeRuleId(), + // biome-ignore lint/style/noNonNullAssertion: path set when lower != none + this.paths.lo!, + this.forMapKey, + ); + } + return; + } + let fail: boolean; + if (this.isNormalRange()) { + fail = isNaNVal || this.aboveHi(v) || this.belowLo(v); + } else { + fail = isNaNVal || (this.aboveHi(v) && this.belowLo(v)); + } + if (fail) { + cursor.violate( + `must be ${this.loMessage()} ${this.conjunction()} ${this.hiMessage()}`, + this.rangeRuleId(), + // biome-ignore lint/style/noNonNullAssertion: path set when lower != none + this.paths.lo!, + this.forMapKey, + ); + } + } + + private belowLo(v: T): boolean { + return this.lower === "gt" ? v <= this.lo : v < this.lo; + } + + private aboveHi(v: T): boolean { + return this.upper === "lt" ? v >= this.hi : v > this.hi; + } + + private isNormalRange(): boolean { + return this.hi >= this.lo; + } + + private loMessage(): string { + return this.lower === "gt" + ? `greater than ${this.config.format(this.lo)}` + : `greater than or equal to ${this.config.format(this.lo)}`; + } + + private hiMessage(): string { + return this.upper === "lt" + ? `less than ${this.config.format(this.hi)}` + : `less than or equal to ${this.config.format(this.hi)}`; + } + + private conjunction(): string { + return this.isNormalRange() ? "and" : "or"; + } + + private rangeRuleId(): string { + const t = this.config.typeName; + if (this.lower === "none") { + return `${t}.${this.upper}`; + } + if (this.upper === "none") { + return `${t}.${this.lower}`; + } + const suffix = this.isNormalRange() ? "" : "_exclusive"; + return `${t}.${this.lower}_${this.upper}${suffix}`; + } + + private formatList(vs: readonly T[]): string { + let out = "["; + for (let i = 0; i < vs.length; i++) { + if (i > 0) out += ", "; + out += this.config.format(vs[i] as T); + } + return `${out}]`; + } +} + +function contains(arr: readonly T[], v: T): boolean { + for (let i = 0; i < arr.length; i++) { + if (arr[i] === v) return true; + } + return false; +} + +function buildNumeric( + rules: + | NumericRulesShape + | NumericRulesWithFinite, + config: NumericConfig, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + if (rules.$unknown && rules.$unknown.length > 0) { + return { kind: "none" }; + } + const handled = new Set(); + const paths: { + const: Path | undefined; + in: Path | undefined; + notIn: Path | undefined; + lo: Path | undefined; + hi: Path | undefined; + finite: Path | undefined; + } = { + const: undefined, + in: undefined, + notIn: undefined, + lo: undefined, + hi: undefined, + finite: undefined, + }; + + let constVal: T | undefined; + if (isFieldSet(rules, config.descs.const)) { + constVal = rules.const; + paths.const = rulePath.clone().field(config.descs.const).toPath(); + handled.add(config.descs.const); + } + + let inVals: readonly T[] = []; + if (rules.in.length > 0) { + inVals = rules.in; + paths.in = rulePath.clone().field(config.descs.in).toPath(); + handled.add(config.descs.in); + } + + let notInVals: readonly T[] = []; + if (rules.notIn.length > 0) { + notInVals = rules.notIn; + paths.notIn = rulePath.clone().field(config.descs.notIn).toPath(); + handled.add(config.descs.notIn); + } + + let lower: LowerBound = "none"; + let lo: T = 0 as T; + if (rules.greaterThan.case === "gt") { + if (isNaNValue(rules.greaterThan.value)) return { kind: "none" }; + lower = "gt"; + lo = rules.greaterThan.value; + paths.lo = rulePath.clone().field(config.descs.gt).toPath(); + handled.add(config.descs.gt); + } else if (rules.greaterThan.case === "gte") { + if (isNaNValue(rules.greaterThan.value)) return { kind: "none" }; + lower = "gte"; + lo = rules.greaterThan.value; + paths.lo = rulePath.clone().field(config.descs.gte).toPath(); + handled.add(config.descs.gte); + } + + let upper: UpperBound = "none"; + let hi: T = 0 as T; + if (rules.lessThan.case === "lt") { + if (isNaNValue(rules.lessThan.value)) return { kind: "none" }; + upper = "lt"; + hi = rules.lessThan.value; + paths.hi = rulePath.clone().field(config.descs.lt).toPath(); + handled.add(config.descs.lt); + } else if (rules.lessThan.case === "lte") { + if (isNaNValue(rules.lessThan.value)) return { kind: "none" }; + upper = "lte"; + hi = rules.lessThan.value; + paths.hi = rulePath.clone().field(config.descs.lte).toPath(); + handled.add(config.descs.lte); + } + + let finite = false; + if (config.descs.finite && isFieldSet(rules, config.descs.finite)) { + finite = (rules as NumericRulesWithFinite).finite; + if (finite) { + paths.finite = rulePath.clone().field(config.descs.finite).toPath(); + } + handled.add(config.descs.finite); + } + + if (handled.size === 0) { + return { kind: "none" }; + } + + return { + kind: "full", + eval: new EvalNativeNumericRules( + config, + forMapKey, + constVal, + inVals, + notInVals, + lower, + lo, + upper, + hi, + finite, + paths, + ), + handledFields: handled, + }; +} + +function isNaNValue(v: number | bigint): boolean { + return typeof v === "number" && Number.isNaN(v); +} + +export function tryBuildNativeInt32Rules( + rules: Int32Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + int32Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeInt64Rules( + rules: Int64Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + int64Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeUint32Rules( + rules: UInt32Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + uint32Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeUint64Rules( + rules: UInt64Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + uint64Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeSint32Rules( + rules: SInt32Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + sint32Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeSint64Rules( + rules: SInt64Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + sint64Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeFixed32Rules( + rules: Fixed32Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + fixed32Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeFixed64Rules( + rules: Fixed64Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + fixed64Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeSfixed32Rules( + rules: SFixed32Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + sfixed32Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeSfixed64Rules( + rules: SFixed64Rules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesShape, + sfixed64Config, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeFloatRules( + rules: FloatRules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesWithFinite, + floatConfig, + rulePath, + forMapKey, + ); +} + +export function tryBuildNativeDoubleRules( + rules: DoubleRules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult { + return buildNumeric( + rules as unknown as NumericRulesWithFinite, + doubleConfig, + rulePath, + forMapKey, + ); +} diff --git a/packages/protovalidate/src/native/sites.ts b/packages/protovalidate/src/native/sites.ts new file mode 100644 index 0000000..02d32fd --- /dev/null +++ b/packages/protovalidate/src/native/sites.ts @@ -0,0 +1,108 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DescField } from "@bufbuild/protobuf"; +import { + BoolRulesSchema, + DoubleRulesSchema, + Fixed32RulesSchema, + Fixed64RulesSchema, + FloatRulesSchema, + Int32RulesSchema, + Int64RulesSchema, + SFixed32RulesSchema, + SFixed64RulesSchema, + SInt32RulesSchema, + SInt64RulesSchema, + UInt32RulesSchema, + UInt64RulesSchema, +} from "../gen/buf/validate/validate_pb.js"; + +/** + * Leaf-field references for the numeric rules schemas. + * + * The dispatcher uses these to (a) consult `isFieldSet(rules, descs.const)` + * for presence and (b) build leaf rule paths via + * `rulePath.clone().field(descs.const).toPath()` at plan time. + */ +export type NumericRulesDescs = { + readonly const: DescField; + readonly gt: DescField; + readonly gte: DescField; + readonly lt: DescField; + readonly lte: DescField; + readonly in: DescField; + readonly notIn: DescField; + /** Only present on FloatRulesSchema and DoubleRulesSchema. */ + readonly finite?: DescField; +}; + +function descs( + schema: + | typeof Int32RulesSchema + | typeof Int64RulesSchema + | typeof UInt32RulesSchema + | typeof UInt64RulesSchema + | typeof SInt32RulesSchema + | typeof SInt64RulesSchema + | typeof Fixed32RulesSchema + | typeof Fixed64RulesSchema + | typeof SFixed32RulesSchema + | typeof SFixed64RulesSchema, +): NumericRulesDescs { + return { + const: schema.field.const, + gt: schema.field.gt, + gte: schema.field.gte, + lt: schema.field.lt, + lte: schema.field.lte, + in: schema.field.in, + notIn: schema.field.notIn, + }; +} + +export const int32Descs: NumericRulesDescs = descs(Int32RulesSchema); +export const int64Descs: NumericRulesDescs = descs(Int64RulesSchema); +export const uint32Descs: NumericRulesDescs = descs(UInt32RulesSchema); +export const uint64Descs: NumericRulesDescs = descs(UInt64RulesSchema); +export const sint32Descs: NumericRulesDescs = descs(SInt32RulesSchema); +export const sint64Descs: NumericRulesDescs = descs(SInt64RulesSchema); +export const fixed32Descs: NumericRulesDescs = descs(Fixed32RulesSchema); +export const fixed64Descs: NumericRulesDescs = descs(Fixed64RulesSchema); +export const sfixed32Descs: NumericRulesDescs = descs(SFixed32RulesSchema); +export const sfixed64Descs: NumericRulesDescs = descs(SFixed64RulesSchema); + +export const floatDescs: NumericRulesDescs = { + const: FloatRulesSchema.field.const, + gt: FloatRulesSchema.field.gt, + gte: FloatRulesSchema.field.gte, + lt: FloatRulesSchema.field.lt, + lte: FloatRulesSchema.field.lte, + in: FloatRulesSchema.field.in, + notIn: FloatRulesSchema.field.notIn, + finite: FloatRulesSchema.field.finite, +}; + +export const doubleDescs: NumericRulesDescs = { + const: DoubleRulesSchema.field.const, + gt: DoubleRulesSchema.field.gt, + gte: DoubleRulesSchema.field.gte, + lt: DoubleRulesSchema.field.lt, + lte: DoubleRulesSchema.field.lte, + in: DoubleRulesSchema.field.in, + notIn: DoubleRulesSchema.field.notIn, + finite: DoubleRulesSchema.field.finite, +}; + +export const boolConstDesc: DescField = BoolRulesSchema.field.const; diff --git a/packages/protovalidate/src/native/wrapper.ts b/packages/protovalidate/src/native/wrapper.ts new file mode 100644 index 0000000..c7a91c5 --- /dev/null +++ b/packages/protovalidate/src/native/wrapper.ts @@ -0,0 +1,58 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { DescField } from "@bufbuild/protobuf"; +import type { + ReflectMessage, + ReflectMessageGet, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; + +/** + * Adapter that bridges a scalar-typed native evaluator to the wrapper message + * eval site. + * + * When a `google.protobuf.{Int32,Int64,...}Value` field is validated against + * scalar rules (Int32Rules, etc.), the planner hands the eval a + * `ReflectMessage` rather than a `ScalarValue`. This adapter reads the inner + * `value` field and delegates to the scalar-typed evaluator. + */ +export class WrappedValueEval implements Eval { + constructor( + private readonly valueField: DescField, + private readonly inner: Eval, + ) {} + + eval(val: ReflectMessage, cursor: Cursor): void { + this.inner.eval(val.get(this.valueField) as ScalarValue, cursor); + } + + prune(): boolean { + return this.inner.prune(); + } +} + +/** + * Cast helper: `Eval` and `Eval` are both + * assignable to `Eval` (the union type that the planner + * stores), but TypeScript doesn't see that directly because Eval is invariant + * in its parameter. Use this once at each handoff. + */ +export function asReflectGet( + e: Eval, +): Eval { + return e as unknown as Eval; +} diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 020e36f..acae03f 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -23,7 +23,10 @@ import { isMessage, ScalarType, } from "@bufbuild/protobuf"; -import { FeatureSet_FieldPresence } from "@bufbuild/protobuf/wkt"; +import { + FeatureSet_FieldPresence, + isWrapperDesc, +} from "@bufbuild/protobuf/wkt"; import { type FieldRules, type MessageRules, @@ -423,7 +426,10 @@ export class Planner { if (isMessage(rules, AnyRulesSchema)) { evals.add(new EvalAnyRules(rulePath, rules)); } - evals.add(this.rules(rules, rulePath, false)); + const wrappedValueField = isWrapperDesc(descMessage) + ? descMessage.fields.find((f) => f.name === "value") + : undefined; + evals.add(this.rules(rules, rulePath, false, wrappedValueField)); } return evals; } @@ -432,6 +438,7 @@ export class Planner { rules: Exclude, rulePath: PathBuilder, forMapKey: boolean, + wrappedValueField: DescField | undefined = undefined, ) { const ruleDesc = getRuleDescriptor(rules.$typeName); const prepared = this.celMan.compileRules(ruleDesc); @@ -442,6 +449,7 @@ export class Planner { rulePath, forMapKey, regexMatch: this.regexMatch, + wrappedValueField, }); const evalStandard = new EvalStandardRulesCel( this.celMan, From bb20f885aca1a9c94549399cb0ff978f948eb7fe Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 12:12:40 -0400 Subject: [PATCH 06/38] Address phase 1 code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanups surfaced by the post-merge review: - Collapse the 12 thin tryBuildNativeXxxRules wrappers into one tryBuildNativeNumericRules that switches on rules.$typeName. Per-type configs are no longer exported. - Refactor EvalNativeNumericRules to hold each rule as a narrowed object ({val, path} or {kind, val, path}) instead of separate const/in/lo/hi fields plus an optional-everywhere paths bag. Removes the `0 as T` default and every biome-ignore non-null assertion. Range/list helpers move to module scope and take the narrowed rule object directly. - Drop the asReflectGet helper; inline the cast at its two call sites with a one-line comment. - Remove the unused regexMatch field from NativeDispatchInput (and from Planner — it was being threaded through but no consumer was reading it). String handlers will re-add it in phase 4. - Throw a CompilationError if a wrapper descriptor lacks a "value" field instead of silently falling back to a non-wrapper code path. - Update printFloat's docstring to describe what it actually does (matches CEL-TS Number.toString) instead of the inaccurate "mirrors protovalidate-go". Note the eventual fix is in cel-es + here together. - Add the 5 test gaps the review flagged: NaN value with float.in list, const + range emitting both violations, unset BoolValue wrapper, int64 max-boundary const, and explicit float.finite=false (no-op rule). Verified: - 862 unit tests pass (+5 gap tests). - Conformance: 2870 pass / 2 expected skips, unchanged. - Bench deltas vs phase1-baseline: 10 improvements (matching phase 1's wins), 0 regressions past 5%. - Lint, attw, build green. Net: 142 lines removed across implementation, 56 lines of new test code. --- .../protovalidate/src/native/dispatcher.ts | 140 +--- packages/protovalidate/src/native/format.ts | 14 +- .../protovalidate/src/native/numeric.test.ts | 56 ++ packages/protovalidate/src/native/numeric.ts | 621 ++++++++---------- packages/protovalidate/src/native/wrapper.ts | 18 +- packages/protovalidate/src/planner.ts | 15 +- packages/protovalidate/src/validator.ts | 4 +- 7 files changed, 363 insertions(+), 505 deletions(-) diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index a4862a8..1f56a41 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -18,55 +18,12 @@ import type { ReflectMessageGet, ScalarValue, } from "@bufbuild/protobuf/reflect"; -import type { - BoolRules, - DoubleRules, - Fixed32Rules, - Fixed64Rules, - FieldRules, - FloatRules, - Int32Rules, - Int64Rules, - SFixed32Rules, - SFixed64Rules, - SInt32Rules, - SInt64Rules, - UInt32Rules, - UInt64Rules, -} from "../gen/buf/validate/validate_pb.js"; -import { - BoolRulesSchema, - DoubleRulesSchema, - Fixed32RulesSchema, - Fixed64RulesSchema, - FloatRulesSchema, - Int32RulesSchema, - Int64RulesSchema, - SFixed32RulesSchema, - SFixed64RulesSchema, - SInt32RulesSchema, - SInt64RulesSchema, - UInt32RulesSchema, - UInt64RulesSchema, -} from "../gen/buf/validate/validate_pb.js"; +import type { BoolRules, FieldRules } from "../gen/buf/validate/validate_pb.js"; +import { BoolRulesSchema } from "../gen/buf/validate/validate_pb.js"; import type { Eval } from "../eval.js"; -import type { RegexMatcher } from "../func.js"; import { tryBuildNativeBoolRules } from "./bool.js"; -import { - tryBuildNativeDoubleRules, - tryBuildNativeFixed32Rules, - tryBuildNativeFixed64Rules, - tryBuildNativeFloatRules, - tryBuildNativeInt32Rules, - tryBuildNativeInt64Rules, - tryBuildNativeSfixed32Rules, - tryBuildNativeSfixed64Rules, - tryBuildNativeSint32Rules, - tryBuildNativeSint64Rules, - tryBuildNativeUint32Rules, - tryBuildNativeUint64Rules, -} from "./numeric.js"; -import { WrappedValueEval, asReflectGet } from "./wrapper.js"; +import { tryBuildNativeNumericRules } from "./numeric.js"; +import { WrappedValueEval } from "./wrapper.js"; /** * Result of {@link tryBuildNative}. @@ -108,7 +65,6 @@ export type NativeDispatchInput = { rules: Exclude; rulePath: PathBuilder; forMapKey: boolean; - regexMatch: RegexMatcher | undefined; /** * When the rules are being applied to a `google.protobuf.*Value` wrapper * field, this is the descriptor of the wrapper's inner `value` field. The @@ -129,88 +85,26 @@ export function tryBuildNative( ): NativeDispatchResult { const inner = buildScalarNative(input); if (inner.kind === "none") return inner; - if (input.wrappedValueField === undefined) { - return { - kind: inner.kind, - eval: asReflectGet(inner.eval), - handledFields: inner.handledFields, - }; - } + // Eval is invariant in its parameter; the cast is safe because every + // ScalarValue is also a valid ReflectMessageGet at runtime. + const lifted = + input.wrappedValueField === undefined + ? (inner.eval as unknown as Eval) + : (new WrappedValueEval( + input.wrappedValueField, + inner.eval, + ) as unknown as Eval); return { kind: inner.kind, - eval: asReflectGet( - new WrappedValueEval(input.wrappedValueField, inner.eval), - ), + eval: lifted, handledFields: inner.handledFields, }; } function buildScalarNative(input: NativeDispatchInput): ScalarNativeResult { const { rules, rulePath, forMapKey } = input; - switch (rules.$typeName) { - case BoolRulesSchema.typeName: - return tryBuildNativeBoolRules(rules as BoolRules, rulePath, forMapKey); - case Int32RulesSchema.typeName: - return tryBuildNativeInt32Rules(rules as Int32Rules, rulePath, forMapKey); - case Int64RulesSchema.typeName: - return tryBuildNativeInt64Rules(rules as Int64Rules, rulePath, forMapKey); - case UInt32RulesSchema.typeName: - return tryBuildNativeUint32Rules( - rules as UInt32Rules, - rulePath, - forMapKey, - ); - case UInt64RulesSchema.typeName: - return tryBuildNativeUint64Rules( - rules as UInt64Rules, - rulePath, - forMapKey, - ); - case SInt32RulesSchema.typeName: - return tryBuildNativeSint32Rules( - rules as SInt32Rules, - rulePath, - forMapKey, - ); - case SInt64RulesSchema.typeName: - return tryBuildNativeSint64Rules( - rules as SInt64Rules, - rulePath, - forMapKey, - ); - case Fixed32RulesSchema.typeName: - return tryBuildNativeFixed32Rules( - rules as Fixed32Rules, - rulePath, - forMapKey, - ); - case Fixed64RulesSchema.typeName: - return tryBuildNativeFixed64Rules( - rules as Fixed64Rules, - rulePath, - forMapKey, - ); - case SFixed32RulesSchema.typeName: - return tryBuildNativeSfixed32Rules( - rules as SFixed32Rules, - rulePath, - forMapKey, - ); - case SFixed64RulesSchema.typeName: - return tryBuildNativeSfixed64Rules( - rules as SFixed64Rules, - rulePath, - forMapKey, - ); - case FloatRulesSchema.typeName: - return tryBuildNativeFloatRules(rules as FloatRules, rulePath, forMapKey); - case DoubleRulesSchema.typeName: - return tryBuildNativeDoubleRules( - rules as DoubleRules, - rulePath, - forMapKey, - ); - default: - return { kind: "none" }; + if (rules.$typeName === BoolRulesSchema.typeName) { + return tryBuildNativeBoolRules(rules as BoolRules, rulePath, forMapKey); } + return tryBuildNativeNumericRules(rules, rulePath, forMapKey); } diff --git a/packages/protovalidate/src/native/format.ts b/packages/protovalidate/src/native/format.ts index 370407b..d452fa5 100644 --- a/packages/protovalidate/src/native/format.ts +++ b/packages/protovalidate/src/native/format.ts @@ -28,10 +28,18 @@ export function codepointLength(s: string): number { } /** - * Format a number for inclusion in a violation message. + * Format a finite double for inclusion in a violation message. * - * Mirrors protovalidate-go's `printFloat` so error messages match the CEL - * implementation byte-for-byte. + * Matches what `@bufbuild/cel`'s `%s` formatter produces for a `number` — i.e. + * `Number.prototype.toString()` — so the native and CEL evaluators emit + * byte-identical messages today. + * + * NOTE: This diverges from protovalidate-go, which uses + * `strconv.FormatFloat(v, 'f', -1, 64)` (always fixed-point). For values in + * the JS scientific-notation zone (outside `[1e-7, 1e21)`) the TS impls + * produce `1e+21` where Go produces `1000000000000000000000`. Both cel-es and + * this helper need to change together to close the gap; do not "fix" one in + * isolation. See protovalidate-es plan and the cel-es `formatFloating` impl. */ export function printFloat(n: number): string { if (Number.isNaN(n)) { diff --git a/packages/protovalidate/src/native/numeric.test.ts b/packages/protovalidate/src/native/numeric.test.ts index c20ee26..e7a4dc3 100644 --- a/packages/protovalidate/src/native/numeric.test.ts +++ b/packages/protovalidate/src/native/numeric.test.ts @@ -333,4 +333,60 @@ void suite("native numeric rules", () => { diff(s, create(s, { x: Number.NaN })); }); }); + + // Review follow-up: gaps surfaced by the code review. + void suite("review gap coverage", () => { + void test("T1: NaN value with float.in list", () => { + // NaN is never === to any list element, so the violation must fire. + const s = compile( + `message M { float x = 1 [(buf.validate.field).float = { in: [1.0, 2.0] }]; }`, + ); + diff(s, create(s, { x: Number.NaN })); + }); + + void test("T2: const and range together both report violations", () => { + const s = compile( + `message M { + int32 n = 1 [(buf.validate.field).int32 = { const: 5, gt: 3, lt: 100 }]; + }`, + ); + // 4 satisfies the range (gt 3, lt 100) but violates const = 5. + diff(s, create(s, { n: 4 })); + // 200 violates both const and the range. + diff(s, create(s, { n: 200 })); + }); + + void test("T3: BoolValue wrapper unset on parent", () => { + const s = compile( + `message M { + google.protobuf.BoolValue b = 1 [(buf.validate.field).bool.const = true]; + }`, + ); + // Wrapper field absent — EvalField's presence check skips validation. + diff(s, create(s, {})); + }); + + void test("T4: int64 max-boundary values", () => { + // 9_223_372_036_854_775_807 is max int64. Validate const at and around it. + const s = compile( + `message M { + int64 n = 1 [(buf.validate.field).int64.const = 9223372036854775807]; + }`, + ); + diff(s, create(s, { n: 9223372036854775807n })); + diff(s, create(s, { n: 9223372036854775806n })); + }); + + void test("T5: explicit float.finite=false claims the field but emits nothing", () => { + const s = compile( + `message M { + float x = 1 [(buf.validate.field).float.finite = false]; + }`, + ); + // finite=false means "no constraint" — every value passes, even NaN. + diff(s, create(s, { x: Number.NaN })); + diff(s, create(s, { x: Number.POSITIVE_INFINITY })); + diff(s, create(s, { x: 1.5 })); + }); + }); }); diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index 6ed80f5..4497c3a 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { type DescField, isFieldSet, type Message } from "@bufbuild/protobuf"; +import { isFieldSet, type Message } from "@bufbuild/protobuf"; import type { Path, PathBuilder, @@ -20,19 +20,19 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { - DoubleRules, - FloatRules, - Fixed32Rules, - Fixed64Rules, - Int32Rules, - Int64Rules, - SFixed32Rules, - SFixed64Rules, - SInt32Rules, - SInt64Rules, - UInt32Rules, - UInt64Rules, +import { + DoubleRulesSchema, + Fixed32RulesSchema, + Fixed64RulesSchema, + FloatRulesSchema, + Int32RulesSchema, + Int64RulesSchema, + SFixed32RulesSchema, + SFixed64RulesSchema, + SInt32RulesSchema, + SInt64RulesSchema, + UInt32RulesSchema, + UInt64RulesSchema, } from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { printFloat } from "./format.js"; @@ -69,73 +69,73 @@ type NumericConfig = { const stringFormat = (v: number | bigint): string => v.toString(); const floatFormat = (v: number): string => printFloat(v); -export const int32Config: NumericConfig = { +const int32Config: NumericConfig = { typeName: "int32", descs: int32Descs, format: stringFormat, nanFailsRange: false, }; -export const int64Config: NumericConfig = { +const int64Config: NumericConfig = { typeName: "int64", descs: int64Descs, format: stringFormat, nanFailsRange: false, }; -export const uint32Config: NumericConfig = { +const uint32Config: NumericConfig = { typeName: "uint32", descs: uint32Descs, format: stringFormat, nanFailsRange: false, }; -export const uint64Config: NumericConfig = { +const uint64Config: NumericConfig = { typeName: "uint64", descs: uint64Descs, format: stringFormat, nanFailsRange: false, }; -export const sint32Config: NumericConfig = { +const sint32Config: NumericConfig = { typeName: "sint32", descs: sint32Descs, format: stringFormat, nanFailsRange: false, }; -export const sint64Config: NumericConfig = { +const sint64Config: NumericConfig = { typeName: "sint64", descs: sint64Descs, format: stringFormat, nanFailsRange: false, }; -export const fixed32Config: NumericConfig = { +const fixed32Config: NumericConfig = { typeName: "fixed32", descs: fixed32Descs, format: stringFormat, nanFailsRange: false, }; -export const fixed64Config: NumericConfig = { +const fixed64Config: NumericConfig = { typeName: "fixed64", descs: fixed64Descs, format: stringFormat, nanFailsRange: false, }; -export const sfixed32Config: NumericConfig = { +const sfixed32Config: NumericConfig = { typeName: "sfixed32", descs: sfixed32Descs, format: stringFormat, nanFailsRange: false, }; -export const sfixed64Config: NumericConfig = { +const sfixed64Config: NumericConfig = { typeName: "sfixed64", descs: sfixed64Descs, format: stringFormat, nanFailsRange: false, }; -export const floatConfig: NumericConfig = { +const floatConfig: NumericConfig = { typeName: "float", descs: floatDescs, format: floatFormat, nanFailsRange: true, }; -export const doubleConfig: NumericConfig = { +const doubleConfig: NumericConfig = { typeName: "double", descs: doubleDescs, format: floatFormat, @@ -165,8 +165,18 @@ type NumericRulesWithFinite = NumericRulesShape & { finite: boolean; }; -type LowerBound = "none" | "gt" | "gte"; -type UpperBound = "none" | "lt" | "lte"; +type ConstRule = { readonly val: T; readonly path: Path }; +type ListRule = { readonly vals: readonly T[]; readonly path: Path }; +type LowerRule = { + readonly kind: "gt" | "gte"; + readonly val: T; + readonly path: Path; +}; +type UpperRule = { + readonly kind: "lt" | "lte"; + readonly val: T; + readonly path: Path; +}; class EvalNativeNumericRules implements Eval @@ -174,67 +184,53 @@ class EvalNativeNumericRules constructor( private readonly config: NumericConfig, private readonly forMapKey: boolean, - private readonly constVal: T | undefined, - private readonly inVals: readonly T[], - private readonly notInVals: readonly T[], - private readonly lower: LowerBound, - private readonly lo: T, - private readonly upper: UpperBound, - private readonly hi: T, - private readonly finite: boolean, - private readonly paths: { - const: Path | undefined; - in: Path | undefined; - notIn: Path | undefined; - lo: Path | undefined; - hi: Path | undefined; - finite: Path | undefined; - }, + private readonly constRule: ConstRule | undefined, + private readonly inRule: ListRule | undefined, + private readonly notInRule: ListRule | undefined, + private readonly lowerRule: LowerRule | undefined, + private readonly upperRule: UpperRule | undefined, + private readonly finitePath: Path | undefined, ) {} eval(val: ScalarValue, cursor: Cursor): void { const v = val as T; - if (this.constVal !== undefined && v !== this.constVal) { + if (this.constRule !== undefined && v !== this.constRule.val) { cursor.violate( - `must equal ${this.config.format(this.constVal)}`, + `must equal ${this.config.format(this.constRule.val)}`, `${this.config.typeName}.const`, - // biome-ignore lint/style/noNonNullAssertion: path is set whenever constVal is set - this.paths.const!, + this.constRule.path, this.forMapKey, ); } - if (this.inVals.length > 0 && !contains(this.inVals, v)) { + if (this.inRule !== undefined && !includesT(this.inRule.vals, v)) { cursor.violate( - `must be in list ${this.formatList(this.inVals)}`, + `must be in list ${formatList(this.inRule.vals, this.config)}`, `${this.config.typeName}.in`, - // biome-ignore lint/style/noNonNullAssertion: path is set whenever inVals is non-empty - this.paths.in!, + this.inRule.path, this.forMapKey, ); } - if (this.notInVals.length > 0 && contains(this.notInVals, v)) { + if (this.notInRule !== undefined && includesT(this.notInRule.vals, v)) { cursor.violate( - `must not be in list ${this.formatList(this.notInVals)}`, + `must not be in list ${formatList(this.notInRule.vals, this.config)}`, `${this.config.typeName}.not_in`, - // biome-ignore lint/style/noNonNullAssertion: path is set whenever notInVals is non-empty - this.paths.notIn!, + this.notInRule.path, this.forMapKey, ); } if ( - this.finite && + this.finitePath !== undefined && typeof v === "number" && (Number.isNaN(v) || !Number.isFinite(v)) ) { cursor.violate( "must be finite", `${this.config.typeName}.finite`, - // biome-ignore lint/style/noNonNullAssertion: path is set whenever finite=true - this.paths.finite!, + this.finitePath, this.forMapKey, ); } @@ -247,114 +243,108 @@ class EvalNativeNumericRules } private evalRange(v: T, cursor: Cursor): void { - if (this.lower === "none" && this.upper === "none") { + const { lowerRule: lo, upperRule: hi } = this; + if (lo === undefined && hi === undefined) { return; } const isNaNVal = this.config.nanFailsRange && typeof v === "number" && Number.isNaN(v); - if (this.lower === "none") { - if (isNaNVal || this.aboveHi(v)) { + if (lo !== undefined && hi !== undefined) { + const isNormal = hi.val >= lo.val; + const fail = isNormal + ? isNaNVal || aboveHi(v, hi) || belowLo(v, lo) + : isNaNVal || (aboveHi(v, hi) && belowLo(v, lo)); + if (fail) { + const suffix = isNormal ? "" : "_exclusive"; cursor.violate( - `must be ${this.hiMessage()}`, - this.rangeRuleId(), - // biome-ignore lint/style/noNonNullAssertion: path set when upper != none - this.paths.hi!, + `must be ${loMessage(lo, this.config)} ${ + isNormal ? "and" : "or" + } ${hiMessage(hi, this.config)}`, + `${this.config.typeName}.${lo.kind}_${hi.kind}${suffix}`, + lo.path, this.forMapKey, ); } return; } - if (this.upper === "none") { - if (isNaNVal || this.belowLo(v)) { + if (lo !== undefined) { + if (isNaNVal || belowLo(v, lo)) { cursor.violate( - `must be ${this.loMessage()}`, - this.rangeRuleId(), - // biome-ignore lint/style/noNonNullAssertion: path set when lower != none - this.paths.lo!, + `must be ${loMessage(lo, this.config)}`, + `${this.config.typeName}.${lo.kind}`, + lo.path, this.forMapKey, ); } return; } - let fail: boolean; - if (this.isNormalRange()) { - fail = isNaNVal || this.aboveHi(v) || this.belowLo(v); - } else { - fail = isNaNVal || (this.aboveHi(v) && this.belowLo(v)); - } - if (fail) { + // hi must be defined since we returned early when both are undefined. + if (hi !== undefined && (isNaNVal || aboveHi(v, hi))) { cursor.violate( - `must be ${this.loMessage()} ${this.conjunction()} ${this.hiMessage()}`, - this.rangeRuleId(), - // biome-ignore lint/style/noNonNullAssertion: path set when lower != none - this.paths.lo!, + `must be ${hiMessage(hi, this.config)}`, + `${this.config.typeName}.${hi.kind}`, + hi.path, this.forMapKey, ); } } +} - private belowLo(v: T): boolean { - return this.lower === "gt" ? v <= this.lo : v < this.lo; - } - - private aboveHi(v: T): boolean { - return this.upper === "lt" ? v >= this.hi : v > this.hi; - } - - private isNormalRange(): boolean { - return this.hi >= this.lo; - } - - private loMessage(): string { - return this.lower === "gt" - ? `greater than ${this.config.format(this.lo)}` - : `greater than or equal to ${this.config.format(this.lo)}`; - } - - private hiMessage(): string { - return this.upper === "lt" - ? `less than ${this.config.format(this.hi)}` - : `less than or equal to ${this.config.format(this.hi)}`; - } +function belowLo(v: T, lo: LowerRule): boolean { + return lo.kind === "gt" ? v <= lo.val : v < lo.val; +} - private conjunction(): string { - return this.isNormalRange() ? "and" : "or"; - } +function aboveHi(v: T, hi: UpperRule): boolean { + return hi.kind === "lt" ? v >= hi.val : v > hi.val; +} - private rangeRuleId(): string { - const t = this.config.typeName; - if (this.lower === "none") { - return `${t}.${this.upper}`; - } - if (this.upper === "none") { - return `${t}.${this.lower}`; - } - const suffix = this.isNormalRange() ? "" : "_exclusive"; - return `${t}.${this.lower}_${this.upper}${suffix}`; - } +function loMessage( + lo: LowerRule, + config: NumericConfig, +): string { + return lo.kind === "gt" + ? `greater than ${config.format(lo.val)}` + : `greater than or equal to ${config.format(lo.val)}`; +} - private formatList(vs: readonly T[]): string { - let out = "["; - for (let i = 0; i < vs.length; i++) { - if (i > 0) out += ", "; - out += this.config.format(vs[i] as T); - } - return `${out}]`; - } +function hiMessage( + hi: UpperRule, + config: NumericConfig, +): string { + return hi.kind === "lt" + ? `less than ${config.format(hi.val)}` + : `less than or equal to ${config.format(hi.val)}`; } -function contains(arr: readonly T[], v: T): boolean { +function includesT( + arr: readonly T[], + v: T, +): boolean { for (let i = 0; i < arr.length; i++) { if (arr[i] === v) return true; } return false; } -function buildNumeric( - rules: - | NumericRulesShape - | NumericRulesWithFinite, +function formatList( + vs: readonly T[], + config: NumericConfig, +): string { + let out = "["; + for (let i = 0; i < vs.length; i++) { + if (i > 0) out += ", "; + out += config.format(vs[i] as T); + } + return `${out}]`; +} + +function isNaNValue(v: number | bigint): boolean { + return typeof v === "number" && Number.isNaN(v); +} + +function build( + rules: NumericRulesShape, config: NumericConfig, rulePath: PathBuilder, forMapKey: boolean, @@ -362,81 +352,69 @@ function buildNumeric( if (rules.$unknown && rules.$unknown.length > 0) { return { kind: "none" }; } - const handled = new Set(); - const paths: { - const: Path | undefined; - in: Path | undefined; - notIn: Path | undefined; - lo: Path | undefined; - hi: Path | undefined; - finite: Path | undefined; - } = { - const: undefined, - in: undefined, - notIn: undefined, - lo: undefined, - hi: undefined, - finite: undefined, - }; - let constVal: T | undefined; + const handled = new Set(); + + let constRule: ConstRule | undefined; if (isFieldSet(rules, config.descs.const)) { - constVal = rules.const; - paths.const = rulePath.clone().field(config.descs.const).toPath(); + constRule = { + val: rules.const, + path: rulePath.clone().field(config.descs.const).toPath(), + }; handled.add(config.descs.const); } - let inVals: readonly T[] = []; + let inRule: ListRule | undefined; if (rules.in.length > 0) { - inVals = rules.in; - paths.in = rulePath.clone().field(config.descs.in).toPath(); + inRule = { + vals: rules.in, + path: rulePath.clone().field(config.descs.in).toPath(), + }; handled.add(config.descs.in); } - let notInVals: readonly T[] = []; + let notInRule: ListRule | undefined; if (rules.notIn.length > 0) { - notInVals = rules.notIn; - paths.notIn = rulePath.clone().field(config.descs.notIn).toPath(); + notInRule = { + vals: rules.notIn, + path: rulePath.clone().field(config.descs.notIn).toPath(), + }; handled.add(config.descs.notIn); } - let lower: LowerBound = "none"; - let lo: T = 0 as T; - if (rules.greaterThan.case === "gt") { - if (isNaNValue(rules.greaterThan.value)) return { kind: "none" }; - lower = "gt"; - lo = rules.greaterThan.value; - paths.lo = rulePath.clone().field(config.descs.gt).toPath(); - handled.add(config.descs.gt); - } else if (rules.greaterThan.case === "gte") { - if (isNaNValue(rules.greaterThan.value)) return { kind: "none" }; - lower = "gte"; - lo = rules.greaterThan.value; - paths.lo = rulePath.clone().field(config.descs.gte).toPath(); - handled.add(config.descs.gte); + let lowerRule: LowerRule | undefined; + if (rules.greaterThan.case !== undefined) { + const kind = rules.greaterThan.case; + const val = rules.greaterThan.value; + if (isNaNValue(val)) return { kind: "none" }; + const desc = kind === "gt" ? config.descs.gt : config.descs.gte; + lowerRule = { + kind, + val, + path: rulePath.clone().field(desc).toPath(), + }; + handled.add(desc); } - let upper: UpperBound = "none"; - let hi: T = 0 as T; - if (rules.lessThan.case === "lt") { - if (isNaNValue(rules.lessThan.value)) return { kind: "none" }; - upper = "lt"; - hi = rules.lessThan.value; - paths.hi = rulePath.clone().field(config.descs.lt).toPath(); - handled.add(config.descs.lt); - } else if (rules.lessThan.case === "lte") { - if (isNaNValue(rules.lessThan.value)) return { kind: "none" }; - upper = "lte"; - hi = rules.lessThan.value; - paths.hi = rulePath.clone().field(config.descs.lte).toPath(); - handled.add(config.descs.lte); + let upperRule: UpperRule | undefined; + if (rules.lessThan.case !== undefined) { + const kind = rules.lessThan.case; + const val = rules.lessThan.value; + if (isNaNValue(val)) return { kind: "none" }; + const desc = kind === "lt" ? config.descs.lt : config.descs.lte; + upperRule = { + kind, + val, + path: rulePath.clone().field(desc).toPath(), + }; + handled.add(desc); } - let finite = false; + let finitePath: Path | undefined; if (config.descs.finite && isFieldSet(rules, config.descs.finite)) { - finite = (rules as NumericRulesWithFinite).finite; + const finite = (rules as unknown as NumericRulesWithFinite).finite; if (finite) { - paths.finite = rulePath.clone().field(config.descs.finite).toPath(); + finitePath = rulePath.clone().field(config.descs.finite).toPath(); } handled.add(config.descs.finite); } @@ -450,176 +428,113 @@ function buildNumeric( eval: new EvalNativeNumericRules( config, forMapKey, - constVal, - inVals, - notInVals, - lower, - lo, - upper, - hi, - finite, - paths, + constRule, + inRule, + notInRule, + lowerRule, + upperRule, + finitePath, ), handledFields: handled, }; } -function isNaNValue(v: number | bigint): boolean { - return typeof v === "number" && Number.isNaN(v); -} - -export function tryBuildNativeInt32Rules( - rules: Int32Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - int32Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeInt64Rules( - rules: Int64Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - int64Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeUint32Rules( - rules: UInt32Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - uint32Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeUint64Rules( - rules: UInt64Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - uint64Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeSint32Rules( - rules: SInt32Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - sint32Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeSint64Rules( - rules: SInt64Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - sint64Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeFixed32Rules( - rules: Fixed32Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - fixed32Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeFixed64Rules( - rules: Fixed64Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - fixed64Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeSfixed32Rules( - rules: SFixed32Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - sfixed32Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeSfixed64Rules( - rules: SFixed64Rules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesShape, - sfixed64Config, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeFloatRules( - rules: FloatRules, - rulePath: PathBuilder, - forMapKey: boolean, -): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesWithFinite, - floatConfig, - rulePath, - forMapKey, - ); -} - -export function tryBuildNativeDoubleRules( - rules: DoubleRules, +/** + * Build a native evaluator for any of the 12 numeric rules messages. + * Returns `kind: "none"` for any unrecognized type or for rules that bail + * out (NaN bound, unknown extensions, no fields set). + */ +export function tryBuildNativeNumericRules( + rules: Message, rulePath: PathBuilder, forMapKey: boolean, ): ScalarNativeResult { - return buildNumeric( - rules as unknown as NumericRulesWithFinite, - doubleConfig, - rulePath, - forMapKey, - ); + switch (rules.$typeName) { + case Int32RulesSchema.typeName: + return build( + rules as NumericRulesShape, + int32Config, + rulePath, + forMapKey, + ); + case Int64RulesSchema.typeName: + return build( + rules as NumericRulesShape, + int64Config, + rulePath, + forMapKey, + ); + case UInt32RulesSchema.typeName: + return build( + rules as NumericRulesShape, + uint32Config, + rulePath, + forMapKey, + ); + case UInt64RulesSchema.typeName: + return build( + rules as NumericRulesShape, + uint64Config, + rulePath, + forMapKey, + ); + case SInt32RulesSchema.typeName: + return build( + rules as NumericRulesShape, + sint32Config, + rulePath, + forMapKey, + ); + case SInt64RulesSchema.typeName: + return build( + rules as NumericRulesShape, + sint64Config, + rulePath, + forMapKey, + ); + case Fixed32RulesSchema.typeName: + return build( + rules as NumericRulesShape, + fixed32Config, + rulePath, + forMapKey, + ); + case Fixed64RulesSchema.typeName: + return build( + rules as NumericRulesShape, + fixed64Config, + rulePath, + forMapKey, + ); + case SFixed32RulesSchema.typeName: + return build( + rules as NumericRulesShape, + sfixed32Config, + rulePath, + forMapKey, + ); + case SFixed64RulesSchema.typeName: + return build( + rules as NumericRulesShape, + sfixed64Config, + rulePath, + forMapKey, + ); + case FloatRulesSchema.typeName: + return build( + rules as NumericRulesShape, + floatConfig, + rulePath, + forMapKey, + ); + case DoubleRulesSchema.typeName: + return build( + rules as NumericRulesShape, + doubleConfig, + rulePath, + forMapKey, + ); + default: + return { kind: "none" }; + } } diff --git a/packages/protovalidate/src/native/wrapper.ts b/packages/protovalidate/src/native/wrapper.ts index c7a91c5..1ee5c48 100644 --- a/packages/protovalidate/src/native/wrapper.ts +++ b/packages/protovalidate/src/native/wrapper.ts @@ -13,11 +13,7 @@ // limitations under the License. import type { DescField } from "@bufbuild/protobuf"; -import type { - ReflectMessage, - ReflectMessageGet, - ScalarValue, -} from "@bufbuild/protobuf/reflect"; +import type { ReflectMessage, ScalarValue } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; @@ -44,15 +40,3 @@ export class WrappedValueEval implements Eval { return this.inner.prune(); } } - -/** - * Cast helper: `Eval` and `Eval` are both - * assignable to `Eval` (the union type that the planner - * stores), but TypeScript doesn't see that directly because Eval is invariant - * in its parameter. Use this once at each handoff. - */ -export function asReflectGet( - e: Eval, -): Eval { - return e as unknown as Eval; -} diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index acae03f..d0dbee1 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -83,7 +83,6 @@ import { } from "./cel.js"; import { CompilationError } from "./error.js"; import { tryBuildNative } from "./native/index.js"; -import type { RegexMatcher } from "./func.js"; export class Planner { private readonly messageCache = new Map>(); @@ -92,7 +91,6 @@ export class Planner { private readonly celMan: CelManager, private readonly legacyRequired: boolean, private readonly disableNativeRules: boolean, - private readonly regexMatch: RegexMatcher | undefined, ) {} plan(message: DescMessage): Eval { @@ -426,9 +424,15 @@ export class Planner { if (isMessage(rules, AnyRulesSchema)) { evals.add(new EvalAnyRules(rulePath, rules)); } - const wrappedValueField = isWrapperDesc(descMessage) - ? descMessage.fields.find((f) => f.name === "value") - : undefined; + let wrappedValueField: DescField | undefined; + if (isWrapperDesc(descMessage)) { + wrappedValueField = descMessage.fields.find((f) => f.name === "value"); + if (wrappedValueField === undefined) { + throw new CompilationError( + `wrapper ${descMessage.typeName} has no "value" field`, + ); + } + } evals.add(this.rules(rules, rulePath, false, wrappedValueField)); } return evals; @@ -448,7 +452,6 @@ export class Planner { rules, rulePath, forMapKey, - regexMatch: this.regexMatch, wrappedValueField, }); const evalStandard = new EvalStandardRulesCel( diff --git a/packages/protovalidate/src/validator.ts b/packages/protovalidate/src/validator.ts index 33c2a94..00587e7 100644 --- a/packages/protovalidate/src/validator.ts +++ b/packages/protovalidate/src/validator.ts @@ -147,13 +147,11 @@ export function createValidator(opt?: ValidatorOptions): Validator { ? createMutableRegistry(opt.registry, file_buf_validate_validate) : createMutableRegistry(file_buf_validate_validate); const failFast = opt?.failFast ?? false; - const regexMatch = opt?.regexMatch; - const celMan = new CelManager(registry, regexMatch); + const celMan = new CelManager(registry, opt?.regexMatch); const planner = new Planner( celMan, opt?.legacyRequired ?? false, opt?.disableNativeRules ?? false, - regexMatch, ); return { validate< From 7712e90f01408a0b37bac4399fa110229d1490cf Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 12:28:32 -0400 Subject: [PATCH 07/38] Drop unused kind discriminator from native dispatch result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "partial" arm of the {kind:"none"|"partial"|"full", ...} union was never produced — every successful native handler in phase 1 returns full handling, and "full"-vs-"partial" pruning is already handled implicitly by EvalMany.prune() dropping any empty EvalStandardRulesCel. Replace the union with `NativeDispatchResult | undefined` / `ScalarNativeResult | undefined` so the producer signals "no match" by returning undefined and the consumer narrows with `native === undefined` / `native?.handledFields.has(…)`. Removes ~10 lines of boilerplate and a layer of TS narrowing. No behavior change. 862 unit tests pass, conformance 2870/2 expected skips/0 fail unchanged, lint/attw/build green. --- packages/protovalidate/src/native/bool.ts | 9 ++-- .../protovalidate/src/native/dispatcher.ts | 45 ++++++++----------- packages/protovalidate/src/native/numeric.ts | 19 ++++---- packages/protovalidate/src/planner.ts | 7 ++- 4 files changed, 35 insertions(+), 45 deletions(-) diff --git a/packages/protovalidate/src/native/bool.ts b/packages/protovalidate/src/native/bool.ts index cea1e5a..49c1d25 100644 --- a/packages/protovalidate/src/native/bool.ts +++ b/packages/protovalidate/src/native/bool.ts @@ -54,23 +54,22 @@ class EvalNativeBoolRules implements Eval { } /** - * Try to build a native evaluator for BoolRules. Returns kind:"none" if no + * Try to build a native evaluator for BoolRules. Returns `undefined` if no * native handler applies (no const set, or unknown extensions present). */ export function tryBuildNativeBoolRules( rules: BoolRules, rulePath: PathBuilder, forMapKey: boolean, -): ScalarNativeResult { +): ScalarNativeResult | undefined { if (rules.$unknown && rules.$unknown.length > 0) { - return { kind: "none" }; + return undefined; } if (!isFieldSet(rules, boolConstDesc)) { - return { kind: "none" }; + return undefined; } const path = rulePath.clone().field(boolConstDesc).toPath(); return { - kind: "full", eval: new EvalNativeBoolRules(forMapKey, rules.const, path), handledFields: new Set([boolConstDesc]), }; diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index 1f56a41..bb0e0b7 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -26,23 +26,16 @@ import { tryBuildNativeNumericRules } from "./numeric.js"; import { WrappedValueEval } from "./wrapper.js"; /** - * Result of {@link tryBuildNative}. + * A successful native dispatch result. The planner skips CEL enrollment for + * the fields in `handledFields` and appends `eval` to the rule's `EvalMany`. * - * - "none": no native handler applies; the planner enrolls every set field in - * the CEL evaluator as it does today. - * - "partial" / "full": at least one field is handled natively. The planner - * skips CEL enrollment for fields in `handledFields` and appends `eval` to - * the rule's `EvalMany`. "full" indicates every set field on the rules - * message was handled natively, so the trailing `EvalStandardRulesCel` will - * be empty and pruned. + * `tryBuildNative` returns `undefined` to mean "no native handler applies" — + * the planner enrolls every set field in the CEL evaluator as before. */ -export type NativeDispatchResult = - | { kind: "none" } - | { - kind: "partial" | "full"; - eval: Eval; - handledFields: ReadonlySet; - }; +export type NativeDispatchResult = { + eval: Eval; + handledFields: ReadonlySet; +}; /** * Internal dispatch result used by the per-rules-type builders. They produce @@ -50,13 +43,10 @@ export type NativeDispatchResult = * `Eval` (the scalar case) or wraps it in a * `WrappedValueEval` for WKT wrapper messages. */ -export type ScalarNativeResult = - | { kind: "none" } - | { - kind: "partial" | "full"; - eval: Eval; - handledFields: ReadonlySet; - }; +export type ScalarNativeResult = { + eval: Eval; + handledFields: ReadonlySet; +}; /** * Inputs to the native rule dispatcher. @@ -79,12 +69,14 @@ export type NativeDispatchInput = { * Decide whether the given rules submessage can be evaluated natively, and * return an `Eval` for the handled subset plus the set of rule fields that * have been claimed (so the planner skips them on the CEL path). + * + * Returns `undefined` if no native handler applies. */ export function tryBuildNative( input: NativeDispatchInput, -): NativeDispatchResult { +): NativeDispatchResult | undefined { const inner = buildScalarNative(input); - if (inner.kind === "none") return inner; + if (inner === undefined) return undefined; // Eval is invariant in its parameter; the cast is safe because every // ScalarValue is also a valid ReflectMessageGet at runtime. const lifted = @@ -95,13 +87,14 @@ export function tryBuildNative( inner.eval, ) as unknown as Eval); return { - kind: inner.kind, eval: lifted, handledFields: inner.handledFields, }; } -function buildScalarNative(input: NativeDispatchInput): ScalarNativeResult { +function buildScalarNative( + input: NativeDispatchInput, +): ScalarNativeResult | undefined { const { rules, rulePath, forMapKey } = input; if (rules.$typeName === BoolRulesSchema.typeName) { return tryBuildNativeBoolRules(rules as BoolRules, rulePath, forMapKey); diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index 4497c3a..f08f4d0 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -348,9 +348,9 @@ function build( config: NumericConfig, rulePath: PathBuilder, forMapKey: boolean, -): ScalarNativeResult { +): ScalarNativeResult | undefined { if (rules.$unknown && rules.$unknown.length > 0) { - return { kind: "none" }; + return undefined; } const handled = new Set(); @@ -386,7 +386,7 @@ function build( if (rules.greaterThan.case !== undefined) { const kind = rules.greaterThan.case; const val = rules.greaterThan.value; - if (isNaNValue(val)) return { kind: "none" }; + if (isNaNValue(val)) return undefined; const desc = kind === "gt" ? config.descs.gt : config.descs.gte; lowerRule = { kind, @@ -400,7 +400,7 @@ function build( if (rules.lessThan.case !== undefined) { const kind = rules.lessThan.case; const val = rules.lessThan.value; - if (isNaNValue(val)) return { kind: "none" }; + if (isNaNValue(val)) return undefined; const desc = kind === "lt" ? config.descs.lt : config.descs.lte; upperRule = { kind, @@ -420,11 +420,10 @@ function build( } if (handled.size === 0) { - return { kind: "none" }; + return undefined; } return { - kind: "full", eval: new EvalNativeNumericRules( config, forMapKey, @@ -441,14 +440,14 @@ function build( /** * Build a native evaluator for any of the 12 numeric rules messages. - * Returns `kind: "none"` for any unrecognized type or for rules that bail - * out (NaN bound, unknown extensions, no fields set). + * Returns `undefined` for any unrecognized type or for rules that bail out + * (NaN bound, unknown extensions, no fields set). */ export function tryBuildNativeNumericRules( rules: Message, rulePath: PathBuilder, forMapKey: boolean, -): ScalarNativeResult { +): ScalarNativeResult | undefined { switch (rules.$typeName) { case Int32RulesSchema.typeName: return build( @@ -535,6 +534,6 @@ export function tryBuildNativeNumericRules( forMapKey, ); default: - return { kind: "none" }; + return undefined; } } diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index d0dbee1..5b28663 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -447,7 +447,7 @@ export class Planner { const ruleDesc = getRuleDescriptor(rules.$typeName); const prepared = this.celMan.compileRules(ruleDesc); const native = this.disableNativeRules - ? ({ kind: "none" } as const) + ? undefined : tryBuildNative({ rules, rulePath, @@ -459,12 +459,11 @@ export class Planner { rules, forMapKey, ); - const handled = native.kind === "none" ? undefined : native.handledFields; for (const plan of prepared.standard) { if (!isFieldSet(rules, plan.field)) { continue; } - if (handled?.has(plan.field)) { + if (native?.handledFields.has(plan.field)) { continue; } evalStandard.add( @@ -499,7 +498,7 @@ export class Planner { evalStandard, evalExtended, ); - if (native.kind !== "none") { + if (native !== undefined) { combined.add(native.eval); } return combined; From 58cbce2dcda646e481d184ad85150a4cffda4f87 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 13:42:57 -0400 Subject: [PATCH 08/38] Add native handlers for enum, repeated, and map rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the protovalidate-go native rules port. - enum: const, in, not_in. defined_only keeps its existing dedicated evaluator (EvalEnumDefinedOnly) — defined_only and the native subset coexist on the same field. - repeated (list-level): min_items, max_items, unique. unique is handled natively for scalar / enum / bytes element kinds; message-element lists fall through to CEL for unique while min/max_items still run natively. - map: min_pairs, max_pairs. The dispatcher grows a switch on rules.$typeName so enum/repeated/map route to the right per-type builder. RepeatedRules dispatch receives the list field descriptor so the unique builder can pick the right comparator (strict-equal Set for number/bigint/string/bool/enum; bytes-string-key Set for Uint8Array). The planner threads the field through. Verified: - 883 unit tests pass (+21 new diff-based tests covering each rule type, the unique-by-element-kind matrix, the message-element fallthrough, and a path-shape assertion). - Conformance: 2870 pass / 2 expected skips (unchanged). - Lint, attw, build green. Benchmark deltas vs phase2-baseline.json (mean latency, two runs): Map -62% Repeated/Scalar -67% Repeated/Unique/Bytes -45% Repeated/Unique/Scalar -46% Repeated/Message -29% ComplexSchema -40% StandardSchema/Complex -31% 0 regressions past 5% threshold across two runs. String/bytes/wrapper suites stay within noise (their rules are still on CEL until phases 3-5). --- .../protovalidate/src/native/dispatcher.ts | 112 ++++++++-- .../protovalidate/src/native/enum.test.ts | 111 ++++++++++ packages/protovalidate/src/native/enum.ts | 141 ++++++++++++ packages/protovalidate/src/native/map.test.ts | 104 +++++++++ packages/protovalidate/src/native/map.ts | 108 +++++++++ .../protovalidate/src/native/repeated.test.ts | 205 ++++++++++++++++++ packages/protovalidate/src/native/repeated.ts | 199 +++++++++++++++++ packages/protovalidate/src/native/sites.ts | 40 ++++ packages/protovalidate/src/planner.ts | 4 +- 9 files changed, 1000 insertions(+), 24 deletions(-) create mode 100644 packages/protovalidate/src/native/enum.test.ts create mode 100644 packages/protovalidate/src/native/enum.ts create mode 100644 packages/protovalidate/src/native/map.test.ts create mode 100644 packages/protovalidate/src/native/map.ts create mode 100644 packages/protovalidate/src/native/repeated.test.ts create mode 100644 packages/protovalidate/src/native/repeated.ts diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index bb0e0b7..c1b85a2 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -18,11 +18,25 @@ import type { ReflectMessageGet, ScalarValue, } from "@bufbuild/protobuf/reflect"; -import type { BoolRules, FieldRules } from "../gen/buf/validate/validate_pb.js"; -import { BoolRulesSchema } from "../gen/buf/validate/validate_pb.js"; +import type { + BoolRules, + EnumRules, + FieldRules, + MapRules, + RepeatedRules, +} from "../gen/buf/validate/validate_pb.js"; +import { + BoolRulesSchema, + EnumRulesSchema, + MapRulesSchema, + RepeatedRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { Eval } from "../eval.js"; import { tryBuildNativeBoolRules } from "./bool.js"; +import { tryBuildNativeEnumRules } from "./enum.js"; +import { tryBuildNativeMapRules } from "./map.js"; import { tryBuildNativeNumericRules } from "./numeric.js"; +import { tryBuildNativeRepeatedRules } from "./repeated.js"; import { WrappedValueEval } from "./wrapper.js"; /** @@ -38,10 +52,10 @@ export type NativeDispatchResult = { }; /** - * Internal dispatch result used by the per-rules-type builders. They produce - * a scalar-typed eval; {@link tryBuildNative} either lifts it directly into - * `Eval` (the scalar case) or wraps it in a - * `WrappedValueEval` for WKT wrapper messages. + * Internal dispatch result used by the scalar/enum/bool per-rules-type + * builders. They produce a `Eval`; {@link tryBuildNative} either + * lifts it directly into `Eval` (the scalar case) or wraps + * it in a `WrappedValueEval` for WKT wrapper messages. */ export type ScalarNativeResult = { eval: Eval; @@ -63,6 +77,13 @@ export type NativeDispatchInput = { * direct scalar fields. */ wrappedValueField: DescField | undefined; + /** + * For RepeatedRules dispatch (from `Planner.planList`), the list field + * descriptor. The repeated builder uses it to decide whether the `unique` + * rule is native-handleable for the element kind. Undefined for non-list + * call sites. + */ + listField: (DescField & { fieldKind: "list" }) | undefined; }; /** @@ -75,29 +96,74 @@ export type NativeDispatchInput = { export function tryBuildNative( input: NativeDispatchInput, ): NativeDispatchResult | undefined { - const inner = buildScalarNative(input); - if (inner === undefined) return undefined; + const { rules, rulePath, forMapKey, wrappedValueField, listField } = input; + switch (rules.$typeName) { + case BoolRulesSchema.typeName: { + const r = tryBuildNativeBoolRules( + rules as BoolRules, + rulePath, + forMapKey, + ); + return liftScalar(r, wrappedValueField); + } + case EnumRulesSchema.typeName: { + const r = tryBuildNativeEnumRules( + rules as EnumRules, + rulePath, + forMapKey, + ); + return liftScalar(r, wrappedValueField); + } + case RepeatedRulesSchema.typeName: { + const r = tryBuildNativeRepeatedRules( + rules as RepeatedRules, + rulePath, + forMapKey, + listField, + ); + if (r === undefined) return undefined; + // Eval is invariant in its parameter; ReflectList is a valid runtime + // ReflectMessageGet at this call site. + return { + eval: r.eval as unknown as Eval, + handledFields: r.handledFields, + }; + } + case MapRulesSchema.typeName: { + const r = tryBuildNativeMapRules(rules as MapRules, rulePath); + if (r === undefined) return undefined; + // Eval is invariant; ReflectMap is a valid runtime ReflectMessageGet here. + return { + eval: r.eval as unknown as Eval, + handledFields: r.handledFields, + }; + } + default: { + // Numeric rule types: int32/int64/uint32/uint64/sint32/sint64/ + // fixed32/fixed64/sfixed32/sfixed64/float/double. Anything else + // (Duration, Timestamp, Any, FieldMask, custom) returns undefined. + const r = tryBuildNativeNumericRules(rules, rulePath, forMapKey); + return liftScalar(r, wrappedValueField); + } + } +} + +function liftScalar( + result: ScalarNativeResult | undefined, + wrappedValueField: DescField | undefined, +): NativeDispatchResult | undefined { + if (result === undefined) return undefined; // Eval is invariant in its parameter; the cast is safe because every // ScalarValue is also a valid ReflectMessageGet at runtime. const lifted = - input.wrappedValueField === undefined - ? (inner.eval as unknown as Eval) + wrappedValueField === undefined + ? (result.eval as unknown as Eval) : (new WrappedValueEval( - input.wrappedValueField, - inner.eval, + wrappedValueField, + result.eval, ) as unknown as Eval); return { eval: lifted, - handledFields: inner.handledFields, + handledFields: result.handledFields, }; } - -function buildScalarNative( - input: NativeDispatchInput, -): ScalarNativeResult | undefined { - const { rules, rulePath, forMapKey } = input; - if (rules.$typeName === BoolRulesSchema.typeName) { - return tryBuildNativeBoolRules(rules as BoolRules, rulePath, forMapKey); - } - return tryBuildNativeNumericRules(rules, rulePath, forMapKey); -} diff --git a/packages/protovalidate/src/native/enum.test.ts b/packages/protovalidate/src/native/enum.test.ts new file mode 100644 index 0000000..005dbc7 --- /dev/null +++ b/packages/protovalidate/src/native/enum.test.ts @@ -0,0 +1,111 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { compileMessage } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; + +const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +const native = createValidator(); +const cel = createValidator({ disableNativeRules: true }); + +function diff(schema: DescMessage, msg: object): void { + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const a = native.validate(schema, msg as any); + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const b = cel.validate(schema, msg as any); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +function compile(proto: string): DescMessage { + return compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; COLOR_BLUE = 3; } + ${proto}`, + bufCompileOptions, + ); +} + +void suite("native enum rules", () => { + void test("enum.const passes and fails", () => { + const s = compile( + `message M { Color c = 1 [(buf.validate.field).enum.const = 1]; }`, + ); + diff(s, create(s, { c: 1 })); + diff(s, create(s, { c: 2 })); + }); + + void test("enum.in passes and fails", () => { + const s = compile( + `message M { Color c = 1 [(buf.validate.field).enum = { in: [1, 3] }]; }`, + ); + diff(s, create(s, { c: 1 })); + diff(s, create(s, { c: 3 })); + diff(s, create(s, { c: 2 })); + }); + + void test("enum.not_in passes and fails", () => { + const s = compile( + `message M { Color c = 1 [(buf.validate.field).enum = { not_in: [0] }]; }`, + ); + diff(s, create(s, { c: 1 })); + diff(s, create(s, { c: 0 })); + }); + + void test("enum.const + in together both report violations", () => { + const s = compile( + `message M { + Color c = 1 [(buf.validate.field).enum = { const: 1, in: [1, 2] }]; + }`, + ); + diff(s, create(s, { c: 3 })); // violates const + in + diff(s, create(s, { c: 2 })); // violates const only + }); + + void test("enum.defined_only still works (handled by EvalEnumDefinedOnly)", () => { + const s = compile( + `message M { Color c = 1 [(buf.validate.field).enum.defined_only = true]; }`, + ); + diff(s, create(s, { c: 1 })); + diff(s, create(s, { c: 99 })); // undefined + }); + + void test("enum.defined_only + const both fire when applicable", () => { + const s = compile( + `message M { + Color c = 1 [(buf.validate.field).enum = { defined_only: true, const: 1 }]; + }`, + ); + // Defined but not const + diff(s, create(s, { c: 2 })); + // Undefined: should fire defined_only and const + diff(s, create(s, { c: 99 })); + }); +}); diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts new file mode 100644 index 0000000..8fe464d --- /dev/null +++ b/packages/protovalidate/src/native/enum.ts @@ -0,0 +1,141 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import type { EnumRules } from "../gen/buf/validate/validate_pb.js"; +import type { ScalarNativeResult } from "./dispatcher.js"; +import { enumDescs } from "./sites.js"; + +type ConstRule = { readonly val: number; readonly path: Path }; +type ListRule = { readonly vals: readonly number[]; readonly path: Path }; + +/** + * Native evaluator for the `enum.const`, `enum.in`, and `enum.not_in` rules. + * + * `enum.defined_only` keeps its existing dedicated evaluator (`EvalEnumDefinedOnly`). + */ +class EvalNativeEnumRules implements Eval { + constructor( + private readonly forMapKey: boolean, + private readonly constRule: ConstRule | undefined, + private readonly inRule: ListRule | undefined, + private readonly notInRule: ListRule | undefined, + ) {} + + eval(val: ScalarValue, cursor: Cursor): void { + const v = val as number; + + if (this.constRule !== undefined && v !== this.constRule.val) { + cursor.violate( + `must equal ${this.constRule.val}`, + "enum.const", + this.constRule.path, + this.forMapKey, + ); + } + + if (this.inRule && !this.inRule.vals.includes(v)) { + cursor.violate( + `must be in list ${formatList(this.inRule.vals)}`, + "enum.in", + this.inRule.path, + this.forMapKey, + ); + } + + if (this.notInRule?.vals.includes(v)) { + cursor.violate( + `must not be in list ${formatList(this.notInRule.vals)}`, + "enum.not_in", + this.notInRule.path, + this.forMapKey, + ); + } + } + + prune(): boolean { + return false; + } +} + +function formatList(vs: readonly number[]): string { + let out = "["; + for (let i = 0; i < vs.length; i++) { + if (i > 0) out += ", "; + out += String(vs[i]); + } + return `${out}]`; +} + +/** + * Try to build a native evaluator for EnumRules. Returns `undefined` if no + * native handler applies (no const/in/not_in set, or unknown extensions). + * + * `defined_only` is intentionally not handled here — `EvalEnumDefinedOnly` + * keeps that path. + */ +export function tryBuildNativeEnumRules( + rules: EnumRules, + rulePath: PathBuilder, + forMapKey: boolean, +): ScalarNativeResult | undefined { + if (rules.$unknown && rules.$unknown.length > 0) { + return undefined; + } + + const handled = new Set(); + + let constRule: ConstRule | undefined; + if (isFieldSet(rules, enumDescs.const)) { + constRule = { + val: rules.const, + path: rulePath.clone().field(enumDescs.const).toPath(), + }; + handled.add(enumDescs.const); + } + + let inRule: ListRule | undefined; + if (rules.in.length > 0) { + inRule = { + vals: rules.in, + path: rulePath.clone().field(enumDescs.in).toPath(), + }; + handled.add(enumDescs.in); + } + + let notInRule: ListRule | undefined; + if (rules.notIn.length > 0) { + notInRule = { + vals: rules.notIn, + path: rulePath.clone().field(enumDescs.notIn).toPath(), + }; + handled.add(enumDescs.notIn); + } + + if (handled.size === 0) { + return undefined; + } + + return { + eval: new EvalNativeEnumRules(forMapKey, constRule, inRule, notInRule), + handledFields: handled, + }; +} diff --git a/packages/protovalidate/src/native/map.test.ts b/packages/protovalidate/src/native/map.test.ts new file mode 100644 index 0000000..fc7841e --- /dev/null +++ b/packages/protovalidate/src/native/map.test.ts @@ -0,0 +1,104 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { compileMessage } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; + +const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +const native = createValidator(); +const cel = createValidator({ disableNativeRules: true }); + +function diff(schema: DescMessage, msg: object): void { + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const a = native.validate(schema, msg as any); + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const b = cel.validate(schema, msg as any); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +function compile(proto: string): DescMessage { + return compileMessage( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + ${proto}`, + bufCompileOptions, + ); +} + +void suite("native map rules", () => { + void test("map.min_pairs passes and fails", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map.min_pairs = 2]; + }`, + ); + diff(s, create(s, { kv: { a: 1, b: 2 } })); + diff(s, create(s, { kv: { a: 1 } })); + diff(s, create(s, { kv: {} })); + }); + + void test("map.max_pairs passes and fails", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map.max_pairs = 2]; + }`, + ); + diff(s, create(s, { kv: { a: 1, b: 2 } })); + diff(s, create(s, { kv: { a: 1, b: 2, c: 3 } })); + }); + + void test("min + max together", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map = { + min_pairs: 1, max_pairs: 3 + }]; + }`, + ); + diff(s, create(s, { kv: { a: 1 } })); + diff(s, create(s, { kv: { a: 1, b: 2, c: 3 } })); + diff(s, create(s, { kv: {} })); + diff(s, create(s, { kv: { a: 1, b: 2, c: 3, d: 4 } })); + }); + + void test("map size rules with key/value rules together", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map = { + min_pairs: 2, + values: { int32: { gt: 0 } } + }]; + }`, + ); + diff(s, create(s, { kv: { a: 1, b: 2 } })); + diff(s, create(s, { kv: { a: 1, b: -1 } })); // min ok, value bad + diff(s, create(s, { kv: { a: -1 } })); // both bad + }); +}); diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts new file mode 100644 index 0000000..5bc9d1e --- /dev/null +++ b/packages/protovalidate/src/native/map.ts @@ -0,0 +1,108 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet } from "@bufbuild/protobuf"; +import type { Path, PathBuilder, ReflectMap } from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import type { MapRules } from "../gen/buf/validate/validate_pb.js"; +import { mapDescs } from "./sites.js"; + +/** + * Internal dispatch result for map-shaped native handlers. + */ +export type MapNativeResult = { + eval: Eval; + handledFields: ReadonlySet; +}; + +class EvalNativeMapRules implements Eval { + constructor( + private readonly minPairs: bigint | undefined, + private readonly minPairsPath: Path | undefined, + private readonly maxPairs: bigint | undefined, + private readonly maxPairsPath: Path | undefined, + ) {} + + eval(val: ReflectMap, cursor: Cursor): void { + const size = BigInt(val.size); + + if (this.minPairs !== undefined && size < this.minPairs) { + cursor.violate( + `map must be at least ${this.minPairs} entries`, + "map.min_pairs", + // biome-ignore lint/style/noNonNullAssertion: path is set whenever minPairs is set + this.minPairsPath!, + ); + } + + if (this.maxPairs !== undefined && size > this.maxPairs) { + cursor.violate( + `map must be at most ${this.maxPairs} entries`, + "map.max_pairs", + // biome-ignore lint/style/noNonNullAssertion: path is set whenever maxPairs is set + this.maxPairsPath!, + ); + } + } + + prune(): boolean { + return false; + } +} + +/** + * Try to build a native evaluator for MapRules (min_pairs, max_pairs). + * Returns `undefined` if no native handler applies. + */ +export function tryBuildNativeMapRules( + rules: MapRules, + rulePath: PathBuilder, +): MapNativeResult | undefined { + if (rules.$unknown && rules.$unknown.length > 0) { + return undefined; + } + + const handled = new Set(); + + let minPairs: bigint | undefined; + let minPairsPath: Path | undefined; + if (isFieldSet(rules, mapDescs.minPairs)) { + minPairs = rules.minPairs; + minPairsPath = rulePath.clone().field(mapDescs.minPairs).toPath(); + handled.add(mapDescs.minPairs); + } + + let maxPairs: bigint | undefined; + let maxPairsPath: Path | undefined; + if (isFieldSet(rules, mapDescs.maxPairs)) { + maxPairs = rules.maxPairs; + maxPairsPath = rulePath.clone().field(mapDescs.maxPairs).toPath(); + handled.add(mapDescs.maxPairs); + } + + if (handled.size === 0) { + return undefined; + } + + return { + eval: new EvalNativeMapRules( + minPairs, + minPairsPath, + maxPairs, + maxPairsPath, + ), + handledFields: handled, + }; +} diff --git a/packages/protovalidate/src/native/repeated.test.ts b/packages/protovalidate/src/native/repeated.test.ts new file mode 100644 index 0000000..9c6c1e4 --- /dev/null +++ b/packages/protovalidate/src/native/repeated.test.ts @@ -0,0 +1,205 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { create, type DescMessage } from "@bufbuild/protobuf"; +import { compileFile } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; +import { pathToString } from "@bufbuild/protobuf/reflect"; + +const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +const native = createValidator(); +const cel = createValidator({ disableNativeRules: true }); + +function diff(schema: DescMessage, msg: object): void { + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const a = native.validate(schema, msg as any); + // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper + const b = cel.validate(schema, msg as any); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +function compile(proto: string): DescMessage { + const file = compileFile( + ` + syntax="proto3"; + import "buf/validate/validate.proto"; + import "google/protobuf/wrappers.proto"; + enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; } + message Inner { int32 x = 1; } + ${proto}`, + bufCompileOptions, + ); + // The test target is always called M; Inner is shared context. + const m = file.messages.find((m) => m.name === "M"); + if (!m) throw new Error("test schema must define a message M"); + return m; +} + +void suite("native repeated rules", () => { + void test("repeated.min_items passes and fails", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.min_items = 2]; }`, + ); + diff(s, create(s, { xs: [1, 2] })); + diff(s, create(s, { xs: [1] })); + diff(s, create(s, { xs: [] })); + }); + + void test("repeated.max_items passes and fails", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 2]; }`, + ); + diff(s, create(s, { xs: [1, 2] })); + diff(s, create(s, { xs: [1, 2, 3] })); + }); + + void test("min_items + max_items together", () => { + const s = compile( + `message M { + repeated int32 xs = 1 [(buf.validate.field).repeated = { min_items: 2, max_items: 4 }]; + }`, + ); + diff(s, create(s, { xs: [1, 2, 3] })); + diff(s, create(s, { xs: [1] })); + diff(s, create(s, { xs: [1, 2, 3, 4, 5] })); + }); + + void suite("repeated.unique", () => { + void test("scalar (int32)", () => { + const s = compile( + `message M { + repeated int32 xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff(s, create(s, { xs: [1, 2, 3] })); + diff(s, create(s, { xs: [1, 2, 1] })); + diff(s, create(s, { xs: [] })); + diff(s, create(s, { xs: [42] })); + }); + + void test("string", () => { + const s = compile( + `message M { + repeated string xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff(s, create(s, { xs: ["a", "b", "c"] })); + diff(s, create(s, { xs: ["a", "b", "a"] })); + }); + + void test("bytes", () => { + const s = compile( + `message M { + repeated bytes xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff( + s, + create(s, { + xs: [new Uint8Array([1, 2]), new Uint8Array([3, 4])], + }), + ); + diff( + s, + create(s, { + xs: [new Uint8Array([1, 2]), new Uint8Array([1, 2])], + }), + ); + // Lookalike sequences that differ only in one byte + diff( + s, + create(s, { + xs: [new Uint8Array([1, 2]), new Uint8Array([1, 3])], + }), + ); + }); + + void test("enum", () => { + const s = compile( + `message M { + repeated Color xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff(s, create(s, { xs: [1, 2] })); + diff(s, create(s, { xs: [1, 1] })); + }); + + void test("int64 (bigint)", () => { + const s = compile( + `message M { + repeated int64 xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff(s, create(s, { xs: [1n, 2n] })); + diff(s, create(s, { xs: [1n, 1n] })); + }); + + void test("bool", () => { + const s = compile( + `message M { + repeated bool xs = 1 [(buf.validate.field).repeated.unique = true]; + }`, + ); + diff(s, create(s, { xs: [true, false] })); + diff(s, create(s, { xs: [true, true] })); + }); + + void test("message elements fall through to CEL", () => { + // For `unique` on message-element lists, the native dispatcher returns + // undefined for the unique field and CEL handles it. min/max_items still + // works natively; output must still match CEL byte-for-byte. + const s = compile( + `message M { + repeated Inner xs = 1 [(buf.validate.field).repeated = { + min_items: 1, unique: true + }]; + }`, + ); + diff(s, create(s, { xs: [] })); + diff(s, create(s, { xs: [{ x: 1 }] })); + }); + }); + + void test("rule path lands at repeated.min_items", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.min_items = 2]; }`, + ); + const r = native.validate(s, create(s, { xs: [] })); + assert.equal(r.kind, "invalid"); + assert.equal(r.violations?.length, 1); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "repeated.min_items"); + // The rule path is the chain inside FieldRules: repeated → min_items. + const rulePathStr = pathToString(v.rule); + assert.ok( + rulePathStr.includes("min_items") || rulePathStr.includes("minItems"), + `expected rule path to include min_items leaf, got: ${rulePathStr}`, + ); + }); +}); diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts new file mode 100644 index 0000000..108f946 --- /dev/null +++ b/packages/protovalidate/src/native/repeated.ts @@ -0,0 +1,199 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet, ScalarType } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ReflectList, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import type { RepeatedRules } from "../gen/buf/validate/validate_pb.js"; +import { repeatedDescs } from "./sites.js"; + +/** + * Internal dispatch result for list-shaped native handlers. + */ +export type ListNativeResult = { + eval: Eval; + handledFields: ReadonlySet; +}; + +type UniqueKind = "scalar" | "bytes" | "enum"; + +class EvalNativeRepeatedRules implements Eval { + constructor( + private readonly minItems: bigint | undefined, + private readonly minItemsPath: Path | undefined, + private readonly maxItems: bigint | undefined, + private readonly maxItemsPath: Path | undefined, + private readonly uniqueKind: UniqueKind | undefined, + private readonly uniquePath: Path | undefined, + ) {} + + eval(val: ReflectList, cursor: Cursor): void { + const size = BigInt(val.size); + + if (this.minItems !== undefined && size < this.minItems) { + cursor.violate( + `must contain at least ${this.minItems} item(s)`, + "repeated.min_items", + // biome-ignore lint/style/noNonNullAssertion: path is set whenever minItems is set + this.minItemsPath!, + ); + } + + if (this.maxItems !== undefined && size > this.maxItems) { + cursor.violate( + `must contain no more than ${this.maxItems} item(s)`, + "repeated.max_items", + // biome-ignore lint/style/noNonNullAssertion: path is set whenever maxItems is set + this.maxItemsPath!, + ); + } + + if (this.uniqueKind !== undefined && !isUnique(val, this.uniqueKind)) { + cursor.violate( + "repeated value must contain unique items", + "repeated.unique", + // biome-ignore lint/style/noNonNullAssertion: path is set whenever uniqueKind is set + this.uniquePath!, + ); + } + } + + prune(): boolean { + return false; + } +} + +function isUnique(list: ReflectList, kind: UniqueKind): boolean { + if (list.size <= 1) return true; + if (kind === "bytes") { + const seen = new Set(); + for (const item of list) { + const key = bytesKey(item as Uint8Array); + if (seen.has(key)) return false; + seen.add(key); + } + return true; + } + // scalar (number/bigint/string/boolean) and enum (number) — strict-equal Set works. + const seen = new Set(); + for (const item of list) { + if (seen.has(item)) return false; + seen.add(item); + } + return true; +} + +/** + * Build a deterministic string key for a Uint8Array. Each byte becomes one + * UTF-16 code unit so equal byte sequences hash to equal keys. + */ +function bytesKey(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) { + out += String.fromCharCode(bytes[i] as number); + } + return out; +} + +/** + * Decide whether the list element type supports native unique handling. + * + * Message-typed elements (including WKT scalar wrappers) need protobuf-level + * equality, which the CEL path covers. Returning undefined here signals the + * caller to fall back to CEL for `unique`. + */ +function uniqueKindForListField( + field: DescField & { fieldKind: "list" }, +): UniqueKind | undefined { + switch (field.listKind) { + case "message": + return undefined; + case "enum": + return "enum"; + case "scalar": + return field.scalar === ScalarType.BYTES ? "bytes" : "scalar"; + } +} + +/** + * Try to build a native evaluator for RepeatedRules (list-level rules: + * min_items, max_items, unique). Returns `undefined` if no native handler + * applies. + * + * `unique` is only handled natively for scalar / enum / bytes element kinds. + * For message-typed elements (including WKT wrapper messages), the dispatcher + * leaves `unique` on the CEL path while still claiming min_items / max_items. + */ +export function tryBuildNativeRepeatedRules( + rules: RepeatedRules, + rulePath: PathBuilder, + forMapKey: boolean, + listField: (DescField & { fieldKind: "list" }) | undefined, +): ListNativeResult | undefined { + if (rules.$unknown && rules.$unknown.length > 0) { + return undefined; + } + // Repeated rules don't apply to map keys; the planner only routes a + // RepeatedRules instance from planList(). Defensive guard: + if (forMapKey) return undefined; + + const handled = new Set(); + + let minItems: bigint | undefined; + let minItemsPath: Path | undefined; + if (isFieldSet(rules, repeatedDescs.minItems)) { + minItems = rules.minItems; + minItemsPath = rulePath.clone().field(repeatedDescs.minItems).toPath(); + handled.add(repeatedDescs.minItems); + } + + let maxItems: bigint | undefined; + let maxItemsPath: Path | undefined; + if (isFieldSet(rules, repeatedDescs.maxItems)) { + maxItems = rules.maxItems; + maxItemsPath = rulePath.clone().field(repeatedDescs.maxItems).toPath(); + handled.add(repeatedDescs.maxItems); + } + + let uniqueKind: UniqueKind | undefined; + let uniquePath: Path | undefined; + if (rules.unique && listField !== undefined) { + uniqueKind = uniqueKindForListField(listField); + if (uniqueKind !== undefined) { + uniquePath = rulePath.clone().field(repeatedDescs.unique).toPath(); + handled.add(repeatedDescs.unique); + } + } + + if (handled.size === 0) { + return undefined; + } + + return { + eval: new EvalNativeRepeatedRules( + minItems, + minItemsPath, + maxItems, + maxItemsPath, + uniqueKind, + uniquePath, + ), + handledFields: handled, + }; +} diff --git a/packages/protovalidate/src/native/sites.ts b/packages/protovalidate/src/native/sites.ts index 02d32fd..a917dcb 100644 --- a/packages/protovalidate/src/native/sites.ts +++ b/packages/protovalidate/src/native/sites.ts @@ -16,11 +16,14 @@ import type { DescField } from "@bufbuild/protobuf"; import { BoolRulesSchema, DoubleRulesSchema, + EnumRulesSchema, Fixed32RulesSchema, Fixed64RulesSchema, FloatRulesSchema, Int32RulesSchema, Int64RulesSchema, + MapRulesSchema, + RepeatedRulesSchema, SFixed32RulesSchema, SFixed64RulesSchema, SInt32RulesSchema, @@ -106,3 +109,40 @@ export const doubleDescs: NumericRulesDescs = { }; export const boolConstDesc: DescField = BoolRulesSchema.field.const; + +/** Leaf-field references for EnumRules. */ +export type EnumRulesDescs = { + readonly const: DescField; + readonly in: DescField; + readonly notIn: DescField; +}; + +export const enumDescs: EnumRulesDescs = { + const: EnumRulesSchema.field.const, + in: EnumRulesSchema.field.in, + notIn: EnumRulesSchema.field.notIn, +}; + +/** Leaf-field references for RepeatedRules (list-level). */ +export type RepeatedRulesDescs = { + readonly minItems: DescField; + readonly maxItems: DescField; + readonly unique: DescField; +}; + +export const repeatedDescs: RepeatedRulesDescs = { + minItems: RepeatedRulesSchema.field.minItems, + maxItems: RepeatedRulesSchema.field.maxItems, + unique: RepeatedRulesSchema.field.unique, +}; + +/** Leaf-field references for MapRules. */ +export type MapRulesDescs = { + readonly minPairs: DescField; + readonly maxPairs: DescField; +}; + +export const mapDescs: MapRulesDescs = { + minPairs: MapRulesSchema.field.minPairs, + maxPairs: MapRulesSchema.field.maxPairs, +}; diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 5b28663..c4b50b3 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -257,7 +257,7 @@ export class Planner { field, ); if (rules) { - evals.add(this.rules(rules, rulePath, false)); + evals.add(this.rules(rules, rulePath, false, undefined, field)); } const itemsRules = rules?.items; switch (field.listKind) { @@ -443,6 +443,7 @@ export class Planner { rulePath: PathBuilder, forMapKey: boolean, wrappedValueField: DescField | undefined = undefined, + listField: (DescField & { fieldKind: "list" }) | undefined = undefined, ) { const ruleDesc = getRuleDescriptor(rules.$typeName); const prepared = this.celMan.compileRules(ruleDesc); @@ -453,6 +454,7 @@ export class Planner { rulePath, forMapKey, wrappedValueField, + listField, }); const evalStandard = new EvalStandardRulesCel( this.celMan, From 84598ddc93684ec8fbc8f6b3d8c23e528123acac Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 14:22:34 -0400 Subject: [PATCH 09/38] Address phase 2 code review Cleanups surfaced by the post-merge review: - Move formatList to format.ts as a generic helper taking an element formatter. numeric.ts and enum.ts now share it. - Refactor EvalNativeRepeatedRules and EvalNativeMapRules to hold each rule as a narrowed {val, path} (or {kind, val, path}) record, matching numeric.ts's phase-1 cleanup. Drops every biome-ignore non-null assertion in repeated.ts and map.ts. - Use isFieldSet(unique) as the gate in repeated.ts so explicit `unique: false` claims the field (no-op rule) for parity with numeric.ts's `finite` treatment. - Add an in-file comment to repeated.ts explaining the deliberate divergence from protovalidate-go: when unique:true is set on a message-element list, the TS port keeps min/max_items native and only releases unique to CEL, instead of bailing the entire RepeatedRules handler. Conformance holds in both shapes. - Drop the misleading `as number` cast on Uint8Array indexing. - Reword the forMapKey guard comment in repeated.ts to call it a type-level invariant tripwire (the planner never dispatches RepeatedRules with forMapKey=true). - Mark ListNativeResult and MapNativeResult as @internal. - Unify enum.ts guard style with numeric.ts by introducing a small `contains` helper so biome doesn't push toward `?.` chaining for one case and leave `!== undefined` for the other. Tests: - Pin pathToString output exactly ("repeated.min_items" etc.) instead of substring matching either casing. - Add path-shape assertions for repeated.max_items, repeated.unique, map.min_pairs, map.max_pairs. - Cover Uint8Array elements in repeated.unique tests: empty buffers (single + duplicate-empty). - Add empty-list test for standalone repeated.max_items. - Add a third assertion to the message-element fallthrough test that exercises the CEL-handled unique violation path. - Add an enum.in test for the proto3 default-zero scenario. - Add a combined min_items + max_items + unique test. - Add a repeated.unique = false test confirming the no-op semantics. Verified: - 891 unit tests pass (+8 from 883). - Conformance: 2870 / 2 expected skips / 0 fail (unchanged). - Lint, attw, build green. - Bench: 0 regressions vs phase2-baseline.json across 17 tasks; 11 improvements (phase 2's wins intact). --- .../protovalidate/src/native/enum.test.ts | 11 +++ packages/protovalidate/src/native/enum.ts | 19 ++-- packages/protovalidate/src/native/format.ts | 15 +++ packages/protovalidate/src/native/map.test.ts | 29 ++++++ packages/protovalidate/src/native/map.ts | 49 +++++---- packages/protovalidate/src/native/numeric.ts | 18 +--- .../protovalidate/src/native/repeated.test.ts | 76 +++++++++++++- packages/protovalidate/src/native/repeated.ts | 99 ++++++++++--------- 8 files changed, 214 insertions(+), 102 deletions(-) diff --git a/packages/protovalidate/src/native/enum.test.ts b/packages/protovalidate/src/native/enum.test.ts index 005dbc7..83ddd62 100644 --- a/packages/protovalidate/src/native/enum.test.ts +++ b/packages/protovalidate/src/native/enum.test.ts @@ -71,6 +71,17 @@ void suite("native enum rules", () => { diff(s, create(s, { c: 2 })); }); + void test("enum.in with default-zero field violates", () => { + // Proto3 default enum value is 0. With `in: [1, 3]`, an unset field + // (which validates as 0) must produce a violation. Diff confirms native + // and CEL agree on this realistic scenario. + const s = compile( + `message M { Color c = 1 [(buf.validate.field).enum = { in: [1, 3] }]; }`, + ); + diff(s, create(s, {})); // c defaults to 0 + diff(s, create(s, { c: 0 })); // explicit zero + }); + void test("enum.not_in passes and fails", () => { const s = compile( `message M { Color c = 1 [(buf.validate.field).enum = { not_in: [0] }]; }`, diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts index 8fe464d..816d008 100644 --- a/packages/protovalidate/src/native/enum.ts +++ b/packages/protovalidate/src/native/enum.ts @@ -22,6 +22,7 @@ import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; import type { EnumRules } from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; +import { formatList } from "./format.js"; import { enumDescs } from "./sites.js"; type ConstRule = { readonly val: number; readonly path: Path }; @@ -52,18 +53,18 @@ class EvalNativeEnumRules implements Eval { ); } - if (this.inRule && !this.inRule.vals.includes(v)) { + if (this.inRule !== undefined && !contains(this.inRule.vals, v)) { cursor.violate( - `must be in list ${formatList(this.inRule.vals)}`, + `must be in list ${formatList(this.inRule.vals, String)}`, "enum.in", this.inRule.path, this.forMapKey, ); } - if (this.notInRule?.vals.includes(v)) { + if (this.notInRule !== undefined && contains(this.notInRule.vals, v)) { cursor.violate( - `must not be in list ${formatList(this.notInRule.vals)}`, + `must not be in list ${formatList(this.notInRule.vals, String)}`, "enum.not_in", this.notInRule.path, this.forMapKey, @@ -76,13 +77,11 @@ class EvalNativeEnumRules implements Eval { } } -function formatList(vs: readonly number[]): string { - let out = "["; - for (let i = 0; i < vs.length; i++) { - if (i > 0) out += ", "; - out += String(vs[i]); +function contains(arr: readonly number[], v: number): boolean { + for (let i = 0; i < arr.length; i++) { + if (arr[i] === v) return true; } - return `${out}]`; + return false; } /** diff --git a/packages/protovalidate/src/native/format.ts b/packages/protovalidate/src/native/format.ts index d452fa5..a38f1cf 100644 --- a/packages/protovalidate/src/native/format.ts +++ b/packages/protovalidate/src/native/format.ts @@ -53,3 +53,18 @@ export function printFloat(n: number): string { } return n.toString(); } + +/** + * Format a list of values as `"[v1, v2, ...]"` for inclusion in a violation + * message. The per-element formatter is supplied by the caller so each rule + * family controls its own element formatting (e.g. numeric uses + * `config.format`, enum uses `String`). + */ +export function formatList(vs: readonly T[], fmt: (v: T) => string): string { + let out = "["; + for (let i = 0; i < vs.length; i++) { + if (i > 0) out += ", "; + out += fmt(vs[i] as T); + } + return `${out}]`; +} diff --git a/packages/protovalidate/src/native/map.test.ts b/packages/protovalidate/src/native/map.test.ts index fc7841e..dc1dc38 100644 --- a/packages/protovalidate/src/native/map.test.ts +++ b/packages/protovalidate/src/native/map.test.ts @@ -17,6 +17,7 @@ import * as assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { create, type DescMessage } from "@bufbuild/protobuf"; import { compileMessage } from "@bufbuild/protocompile"; +import { pathToString } from "@bufbuild/protobuf/reflect"; import { createValidator } from "../validator.js"; import type { Violation } from "../error.js"; @@ -101,4 +102,32 @@ void suite("native map rules", () => { diff(s, create(s, { kv: { a: 1, b: -1 } })); // min ok, value bad diff(s, create(s, { kv: { a: -1 } })); // both bad }); + + void test("rule path lands at map.min_pairs", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map.min_pairs = 2]; + }`, + ); + const r = native.validate(s, create(s, { kv: { a: 1 } })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "map.min_pairs"); + assert.equal(pathToString(v.rule), "map.min_pairs"); + }); + + void test("rule path lands at map.max_pairs", () => { + const s = compile( + `message M { + map kv = 1 [(buf.validate.field).map.max_pairs = 1]; + }`, + ); + const r = native.validate(s, create(s, { kv: { a: 1, b: 2 } })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "map.max_pairs"); + assert.equal(pathToString(v.rule), "map.max_pairs"); + }); }); diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts index 5bc9d1e..6a3d000 100644 --- a/packages/protovalidate/src/native/map.ts +++ b/packages/protovalidate/src/native/map.ts @@ -21,38 +21,38 @@ import { mapDescs } from "./sites.js"; /** * Internal dispatch result for map-shaped native handlers. + * + * @internal */ export type MapNativeResult = { eval: Eval; handledFields: ReadonlySet; }; +type SizeRule = { readonly val: bigint; readonly path: Path }; + class EvalNativeMapRules implements Eval { constructor( - private readonly minPairs: bigint | undefined, - private readonly minPairsPath: Path | undefined, - private readonly maxPairs: bigint | undefined, - private readonly maxPairsPath: Path | undefined, + private readonly minPairsRule: SizeRule | undefined, + private readonly maxPairsRule: SizeRule | undefined, ) {} eval(val: ReflectMap, cursor: Cursor): void { const size = BigInt(val.size); - if (this.minPairs !== undefined && size < this.minPairs) { + if (this.minPairsRule !== undefined && size < this.minPairsRule.val) { cursor.violate( - `map must be at least ${this.minPairs} entries`, + `map must be at least ${this.minPairsRule.val} entries`, "map.min_pairs", - // biome-ignore lint/style/noNonNullAssertion: path is set whenever minPairs is set - this.minPairsPath!, + this.minPairsRule.path, ); } - if (this.maxPairs !== undefined && size > this.maxPairs) { + if (this.maxPairsRule !== undefined && size > this.maxPairsRule.val) { cursor.violate( - `map must be at most ${this.maxPairs} entries`, + `map must be at most ${this.maxPairsRule.val} entries`, "map.max_pairs", - // biome-ignore lint/style/noNonNullAssertion: path is set whenever maxPairs is set - this.maxPairsPath!, + this.maxPairsRule.path, ); } } @@ -76,19 +76,21 @@ export function tryBuildNativeMapRules( const handled = new Set(); - let minPairs: bigint | undefined; - let minPairsPath: Path | undefined; + let minPairsRule: SizeRule | undefined; if (isFieldSet(rules, mapDescs.minPairs)) { - minPairs = rules.minPairs; - minPairsPath = rulePath.clone().field(mapDescs.minPairs).toPath(); + minPairsRule = { + val: rules.minPairs, + path: rulePath.clone().field(mapDescs.minPairs).toPath(), + }; handled.add(mapDescs.minPairs); } - let maxPairs: bigint | undefined; - let maxPairsPath: Path | undefined; + let maxPairsRule: SizeRule | undefined; if (isFieldSet(rules, mapDescs.maxPairs)) { - maxPairs = rules.maxPairs; - maxPairsPath = rulePath.clone().field(mapDescs.maxPairs).toPath(); + maxPairsRule = { + val: rules.maxPairs, + path: rulePath.clone().field(mapDescs.maxPairs).toPath(), + }; handled.add(mapDescs.maxPairs); } @@ -97,12 +99,7 @@ export function tryBuildNativeMapRules( } return { - eval: new EvalNativeMapRules( - minPairs, - minPairsPath, - maxPairs, - maxPairsPath, - ), + eval: new EvalNativeMapRules(minPairsRule, maxPairsRule), handledFields: handled, }; } diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index f08f4d0..1b73b2d 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -35,7 +35,7 @@ import { UInt64RulesSchema, } from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; -import { printFloat } from "./format.js"; +import { formatList, printFloat } from "./format.js"; import { doubleDescs, fixed32Descs, @@ -206,7 +206,7 @@ class EvalNativeNumericRules if (this.inRule !== undefined && !includesT(this.inRule.vals, v)) { cursor.violate( - `must be in list ${formatList(this.inRule.vals, this.config)}`, + `must be in list ${formatList(this.inRule.vals, this.config.format)}`, `${this.config.typeName}.in`, this.inRule.path, this.forMapKey, @@ -215,7 +215,7 @@ class EvalNativeNumericRules if (this.notInRule !== undefined && includesT(this.notInRule.vals, v)) { cursor.violate( - `must not be in list ${formatList(this.notInRule.vals, this.config)}`, + `must not be in list ${formatList(this.notInRule.vals, this.config.format)}`, `${this.config.typeName}.not_in`, this.notInRule.path, this.forMapKey, @@ -327,18 +327,6 @@ function includesT( return false; } -function formatList( - vs: readonly T[], - config: NumericConfig, -): string { - let out = "["; - for (let i = 0; i < vs.length; i++) { - if (i > 0) out += ", "; - out += config.format(vs[i] as T); - } - return `${out}]`; -} - function isNaNValue(v: number | bigint): boolean { return typeof v === "number" && Number.isNaN(v); } diff --git a/packages/protovalidate/src/native/repeated.test.ts b/packages/protovalidate/src/native/repeated.test.ts index 9c6c1e4..9fff3c5 100644 --- a/packages/protovalidate/src/native/repeated.test.ts +++ b/packages/protovalidate/src/native/repeated.test.ts @@ -137,6 +137,15 @@ void suite("native repeated rules", () => { xs: [new Uint8Array([1, 2]), new Uint8Array([1, 3])], }), ); + // Empty Uint8Array elements: two equal-length empty buffers must + // collide; one is trivially unique. + diff(s, create(s, { xs: [new Uint8Array([])] })); + diff( + s, + create(s, { + xs: [new Uint8Array([]), new Uint8Array([])], + }), + ); }); void test("enum", () => { @@ -182,9 +191,35 @@ void suite("native repeated rules", () => { ); diff(s, create(s, { xs: [] })); diff(s, create(s, { xs: [{ x: 1 }] })); + // Two identical messages — exercises the CEL-handled unique path so we + // confirm fallthrough actually triggers the violation. + diff(s, create(s, { xs: [{ x: 1 }, { x: 1 }] })); }); }); + void test("repeated.max_items with empty list passes", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 2]; }`, + ); + diff(s, create(s, { xs: [] })); + }); + + void test("min_items + max_items + unique together", () => { + const s = compile( + `message M { + repeated int32 xs = 1 [(buf.validate.field).repeated = { + min_items: 2, max_items: 4, unique: true + }]; + }`, + ); + diff(s, create(s, { xs: [1, 2, 3] })); // valid + diff(s, create(s, { xs: [1] })); // min fails + diff(s, create(s, { xs: [1, 2, 3, 4, 5] })); // max fails + diff(s, create(s, { xs: [1, 2, 2] })); // unique fails + diff(s, create(s, { xs: [1, 1, 1, 1, 1] })); // max + unique fail + diff(s, create(s, { xs: [1, 1] })); // unique fails (min satisfied) + }); + void test("rule path lands at repeated.min_items", () => { const s = compile( `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.min_items = 2]; }`, @@ -195,11 +230,42 @@ void suite("native repeated rules", () => { const v = r.violations?.[0]; assert.ok(v); assert.equal(v.ruleId, "repeated.min_items"); - // The rule path is the chain inside FieldRules: repeated → min_items. - const rulePathStr = pathToString(v.rule); - assert.ok( - rulePathStr.includes("min_items") || rulePathStr.includes("minItems"), - `expected rule path to include min_items leaf, got: ${rulePathStr}`, + assert.equal(pathToString(v.rule), "repeated.min_items"); + }); + + void test("rule path lands at repeated.max_items", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.max_items = 1]; }`, + ); + const r = native.validate(s, create(s, { xs: [1, 2] })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "repeated.max_items"); + assert.equal(pathToString(v.rule), "repeated.max_items"); + }); + + void test("rule path lands at repeated.unique", () => { + const s = compile( + `message M { repeated int32 xs = 1 [(buf.validate.field).repeated.unique = true]; }`, + ); + const r = native.validate(s, create(s, { xs: [1, 1] })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "repeated.unique"); + assert.equal(pathToString(v.rule), "repeated.unique"); + }); + + void test("repeated.unique = false claims the field (no-op rule)", () => { + // Explicit unique=false is a no-op; the native handler claims the field + // so CEL doesn't re-evaluate. Behavior is unchanged from CEL. + const s = compile( + `message M { + repeated int32 xs = 1 [(buf.validate.field).repeated.unique = false]; + }`, ); + diff(s, create(s, { xs: [1, 1] })); // duplicates allowed + diff(s, create(s, { xs: [] })); }); }); diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts index 108f946..26cb02b 100644 --- a/packages/protovalidate/src/native/repeated.ts +++ b/packages/protovalidate/src/native/repeated.ts @@ -25,6 +25,8 @@ import { repeatedDescs } from "./sites.js"; /** * Internal dispatch result for list-shaped native handlers. + * + * @internal */ export type ListNativeResult = { eval: Eval; @@ -33,43 +35,40 @@ export type ListNativeResult = { type UniqueKind = "scalar" | "bytes" | "enum"; +type SizeRule = { readonly val: bigint; readonly path: Path }; +type UniqueRule = { readonly kind: UniqueKind; readonly path: Path }; + class EvalNativeRepeatedRules implements Eval { constructor( - private readonly minItems: bigint | undefined, - private readonly minItemsPath: Path | undefined, - private readonly maxItems: bigint | undefined, - private readonly maxItemsPath: Path | undefined, - private readonly uniqueKind: UniqueKind | undefined, - private readonly uniquePath: Path | undefined, + private readonly minItemsRule: SizeRule | undefined, + private readonly maxItemsRule: SizeRule | undefined, + private readonly uniqueRule: UniqueRule | undefined, ) {} eval(val: ReflectList, cursor: Cursor): void { const size = BigInt(val.size); - if (this.minItems !== undefined && size < this.minItems) { + if (this.minItemsRule !== undefined && size < this.minItemsRule.val) { cursor.violate( - `must contain at least ${this.minItems} item(s)`, + `must contain at least ${this.minItemsRule.val} item(s)`, "repeated.min_items", - // biome-ignore lint/style/noNonNullAssertion: path is set whenever minItems is set - this.minItemsPath!, + this.minItemsRule.path, ); } - if (this.maxItems !== undefined && size > this.maxItems) { + if (this.maxItemsRule !== undefined && size > this.maxItemsRule.val) { cursor.violate( - `must contain no more than ${this.maxItems} item(s)`, + `must contain no more than ${this.maxItemsRule.val} item(s)`, "repeated.max_items", - // biome-ignore lint/style/noNonNullAssertion: path is set whenever maxItems is set - this.maxItemsPath!, + this.maxItemsRule.path, ); } - if (this.uniqueKind !== undefined && !isUnique(val, this.uniqueKind)) { + if (this.uniqueRule !== undefined && !isUnique(val, this.uniqueRule.kind)) { cursor.violate( "repeated value must contain unique items", "repeated.unique", - // biome-ignore lint/style/noNonNullAssertion: path is set whenever uniqueKind is set - this.uniquePath!, + this.uniqueRule.path, ); } } @@ -106,7 +105,7 @@ function isUnique(list: ReflectList, kind: UniqueKind): boolean { function bytesKey(bytes: Uint8Array): string { let out = ""; for (let i = 0; i < bytes.length; i++) { - out += String.fromCharCode(bytes[i] as number); + out += String.fromCharCode(bytes[i]); } return out; } @@ -135,10 +134,6 @@ function uniqueKindForListField( * Try to build a native evaluator for RepeatedRules (list-level rules: * min_items, max_items, unique). Returns `undefined` if no native handler * applies. - * - * `unique` is only handled natively for scalar / enum / bytes element kinds. - * For message-typed elements (including WKT wrapper messages), the dispatcher - * leaves `unique` on the CEL path while still claiming min_items / max_items. */ export function tryBuildNativeRepeatedRules( rules: RepeatedRules, @@ -149,35 +144,54 @@ export function tryBuildNativeRepeatedRules( if (rules.$unknown && rules.$unknown.length > 0) { return undefined; } - // Repeated rules don't apply to map keys; the planner only routes a - // RepeatedRules instance from planList(). Defensive guard: + // Type-level invariant: the planner only routes RepeatedRules from + // planList(), which always passes forMapKey=false. Kept as a tripwire. if (forMapKey) return undefined; const handled = new Set(); - let minItems: bigint | undefined; - let minItemsPath: Path | undefined; + let minItemsRule: SizeRule | undefined; if (isFieldSet(rules, repeatedDescs.minItems)) { - minItems = rules.minItems; - minItemsPath = rulePath.clone().field(repeatedDescs.minItems).toPath(); + minItemsRule = { + val: rules.minItems, + path: rulePath.clone().field(repeatedDescs.minItems).toPath(), + }; handled.add(repeatedDescs.minItems); } - let maxItems: bigint | undefined; - let maxItemsPath: Path | undefined; + let maxItemsRule: SizeRule | undefined; if (isFieldSet(rules, repeatedDescs.maxItems)) { - maxItems = rules.maxItems; - maxItemsPath = rulePath.clone().field(repeatedDescs.maxItems).toPath(); + maxItemsRule = { + val: rules.maxItems, + path: rulePath.clone().field(repeatedDescs.maxItems).toPath(), + }; handled.add(repeatedDescs.maxItems); } - let uniqueKind: UniqueKind | undefined; - let uniquePath: Path | undefined; - if (rules.unique && listField !== undefined) { - uniqueKind = uniqueKindForListField(listField); - if (uniqueKind !== undefined) { - uniquePath = rulePath.clone().field(repeatedDescs.unique).toPath(); + let uniqueRule: UniqueRule | undefined; + if (isFieldSet(rules, repeatedDescs.unique)) { + if (!rules.unique) { + // Explicit `unique: false` is a no-op rule. Claim the field so CEL + // doesn't bother re-evaluating it. Matches numeric.ts's treatment of + // `finite: false`. handled.add(repeatedDescs.unique); + } else if (listField !== undefined) { + const kind = uniqueKindForListField(listField); + if (kind !== undefined) { + uniqueRule = { + kind, + path: rulePath.clone().field(repeatedDescs.unique).toPath(), + }; + handled.add(repeatedDescs.unique); + } + // When `kind === undefined` (message-element list with unique:true) we + // deliberately do NOT claim the unique field; CEL handles it. + // + // protovalidate-go bails the entire RepeatedRules handler in this case + // — releasing min/max_items back to CEL too — to keep ownership + // all-or-nothing. We split ownership instead because in TS the + // partial-claim cost is zero and unique on message elements is + // uncommon. Conformance with the CEL path holds in both shapes. } } @@ -186,14 +200,7 @@ export function tryBuildNativeRepeatedRules( } return { - eval: new EvalNativeRepeatedRules( - minItems, - minItemsPath, - maxItems, - maxItemsPath, - uniqueKind, - uniquePath, - ), + eval: new EvalNativeRepeatedRules(minItemsRule, maxItemsRule, uniqueRule), handledFields: handled, }; } From 5dc6d9a202eb96ce7de1367999ade2b665740147 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 14:36:12 -0400 Subject: [PATCH 10/38] Extract shared diff/compile helpers into native/testing.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 native-rule test files each carried a verbatim copy of the same 30 lines of boilerplate: `bufCompileOptions`, the `native` and `cel` validators, the `diff(schema, msg)` helper, and a near-identical `compile(proto)` helper. Repeated.test.ts even diverged into compileFile to support multi-message schemas, drifting from the others. Consolidate into `src/native/testing.ts`: - `bufCompileOptions`, `native`, `cel` exported once. - `diff(schema, msg)` validates with both paths and asserts violation arrays are byte-identical. - `compile(definition, { preamble? })` always uses compileFile and expects `message M { ... }` as the validation target. Helper messages / enums go in `preamble`. Wrappers are always imported (harmless when unused) so callers no longer need to manage that. Test files now declare only their per-suite preambles: - bool.test.ts: no preamble - numeric.test.ts: no preamble - map.test.ts: no preamble - enum.test.ts: `enum Color { … }` - repeated.test.ts: `enum Color { … } message Inner { … }` Net diff: -169 lines across the 5 test files, +88 lines for testing.ts. 891 unit tests pass unchanged, conformance 2870/2 skipped/0 fail unchanged, lint/attw/build green. --- .../protovalidate/src/native/bool.test.ts | 52 ++--------- .../protovalidate/src/native/enum.test.ts | 46 +++------- packages/protovalidate/src/native/map.test.ts | 39 +-------- .../protovalidate/src/native/numeric.test.ts | 41 +-------- .../protovalidate/src/native/repeated.test.ts | 47 ++-------- packages/protovalidate/src/native/testing.ts | 87 +++++++++++++++++++ 6 files changed, 115 insertions(+), 197 deletions(-) create mode 100644 packages/protovalidate/src/native/testing.ts diff --git a/packages/protovalidate/src/native/bool.test.ts b/packages/protovalidate/src/native/bool.test.ts index 74bde8a..b3abdca 100644 --- a/packages/protovalidate/src/native/bool.test.ts +++ b/packages/protovalidate/src/native/bool.test.ts @@ -13,50 +13,15 @@ // limitations under the License. import { suite, test } from "node:test"; -import * as assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { create, type DescMessage } from "@bufbuild/protobuf"; -import { compileMessage } from "@bufbuild/protocompile"; -import { createValidator } from "../validator.js"; -import type { Violation } from "../error.js"; - -const bufCompileOptions = { - imports: { - "buf/validate/validate.proto": readFileSync( - "proto/buf/validate/validate.proto", - "utf-8", - ), - }, -}; - -const native = createValidator(); -const cel = createValidator({ disableNativeRules: true }); - -/** - * Validate a fixture under both the native and CEL paths and assert their - * Violation arrays are byte-identical (message + ruleId + rule path + field - * path, via Violation.toString()). - */ -function diff(schema: DescMessage, msg: object): void { - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const a = native.validate(schema, msg as any); - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const b = cel.validate(schema, msg as any); - assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); - assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); -} +import { create } from "@bufbuild/protobuf"; +import { compile, diff } from "./testing.js"; void suite("native bool rules", () => { void suite("bool.const", () => { - const schema = compileMessage( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - message M { + const schema = compile( + `message M { bool b = 1 [(buf.validate.field).bool.const = true]; }`, - bufCompileOptions, ); void test("matches: valid", () => { diff(schema, create(schema, { b: true })); @@ -67,15 +32,10 @@ void suite("native bool rules", () => { }); void suite("BoolValue wrapper", () => { - const schema = compileMessage( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - import "google/protobuf/wrappers.proto"; - message M { + const schema = compile( + `message M { google.protobuf.BoolValue b = 1 [(buf.validate.field).bool.const = true]; }`, - bufCompileOptions, ); void test("inner value matches: valid", () => { diff(schema, create(schema, { b: true })); diff --git a/packages/protovalidate/src/native/enum.test.ts b/packages/protovalidate/src/native/enum.test.ts index 83ddd62..7dcdd48 100644 --- a/packages/protovalidate/src/native/enum.test.ts +++ b/packages/protovalidate/src/native/enum.test.ts @@ -13,44 +13,20 @@ // limitations under the License. import { suite, test } from "node:test"; -import * as assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { create, type DescMessage } from "@bufbuild/protobuf"; -import { compileMessage } from "@bufbuild/protocompile"; -import { createValidator } from "../validator.js"; -import type { Violation } from "../error.js"; +import { compile as compileWithPreamble, diff } from "./testing.js"; -const bufCompileOptions = { - imports: { - "buf/validate/validate.proto": readFileSync( - "proto/buf/validate/validate.proto", - "utf-8", - ), - }, -}; +const COLOR_PREAMBLE = ` + enum Color { + COLOR_UNSPECIFIED = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; + } +`; -const native = createValidator(); -const cel = createValidator({ disableNativeRules: true }); - -function diff(schema: DescMessage, msg: object): void { - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const a = native.validate(schema, msg as any); - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const b = cel.validate(schema, msg as any); - assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); - assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); -} - -function compile(proto: string): DescMessage { - return compileMessage( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; COLOR_BLUE = 3; } - ${proto}`, - bufCompileOptions, - ); +function compile(definition: string): DescMessage { + return compileWithPreamble(definition, { preamble: COLOR_PREAMBLE }); } void suite("native enum rules", () => { diff --git a/packages/protovalidate/src/native/map.test.ts b/packages/protovalidate/src/native/map.test.ts index dc1dc38..2180496 100644 --- a/packages/protovalidate/src/native/map.test.ts +++ b/packages/protovalidate/src/native/map.test.ts @@ -14,44 +14,9 @@ import { suite, test } from "node:test"; import * as assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { create, type DescMessage } from "@bufbuild/protobuf"; -import { compileMessage } from "@bufbuild/protocompile"; +import { create } from "@bufbuild/protobuf"; import { pathToString } from "@bufbuild/protobuf/reflect"; -import { createValidator } from "../validator.js"; -import type { Violation } from "../error.js"; - -const bufCompileOptions = { - imports: { - "buf/validate/validate.proto": readFileSync( - "proto/buf/validate/validate.proto", - "utf-8", - ), - }, -}; - -const native = createValidator(); -const cel = createValidator({ disableNativeRules: true }); - -function diff(schema: DescMessage, msg: object): void { - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const a = native.validate(schema, msg as any); - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const b = cel.validate(schema, msg as any); - assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); - assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); -} - -function compile(proto: string): DescMessage { - return compileMessage( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - ${proto}`, - bufCompileOptions, - ); -} +import { compile, diff, native } from "./testing.js"; void suite("native map rules", () => { void test("map.min_pairs passes and fails", () => { diff --git a/packages/protovalidate/src/native/numeric.test.ts b/packages/protovalidate/src/native/numeric.test.ts index e7a4dc3..08aa51d 100644 --- a/packages/protovalidate/src/native/numeric.test.ts +++ b/packages/protovalidate/src/native/numeric.test.ts @@ -13,45 +13,8 @@ // limitations under the License. import { suite, test } from "node:test"; -import * as assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { create, type DescMessage } from "@bufbuild/protobuf"; -import { compileMessage } from "@bufbuild/protocompile"; -import { createValidator } from "../validator.js"; -import type { Violation } from "../error.js"; - -const bufCompileOptions = { - imports: { - "buf/validate/validate.proto": readFileSync( - "proto/buf/validate/validate.proto", - "utf-8", - ), - }, -}; - -const native = createValidator(); -const cel = createValidator({ disableNativeRules: true }); - -function diff(schema: DescMessage, msg: object): void { - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const a = native.validate(schema, msg as any); - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const b = cel.validate(schema, msg as any); - assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); - assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); -} - -function compile(proto: string): DescMessage { - return compileMessage( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - import "google/protobuf/wrappers.proto"; - ${proto}`, - bufCompileOptions, - ); -} +import { create } from "@bufbuild/protobuf"; +import { compile, diff } from "./testing.js"; void suite("native numeric rules", () => { void suite("int32", () => { diff --git a/packages/protovalidate/src/native/repeated.test.ts b/packages/protovalidate/src/native/repeated.test.ts index 9fff3c5..befe51e 100644 --- a/packages/protovalidate/src/native/repeated.test.ts +++ b/packages/protovalidate/src/native/repeated.test.ts @@ -14,50 +14,17 @@ import { suite, test } from "node:test"; import * as assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { create, type DescMessage } from "@bufbuild/protobuf"; -import { compileFile } from "@bufbuild/protocompile"; -import { createValidator } from "../validator.js"; -import type { Violation } from "../error.js"; import { pathToString } from "@bufbuild/protobuf/reflect"; +import { compile as compileWithPreamble, diff, native } from "./testing.js"; -const bufCompileOptions = { - imports: { - "buf/validate/validate.proto": readFileSync( - "proto/buf/validate/validate.proto", - "utf-8", - ), - }, -}; +const PREAMBLE = ` + enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; } + message Inner { int32 x = 1; } +`; -const native = createValidator(); -const cel = createValidator({ disableNativeRules: true }); - -function diff(schema: DescMessage, msg: object): void { - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const a = native.validate(schema, msg as any); - // biome-ignore lint/suspicious/noExplicitAny: cross-schema test helper - const b = cel.validate(schema, msg as any); - assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); - assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); -} - -function compile(proto: string): DescMessage { - const file = compileFile( - ` - syntax="proto3"; - import "buf/validate/validate.proto"; - import "google/protobuf/wrappers.proto"; - enum Color { COLOR_UNSPECIFIED = 0; COLOR_RED = 1; COLOR_GREEN = 2; } - message Inner { int32 x = 1; } - ${proto}`, - bufCompileOptions, - ); - // The test target is always called M; Inner is shared context. - const m = file.messages.find((m) => m.name === "M"); - if (!m) throw new Error("test schema must define a message M"); - return m; +function compile(definition: string): DescMessage { + return compileWithPreamble(definition, { preamble: PREAMBLE }); } void suite("native repeated rules", () => { diff --git a/packages/protovalidate/src/native/testing.ts b/packages/protovalidate/src/native/testing.ts new file mode 100644 index 0000000..9c5f18c --- /dev/null +++ b/packages/protovalidate/src/native/testing.ts @@ -0,0 +1,87 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { readFileSync } from "node:fs"; +import * as assert from "node:assert/strict"; +import type { DescMessage, Message } from "@bufbuild/protobuf"; +import { compileFile } from "@bufbuild/protocompile"; +import { createValidator } from "../validator.js"; +import type { Violation } from "../error.js"; + +/** + * Shared compile options that hand the inline-proto compiler the + * `buf.validate.validate.proto` from this package's `proto/` directory. + */ +export const bufCompileOptions = { + imports: { + "buf/validate/validate.proto": readFileSync( + "proto/buf/validate/validate.proto", + "utf-8", + ), + }, +}; + +/** Validator running the native path (default). */ +export const native = createValidator(); + +/** Validator with native rules disabled — i.e., the pure-CEL reference. */ +export const cel = createValidator({ disableNativeRules: true }); + +/** + * Validate a fixture under both the native and CEL paths and assert their + * Violation arrays are byte-identical (message + ruleId + rule path + field + * path, via `Violation.toString()`). + * + * This is the workhorse assertion for native-rule unit tests — every native + * handler must reproduce CEL output exactly, and the simplest way to prove + * that is to run the same input through both paths and compare. + */ +export function diff(schema: DescMessage, msg: Message): void { + const a = native.validate(schema, msg); + const b = cel.validate(schema, msg); + assert.equal(a.kind, b.kind, "kind mismatch"); + const fmt = (v: Violation) => v.toString(); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); +} + +/** + * Compile an inline proto3 schema and return the message named `M`. + * + * The supplied `definition` must declare a `message M { ... }`. Tests that + * need helper types (extra messages, enums) declare them via `preamble`. + * Imports for `buf.validate.validate.proto` and the WKT scalar wrappers are + * always included; they are harmless when unused. + */ +export function compile( + definition: string, + opts?: { preamble?: string }, +): DescMessage { + const file = compileFile( + ` + syntax = "proto3"; + import "buf/validate/validate.proto"; + import "google/protobuf/wrappers.proto"; + ${opts?.preamble ?? ""} + ${definition} + `, + bufCompileOptions, + ); + const m = file.messages.find((m) => m.name === "M"); + if (!m) { + throw new Error( + "native-rule tests must define `message M { ... }` as the validation target", + ); + } + return m; +} From 057ebece14d7302f2a55f4da124521351e2cc208 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 15:32:48 -0400 Subject: [PATCH 11/38] Add native handler for bytes rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the protovalidate-go native rules port. Bytes rules now run through a hand-written TS evaluator instead of CEL: const, len, min_len, max_len, prefix, suffix, contains, in, not_in, pattern, plus the well-known formats ip/ipv4/ipv6/uuid. The well-known formats validate byte-slice lengths (4 and/or 16) the same way Go does and emit the bytes.{ip,ipv4,ipv6,uuid}[_empty] rule IDs that match CEL's predefined annotations byte-for-byte. bytes.pattern requires valid UTF-8: invalid input surfaces as a RuntimeError, matching CEL's `string(bytes)` cast which uses `new TextDecoder("utf-8", { fatal: true })`. The pattern engine is honored from the validator's regexMatch option when supplied; the default uses the ECMAScript `RegExp` engine, the same fallback CEL uses today. Phase 4 will swap the default to the cel-es `re2` package. regexMatch is re-threaded through Planner → tryBuildNative; phase 1's cleanup removed it because no handler used it. Bytes is the first handler that needs it. validator.ts captures opt?.regexMatch into a local and passes it to both CelManager and Planner so they share a single matcher. BytesValue (WKT) wraps the native scalar evaluator via the existing WrappedValueEval adapter — no new plumbing needed. Verified: - 915 unit tests pass (+24 new bytes tests covering every rule, well- known formats with valid/empty/wrong-size paths, UTF-8 RuntimeError, regexMatch override, BytesValue wrapper, and four rule-path-shape assertions). - Conformance: 2870 pass / 2 expected skips / 0 fail — unchanged. - Lint, attw, build green. Benchmark deltas vs phase3-baseline.json (mean latency): TestByteMatching -84% WrapperTesting -19% ComplexSchema -13% StandardSchema/ComplexSchema -6% 0 regressions past 5%. Numeric/repeated/map/enum suites stay within noise; their rules were already native. --- .../protovalidate/src/native/bytes.test.ts | 263 ++++++++++ packages/protovalidate/src/native/bytes.ts | 490 ++++++++++++++++++ .../protovalidate/src/native/dispatcher.ts | 28 +- packages/protovalidate/src/native/sites.ts | 42 ++ packages/protovalidate/src/planner.ts | 3 + packages/protovalidate/src/validator.ts | 4 +- 6 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 packages/protovalidate/src/native/bytes.test.ts create mode 100644 packages/protovalidate/src/native/bytes.ts diff --git a/packages/protovalidate/src/native/bytes.test.ts b/packages/protovalidate/src/native/bytes.test.ts new file mode 100644 index 0000000..01f81a5 --- /dev/null +++ b/packages/protovalidate/src/native/bytes.test.ts @@ -0,0 +1,263 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { create } from "@bufbuild/protobuf"; +import { pathToString } from "@bufbuild/protobuf/reflect"; +import { compile, diff, native } from "./testing.js"; +import { RuntimeError } from "../error.js"; +import { createValidator } from "../validator.js"; + +function bytes(...vs: number[]): Uint8Array { + return new Uint8Array(vs); +} + +void suite("native bytes rules", () => { + void test("bytes.const passes and fails", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.const = "\\x01\\x02"]; }`, + ); + diff(s, create(s, { b: bytes(0x01, 0x02) })); + diff(s, create(s, { b: bytes(0x01, 0x03) })); + diff(s, create(s, { b: bytes() })); + }); + + void test("bytes.len passes and fails", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.len = 4]; }`, + ); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + diff(s, create(s, { b: bytes(1, 2, 3) })); + diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) })); + }); + + void test("bytes.min_len + max_len", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes = { min_len: 2, max_len: 4 }]; }`, + ); + diff(s, create(s, { b: bytes(1, 2) })); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + diff(s, create(s, { b: bytes(1) })); + diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) })); + }); + + void test("bytes.prefix", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.prefix = "\\xaa\\xbb"]; }`, + ); + diff(s, create(s, { b: bytes(0xaa, 0xbb, 0xcc) })); + diff(s, create(s, { b: bytes(0xaa, 0xcc) })); + diff(s, create(s, { b: bytes() })); + }); + + void test("bytes.suffix", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.suffix = "\\xee\\xff"]; }`, + ); + diff(s, create(s, { b: bytes(0xdd, 0xee, 0xff) })); + diff(s, create(s, { b: bytes(0xee, 0xfe) })); + }); + + void test("bytes.contains", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.contains = "\\xab\\xcd"]; }`, + ); + diff(s, create(s, { b: bytes(0x00, 0xab, 0xcd, 0xff) })); + diff(s, create(s, { b: bytes(0x00, 0xab, 0xff) })); + }); + + void test("bytes.in / not_in", () => { + const s = compile( + `message M { + bytes b = 1 [(buf.validate.field).bytes = { + in: ["\\x01", "\\x02"], + not_in: ["\\x03"] + }]; + }`, + ); + diff(s, create(s, { b: bytes(0x01) })); + diff(s, create(s, { b: bytes(0x02) })); + diff(s, create(s, { b: bytes(0x03) })); // violates both + diff(s, create(s, { b: bytes(0x04) })); // violates in only + }); + + void suite("bytes.pattern", () => { + void test("valid match passes", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; }`, + ); + diff(s, create(s, { b: new TextEncoder().encode("hello") })); + }); + void test("mismatch fails", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; }`, + ); + diff(s, create(s, { b: new TextEncoder().encode("HELLO") })); + }); + void test("non-UTF-8 input is a RuntimeError, not a violation", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`, + ); + // 0xff alone is not valid UTF-8. + diff(s, create(s, { b: bytes(0xff) })); + const r = native.validate(s, create(s, { b: bytes(0xff) })); + assert.equal(r.kind, "error"); + assert.ok(r.error instanceof RuntimeError); + }); + void test("custom regexMatch override is honored", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`, + ); + let calledWith: { pattern: string; against: string } | undefined; + const v = createValidator({ + regexMatch: (pattern, against) => { + calledWith = { pattern, against }; + return false; // always fail + }, + }); + const r = v.validate(s, create(s, { b: new TextEncoder().encode("x") })); + assert.equal(r.kind, "invalid"); + assert.equal(calledWith?.pattern, ".+"); + assert.equal(calledWith?.against, "x"); + }); + }); + + void suite("well-known formats", () => { + void test("bytes.ip accepts 4-byte and 16-byte values", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`, + ); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); + }); + void test("bytes.ip rejects 5-byte values", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`, + ); + diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) })); + }); + void test("bytes.ip rejects empty (emits *_empty rule)", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`, + ); + diff(s, create(s, { b: bytes() })); + const r = native.validate(s, create(s, { b: bytes() })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "bytes.ip_empty"); + }); + void test("bytes.ipv4 requires exactly 4 bytes", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ipv4 = true]; }`, + ); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + diff(s, create(s, { b: bytes(1, 2, 3) })); // too short + diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); // v6 size + }); + void test("bytes.ipv6 requires exactly 16 bytes", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ipv6 = true]; }`, + ); + diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); // v4 size + }); + void test("bytes.uuid requires exactly 16 bytes", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.uuid = true]; }`, + ); + diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + }); + void test("explicit ip=false is a no-op (claimed but emits nothing)", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = false]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) })); + }); + }); + + void suite("BytesValue wrapper", () => { + void test("inner value validated against bytes.len", () => { + const s = compile( + `message M { + google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.len = 2]; + }`, + ); + diff(s, create(s, { b: bytes(1, 2) })); + diff(s, create(s, { b: bytes(1) })); + }); + void test("inner value validated against bytes.ip", () => { + const s = compile( + `message M { + google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.ip = true]; + }`, + ); + diff(s, create(s, { b: bytes(1, 2, 3, 4) })); + diff(s, create(s, { b: bytes(1, 2, 3) })); + }); + }); + + void suite("rule path assertions", () => { + void test("path lands at bytes.const", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.const = "\\x01"]; }`, + ); + const r = native.validate(s, create(s, { b: bytes(0x02) })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "bytes.const"); + }); + void test("path lands at bytes.pattern", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = "^x$"]; }`, + ); + const r = native.validate( + s, + create(s, { b: new TextEncoder().encode("y") }), + ); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "bytes.pattern"); + }); + void test("path lands at bytes.ip (non-empty wrong size)", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`, + ); + const r = native.validate(s, create(s, { b: bytes(1, 2, 3) })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "bytes.ip"); + assert.equal(v.ruleId, "bytes.ip"); + }); + }); + + void test("combined len + pattern fires both", () => { + const s = compile( + `message M { + bytes b = 1 [(buf.validate.field).bytes = { + len: 3, pattern: "^[a-z]+$" + }]; + }`, + ); + diff(s, create(s, { b: new TextEncoder().encode("ab") })); // len fails, pattern passes + diff(s, create(s, { b: new TextEncoder().encode("AB") })); // both fail + diff(s, create(s, { b: new TextEncoder().encode("abc") })); // both pass + }); +}); diff --git a/packages/protovalidate/src/native/bytes.ts b/packages/protovalidate/src/native/bytes.ts new file mode 100644 index 0000000..eb53a64 --- /dev/null +++ b/packages/protovalidate/src/native/bytes.ts @@ -0,0 +1,490 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import { RuntimeError } from "../error.js"; +import type { BytesRules } from "../gen/buf/validate/validate_pb.js"; +import type { ScalarNativeResult } from "./dispatcher.js"; +import { formatList } from "./format.js"; +import { bytesDescs } from "./sites.js"; +import type { RegexMatcher } from "../func.js"; + +type BytesConstRule = { readonly val: Uint8Array; readonly path: Path }; +type SizeRule = { readonly val: bigint; readonly path: Path }; +type BytesValRule = { readonly val: Uint8Array; readonly path: Path }; +type BytesListRule = { + readonly vals: readonly Uint8Array[]; + readonly path: Path; +}; +type PatternRule = { + readonly src: string; + readonly test: (against: string) => boolean; + readonly path: Path; +}; + +type WellKnownKind = "ip" | "ipv4" | "ipv6" | "uuid"; +type WellKnownRule = { + readonly kind: WellKnownKind; + readonly path: Path; +}; + +/** + * Sizes (in bytes) accepted for each well-known format. Matches Go's + * bytesWellKnown.validSizes — see `native_bytes.go`. + */ +const WELL_KNOWN_VALID_SIZES: Record = { + ip: [4, 16], + ipv4: [4], + ipv6: [16], + uuid: [16], +}; + +const WELL_KNOWN_MSG: Record = { + ip: "must be a valid IP address", + ipv4: "must be a valid IPv4 address", + ipv6: "must be a valid IPv6 address", + uuid: "must be a valid UUID", +}; + +const WELL_KNOWN_EMPTY_MSG: Record = { + ip: "value is empty, which is not a valid IP address", + ipv4: "value is empty, which is not a valid IPv4 address", + ipv6: "value is empty, which is not a valid IPv6 address", + uuid: "value is empty, which is not a valid UUID", +}; + +/** + * Strict UTF-8 decoder. `bytes.pattern` requires valid UTF-8 — non-UTF-8 + * input surfaces as a RuntimeError to match CEL's `string(bytes)` cast, + * which uses `TextDecoder` with `fatal: true`. + */ +const utf8FatalDecoder = new TextDecoder("utf-8", { fatal: true }); + +/** + * Non-fatal UTF-8 decoder for formatting bytes in error messages. Matches + * CEL-TS's `formatString` on a Uint8Array, which uses a default + * `new TextDecoder()` (non-fatal — invalid sequences become U+FFFD). + */ +const utf8NonFatalDecoder = new TextDecoder(); + +class EvalNativeBytesRules implements Eval { + constructor( + private readonly forMapKey: boolean, + private readonly constRule: BytesConstRule | undefined, + private readonly exactLen: SizeRule | undefined, + private readonly minLen: SizeRule | undefined, + private readonly maxLen: SizeRule | undefined, + private readonly pattern: PatternRule | undefined, + private readonly prefix: BytesValRule | undefined, + private readonly suffix: BytesValRule | undefined, + private readonly containsRule: BytesValRule | undefined, + private readonly inRule: BytesListRule | undefined, + private readonly notInRule: BytesListRule | undefined, + private readonly wellKnown: WellKnownRule | undefined, + ) {} + + eval(val: ScalarValue, cursor: Cursor): void { + const v = val as Uint8Array; + const len = BigInt(v.length); + + if (this.constRule !== undefined && !bytesEqual(v, this.constRule.val)) { + cursor.violate( + `must be ${toHex(this.constRule.val)}`, + "bytes.const", + this.constRule.path, + this.forMapKey, + ); + } + + if (this.exactLen !== undefined && len !== this.exactLen.val) { + cursor.violate( + `must be ${this.exactLen.val} bytes`, + "bytes.len", + this.exactLen.path, + this.forMapKey, + ); + } + + if (this.minLen !== undefined && len < this.minLen.val) { + cursor.violate( + `must be at least ${this.minLen.val} bytes`, + "bytes.min_len", + this.minLen.path, + this.forMapKey, + ); + } + + if (this.maxLen !== undefined && len > this.maxLen.val) { + cursor.violate( + `must be at most ${this.maxLen.val} bytes`, + "bytes.max_len", + this.maxLen.path, + this.forMapKey, + ); + } + + if (this.pattern !== undefined) { + let decoded: string; + try { + decoded = utf8FatalDecoder.decode(v); + } catch (cause) { + throw new RuntimeError("must be valid UTF-8 to apply regexp", { + cause, + }); + } + if (!this.pattern.test(decoded)) { + cursor.violate( + `must match regex pattern \`${this.pattern.src}\``, + "bytes.pattern", + this.pattern.path, + this.forMapKey, + ); + } + } + + if (this.prefix !== undefined && !startsWith(v, this.prefix.val)) { + cursor.violate( + `does not have prefix ${toHex(this.prefix.val)}`, + "bytes.prefix", + this.prefix.path, + this.forMapKey, + ); + } + + if (this.suffix !== undefined && !endsWith(v, this.suffix.val)) { + cursor.violate( + `does not have suffix ${toHex(this.suffix.val)}`, + "bytes.suffix", + this.suffix.path, + this.forMapKey, + ); + } + + if ( + this.containsRule !== undefined && + !containsBytes(v, this.containsRule.val) + ) { + cursor.violate( + `does not contain ${toHex(this.containsRule.val)}`, + "bytes.contains", + this.containsRule.path, + this.forMapKey, + ); + } + + if (this.inRule !== undefined && !bytesListContains(this.inRule.vals, v)) { + cursor.violate( + `must be in list ${formatList(this.inRule.vals, bytesToCelString)}`, + "bytes.in", + this.inRule.path, + this.forMapKey, + ); + } + + if ( + this.notInRule !== undefined && + bytesListContains(this.notInRule.vals, v) + ) { + cursor.violate( + `must not be in list ${formatList(this.notInRule.vals, bytesToCelString)}`, + "bytes.not_in", + this.notInRule.path, + this.forMapKey, + ); + } + + if (this.wellKnown !== undefined) { + const size = v.length; + const kind = this.wellKnown.kind; + if (size === 0) { + cursor.violate( + WELL_KNOWN_EMPTY_MSG[kind], + `bytes.${kind}_empty`, + this.wellKnown.path, + this.forMapKey, + ); + } else if (!WELL_KNOWN_VALID_SIZES[kind].includes(size)) { + cursor.violate( + WELL_KNOWN_MSG[kind], + `bytes.${kind}`, + this.wellKnown.path, + this.forMapKey, + ); + } + } + } + + prune(): boolean { + return false; + } +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function startsWith(haystack: Uint8Array, needle: Uint8Array): boolean { + if (needle.length > haystack.length) return false; + for (let i = 0; i < needle.length; i++) { + if (haystack[i] !== needle[i]) return false; + } + return true; +} + +function endsWith(haystack: Uint8Array, needle: Uint8Array): boolean { + if (needle.length > haystack.length) return false; + const offset = haystack.length - needle.length; + for (let i = 0; i < needle.length; i++) { + if (haystack[offset + i] !== needle[i]) return false; + } + return true; +} + +function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean { + if (needle.length === 0) return true; + if (needle.length > haystack.length) return false; + const limit = haystack.length - needle.length; + outer: for (let i = 0; i <= limit; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return true; + } + return false; +} + +function bytesListContains( + list: readonly Uint8Array[], + v: Uint8Array, +): boolean { + for (let i = 0; i < list.length; i++) { + if (bytesEqual(list[i] as Uint8Array, v)) return true; + } + return false; +} + +/** + * Format bytes as concatenated lowercase hex pairs without separators or + * prefix. Matches Go's `%x` formatter for `[]byte` and cel-es's `%x` + * formatting for Uint8Array. + */ +function toHex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) { + out += (bytes[i] as number).toString(16).padStart(2, "0"); + } + return out; +} + +/** + * Format a Uint8Array the way cel-es's `%s` does — non-fatal UTF-8 + * decode, with U+FFFD substitution for invalid sequences. Used inside + * `bytes.in` / `bytes.not_in` list formatting. + */ +function bytesToCelString(b: Uint8Array): string { + return utf8NonFatalDecoder.decode(b); +} + +/** + * Default regex test using the platform `RegExp` engine. Used when no + * `regexMatch` override is supplied. Phase 4 swaps this for the cel-es + * `re2` package. + */ +function defaultRegexTest(pattern: string): (against: string) => boolean { + const re = new RegExp(pattern); + return (against) => re.test(against); +} + +/** + * Try to build a native evaluator for BytesRules. Returns `undefined` if no + * native handler applies (no fields set, unknown extensions, or an + * uncompilable pattern that we let CEL surface as a CompilationError). + */ +export function tryBuildNativeBytesRules( + rules: BytesRules, + rulePath: PathBuilder, + forMapKey: boolean, + regexMatch: RegexMatcher | undefined, +): ScalarNativeResult | undefined { + if (rules.$unknown && rules.$unknown.length > 0) { + return undefined; + } + + const handled = new Set(); + + let constRule: BytesConstRule | undefined; + if (isFieldSet(rules, bytesDescs.const)) { + constRule = { + val: rules.const, + path: rulePath.clone().field(bytesDescs.const).toPath(), + }; + handled.add(bytesDescs.const); + } + + let exactLen: SizeRule | undefined; + if (isFieldSet(rules, bytesDescs.len)) { + exactLen = { + val: rules.len, + path: rulePath.clone().field(bytesDescs.len).toPath(), + }; + handled.add(bytesDescs.len); + } + + let minLen: SizeRule | undefined; + if (isFieldSet(rules, bytesDescs.minLen)) { + minLen = { + val: rules.minLen, + path: rulePath.clone().field(bytesDescs.minLen).toPath(), + }; + handled.add(bytesDescs.minLen); + } + + let maxLen: SizeRule | undefined; + if (isFieldSet(rules, bytesDescs.maxLen)) { + maxLen = { + val: rules.maxLen, + path: rulePath.clone().field(bytesDescs.maxLen).toPath(), + }; + handled.add(bytesDescs.maxLen); + } + + let pattern: PatternRule | undefined; + if (isFieldSet(rules, bytesDescs.pattern)) { + const src = rules.pattern; + let test: ((against: string) => boolean) | undefined; + try { + test = regexMatch + ? (against: string) => regexMatch(src, against) + : defaultRegexTest(src); + } catch { + // Invalid pattern. Let CEL produce the CompilationError it already + // emits today. + return undefined; + } + pattern = { + src, + test, + path: rulePath.clone().field(bytesDescs.pattern).toPath(), + }; + handled.add(bytesDescs.pattern); + } + + let prefix: BytesValRule | undefined; + if (isFieldSet(rules, bytesDescs.prefix)) { + prefix = { + val: rules.prefix, + path: rulePath.clone().field(bytesDescs.prefix).toPath(), + }; + handled.add(bytesDescs.prefix); + } + + let suffix: BytesValRule | undefined; + if (isFieldSet(rules, bytesDescs.suffix)) { + suffix = { + val: rules.suffix, + path: rulePath.clone().field(bytesDescs.suffix).toPath(), + }; + handled.add(bytesDescs.suffix); + } + + let containsRule: BytesValRule | undefined; + if (isFieldSet(rules, bytesDescs.contains)) { + containsRule = { + val: rules.contains, + path: rulePath.clone().field(bytesDescs.contains).toPath(), + }; + handled.add(bytesDescs.contains); + } + + let inRule: BytesListRule | undefined; + if (rules.in.length > 0) { + inRule = { + vals: rules.in, + path: rulePath.clone().field(bytesDescs.in).toPath(), + }; + handled.add(bytesDescs.in); + } + + let notInRule: BytesListRule | undefined; + if (rules.notIn.length > 0) { + notInRule = { + vals: rules.notIn, + path: rulePath.clone().field(bytesDescs.notIn).toPath(), + }; + handled.add(bytesDescs.notIn); + } + + // Well-known: at most one of ip/ipv4/ipv6/uuid is set (oneof). Only emit + // a violation when the corresponding bool is `true`. + let wellKnown: WellKnownRule | undefined; + const wk = rules.wellKnown; + if (wk.case === "ip" && wk.value) { + wellKnown = { + kind: "ip", + path: rulePath.clone().field(bytesDescs.ip).toPath(), + }; + handled.add(bytesDescs.ip); + } else if (wk.case === "ipv4" && wk.value) { + wellKnown = { + kind: "ipv4", + path: rulePath.clone().field(bytesDescs.ipv4).toPath(), + }; + handled.add(bytesDescs.ipv4); + } else if (wk.case === "ipv6" && wk.value) { + wellKnown = { + kind: "ipv6", + path: rulePath.clone().field(bytesDescs.ipv6).toPath(), + }; + handled.add(bytesDescs.ipv6); + } else if (wk.case === "uuid" && wk.value) { + wellKnown = { + kind: "uuid", + path: rulePath.clone().field(bytesDescs.uuid).toPath(), + }; + handled.add(bytesDescs.uuid); + } + + if (handled.size === 0) { + return undefined; + } + + return { + eval: new EvalNativeBytesRules( + forMapKey, + constRule, + exactLen, + minLen, + maxLen, + pattern, + prefix, + suffix, + containsRule, + inRule, + notInRule, + wellKnown, + ), + handledFields: handled, + }; +} diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index c1b85a2..f03f463 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -20,6 +20,7 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { BoolRules, + BytesRules, EnumRules, FieldRules, MapRules, @@ -27,12 +28,15 @@ import type { } from "../gen/buf/validate/validate_pb.js"; import { BoolRulesSchema, + BytesRulesSchema, EnumRulesSchema, MapRulesSchema, RepeatedRulesSchema, } from "../gen/buf/validate/validate_pb.js"; import type { Eval } from "../eval.js"; +import type { RegexMatcher } from "../func.js"; import { tryBuildNativeBoolRules } from "./bool.js"; +import { tryBuildNativeBytesRules } from "./bytes.js"; import { tryBuildNativeEnumRules } from "./enum.js"; import { tryBuildNativeMapRules } from "./map.js"; import { tryBuildNativeNumericRules } from "./numeric.js"; @@ -84,6 +88,12 @@ export type NativeDispatchInput = { * call sites. */ listField: (DescField & { fieldKind: "list" }) | undefined; + /** + * Regex matcher to use for rules that compile a pattern (bytes.pattern, + * string.pattern). When undefined, handlers use the same ECMAScript regex + * engine the CEL path falls back to. Phase 4 swaps the default to RE2. + */ + regexMatch: RegexMatcher | undefined; }; /** @@ -96,7 +106,14 @@ export type NativeDispatchInput = { export function tryBuildNative( input: NativeDispatchInput, ): NativeDispatchResult | undefined { - const { rules, rulePath, forMapKey, wrappedValueField, listField } = input; + const { + rules, + rulePath, + forMapKey, + wrappedValueField, + listField, + regexMatch, + } = input; switch (rules.$typeName) { case BoolRulesSchema.typeName: { const r = tryBuildNativeBoolRules( @@ -106,6 +123,15 @@ export function tryBuildNative( ); return liftScalar(r, wrappedValueField); } + case BytesRulesSchema.typeName: { + const r = tryBuildNativeBytesRules( + rules as BytesRules, + rulePath, + forMapKey, + regexMatch, + ); + return liftScalar(r, wrappedValueField); + } case EnumRulesSchema.typeName: { const r = tryBuildNativeEnumRules( rules as EnumRules, diff --git a/packages/protovalidate/src/native/sites.ts b/packages/protovalidate/src/native/sites.ts index a917dcb..3c2d08c 100644 --- a/packages/protovalidate/src/native/sites.ts +++ b/packages/protovalidate/src/native/sites.ts @@ -15,6 +15,7 @@ import type { DescField } from "@bufbuild/protobuf"; import { BoolRulesSchema, + BytesRulesSchema, DoubleRulesSchema, EnumRulesSchema, Fixed32RulesSchema, @@ -146,3 +147,44 @@ export const mapDescs: MapRulesDescs = { minPairs: MapRulesSchema.field.minPairs, maxPairs: MapRulesSchema.field.maxPairs, }; + +/** + * Leaf-field references for BytesRules. + * + * The well-known fields (`ip`, `ipv4`, `ipv6`, `uuid`) sit inside the + * `well_known` oneof in the proto; protobuf-es still exposes them as + * top-level entries on `BytesRulesSchema.field`. + */ +export type BytesRulesDescs = { + readonly const: DescField; + readonly len: DescField; + readonly minLen: DescField; + readonly maxLen: DescField; + readonly pattern: DescField; + readonly prefix: DescField; + readonly suffix: DescField; + readonly contains: DescField; + readonly in: DescField; + readonly notIn: DescField; + readonly ip: DescField; + readonly ipv4: DescField; + readonly ipv6: DescField; + readonly uuid: DescField; +}; + +export const bytesDescs: BytesRulesDescs = { + const: BytesRulesSchema.field.const, + len: BytesRulesSchema.field.len, + minLen: BytesRulesSchema.field.minLen, + maxLen: BytesRulesSchema.field.maxLen, + pattern: BytesRulesSchema.field.pattern, + prefix: BytesRulesSchema.field.prefix, + suffix: BytesRulesSchema.field.suffix, + contains: BytesRulesSchema.field.contains, + in: BytesRulesSchema.field.in, + notIn: BytesRulesSchema.field.notIn, + ip: BytesRulesSchema.field.ip, + ipv4: BytesRulesSchema.field.ipv4, + ipv6: BytesRulesSchema.field.ipv6, + uuid: BytesRulesSchema.field.uuid, +}; diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index c4b50b3..fc9d9db 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -83,6 +83,7 @@ import { } from "./cel.js"; import { CompilationError } from "./error.js"; import { tryBuildNative } from "./native/index.js"; +import type { RegexMatcher } from "./func.js"; export class Planner { private readonly messageCache = new Map>(); @@ -91,6 +92,7 @@ export class Planner { private readonly celMan: CelManager, private readonly legacyRequired: boolean, private readonly disableNativeRules: boolean, + private readonly regexMatch: RegexMatcher | undefined, ) {} plan(message: DescMessage): Eval { @@ -455,6 +457,7 @@ export class Planner { forMapKey, wrappedValueField, listField, + regexMatch: this.regexMatch, }); const evalStandard = new EvalStandardRulesCel( this.celMan, diff --git a/packages/protovalidate/src/validator.ts b/packages/protovalidate/src/validator.ts index 00587e7..33c2a94 100644 --- a/packages/protovalidate/src/validator.ts +++ b/packages/protovalidate/src/validator.ts @@ -147,11 +147,13 @@ export function createValidator(opt?: ValidatorOptions): Validator { ? createMutableRegistry(opt.registry, file_buf_validate_validate) : createMutableRegistry(file_buf_validate_validate); const failFast = opt?.failFast ?? false; - const celMan = new CelManager(registry, opt?.regexMatch); + const regexMatch = opt?.regexMatch; + const celMan = new CelManager(registry, regexMatch); const planner = new Planner( celMan, opt?.legacyRequired ?? false, opt?.disableNativeRules ?? false, + regexMatch, ); return { validate< From 5fc105436f460dbda8020337ee6179d373c17af6 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 15:53:35 -0400 Subject: [PATCH 12/38] Address phase 3 code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanups surfaced by the post-merge review: Bytes handler refactor: - Collapse the three parallel WELL_KNOWN_* tables (validSizes, msg, emptyMsg) into one Record so the per-kind specs can't drift apart when a new format is added. - Inline the resolved well-known spec into WellKnownRule itself, so eval() no longer keys into lookup tables on every fire. - Collapse BytesConstRule and BytesValRule into one BytesRule type — they had identical shape and were used by const/prefix/suffix/contains. - Bundle the 12 positional constructor args into one BytesRulesConfig options object. Each rule is now `cfg.constRule?`, `cfg.prefix?`, etc. - Drop the `let test: undefined` + try/catch shape — `test` is now a proper local declared inside the try and assigned exactly once. - Inline the `bytesToCelString` one-line shim at the two call sites. - Claim well-known fields on isFieldSet regardless of value: explicit `ip: false` (or any *: false) is a no-op rule but now claims the field, matching `repeated.unique: false` and `float.finite: false`. - Wrap user-supplied regexMatch exceptions in RuntimeError so the CEL behavior is preserved end-to-end when a custom engine throws at match time. The default RegExp engine doesn't throw at match time, so this only matters for opt.regexMatch overrides. - Add a clarifying comment to bytes.contains documenting that an empty needle matches every input (matches Go's bytes.Contains semantics). - Add a comment to bytes.not_in noting the asymmetry with bytes.in: CEL's not_in expression has no size() > 0 guard, but `_ in []` is always false so the behavior is identical. Tests: - Fix the misleading "claimed but emits nothing" comment in the ip=false test — the field IS now claimed under the new convention. Add direct native.validate() assertions to lock in the claim semantics. - Add a bytes.ip 1-byte test confirming it emits `bytes.ip` (not `bytes.ip_empty`). - Add bytes.const empty-rule + empty-input + non-empty-input cases. - Add bytes.pattern with empty pattern (always-pass). - Add bytes.in: [] and bytes.not_in: [] empty-list no-op cases. - Add bytes.contains with empty needle. - Add BytesValue wrapper with absent inner value. - Add a regexMatch-throws test confirming the RuntimeError wrap. Verified: - 923 unit tests pass (+8 new). - Conformance: 2870 / 2 expected skips / 0 fail — unchanged. - Lint, attw, build green. - Bench: 0 regressions vs phase3-baseline.json; phase 3's wins intact (TestByteMatching -84%, WrapperTesting -18%, ComplexSchema -12%). --- .../protovalidate/src/native/bytes.test.ts | 100 +++++- packages/protovalidate/src/native/bytes.ts | 337 +++++++++--------- 2 files changed, 264 insertions(+), 173 deletions(-) diff --git a/packages/protovalidate/src/native/bytes.test.ts b/packages/protovalidate/src/native/bytes.test.ts index 01f81a5..9975305 100644 --- a/packages/protovalidate/src/native/bytes.test.ts +++ b/packages/protovalidate/src/native/bytes.test.ts @@ -181,12 +181,33 @@ void suite("native bytes rules", () => { diff(s, create(s, { b: bytes(...new Array(16).fill(0)) })); diff(s, create(s, { b: bytes(1, 2, 3, 4) })); }); - void test("explicit ip=false is a no-op (claimed but emits nothing)", () => { + void test("explicit ip=false is a no-op claim — never emits a violation", () => { + // `ip: false` is field-set, so the native handler claims the well-known + // field and emits nothing. Behavior is identical to CEL (whose + // predicate `!rules.ip` short-circuits to no violation). const s = compile( `message M { bytes b = 1 [(buf.validate.field).bytes.ip = false]; }`, ); diff(s, create(s, { b: bytes() })); diff(s, create(s, { b: bytes(1, 2, 3, 4, 5) })); + // Direct assertions on the native path so a future refactor that + // breaks claim semantics surfaces immediately. + assert.equal(native.validate(s, create(s, { b: bytes() })).kind, "valid"); + assert.equal( + native.validate(s, create(s, { b: bytes(1, 2, 3, 4, 5) })).kind, + "valid", + ); + }); + + void test("bytes.ip with 1-byte input fails as wrong-size", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.ip = true]; }`, + ); + diff(s, create(s, { b: bytes(0x01) })); + // Confirm it hits `bytes.ip` (wrong-size), not `bytes.ip_empty`. + const r = native.validate(s, create(s, { b: bytes(0x01) })); + assert.equal(r.kind, "invalid"); + assert.equal(r.violations?.[0]?.ruleId, "bytes.ip"); }); }); @@ -260,4 +281,81 @@ void suite("native bytes rules", () => { diff(s, create(s, { b: new TextEncoder().encode("AB") })); // both fail diff(s, create(s, { b: new TextEncoder().encode("abc") })); // both pass }); + + // Review follow-up: gaps surfaced by the code review. + void suite("review gap coverage", () => { + void test("bytes.const with empty rule value vs empty input", () => { + // `const = ""` matches an empty input; the violation message for any + // mismatch is `"must be "` (trailing space, empty hex). + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.const = ""]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: bytes(0x01) })); + }); + + void test("bytes.pattern with empty pattern matches any input", () => { + // `new RegExp("")` matches the empty string at every position. Confirm + // native and CEL agree. + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ""]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: new TextEncoder().encode("hello") })); + }); + + void test("bytes.in with explicitly-set empty list is a no-op", () => { + // `in: []` (proto3 repeated, length 0) is treated as unset by both + // native and CEL — no violation regardless of input. + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes = { in: [] }]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: bytes(0x01) })); + }); + + void test("bytes.not_in with explicitly-set empty list is a no-op", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes = { not_in: [] }]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: bytes(0x01) })); + }); + + void test("bytes.contains with empty needle accepts every input", () => { + // Matches Go's `bytes.Contains(_, []byte{})` returning true. + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.contains = ""]; }`, + ); + diff(s, create(s, { b: bytes() })); + diff(s, create(s, { b: bytes(0x01) })); + diff(s, create(s, { b: bytes(0x01, 0x02, 0x03) })); + }); + + void test("BytesValue wrapper with absent inner value", () => { + // Wrapper field unset on the parent — EvalField's presence check + // skips validation entirely, so no spurious bytes.len violation. + const s = compile( + `message M { + google.protobuf.BytesValue b = 1 [(buf.validate.field).bytes.len = 2]; + }`, + ); + diff(s, create(s, {})); // wrapper absent + assert.equal(native.validate(s, create(s, {})).kind, "valid"); + }); + + void test("custom regexMatch that throws is wrapped in RuntimeError", () => { + const s = compile( + `message M { bytes b = 1 [(buf.validate.field).bytes.pattern = ".+"]; }`, + ); + const v = createValidator({ + regexMatch: () => { + throw new Error("synthetic engine failure"); + }, + }); + const r = v.validate(s, create(s, { b: new TextEncoder().encode("x") })); + assert.equal(r.kind, "error"); + assert.ok(r.error instanceof RuntimeError); + }); + }); }); diff --git a/packages/protovalidate/src/native/bytes.ts b/packages/protovalidate/src/native/bytes.ts index eb53a64..97561f8 100644 --- a/packages/protovalidate/src/native/bytes.ts +++ b/packages/protovalidate/src/native/bytes.ts @@ -27,9 +27,11 @@ import { formatList } from "./format.js"; import { bytesDescs } from "./sites.js"; import type { RegexMatcher } from "../func.js"; -type BytesConstRule = { readonly val: Uint8Array; readonly path: Path }; +/** A rule with a Uint8Array operand: const, prefix, suffix, contains. */ +type BytesRule = { readonly val: Uint8Array; readonly path: Path }; +/** A rule with a numeric size operand: len, min_len, max_len. */ type SizeRule = { readonly val: bigint; readonly path: Path }; -type BytesValRule = { readonly val: Uint8Array; readonly path: Path }; +/** A rule with a Uint8Array list operand: in, not_in. */ type BytesListRule = { readonly vals: readonly Uint8Array[]; readonly path: Path; @@ -41,34 +43,52 @@ type PatternRule = { }; type WellKnownKind = "ip" | "ipv4" | "ipv6" | "uuid"; + +/** + * Spec carried alongside an active well-known constraint, so `eval()` does + * no per-call table lookups. + */ type WellKnownRule = { readonly kind: WellKnownKind; + readonly validSizes: readonly number[]; + readonly msg: string; + readonly emptyMsg: string; readonly path: Path; }; /** - * Sizes (in bytes) accepted for each well-known format. Matches Go's - * bytesWellKnown.validSizes — see `native_bytes.go`. + * Per-kind specs for the well-known bytes formats. Matches Go's + * `bytesWellKnown` structs in `native_bytes.go` and CEL's predefined + * annotations on the corresponding `BytesRules` fields. */ -const WELL_KNOWN_VALID_SIZES: Record = { - ip: [4, 16], - ipv4: [4], - ipv6: [16], - uuid: [16], -}; - -const WELL_KNOWN_MSG: Record = { - ip: "must be a valid IP address", - ipv4: "must be a valid IPv4 address", - ipv6: "must be a valid IPv6 address", - uuid: "must be a valid UUID", -}; - -const WELL_KNOWN_EMPTY_MSG: Record = { - ip: "value is empty, which is not a valid IP address", - ipv4: "value is empty, which is not a valid IPv4 address", - ipv6: "value is empty, which is not a valid IPv6 address", - uuid: "value is empty, which is not a valid UUID", +const WELL_KNOWN: Record< + WellKnownKind, + { + readonly validSizes: readonly number[]; + readonly msg: string; + readonly emptyMsg: string; + } +> = { + ip: { + validSizes: [4, 16], + msg: "must be a valid IP address", + emptyMsg: "value is empty, which is not a valid IP address", + }, + ipv4: { + validSizes: [4], + msg: "must be a valid IPv4 address", + emptyMsg: "value is empty, which is not a valid IPv4 address", + }, + ipv6: { + validSizes: [16], + msg: "must be a valid IPv6 address", + emptyMsg: "value is empty, which is not a valid IPv6 address", + }, + uuid: { + validSizes: [16], + msg: "must be a valid UUID", + emptyMsg: "value is empty, which is not a valid UUID", + }, }; /** @@ -85,63 +105,70 @@ const utf8FatalDecoder = new TextDecoder("utf-8", { fatal: true }); */ const utf8NonFatalDecoder = new TextDecoder(); +/** + * Configuration for {@link EvalNativeBytesRules}. Bundled into a single + * object so callers don't have to track ~12 positional constructor args. + */ +type BytesRulesConfig = { + readonly forMapKey: boolean; + readonly constRule?: BytesRule; + readonly exactLen?: SizeRule; + readonly minLen?: SizeRule; + readonly maxLen?: SizeRule; + readonly pattern?: PatternRule; + readonly prefix?: BytesRule; + readonly suffix?: BytesRule; + readonly containsRule?: BytesRule; + readonly inRule?: BytesListRule; + readonly notInRule?: BytesListRule; + readonly wellKnown?: WellKnownRule; +}; + class EvalNativeBytesRules implements Eval { - constructor( - private readonly forMapKey: boolean, - private readonly constRule: BytesConstRule | undefined, - private readonly exactLen: SizeRule | undefined, - private readonly minLen: SizeRule | undefined, - private readonly maxLen: SizeRule | undefined, - private readonly pattern: PatternRule | undefined, - private readonly prefix: BytesValRule | undefined, - private readonly suffix: BytesValRule | undefined, - private readonly containsRule: BytesValRule | undefined, - private readonly inRule: BytesListRule | undefined, - private readonly notInRule: BytesListRule | undefined, - private readonly wellKnown: WellKnownRule | undefined, - ) {} + constructor(private readonly cfg: BytesRulesConfig) {} eval(val: ScalarValue, cursor: Cursor): void { const v = val as Uint8Array; const len = BigInt(v.length); + const c = this.cfg; - if (this.constRule !== undefined && !bytesEqual(v, this.constRule.val)) { + if (c.constRule !== undefined && !bytesEqual(v, c.constRule.val)) { cursor.violate( - `must be ${toHex(this.constRule.val)}`, + `must be ${toHex(c.constRule.val)}`, "bytes.const", - this.constRule.path, - this.forMapKey, + c.constRule.path, + c.forMapKey, ); } - if (this.exactLen !== undefined && len !== this.exactLen.val) { + if (c.exactLen !== undefined && len !== c.exactLen.val) { cursor.violate( - `must be ${this.exactLen.val} bytes`, + `must be ${c.exactLen.val} bytes`, "bytes.len", - this.exactLen.path, - this.forMapKey, + c.exactLen.path, + c.forMapKey, ); } - if (this.minLen !== undefined && len < this.minLen.val) { + if (c.minLen !== undefined && len < c.minLen.val) { cursor.violate( - `must be at least ${this.minLen.val} bytes`, + `must be at least ${c.minLen.val} bytes`, "bytes.min_len", - this.minLen.path, - this.forMapKey, + c.minLen.path, + c.forMapKey, ); } - if (this.maxLen !== undefined && len > this.maxLen.val) { + if (c.maxLen !== undefined && len > c.maxLen.val) { cursor.violate( - `must be at most ${this.maxLen.val} bytes`, + `must be at most ${c.maxLen.val} bytes`, "bytes.max_len", - this.maxLen.path, - this.forMapKey, + c.maxLen.path, + c.forMapKey, ); } - if (this.pattern !== undefined) { + if (c.pattern !== undefined) { let decoded: string; try { decoded = utf8FatalDecoder.decode(v); @@ -150,84 +177,84 @@ class EvalNativeBytesRules implements Eval { cause, }); } - if (!this.pattern.test(decoded)) { + // Wrap test() — if a user-supplied regexMatch throws, surface it as a + // RuntimeError so CEL's behavior is preserved end-to-end. The default + // RegExp engine doesn't throw at match time. + let matched: boolean; + try { + matched = c.pattern.test(decoded); + } catch (cause) { + throw new RuntimeError(`regex match failed for ${c.pattern.src}`, { + cause, + }); + } + if (!matched) { cursor.violate( - `must match regex pattern \`${this.pattern.src}\``, + `must match regex pattern \`${c.pattern.src}\``, "bytes.pattern", - this.pattern.path, - this.forMapKey, + c.pattern.path, + c.forMapKey, ); } } - if (this.prefix !== undefined && !startsWith(v, this.prefix.val)) { + if (c.prefix !== undefined && !startsWith(v, c.prefix.val)) { cursor.violate( - `does not have prefix ${toHex(this.prefix.val)}`, + `does not have prefix ${toHex(c.prefix.val)}`, "bytes.prefix", - this.prefix.path, - this.forMapKey, + c.prefix.path, + c.forMapKey, ); } - if (this.suffix !== undefined && !endsWith(v, this.suffix.val)) { + if (c.suffix !== undefined && !endsWith(v, c.suffix.val)) { cursor.violate( - `does not have suffix ${toHex(this.suffix.val)}`, + `does not have suffix ${toHex(c.suffix.val)}`, "bytes.suffix", - this.suffix.path, - this.forMapKey, + c.suffix.path, + c.forMapKey, ); } - if ( - this.containsRule !== undefined && - !containsBytes(v, this.containsRule.val) - ) { + if (c.containsRule !== undefined && !containsBytes(v, c.containsRule.val)) { cursor.violate( - `does not contain ${toHex(this.containsRule.val)}`, + `does not contain ${toHex(c.containsRule.val)}`, "bytes.contains", - this.containsRule.path, - this.forMapKey, + c.containsRule.path, + c.forMapKey, ); } - if (this.inRule !== undefined && !bytesListContains(this.inRule.vals, v)) { + if (c.inRule !== undefined && !bytesListContains(c.inRule.vals, v)) { cursor.violate( - `must be in list ${formatList(this.inRule.vals, bytesToCelString)}`, + `must be in list ${formatList(c.inRule.vals, (b) => utf8NonFatalDecoder.decode(b))}`, "bytes.in", - this.inRule.path, - this.forMapKey, + c.inRule.path, + c.forMapKey, ); } - if ( - this.notInRule !== undefined && - bytesListContains(this.notInRule.vals, v) - ) { + if (c.notInRule !== undefined && bytesListContains(c.notInRule.vals, v)) { cursor.violate( - `must not be in list ${formatList(this.notInRule.vals, bytesToCelString)}`, + `must not be in list ${formatList(c.notInRule.vals, (b) => utf8NonFatalDecoder.decode(b))}`, "bytes.not_in", - this.notInRule.path, - this.forMapKey, + c.notInRule.path, + c.forMapKey, ); } - if (this.wellKnown !== undefined) { + if (c.wellKnown !== undefined) { + const wk = c.wellKnown; const size = v.length; - const kind = this.wellKnown.kind; if (size === 0) { cursor.violate( - WELL_KNOWN_EMPTY_MSG[kind], - `bytes.${kind}_empty`, - this.wellKnown.path, - this.forMapKey, - ); - } else if (!WELL_KNOWN_VALID_SIZES[kind].includes(size)) { - cursor.violate( - WELL_KNOWN_MSG[kind], - `bytes.${kind}`, - this.wellKnown.path, - this.forMapKey, + wk.emptyMsg, + `bytes.${wk.kind}_empty`, + wk.path, + c.forMapKey, ); + } else if (!wk.validSizes.includes(size)) { + cursor.violate(wk.msg, `bytes.${wk.kind}`, wk.path, c.forMapKey); } } } @@ -263,6 +290,8 @@ function endsWith(haystack: Uint8Array, needle: Uint8Array): boolean { } function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean { + // Empty needle is contained in every byte slice — matches Go's + // `bytes.Contains(_, []byte{})` (returns true). if (needle.length === 0) return true; if (needle.length > haystack.length) return false; const limit = haystack.length - needle.length; @@ -298,15 +327,6 @@ function toHex(bytes: Uint8Array): string { return out; } -/** - * Format a Uint8Array the way cel-es's `%s` does — non-fatal UTF-8 - * decode, with U+FFFD substitution for invalid sequences. Used inside - * `bytes.in` / `bytes.not_in` list formatting. - */ -function bytesToCelString(b: Uint8Array): string { - return utf8NonFatalDecoder.decode(b); -} - /** * Default regex test using the platform `RegExp` engine. Used when no * `regexMatch` override is supplied. Phase 4 swaps this for the cel-es @@ -333,57 +353,54 @@ export function tryBuildNativeBytesRules( } const handled = new Set(); + const cfg: { -readonly [K in keyof BytesRulesConfig]: BytesRulesConfig[K] } = + { forMapKey }; - let constRule: BytesConstRule | undefined; if (isFieldSet(rules, bytesDescs.const)) { - constRule = { + cfg.constRule = { val: rules.const, path: rulePath.clone().field(bytesDescs.const).toPath(), }; handled.add(bytesDescs.const); } - let exactLen: SizeRule | undefined; if (isFieldSet(rules, bytesDescs.len)) { - exactLen = { + cfg.exactLen = { val: rules.len, path: rulePath.clone().field(bytesDescs.len).toPath(), }; handled.add(bytesDescs.len); } - let minLen: SizeRule | undefined; if (isFieldSet(rules, bytesDescs.minLen)) { - minLen = { + cfg.minLen = { val: rules.minLen, path: rulePath.clone().field(bytesDescs.minLen).toPath(), }; handled.add(bytesDescs.minLen); } - let maxLen: SizeRule | undefined; if (isFieldSet(rules, bytesDescs.maxLen)) { - maxLen = { + cfg.maxLen = { val: rules.maxLen, path: rulePath.clone().field(bytesDescs.maxLen).toPath(), }; handled.add(bytesDescs.maxLen); } - let pattern: PatternRule | undefined; if (isFieldSet(rules, bytesDescs.pattern)) { const src = rules.pattern; - let test: ((against: string) => boolean) | undefined; + let test: (against: string) => boolean; try { test = regexMatch - ? (against: string) => regexMatch(src, against) + ? (against) => regexMatch(src, against) : defaultRegexTest(src); } catch { - // Invalid pattern. Let CEL produce the CompilationError it already - // emits today. + // Invalid pattern at plan time. Let CEL produce the CompilationError + // it already emits today. return undefined; } - pattern = { + cfg.pattern = { src, test, path: rulePath.clone().field(bytesDescs.pattern).toPath(), @@ -391,79 +408,68 @@ export function tryBuildNativeBytesRules( handled.add(bytesDescs.pattern); } - let prefix: BytesValRule | undefined; if (isFieldSet(rules, bytesDescs.prefix)) { - prefix = { + cfg.prefix = { val: rules.prefix, path: rulePath.clone().field(bytesDescs.prefix).toPath(), }; handled.add(bytesDescs.prefix); } - let suffix: BytesValRule | undefined; if (isFieldSet(rules, bytesDescs.suffix)) { - suffix = { + cfg.suffix = { val: rules.suffix, path: rulePath.clone().field(bytesDescs.suffix).toPath(), }; handled.add(bytesDescs.suffix); } - let containsRule: BytesValRule | undefined; if (isFieldSet(rules, bytesDescs.contains)) { - containsRule = { + cfg.containsRule = { val: rules.contains, path: rulePath.clone().field(bytesDescs.contains).toPath(), }; handled.add(bytesDescs.contains); } - let inRule: BytesListRule | undefined; if (rules.in.length > 0) { - inRule = { + cfg.inRule = { vals: rules.in, path: rulePath.clone().field(bytesDescs.in).toPath(), }; handled.add(bytesDescs.in); } - let notInRule: BytesListRule | undefined; + // Note: `bytes.not_in`'s CEL expression doesn't include a `size() > 0` + // guard like `bytes.in` does, but `this in []` is always false in CEL, + // so an empty `not_in` list never fires. We treat both the same — skip + // when the list is empty. if (rules.notIn.length > 0) { - notInRule = { + cfg.notInRule = { vals: rules.notIn, path: rulePath.clone().field(bytesDescs.notIn).toPath(), }; handled.add(bytesDescs.notIn); } - // Well-known: at most one of ip/ipv4/ipv6/uuid is set (oneof). Only emit - // a violation when the corresponding bool is `true`. - let wellKnown: WellKnownRule | undefined; - const wk = rules.wellKnown; - if (wk.case === "ip" && wk.value) { - wellKnown = { - kind: "ip", - path: rulePath.clone().field(bytesDescs.ip).toPath(), - }; - handled.add(bytesDescs.ip); - } else if (wk.case === "ipv4" && wk.value) { - wellKnown = { - kind: "ipv4", - path: rulePath.clone().field(bytesDescs.ipv4).toPath(), - }; - handled.add(bytesDescs.ipv4); - } else if (wk.case === "ipv6" && wk.value) { - wellKnown = { - kind: "ipv6", - path: rulePath.clone().field(bytesDescs.ipv6).toPath(), - }; - handled.add(bytesDescs.ipv6); - } else if (wk.case === "uuid" && wk.value) { - wellKnown = { - kind: "uuid", - path: rulePath.clone().field(bytesDescs.uuid).toPath(), - }; - handled.add(bytesDescs.uuid); + // Well-known: at most one of ip/ipv4/ipv6/uuid is set (oneof). Claim the + // leaf field on isFieldSet regardless of value — explicit `ip: false` is + // a no-op rule, matching `repeated.unique: false` (`repeated.ts`) and + // `float.finite: false` (`numeric.ts`). + const wkCase = rules.wellKnown.case; + if (wkCase !== undefined) { + const desc = bytesDescs[wkCase]; + handled.add(desc); + if (rules.wellKnown.value) { + const spec = WELL_KNOWN[wkCase]; + cfg.wellKnown = { + kind: wkCase, + validSizes: spec.validSizes, + msg: spec.msg, + emptyMsg: spec.emptyMsg, + path: rulePath.clone().field(desc).toPath(), + }; + } } if (handled.size === 0) { @@ -471,20 +477,7 @@ export function tryBuildNativeBytesRules( } return { - eval: new EvalNativeBytesRules( - forMapKey, - constRule, - exactLen, - minLen, - maxLen, - pattern, - prefix, - suffix, - containsRule, - inRule, - notInRule, - wellKnown, - ), + eval: new EvalNativeBytesRules(cfg), handledFields: handled, }; } From 33755e1130ba440334d6d473aaa238c788035dd8 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 16:11:05 -0400 Subject: [PATCH 13/38] Drop redundant *Descs aliases for bytes/enum/repeated/map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of bytesDescs, enumDescs, repeatedDescs, and mapDescs had exactly one consumer and one schema, so the indirection was just renaming *RulesSchema.field as something shorter. Each handler now imports its schema directly and aliases `.field` locally as `F`: const F = BytesRulesSchema.field; ... if (isFieldSet(rules, F.const)) { ... } rulePath.clone().field(F.const).toPath(); NumericRulesDescs and the per-numeric-type descs stay — there are 12 scalar types sharing one shape via NumericConfig.descs, so the abstraction earns its keep. boolConstDesc also stays as a one-liner since it has a meaningful name (just "the field for bool.const") and no factory/shape to remove. Net: -65 lines (sites.ts shrinks from 156 to 109; handlers gain ~3-4 chars per field reference but lose an import line). Verified: 923 unit tests pass, conformance 2870/2 expected skips/0 fail unchanged, lint/attw/build green. --- packages/protovalidate/src/native/bytes.ts | 66 ++++++------- packages/protovalidate/src/native/enum.ts | 22 +++-- packages/protovalidate/src/native/map.ts | 20 ++-- packages/protovalidate/src/native/repeated.ts | 28 +++--- packages/protovalidate/src/native/sites.ts | 92 ++----------------- 5 files changed, 82 insertions(+), 146 deletions(-) diff --git a/packages/protovalidate/src/native/bytes.ts b/packages/protovalidate/src/native/bytes.ts index 97561f8..3348075 100644 --- a/packages/protovalidate/src/native/bytes.ts +++ b/packages/protovalidate/src/native/bytes.ts @@ -21,12 +21,16 @@ import type { import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; import { RuntimeError } from "../error.js"; -import type { BytesRules } from "../gen/buf/validate/validate_pb.js"; +import { + type BytesRules, + BytesRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { formatList } from "./format.js"; -import { bytesDescs } from "./sites.js"; import type { RegexMatcher } from "../func.js"; +const F = BytesRulesSchema.field; + /** A rule with a Uint8Array operand: const, prefix, suffix, contains. */ type BytesRule = { readonly val: Uint8Array; readonly path: Path }; /** A rule with a numeric size operand: len, min_len, max_len. */ @@ -356,39 +360,39 @@ export function tryBuildNativeBytesRules( const cfg: { -readonly [K in keyof BytesRulesConfig]: BytesRulesConfig[K] } = { forMapKey }; - if (isFieldSet(rules, bytesDescs.const)) { + if (isFieldSet(rules, F.const)) { cfg.constRule = { val: rules.const, - path: rulePath.clone().field(bytesDescs.const).toPath(), + path: rulePath.clone().field(F.const).toPath(), }; - handled.add(bytesDescs.const); + handled.add(F.const); } - if (isFieldSet(rules, bytesDescs.len)) { + if (isFieldSet(rules, F.len)) { cfg.exactLen = { val: rules.len, - path: rulePath.clone().field(bytesDescs.len).toPath(), + path: rulePath.clone().field(F.len).toPath(), }; - handled.add(bytesDescs.len); + handled.add(F.len); } - if (isFieldSet(rules, bytesDescs.minLen)) { + if (isFieldSet(rules, F.minLen)) { cfg.minLen = { val: rules.minLen, - path: rulePath.clone().field(bytesDescs.minLen).toPath(), + path: rulePath.clone().field(F.minLen).toPath(), }; - handled.add(bytesDescs.minLen); + handled.add(F.minLen); } - if (isFieldSet(rules, bytesDescs.maxLen)) { + if (isFieldSet(rules, F.maxLen)) { cfg.maxLen = { val: rules.maxLen, - path: rulePath.clone().field(bytesDescs.maxLen).toPath(), + path: rulePath.clone().field(F.maxLen).toPath(), }; - handled.add(bytesDescs.maxLen); + handled.add(F.maxLen); } - if (isFieldSet(rules, bytesDescs.pattern)) { + if (isFieldSet(rules, F.pattern)) { const src = rules.pattern; let test: (against: string) => boolean; try { @@ -403,41 +407,41 @@ export function tryBuildNativeBytesRules( cfg.pattern = { src, test, - path: rulePath.clone().field(bytesDescs.pattern).toPath(), + path: rulePath.clone().field(F.pattern).toPath(), }; - handled.add(bytesDescs.pattern); + handled.add(F.pattern); } - if (isFieldSet(rules, bytesDescs.prefix)) { + if (isFieldSet(rules, F.prefix)) { cfg.prefix = { val: rules.prefix, - path: rulePath.clone().field(bytesDescs.prefix).toPath(), + path: rulePath.clone().field(F.prefix).toPath(), }; - handled.add(bytesDescs.prefix); + handled.add(F.prefix); } - if (isFieldSet(rules, bytesDescs.suffix)) { + if (isFieldSet(rules, F.suffix)) { cfg.suffix = { val: rules.suffix, - path: rulePath.clone().field(bytesDescs.suffix).toPath(), + path: rulePath.clone().field(F.suffix).toPath(), }; - handled.add(bytesDescs.suffix); + handled.add(F.suffix); } - if (isFieldSet(rules, bytesDescs.contains)) { + if (isFieldSet(rules, F.contains)) { cfg.containsRule = { val: rules.contains, - path: rulePath.clone().field(bytesDescs.contains).toPath(), + path: rulePath.clone().field(F.contains).toPath(), }; - handled.add(bytesDescs.contains); + handled.add(F.contains); } if (rules.in.length > 0) { cfg.inRule = { vals: rules.in, - path: rulePath.clone().field(bytesDescs.in).toPath(), + path: rulePath.clone().field(F.in).toPath(), }; - handled.add(bytesDescs.in); + handled.add(F.in); } // Note: `bytes.not_in`'s CEL expression doesn't include a `size() > 0` @@ -447,9 +451,9 @@ export function tryBuildNativeBytesRules( if (rules.notIn.length > 0) { cfg.notInRule = { vals: rules.notIn, - path: rulePath.clone().field(bytesDescs.notIn).toPath(), + path: rulePath.clone().field(F.notIn).toPath(), }; - handled.add(bytesDescs.notIn); + handled.add(F.notIn); } // Well-known: at most one of ip/ipv4/ipv6/uuid is set (oneof). Claim the @@ -458,7 +462,7 @@ export function tryBuildNativeBytesRules( // `float.finite: false` (`numeric.ts`). const wkCase = rules.wellKnown.case; if (wkCase !== undefined) { - const desc = bytesDescs[wkCase]; + const desc = F[wkCase]; handled.add(desc); if (rules.wellKnown.value) { const spec = WELL_KNOWN[wkCase]; diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts index 816d008..545df02 100644 --- a/packages/protovalidate/src/native/enum.ts +++ b/packages/protovalidate/src/native/enum.ts @@ -20,10 +20,14 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { EnumRules } from "../gen/buf/validate/validate_pb.js"; +import { + type EnumRules, + EnumRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { formatList } from "./format.js"; -import { enumDescs } from "./sites.js"; + +const F = EnumRulesSchema.field; type ConstRule = { readonly val: number; readonly path: Path }; type ListRule = { readonly vals: readonly number[]; readonly path: Path }; @@ -103,30 +107,30 @@ export function tryBuildNativeEnumRules( const handled = new Set(); let constRule: ConstRule | undefined; - if (isFieldSet(rules, enumDescs.const)) { + if (isFieldSet(rules, F.const)) { constRule = { val: rules.const, - path: rulePath.clone().field(enumDescs.const).toPath(), + path: rulePath.clone().field(F.const).toPath(), }; - handled.add(enumDescs.const); + handled.add(F.const); } let inRule: ListRule | undefined; if (rules.in.length > 0) { inRule = { vals: rules.in, - path: rulePath.clone().field(enumDescs.in).toPath(), + path: rulePath.clone().field(F.in).toPath(), }; - handled.add(enumDescs.in); + handled.add(F.in); } let notInRule: ListRule | undefined; if (rules.notIn.length > 0) { notInRule = { vals: rules.notIn, - path: rulePath.clone().field(enumDescs.notIn).toPath(), + path: rulePath.clone().field(F.notIn).toPath(), }; - handled.add(enumDescs.notIn); + handled.add(F.notIn); } if (handled.size === 0) { diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts index 6a3d000..df4a945 100644 --- a/packages/protovalidate/src/native/map.ts +++ b/packages/protovalidate/src/native/map.ts @@ -16,8 +16,12 @@ import { type DescField, isFieldSet } from "@bufbuild/protobuf"; import type { Path, PathBuilder, ReflectMap } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { MapRules } from "../gen/buf/validate/validate_pb.js"; -import { mapDescs } from "./sites.js"; +import { + type MapRules, + MapRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; + +const F = MapRulesSchema.field; /** * Internal dispatch result for map-shaped native handlers. @@ -77,21 +81,21 @@ export function tryBuildNativeMapRules( const handled = new Set(); let minPairsRule: SizeRule | undefined; - if (isFieldSet(rules, mapDescs.minPairs)) { + if (isFieldSet(rules, F.minPairs)) { minPairsRule = { val: rules.minPairs, - path: rulePath.clone().field(mapDescs.minPairs).toPath(), + path: rulePath.clone().field(F.minPairs).toPath(), }; - handled.add(mapDescs.minPairs); + handled.add(F.minPairs); } let maxPairsRule: SizeRule | undefined; - if (isFieldSet(rules, mapDescs.maxPairs)) { + if (isFieldSet(rules, F.maxPairs)) { maxPairsRule = { val: rules.maxPairs, - path: rulePath.clone().field(mapDescs.maxPairs).toPath(), + path: rulePath.clone().field(F.maxPairs).toPath(), }; - handled.add(mapDescs.maxPairs); + handled.add(F.maxPairs); } if (handled.size === 0) { diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts index 26cb02b..d41e890 100644 --- a/packages/protovalidate/src/native/repeated.ts +++ b/packages/protovalidate/src/native/repeated.ts @@ -20,8 +20,12 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { RepeatedRules } from "../gen/buf/validate/validate_pb.js"; -import { repeatedDescs } from "./sites.js"; +import { + type RepeatedRules, + RepeatedRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; + +const F = RepeatedRulesSchema.field; /** * Internal dispatch result for list-shaped native handlers. @@ -151,38 +155,38 @@ export function tryBuildNativeRepeatedRules( const handled = new Set(); let minItemsRule: SizeRule | undefined; - if (isFieldSet(rules, repeatedDescs.minItems)) { + if (isFieldSet(rules, F.minItems)) { minItemsRule = { val: rules.minItems, - path: rulePath.clone().field(repeatedDescs.minItems).toPath(), + path: rulePath.clone().field(F.minItems).toPath(), }; - handled.add(repeatedDescs.minItems); + handled.add(F.minItems); } let maxItemsRule: SizeRule | undefined; - if (isFieldSet(rules, repeatedDescs.maxItems)) { + if (isFieldSet(rules, F.maxItems)) { maxItemsRule = { val: rules.maxItems, - path: rulePath.clone().field(repeatedDescs.maxItems).toPath(), + path: rulePath.clone().field(F.maxItems).toPath(), }; - handled.add(repeatedDescs.maxItems); + handled.add(F.maxItems); } let uniqueRule: UniqueRule | undefined; - if (isFieldSet(rules, repeatedDescs.unique)) { + if (isFieldSet(rules, F.unique)) { if (!rules.unique) { // Explicit `unique: false` is a no-op rule. Claim the field so CEL // doesn't bother re-evaluating it. Matches numeric.ts's treatment of // `finite: false`. - handled.add(repeatedDescs.unique); + handled.add(F.unique); } else if (listField !== undefined) { const kind = uniqueKindForListField(listField); if (kind !== undefined) { uniqueRule = { kind, - path: rulePath.clone().field(repeatedDescs.unique).toPath(), + path: rulePath.clone().field(F.unique).toPath(), }; - handled.add(repeatedDescs.unique); + handled.add(F.unique); } // When `kind === undefined` (message-element list with unique:true) we // deliberately do NOT claim the unique field; CEL handles it. diff --git a/packages/protovalidate/src/native/sites.ts b/packages/protovalidate/src/native/sites.ts index 3c2d08c..c70b978 100644 --- a/packages/protovalidate/src/native/sites.ts +++ b/packages/protovalidate/src/native/sites.ts @@ -15,16 +15,12 @@ import type { DescField } from "@bufbuild/protobuf"; import { BoolRulesSchema, - BytesRulesSchema, DoubleRulesSchema, - EnumRulesSchema, + FloatRulesSchema, Fixed32RulesSchema, Fixed64RulesSchema, - FloatRulesSchema, Int32RulesSchema, Int64RulesSchema, - MapRulesSchema, - RepeatedRulesSchema, SFixed32RulesSchema, SFixed64RulesSchema, SInt32RulesSchema, @@ -36,9 +32,11 @@ import { /** * Leaf-field references for the numeric rules schemas. * - * The dispatcher uses these to (a) consult `isFieldSet(rules, descs.const)` - * for presence and (b) build leaf rule paths via - * `rulePath.clone().field(descs.const).toPath()` at plan time. + * Twelve numeric scalar types share this shape; the per-type configs in + * `numeric.ts` consume `descs` via `NumericConfig.descs`. Other rule + * families (bool, bytes, enum, repeated, map) have only one schema and + * one consumer each, so they reference `*RulesSchema.field.X` directly + * instead of going through a `*Descs` alias. */ export type NumericRulesDescs = { readonly const: DescField; @@ -110,81 +108,3 @@ export const doubleDescs: NumericRulesDescs = { }; export const boolConstDesc: DescField = BoolRulesSchema.field.const; - -/** Leaf-field references for EnumRules. */ -export type EnumRulesDescs = { - readonly const: DescField; - readonly in: DescField; - readonly notIn: DescField; -}; - -export const enumDescs: EnumRulesDescs = { - const: EnumRulesSchema.field.const, - in: EnumRulesSchema.field.in, - notIn: EnumRulesSchema.field.notIn, -}; - -/** Leaf-field references for RepeatedRules (list-level). */ -export type RepeatedRulesDescs = { - readonly minItems: DescField; - readonly maxItems: DescField; - readonly unique: DescField; -}; - -export const repeatedDescs: RepeatedRulesDescs = { - minItems: RepeatedRulesSchema.field.minItems, - maxItems: RepeatedRulesSchema.field.maxItems, - unique: RepeatedRulesSchema.field.unique, -}; - -/** Leaf-field references for MapRules. */ -export type MapRulesDescs = { - readonly minPairs: DescField; - readonly maxPairs: DescField; -}; - -export const mapDescs: MapRulesDescs = { - minPairs: MapRulesSchema.field.minPairs, - maxPairs: MapRulesSchema.field.maxPairs, -}; - -/** - * Leaf-field references for BytesRules. - * - * The well-known fields (`ip`, `ipv4`, `ipv6`, `uuid`) sit inside the - * `well_known` oneof in the proto; protobuf-es still exposes them as - * top-level entries on `BytesRulesSchema.field`. - */ -export type BytesRulesDescs = { - readonly const: DescField; - readonly len: DescField; - readonly minLen: DescField; - readonly maxLen: DescField; - readonly pattern: DescField; - readonly prefix: DescField; - readonly suffix: DescField; - readonly contains: DescField; - readonly in: DescField; - readonly notIn: DescField; - readonly ip: DescField; - readonly ipv4: DescField; - readonly ipv6: DescField; - readonly uuid: DescField; -}; - -export const bytesDescs: BytesRulesDescs = { - const: BytesRulesSchema.field.const, - len: BytesRulesSchema.field.len, - minLen: BytesRulesSchema.field.minLen, - maxLen: BytesRulesSchema.field.maxLen, - pattern: BytesRulesSchema.field.pattern, - prefix: BytesRulesSchema.field.prefix, - suffix: BytesRulesSchema.field.suffix, - contains: BytesRulesSchema.field.contains, - in: BytesRulesSchema.field.in, - notIn: BytesRulesSchema.field.notIn, - ip: BytesRulesSchema.field.ip, - ipv4: BytesRulesSchema.field.ipv4, - ipv6: BytesRulesSchema.field.ipv6, - uuid: BytesRulesSchema.field.uuid, -}; From 58f347b1bbac7bf66822a744f4942d6f44fb602b Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 14 May 2026 16:21:46 -0400 Subject: [PATCH 14/38] Probe custom regexMatch at plan time for symmetry with default engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default regex path eagerly compiles the pattern via `new RegExp(src)` inside `defaultRegexTest`, so an invalid pattern throws at plan time and the surrounding try/catch routes that field back to CEL. The custom `regexMatch` path was lazy: storing `(against) => regexMatch(src, against)` without ever invoking the engine, so the same try/catch had no effect on that branch. Probe `regexMatch(src, "")` at plan time so a user-supplied engine that can't compile the pattern fails fast. The empty string is the contract-safe probe — any regex engine must be able to test an arbitrary pattern against the empty string. If the engine throws, native bails and CEL's own `matches()` call surfaces the failure as a RuntimeError via the same engine. Also corrects the catch comment, which previously claimed CEL produces a CompilationError — both paths actually produce a RuntimeError at eval time, which is what conformance expects. 923 unit tests pass unchanged, conformance 2870/2 expected skips/0 fail unchanged, lint/attw/build green. --- packages/protovalidate/src/native/bytes.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/protovalidate/src/native/bytes.ts b/packages/protovalidate/src/native/bytes.ts index 3348075..221105c 100644 --- a/packages/protovalidate/src/native/bytes.ts +++ b/packages/protovalidate/src/native/bytes.ts @@ -396,12 +396,20 @@ export function tryBuildNativeBytesRules( const src = rules.pattern; let test: (against: string) => boolean; try { - test = regexMatch - ? (against) => regexMatch(src, against) - : defaultRegexTest(src); + if (regexMatch) { + // Probe the user-supplied engine at plan time so an invalid pattern + // surfaces here, symmetric with the default engine's eager compile. + // Empty input is the contract-safe probe — a regex engine must be + // able to test any pattern against the empty string. + regexMatch(src, ""); + test = (against) => regexMatch(src, against); + } else { + test = defaultRegexTest(src); + } } catch { - // Invalid pattern at plan time. Let CEL produce the CompilationError - // it already emits today. + // The pattern doesn't compile under the active engine. Fall through + // to CEL, whose own `matches()` call hits the same throw at eval + // time and surfaces it as a RuntimeError. return undefined; } cfg.pattern = { From efb400fddbbad22af21fa6120699be49ac5af00b Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 26 May 2026 14:43:27 -0400 Subject: [PATCH 15/38] improve benchmark file selection and argument parsing. --- .../protovalidate-bench/scripts/checkbench.js | 157 ++++++++++++------ 1 file changed, 103 insertions(+), 54 deletions(-) diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/scripts/checkbench.js index c1cb840..52fca9e 100755 --- a/packages/protovalidate-bench/scripts/checkbench.js +++ b/packages/protovalidate-bench/scripts/checkbench.js @@ -28,73 +28,34 @@ // delta exceeds both the threshold AND the combined RME of the two samples // (so we don't flag noise as a regression). -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; const BENCH_DIR = ".tmp/bench"; const DEFAULT_THRESHOLD = 5; -function parseArgs(argv) { - const positional = []; - let threshold = DEFAULT_THRESHOLD; - let dir = BENCH_DIR; - let quiet = false; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === "--threshold") { - threshold = Number(argv[++i]); - } else if (a === "--dir") { - dir = argv[++i]; - } else if (a === "--quiet" || a === "-q") { - quiet = true; - } else if (a === "-h" || a === "--help") { - usage(); - process.exit(0); - } else if (a.startsWith("--")) { - console.error(`unknown flag: ${a}`); - process.exit(2); - } else { - positional.push(a); - } - } - return { positional, threshold, dir, quiet }; -} - function usage() { process.stdout.write( [ "Usage: node scripts/checkbench.js [options]", "", - "Arguments may be paths to JSON files or one of the shortcuts:", - " latest most recent file in .tmp/bench/", - " previous second-most recent file in .tmp/bench/", + "Arguments are paths to JSON files relative to the benchmark directory (default: .tmp/bench/).", + "If neither argument is present, the two most recent files are used, with the older file being the baseline.", + "If one argument is present, the named file in the benchmark directory is used as the baseline and the most recent file is used as the current.", "", "Options:", " --threshold regression threshold percent (default: 5)", " --dir bench results directory (default: .tmp/bench)", " --quiet, -q only print summary line", + " --help, -h show this help and exit", "", - "Exit code: 0 if no regressions past threshold, 1 otherwise.", + "Exit code: 0 if no regressions past threshold, 1 for regressions, 2 for other errors.", "", ].join("\n"), ); } -function resolveFile(arg, dir) { - if (arg === "latest" || arg === "previous") { - const entries = readdirSync(dir) - .filter((f) => f.endsWith(".json")) - .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); - const idx = arg === "latest" ? 0 : 1; - if (entries.length <= idx) { - throw new Error(`not enough JSON files in ${dir} to resolve "${arg}"`); - } - return resolve(dir, entries[idx].f); - } - return resolve(arg); -} - function load(path) { const data = JSON.parse(readFileSync(path, "utf-8")); const byName = new Map(); @@ -127,19 +88,107 @@ function color(s, code) { return `\x1b[${code}m${s}\x1b[0m`; } -const args = parseArgs(process.argv.slice(2)); -if (args.positional.length === 0 || args.positional.length > 2) { +function getFile(dir, arg) { + const path = resolve(dir, arg); + try { + if (!statSync(path).isFile()) { + console.error(`not a file: ${path}`); + process.exit(2); + } + } catch (err) { + if (err.code === "ENOENT") { + console.error(`file does not exist: ${path}`); + process.exit(2); + } + throw err; + } + return path; +} + +function getSortedDirEntries(dir) { + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); +} + +function getNewestFile(dir) { + const entries = getSortedDirEntries(dir); + if (entries.length === 0) { + console.error(`no JSON files in ${dir}`); + process.exit(2); + } + return getFile(dir, entries[0].f); +} + +function getSecondNewestFile(dir) { + const entries = getSortedDirEntries(dir); + if (entries.length < 2) { + console.error(`not enough JSON files in ${dir} to resolve previous file`); + process.exit(2); + } + return getFile(dir, entries[1].f); +} + +function buildArgs(values) { + const dir = values.dir ?? BENCH_DIR; + try { + if (!statSync(dir).isDirectory()) { + console.error(`--dir is not a directory: ${dir}`); + process.exit(2); + } + } catch (err) { + if (err.code === "ENOENT") { + console.error(`--dir does not exist: ${dir}`); + process.exit(2); + } + throw err; + } + const threshold = values.threshold + ? Number(values.threshold) + : DEFAULT_THRESHOLD; + return { threshold, dir, quiet: values.quiet ?? false }; +} + +const options = { + threshold: { + type: "string", + }, + dir: { + type: "string", + }, + quiet: { + type: "boolean", + short: "q", + }, + help: { + type: "boolean", + short: "h", + }, +}; +const { values, positionals } = parseArgs({ + options, + allowPositionals: true, +}); +if (values.help) { + usage(); + process.exit(0); +} +if (positionals.length > 2) { usage(); process.exit(2); } -const baselineArg = - args.positional.length === 2 ? args.positional[0] : "previous"; -const currentArg = - args.positional.length === 2 ? args.positional[1] : args.positional[0]; +const args = buildArgs(values); -const baselinePath = resolveFile(baselineArg, args.dir); -const currentPath = resolveFile(currentArg, args.dir); +const baselinePath = + positionals.length > 0 + ? getFile(args.dir, positionals[0]) + : getSecondNewestFile(args.dir); +const currentPath = + positionals.length === 2 + ? getFile(args.dir, positionals[1]) + : getNewestFile(args.dir); if (baselinePath === currentPath) { console.error( From 35b0ab091f7a2b9a639a52a87211f62583b4375e Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 26 May 2026 14:56:05 -0400 Subject: [PATCH 16/38] improve threshold validation Signed-off-by: Jon Bodner --- .../protovalidate-bench/scripts/checkbench.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/scripts/checkbench.js index 52fca9e..5268763 100755 --- a/packages/protovalidate-bench/scripts/checkbench.js +++ b/packages/protovalidate-bench/scripts/checkbench.js @@ -144,9 +144,18 @@ function buildArgs(values) { } throw err; } - const threshold = values.threshold - ? Number(values.threshold) - : DEFAULT_THRESHOLD; + let threshold = DEFAULT_THRESHOLD; + if (values.threshold !== undefined) { + const raw = values.threshold.trim(); + const n = Number(raw); + if (raw === "" || !Number.isFinite(n) || n < 0) { + console.error( + `--threshold must be a non-negative number: ${values.threshold}`, + ); + process.exit(2); + } + threshold = n; + } return { threshold, dir, quiet: values.quiet ?? false }; } From edc476140e671bce1df8273dfa83027ceeb4aa6b Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 27 May 2026 12:32:36 -0400 Subject: [PATCH 17/38] relocate checkbench to the src directory and convert it to typescript. Update documentation to reflect its current arguments. Signed-off-by: Jon Bodner --- packages/protovalidate-bench/README.md | 17 ++--- packages/protovalidate-bench/package.json | 2 +- .../checkbench.js => src/checkbench.ts} | 74 +++++++++++-------- 3 files changed, 54 insertions(+), 39 deletions(-) rename packages/protovalidate-bench/{scripts/checkbench.js => src/checkbench.ts} (86%) diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index a9dea47..d344636 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -78,16 +78,15 @@ between languages stay meaningful. Use `checkbench` to diff two result files and surface regressions: ```shell -# After running on main, then on your branch: -node scripts/checkbench.js previous latest +# compares the last two JSON files in .tmp/bench/ +tsx src/checkbench.ts -# Or pass explicit paths: -node scripts/checkbench.js .tmp/bench/baseline.json .tmp/bench/current.json -``` +# compare the latest JSON file against a specific baseline file in .tmp/bench/: +tsx src/checkbench.ts baseline.json -The shortcuts `latest` and `previous` resolve to the newest and second-newest -JSON files in `.tmp/bench/` (by mtime). Calling with only one argument -defaults the baseline to `previous`. +# Or pass explicit files for baseline and current: +tsx src/checkbench.ts baseline.json current.json +``` Output is per task: baseline mean, current mean, `±%` delta, and a marker — `REGRESS`, `faster`, or `(noise)`. A delta is treated as noise if it falls @@ -114,7 +113,7 @@ npm run bench # produces .tmp/bench/.json (baseline git checkout my-optimization-branch npm run bench # produces .tmp/bench/.json (current) -node scripts/checkbench.js latest # diff vs previous +node scripts/checkbench.ts latest # diff vs previous ``` Heads up: bench-to-bench wall-time numbers are sensitive to other load on the diff --git a/packages/protovalidate-bench/package.json b/packages/protovalidate-bench/package.json index 60411f5..df35658 100644 --- a/packages/protovalidate-bench/package.json +++ b/packages/protovalidate-bench/package.json @@ -7,7 +7,7 @@ "generate": "buf generate", "postgenerate": "license-header src/gen", "bench": "tsx src/bench.ts", - "checkbench": "node scripts/checkbench.js", + "checkbench": "tsx src/checkbench.ts", "format": "biome format --write", "lint": "biome lint --error-on-warnings && buf lint", "license-header": "license-header" diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/src/checkbench.ts similarity index 86% rename from packages/protovalidate-bench/scripts/checkbench.js rename to packages/protovalidate-bench/src/checkbench.ts index 5268763..565816d 100755 --- a/packages/protovalidate-bench/scripts/checkbench.js +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -14,20 +14,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Compare two bench JSON files written by src/bench.ts. -// -// Usage: -// node scripts/checkbench.js [--threshold 5] -// -// "latest" / "previous" shortcuts pick the most recent files in .tmp/bench/: -// node scripts/checkbench.js latest -// node scripts/checkbench.js previous latest -// -// Exits non-zero if any task regresses by more than --threshold percent -// (default 5%). A regression is defined as a slower mean latency where the -// delta exceeds both the threshold AND the combined RME of the two samples -// (so we don't flag noise as a regression). - import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; @@ -38,7 +24,7 @@ const DEFAULT_THRESHOLD = 5; function usage() { process.stdout.write( [ - "Usage: node scripts/checkbench.js [options]", + "Usage: tsx src/checkbench.ts [options]", "", "Arguments are paths to JSON files relative to the benchmark directory (default: .tmp/bench/).", "If neither argument is present, the two most recent files are used, with the older file being the baseline.", @@ -56,7 +42,26 @@ function usage() { ); } -function load(path) { +type FileInfo = { + meta: { + node: string; + platform: string; + timestamp: string; + path: string; + }; + byName: Map; +}; + +type Task = { + name: string; + meanLatencyNs: number; + p99LatencyNs: number; + throughputOpsPerSec: number; + rmePercent: number; + samples: number; +}; + +function load(path: string): FileInfo { const data = JSON.parse(readFileSync(path, "utf-8")); const byName = new Map(); for (const task of data.tasks) { @@ -73,22 +78,22 @@ function load(path) { }; } -function pad(s, n) { +function pad(s: string, n: number): string { return String(s).padEnd(n); } -function fmtNs(n) { +function fmtNs(n: number): string { if (n < 1000) return `${n.toFixed(0)} ns`; if (n < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; return `${(n / 1_000_000).toFixed(2)} ms`; } -function color(s, code) { +function color(s: string, code: string): string { if (!process.stdout.isTTY) return s; return `\x1b[${code}m${s}\x1b[0m`; } -function getFile(dir, arg) { +function getFile(dir: string, arg: string): string { const path = resolve(dir, arg); try { if (!statSync(path).isFile()) { @@ -96,7 +101,8 @@ function getFile(dir, arg) { process.exit(2); } } catch (err) { - if (err.code === "ENOENT") { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { console.error(`file does not exist: ${path}`); process.exit(2); } @@ -105,14 +111,16 @@ function getFile(dir, arg) { return path; } -function getSortedDirEntries(dir) { +type DirEntry = { f: string; mtime: number }; + +function getSortedDirEntries(dir: string): DirEntry[] { return readdirSync(dir) .filter((f) => f.endsWith(".json")) .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); } -function getNewestFile(dir) { +function getNewestFile(dir: string): string { const entries = getSortedDirEntries(dir); if (entries.length === 0) { console.error(`no JSON files in ${dir}`); @@ -121,7 +129,7 @@ function getNewestFile(dir) { return getFile(dir, entries[0].f); } -function getSecondNewestFile(dir) { +function getSecondNewestFile(dir: string): string { const entries = getSortedDirEntries(dir); if (entries.length < 2) { console.error(`not enough JSON files in ${dir} to resolve previous file`); @@ -130,7 +138,14 @@ function getSecondNewestFile(dir) { return getFile(dir, entries[1].f); } -function buildArgs(values) { +type ParsedValues = { + threshold?: string; + dir?: string; + quiet?: boolean; + help?: boolean; +}; + +function buildArgs(values: ParsedValues) { const dir = values.dir ?? BENCH_DIR; try { if (!statSync(dir).isDirectory()) { @@ -138,7 +153,8 @@ function buildArgs(values) { process.exit(2); } } catch (err) { - if (err.code === "ENOENT") { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { console.error(`--dir does not exist: ${dir}`); process.exit(2); } @@ -174,7 +190,7 @@ const options = { type: "boolean", short: "h", }, -}; +} as const; const { values, positionals } = parseArgs({ options, allowPositionals: true, @@ -250,7 +266,7 @@ for (const name of [...names].sort()) { kind: "new", text: color("NEW", "36"), bMean: undefined, - cMean: c.meanLatencyNs, + cMean: c?c.meanLatencyNs:undefined, delta: undefined, }); continue; @@ -299,7 +315,7 @@ if (!args.quiet) { `${pad("task", nameW)} ${pad("baseline", 12)} ${pad("current", 12)} delta`, ); console.log( - `${pad("", nameW).replaceAll(" ", "-")} ${"-".repeat(12)} ${"-".repeat(12)} ${"-".repeat(20)}`, + `${"-".repeat(nameW)} ${"-".repeat(12)} ${"-".repeat(12)} ${"-".repeat(20)}`, ); for (const r of rows) { const b = r.bMean !== undefined ? fmtNs(r.bMean) : "—"; From 5ec5fb237ae6580dcf484c19a254dc9c650cdbd2 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 27 May 2026 13:35:38 -0400 Subject: [PATCH 18/38] remove unneeded file ignores from biome.json Signed-off-by: Jon Bodner --- packages/protovalidate-bench/biome.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/protovalidate-bench/biome.json b/packages/protovalidate-bench/biome.json index b3d076a..6ae5546 100644 --- a/packages/protovalidate-bench/biome.json +++ b/packages/protovalidate-bench/biome.json @@ -1,7 +1,4 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "extends": ["../../biome.base.json"], - "files": { - "ignore": ["src/gen", ".tmp"] - } + "extends": ["../../biome.base.json"] } From a2ffbb25b4c5957c364d23c7f7ddc84a9a04459f Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 27 May 2026 13:38:30 -0400 Subject: [PATCH 19/38] fix formatting Signed-off-by: Jon Bodner --- packages/protovalidate-bench/src/checkbench.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index 565816d..ee63dd2 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -266,7 +266,7 @@ for (const name of [...names].sort()) { kind: "new", text: color("NEW", "36"), bMean: undefined, - cMean: c?c.meanLatencyNs:undefined, + cMean: c ? c.meanLatencyNs : undefined, delta: undefined, }); continue; From 492bc411f326c0191b9fcdc664ce948bf9ee65f4 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 27 May 2026 15:01:55 -0400 Subject: [PATCH 20/38] switch benchmarking from tinybench to mitata Signed-off-by: Jon Bodner --- package-lock.json | 24 ++- packages/protovalidate-bench/README.md | 77 +++++-- packages/protovalidate-bench/package.json | 5 +- packages/protovalidate-bench/src/bench.ts | 146 +++++++------ .../protovalidate-bench/src/checkbench.ts | 197 +++++++++++++++--- .../src/suites/compile.bench.ts | 8 +- .../src/suites/standard-schema.bench.ts | 8 +- .../src/suites/validate.bench.ts | 8 +- 8 files changed, 339 insertions(+), 134 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44d621a..d706323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -987,6 +987,12 @@ "@braidai/lang": "^1.0.0" } }, + "node_modules/@mitata/counters": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@mitata/counters/-/counters-0.0.8.tgz", + "integrity": "sha512-f11w0Y1ETFlarDP7CePj8Z+y8Gv5Ax4gMxWsEwrqh0kH/YIY030Ezx5SUJeQg0YPTZ2OHKGcLG1oGJbIqHzaJA==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1500,6 +1506,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/mitata": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/mitata/-/mitata-1.0.34.tgz", + "integrity": "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1699,15 +1711,6 @@ "node": ">=0.8" } }, - "node_modules/tinybench": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-3.1.1.tgz", - "integrity": "sha512-74pmf47HY/bHqamcCMGris+1AtGGsqTZ3Hc/UK4QvSmRuf/9PIF9753+c8XBh7JfX2r9KeZtVjOYjd6vFpc0qQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1881,7 +1884,8 @@ "dependencies": { "@bufbuild/protobuf": "^2.11.0", "@bufbuild/protovalidate": "^1.2.0", - "tinybench": "^3.1.1" + "@mitata/counters": "^0.0.8", + "mitata": "^1.0.34" }, "devDependencies": { "@bufbuild/buf": "^1.62.1", diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index d344636..8639f58 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -4,9 +4,11 @@ Performance benchmarks for `@bufbuild/protovalidate`. This package is private an mirrors the suite in [`protovalidate-go/validator_bench_test.go`](https://github.com/bufbuild/protovalidate-go/blob/main/validator_bench_test.go) so that runtime cost can be tracked across changes and compared cross-language. -The harness is [tinybench](https://github.com/tinylibs/tinybench). Fixtures are -hand-built (no faker dependency) and seeded with a deterministic PRNG so every -run validates the same messages. +The harness is [mitata](https://github.com/evanwashere/mitata), which +auto-tunes warmup and sample counts and reports per-task histograms, p99, and +optimization-elimination warnings. Fixtures are hand-built (no faker +dependency) and seeded with a deterministic PRNG so every run validates the +same messages. ## Running @@ -30,24 +32,47 @@ The runner prints a table of results and writes a JSON file to `.tmp/bench/` | Flag | Default | Description | | --------------------- | ----------- | --------------------------------------------------------------- | | `--filter ` | _(none)_ | Only run tasks whose name contains `` | -| `--time ` | `1000` | Per-task wall-time budget | -| `--iterations ` | _(time)_ | Force fixed iteration count instead of the time budget | -| `--warmup ` | `16` | Warmup iterations per task | | `--out ` | `.tmp/bench`| Output directory for JSON results | -Examples: +mitata auto-tunes warmup, sample count, and per-task wall time. To slow the +runner down, run fewer benchmarks via `--filter`. -```shell -# Quick smoke run -npm run bench -- --time 200 --warmup 5 +Example: +```shell # Only validation benchmarks (skip the Compile/* tasks) npm run bench -- --filter Scalar +``` -# Long, stable run -npm run bench -- --time 5000 --warmup 32 +### Output schema + +Each run writes a JSON file like: + +```json +{ + "node": "v22.x.x", + "platform": "darwin/arm64", + "timestamp": "2026-05-27T...", + "tasks": [ + { + "name": "Scalar", + "meanLatencyNs": 110.42, + "minLatencyNs": 104.18, + "medianLatencyNs": 108.93, + "p99LatencyNs": 142.07, + "throughputOpsPerSec": 9056221, + "rmePercent": 4.71, + "samples": 128, + "gcTotalNs": 0, + "heapAvgBytes": 0 + } + ] +} ``` +`gcTotalNs` and `heapAvgBytes` are only present when mitata can observe them +(Node started with `--expose-gc` for GC stats; `node:v8` heap stats for heap). + ## Benchmarks Each task mirrors the equivalent `Benchmark*` in `protovalidate-go` so deltas @@ -88,10 +113,30 @@ tsx src/checkbench.ts baseline.json tsx src/checkbench.ts baseline.json current.json ``` -Output is per task: baseline mean, current mean, `±%` delta, and a marker — -`REGRESS`, `faster`, or `(noise)`. A delta is treated as noise if it falls -inside the combined RME of the two runs, so jitter in low-RME benchmarks does -not trigger false alarms. +Output is per task: baseline mean, current mean, `min Δ`, `heap Δ` +(when available), an optional `gc Δ` (when both runs have GC stats), and +`mean Δ` with a marker — `REGRESS (mean|min|heap|...)`, `faster (...)`, or +`(noise)`. A delta is treated as noise if it falls inside the combined +relative standard deviation of the two runs. + +The tool gates on three signals: **mean latency, min latency, and heap +allocation per iteration**. A task fails if any of these deltas exceeds +`--threshold` and falls outside the noise floor. + +- **Mean** catches the typical-case slowdown. +- **Min** is the JIT-warm floor — immune to GC pauses, so it surfaces real + CPU regressions that mean might hide in tail noise. +- **Heap Δ** is bytes allocated per iteration (via `node:v8` + `getHeapStatistics()`). Catches allocation regressions even when wall-clock + is flat — those still hurt in production because they amplify GC pressure. + The heap signal is mostly deterministic for short benches; for long-running + alloc-heavy benches (`Compile/*`) it can drift with GC scheduling, which + the noise floor absorbs. +- **GC Δ** is informational only (no gating) and only appears when both runs + were produced with `--expose-gc` so mitata can observe gc time. + +Note on `rmePercent`: it's the relative standard deviation of the +samples (`stddev / mean × 100`). ### Options diff --git a/packages/protovalidate-bench/package.json b/packages/protovalidate-bench/package.json index df35658..fb0fa5c 100644 --- a/packages/protovalidate-bench/package.json +++ b/packages/protovalidate-bench/package.json @@ -6,7 +6,7 @@ "scripts": { "generate": "buf generate", "postgenerate": "license-header src/gen", - "bench": "tsx src/bench.ts", + "bench": "tsx --expose-gc src/bench.ts", "checkbench": "tsx src/checkbench.ts", "format": "biome format --write", "lint": "biome lint --error-on-warnings && buf lint", @@ -17,7 +17,8 @@ "dependencies": { "@bufbuild/protobuf": "^2.11.0", "@bufbuild/protovalidate": "^1.2.0", - "tinybench": "^3.1.1" + "@mitata/counters": "^0.0.8", + "mitata": "^1.0.34" }, "devDependencies": { "@bufbuild/buf": "^1.62.1", diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index 9f562a5..de2eeaa 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Bench } from "tinybench"; +import { run } from "mitata"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { register as registerValidate } from "./suites/validate.bench.js"; @@ -21,17 +21,11 @@ import { register as registerStandardSchema } from "./suites/standard-schema.ben interface CliOptions { filter: string | undefined; - iterations: number; - warmupIterations: number; - time: number; outDir: string; } function parseArgs(argv: readonly string[]): CliOptions { let filter: string | undefined; - let iterations = 0; - let warmupIterations = 16; - let time = 1000; let outDir = ".tmp/bench"; for (let i = 0; i < argv.length; i++) { const a = argv[i]; @@ -39,15 +33,6 @@ function parseArgs(argv: readonly string[]): CliOptions { case "--filter": filter = argv[++i]; break; - case "--iterations": - iterations = Number(argv[++i]); - break; - case "--warmup": - warmupIterations = Number(argv[++i]); - break; - case "--time": - time = Number(argv[++i]); - break; case "--out": outDir = String(argv[++i]); break; @@ -63,7 +48,7 @@ function parseArgs(argv: readonly string[]): CliOptions { } } } - return { filter, iterations, warmupIterations, time, outDir }; + return { filter, outDir }; } function printUsage(): void { @@ -73,62 +58,64 @@ function printUsage(): void { "", "Options:", " --filter Only run benchmarks whose name contains ", - " --time Per-task wall time budget (default: 1000)", - " --iterations Force fixed iteration count instead of time budget", - " --warmup Warmup iterations per task (default: 16)", " --out Output directory for JSON results (default: .tmp/bench)", "", ].join("\n"), ); } -const opts = parseArgs(process.argv.slice(2)); +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} -const bench = new Bench({ - name: "protovalidate-es", - time: opts.iterations > 0 ? 0 : opts.time, - iterations: opts.iterations > 0 ? opts.iterations : 10, - warmupIterations: opts.warmupIterations, -}); - -registerValidate(bench); -registerCompile(bench); -registerStandardSchema(bench); - -if (opts.filter !== undefined) { - const f = opts.filter; - for (const t of bench.tasks.slice()) { - if (!t.name.includes(f)) { - bench.remove(t.name); - } - } +// Subset of mitata's trial/stats shape that we actually consume. Mitata's +// declarations expose these types as anonymous interfaces, so we restate the +// fields we read. +interface MitataStats { + avg: number; + min: number; + p50: number; + p99: number; + samples: number[]; + gc?: { total: number }; + heap?: { avg: number }; +} + +interface MitataTrial { + alias: string; + runs: { stats?: MitataStats; error?: unknown; name: string }[]; } +const opts = parseArgs(process.argv.slice(2)); + +registerValidate(); +registerCompile(); +registerStandardSchema(); + console.log(`# protovalidate-es bench`); console.log(`# node ${process.version} ${process.platform}/${process.arch}`); -console.log(`# tasks: ${bench.tasks.length}`); -if (bench.tasks.length === 0) { + +const result = (await run( + opts.filter !== undefined + ? { filter: new RegExp(escapeRegExp(opts.filter)) } + : {}, +)) as { benchmarks: MitataTrial[] }; + +console.log(`# tasks: ${result.benchmarks.length}`); +if (result.benchmarks.length === 0) { console.error("no tasks matched filter"); process.exit(2); } -await bench.run(); - -const tableRows = bench.table((task) => { - const r = task.result; - if (!r) { - return { Task: task.name }; +function relativeStddevPercent(samples: number[], mean: number): number { + if (samples.length === 0 || mean === 0) return 0; + let sumSq = 0; + for (const s of samples) { + const d = s - mean; + sumSq += d * d; } - return { - Task: task.name, - "ops/sec": Math.round(r.throughput.mean).toLocaleString(), - "avg (ns)": (r.latency.mean * 1e6).toFixed(0), - "p99 (ns)": ((r.latency.p99 ?? 0) * 1e6).toFixed(0), - rme: `±${r.latency.rme.toFixed(2)}%`, - samples: r.latency.samples.length, - }; -}); -console.table(tableRows); + return (Math.sqrt(sumSq / samples.length) / mean) * 100; +} const stamp = new Date() .toISOString() @@ -137,20 +124,45 @@ const stamp = new Date() .replace(/Z$/, ""); mkdirSync(opts.outDir, { recursive: true }); const outPath = join(opts.outDir, `${stamp}.json`); + +interface TaskPayload { + name: string; + meanLatencyNs: number; + minLatencyNs: number; + medianLatencyNs: number; + p99LatencyNs: number; + throughputOpsPerSec: number; + rmePercent: number; + samples: number; + gcTotalNs?: number; + heapAvgBytes?: number; +} + +const tasks: TaskPayload[] = []; +for (const trial of result.benchmarks) { + const r = trial.runs[0]; + if (!r || r.error !== undefined || !r.stats) continue; + const s = r.stats; + const task: TaskPayload = { + name: trial.alias, + meanLatencyNs: s.avg, + minLatencyNs: s.min, + medianLatencyNs: s.p50, + p99LatencyNs: s.p99, + throughputOpsPerSec: 1e9 / s.avg, + rmePercent: relativeStddevPercent(s.samples, s.avg), + samples: s.samples.length, + }; + if (s.gc !== undefined) task.gcTotalNs = s.gc.total; + if (s.heap !== undefined) task.heapAvgBytes = s.heap.avg; + tasks.push(task); +} + const payload = { node: process.version, platform: `${process.platform}/${process.arch}`, timestamp: new Date().toISOString(), - tasks: bench.tasks - .filter((t) => t.result !== undefined) - .map((t) => ({ - name: t.name, - meanLatencyNs: (t.result?.latency.mean ?? 0) * 1e6, - p99LatencyNs: (t.result?.latency.p99 ?? 0) * 1e6, - throughputOpsPerSec: t.result?.throughput.mean ?? 0, - rmePercent: t.result?.latency.rme ?? 0, - samples: t.result?.latency.samples.length ?? 0, - })), + tasks, }; writeFileSync(outPath, JSON.stringify(payload, null, 2)); console.log(`wrote ${outPath}`); diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index ee63dd2..5157b89 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -55,10 +55,14 @@ type FileInfo = { type Task = { name: string; meanLatencyNs: number; + minLatencyNs: number; + medianLatencyNs: number; p99LatencyNs: number; throughputOpsPerSec: number; rmePercent: number; samples: number; + gcTotalNs?: number; + heapAvgBytes?: number; }; function load(path: string): FileInfo { @@ -256,7 +260,50 @@ const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); let regressions = 0; let improvements = 0; -const rows = []; +type SignalVerdict = "regress" | "improve" | "noise" | "ok"; + +function classify( + deltaPct: number, + noiseFloor: number, + threshold: number, +): SignalVerdict { + if (Math.abs(deltaPct) <= noiseFloor) return "noise"; + if (deltaPct > threshold) return "regress"; + if (deltaPct < -threshold) return "improve"; + return "ok"; +} + +function fmtDelta(deltaPct: number, verdict: SignalVerdict): string { + const base = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; + switch (verdict) { + case "regress": + return color(base, "31"); + case "improve": + return color(base, "32"); + case "noise": + return color(base, "90"); + default: + return base; + } +} + +type Row = { + name: string; + kind: "new" | "gone" | "regress" | "improve" | "ok"; + bMean: number | undefined; + cMean: number | undefined; + meanText: string; + minText: string; + heapText: string; + gcText: string; +}; + +const rows: Row[] = []; +// Track whether any row has heap/gc info so we can skip those columns entirely +// when neither file has them (e.g. comparing against a pre-mitata JSON). +let anyHeap = false; +let anyGc = false; + for (const name of [...names].sort()) { const b = baseline.byName.get(name); const c = current.byName.get(name); @@ -264,10 +311,12 @@ for (const name of [...names].sort()) { rows.push({ name, kind: "new", - text: color("NEW", "36"), bMean: undefined, - cMean: c ? c.meanLatencyNs : undefined, - delta: undefined, + cMean: c?.meanLatencyNs, + meanText: color("NEW", "36"), + minText: "", + heapText: "", + gcText: "", }); continue; } @@ -275,54 +324,148 @@ for (const name of [...names].sort()) { rows.push({ name, kind: "gone", - text: color("GONE", "90"), bMean: b.meanLatencyNs, cMean: undefined, - delta: undefined, + meanText: color("GONE", "90"), + minText: "", + heapText: "", + gcText: "", }); continue; } - const deltaPct = + const meanDelta = ((c.meanLatencyNs - b.meanLatencyNs) / b.meanLatencyNs) * 100; - // Combined relative margin of error; deltas inside this are noise. + const minDelta = ((c.minLatencyNs - b.minLatencyNs) / b.minLatencyNs) * 100; + // Combined relative stddev; deltas inside this are treated as noise. const noiseFloor = (b.rmePercent ?? 0) + (c.rmePercent ?? 0); - let kind = "ok"; - let text = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; - if (deltaPct > args.threshold && Math.abs(deltaPct) > noiseFloor) { + const meanV = classify(meanDelta, noiseFloor, args.threshold); + const minV = classify(minDelta, noiseFloor, args.threshold); + + // Heap is mostly deterministic per code+fixture, but for long-running + // alloc-heavy benches (Compile/*) the GC scheduler can fire mid-iteration + // and make `getHeapStatistics()` snapshots noisy. Reuse the timing noise + // floor (combined rmePercent) as a soft upper bound on measurement noise — + // not exact, but it suppresses the same kind of jitter that timing sees. + let heapDelta: number | undefined; + let heapV: SignalVerdict = "ok"; + if (b.heapAvgBytes !== undefined && c.heapAvgBytes !== undefined) { + anyHeap = true; + const heapAbsDelta = c.heapAvgBytes - b.heapAvgBytes; + if (b.heapAvgBytes === 0) { + heapDelta = c.heapAvgBytes === 0 ? 0 : Number.POSITIVE_INFINITY; + } else { + heapDelta = (heapAbsDelta / b.heapAvgBytes) * 100; + } + if (Math.abs(heapAbsDelta) < 1) { + heapV = "ok"; + } else { + heapV = classify(heapDelta, noiseFloor, args.threshold); + } + } + + // GC time is reported only when the runtime exposes gc(). Informational + // only — we don't gate on it because per-iter GC cost is noisy and already + // captured (in a noisier form) by heap allocation. + let gcDelta: number | undefined; + if (b.gcTotalNs !== undefined && c.gcTotalNs !== undefined) { + anyGc = true; + if (b.gcTotalNs === 0) { + gcDelta = c.gcTotalNs === 0 ? 0 : Number.POSITIVE_INFINITY; + } else { + gcDelta = ((c.gcTotalNs - b.gcTotalNs) / b.gcTotalNs) * 100; + } + } + + const tags: string[] = []; + if (meanV === "regress") tags.push("mean"); + if (minV === "regress") tags.push("min"); + if (heapV === "regress") tags.push("heap"); + const fasterTags: string[] = []; + if (meanV === "improve") fasterTags.push("mean"); + if (minV === "improve") fasterTags.push("min"); + if (heapV === "improve") fasterTags.push("heap"); + + let kind: Row["kind"] = "ok"; + let meanText = fmtDelta(meanDelta, meanV); + const minText = fmtDelta(minDelta, minV); + const heapText = + heapDelta === undefined ? "" : fmtDelta(heapDelta, heapV); + const gcText = + gcDelta === undefined + ? "" + : Number.isFinite(gcDelta) + ? `${gcDelta >= 0 ? "+" : ""}${gcDelta.toFixed(2)}%` + : "∞"; + + if (tags.length > 0) { kind = "regress"; - text = color(`${text} REGRESS`, "31"); regressions++; - } else if (deltaPct < -args.threshold && Math.abs(deltaPct) > noiseFloor) { + const marker = color(`REGRESS (${tags.join("+")})`, "31"); + meanText = `${meanText} ${marker}`; + } else if (fasterTags.length > 0) { kind = "improve"; - text = color(`${text} faster`, "32"); improvements++; - } else if (Math.abs(deltaPct) <= noiseFloor) { - text = color(`${text} (noise)`, "90"); + const marker = color(`faster (${fasterTags.join("+")})`, "32"); + meanText = `${meanText} ${marker}`; + } else if (meanV === "noise" || minV === "noise") { + meanText = `${meanText} ${color("(noise)", "90")}`; } + rows.push({ name, kind, - text, bMean: b.meanLatencyNs, cMean: c.meanLatencyNs, - delta: deltaPct, + meanText, + minText, + heapText, + gcText, }); } +// padVisible pads s to width n based on its visible (ANSI-stripped) length. +const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +function padVisible(s: string, n: number): string { + const visible = s.replace(ansiPattern, ""); + const padding = Math.max(0, n - visible.length); + return s + " ".repeat(padding); +} + if (!args.quiet) { const nameW = Math.max(4, ...rows.map((r) => r.name.length)); - console.log( - `${pad("task", nameW)} ${pad("baseline", 12)} ${pad("current", 12)} delta`, - ); - console.log( - `${"-".repeat(nameW)} ${"-".repeat(12)} ${"-".repeat(12)} ${"-".repeat(20)}`, - ); + const cols = [ + `${pad("task", nameW)}`, + pad("baseline", 12), + pad("current", 12), + pad("min Δ", 10), + ]; + const seps = [ + "-".repeat(nameW), + "-".repeat(12), + "-".repeat(12), + "-".repeat(10), + ]; + if (anyHeap) { + cols.push(pad("heap Δ", 10)); + seps.push("-".repeat(10)); + } + if (anyGc) { + cols.push(pad("gc Δ", 10)); + seps.push("-".repeat(10)); + } + cols.push("mean Δ"); + seps.push("-".repeat(28)); + console.log(cols.join(" ")); + console.log(seps.join(" ")); for (const r of rows) { const b = r.bMean !== undefined ? fmtNs(r.bMean) : "—"; const c = r.cMean !== undefined ? fmtNs(r.cMean) : "—"; - console.log( - `${pad(r.name, nameW)} ${pad(b, 12)} ${pad(c, 12)} ${r.text}`, - ); + const minCell = padVisible(r.minText, 10); + const cells = [pad(r.name, nameW), pad(b, 12), pad(c, 12), minCell]; + if (anyHeap) cells.push(padVisible(r.heapText || "—", 10)); + if (anyGc) cells.push(padVisible(r.gcText || "—", 10)); + cells.push(r.meanText); + console.log(cells.join(" ")); } console.log(""); } diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts index 401783c..badb8f2 100644 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Bench } from "tinybench"; +import { bench } from "mitata"; import { createValidator } from "@bufbuild/protovalidate"; import { caseByName } from "./cases.js"; @@ -22,12 +22,12 @@ import { caseByName } from "./cases.js"; const compileTargets = ["ComplexSchema", "Int32GT"] as const; -export function register(bench: Bench): void { +export function register(): void { for (const name of compileTargets) { const c = caseByName(name); - bench.add(`Compile/${c.name}`, () => { + bench(`Compile/${c.name}`, () => { const v = createValidator(); v.validate(c.schema, c.fixture); - }); + }).gc('inner'); } } diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index ee14308..fa2756f 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Bench } from "tinybench"; +import { bench } from "mitata"; import { createStandardSchema } from "@bufbuild/protovalidate"; import { caseByName } from "./cases.js"; @@ -22,13 +22,13 @@ import { caseByName } from "./cases.js"; const adapterTargets = ["Scalar", "ComplexSchema"] as const; -export function register(bench: Bench): void { +export function register(): void { for (const name of adapterTargets) { const c = caseByName(name); const adapter = createStandardSchema(c.schema); adapter["~standard"].validate(c.fixture); // warm - bench.add(`StandardSchema/${c.name}`, () => { + bench(`StandardSchema/${c.name}`, () => { adapter["~standard"].validate(c.fixture); - }); + }).gc('inner'); } } diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts index 5c25266..706f901 100644 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { Bench } from "tinybench"; +import { bench } from "mitata"; import { createValidator } from "@bufbuild/protovalidate"; import { cases } from "./cases.js"; @@ -20,12 +20,12 @@ import { cases } from "./cases.js"; // reused across iterations, matching Go's BenchmarkValidate*. The set of // cases lives in cases.ts — add a row there to add a benchmark. -export function register(bench: Bench): void { +export function register(): void { const validator = createValidator(); for (const c of cases) { validator.validate(c.schema, c.fixture); // warm the planner cache - bench.add(c.name, () => { + bench(c.name, () => { validator.validate(c.schema, c.fixture); - }); + }).gc('inner'); } } From 70a29692f3f85c1ef9185a659e189d0ff7210c59 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 27 May 2026 15:02:21 -0400 Subject: [PATCH 21/38] fix formatting Signed-off-by: Jon Bodner --- packages/protovalidate-bench/src/checkbench.ts | 3 +-- packages/protovalidate-bench/src/suites/compile.bench.ts | 2 +- .../protovalidate-bench/src/suites/standard-schema.bench.ts | 2 +- packages/protovalidate-bench/src/suites/validate.bench.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index 5157b89..dda5321 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -388,8 +388,7 @@ for (const name of [...names].sort()) { let kind: Row["kind"] = "ok"; let meanText = fmtDelta(meanDelta, meanV); const minText = fmtDelta(minDelta, minV); - const heapText = - heapDelta === undefined ? "" : fmtDelta(heapDelta, heapV); + const heapText = heapDelta === undefined ? "" : fmtDelta(heapDelta, heapV); const gcText = gcDelta === undefined ? "" diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts index badb8f2..17723b2 100644 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -28,6 +28,6 @@ export function register(): void { bench(`Compile/${c.name}`, () => { const v = createValidator(); v.validate(c.schema, c.fixture); - }).gc('inner'); + }).gc("inner"); } } diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index fa2756f..dec57eb 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -29,6 +29,6 @@ export function register(): void { adapter["~standard"].validate(c.fixture); // warm bench(`StandardSchema/${c.name}`, () => { adapter["~standard"].validate(c.fixture); - }).gc('inner'); + }).gc("inner"); } } diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts index 706f901..1a52f73 100644 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -26,6 +26,6 @@ export function register(): void { validator.validate(c.schema, c.fixture); // warm the planner cache bench(c.name, () => { validator.validate(c.schema, c.fixture); - }).gc('inner'); + }).gc("inner"); } } From 1fc15f563fd8da2a0772f539db33a26a26522d6a Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 28 May 2026 12:20:54 -0400 Subject: [PATCH 22/38] improve benchmark stability by adding multi-run support and different gc settings for different tests. Signed-off-by: Jon Bodner --- packages/protovalidate-bench/README.md | 151 +++--- packages/protovalidate-bench/src/bench.ts | 470 +++++++++++++++--- .../protovalidate-bench/src/checkbench.ts | 54 +- .../src/suites/compile.bench.ts | 18 +- .../src/suites/registry.ts | 36 ++ .../src/suites/standard-schema.bench.ts | 15 +- .../src/suites/validate.bench.ts | 18 +- 7 files changed, 611 insertions(+), 151 deletions(-) create mode 100644 packages/protovalidate-bench/src/suites/registry.ts diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index 8639f58..aa78f3f 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -4,11 +4,14 @@ Performance benchmarks for `@bufbuild/protovalidate`. This package is private an mirrors the suite in [`protovalidate-go/validator_bench_test.go`](https://github.com/bufbuild/protovalidate-go/blob/main/validator_bench_test.go) so that runtime cost can be tracked across changes and compared cross-language. -The harness is [mitata](https://github.com/evanwashere/mitata), which -auto-tunes warmup and sample counts and reports per-task histograms, p99, and -optimization-elimination warnings. Fixtures are hand-built (no faker -dependency) and seeded with a deterministic PRNG so every run validates the -same messages. +The harness is [mitata](https://github.com/evanwashere/mitata), called via +its low-level `measure()` API so we can disable mitata's symmetric sample trim +and observe the raw min. By default, the runner spawns **5 fresh Node +processes** and aggregates per-task stats across them — between-process +variance (thermal state, JIT decisions, scheduling) dominates over within-run +sample noise, and a single process can't see it. +Fixtures are hand-built (no faker dependency) and seeded with a +deterministic PRNG so every run validates the same messages. ## Running @@ -29,74 +32,103 @@ The runner prints a table of results and writes a JSON file to `.tmp/bench/` ### Options -| Flag | Default | Description | -| --------------------- | ----------- | --------------------------------------------------------------- | -| `--filter ` | _(none)_ | Only run tasks whose name contains `` | -| `--out ` | `.tmp/bench`| Output directory for JSON results | +| Flag | Default | Description | +|---------------------|--------------|--------------------------------------------------------------------------------| +| `--filter ` | _(none)_ | Only run tasks whose name contains `` | +| `--out ` | `.tmp/bench` | Output directory for JSON results | +| `--runs ` | `5` | Number of fresh Node processes to spawn and aggregate. `--runs 1` runs inline. | -mitata auto-tunes warmup, sample count, and per-task wall time. To slow the -runner down, run fewer benchmarks via `--filter`. +A 5-run pass over the full suite takes a few minutes. For +quick iteration, drop to `--runs 1` and accept the wider noise floor, or +combine `--runs 1 --filter ` to only re-measure the tasks you're +changing. Example: ```shell -# Only validation benchmarks (skip the Compile/* tasks) -npm run bench -- --filter Scalar +# Quick single-process check, validation benchmarks only +npm run bench -- --runs 1 --filter Scalar + +# Full 5-run aggregate +npm run bench ``` ### Output schema -Each run writes a JSON file like: +Each invocation writes a single JSON file. The shape (schemaVersion 2): ```json { + "schemaVersion": 2, "node": "v22.x.x", "platform": "darwin/arm64", "timestamp": "2026-05-27T...", + "runs": 5, "tasks": [ { "name": "Scalar", - "meanLatencyNs": 110.42, - "minLatencyNs": 104.18, - "medianLatencyNs": 108.93, - "p99LatencyNs": 142.07, - "throughputOpsPerSec": 9056221, - "rmePercent": 4.71, - "samples": 128, - "gcTotalNs": 0, - "heapAvgBytes": 0 + "meanLatencyNs": 4099.62, + "minLatencyNs": 3920.79, + "medianLatencyNs": 4060.41, + "p99LatencyNs": 5008.19, + "throughputOpsPerSec": 243924, + "rmePercent": 4.10, + "crossRunRsdPercent": 0.13, + "samples": 125, + "runs": 5, + "perRunMeanLatencyNs": [4108.5, 4099.6, 4098.2, 4101.1, 4099.8], + "heapAvgBytes": 5315.2, + "gcTotalNs": 161677084 } ] } ``` -`gcTotalNs` and `heapAvgBytes` are only present when mitata can observe them -(Node started with `--expose-gc` for GC stats; `node:v8` heap stats for heap). +Field notes: + +- `meanLatencyNs` — trimmed mean (drops the lowest two and highest two samples) of + each process's sample set, then median across runs. +- `minLatencyNs` — true raw minimum sample across all runs. Older + schemaVersion-1 files reported mitata's trimmed min (third-lowest), so + comparing v1↔v2 will show min deltas that don't reflect real changes; + checkbench warns about this. +- `rmePercent` — within-run sample RSD (median across runs). + Informational only — overstates real signal when comparing across + processes. +- `crossRunRsdPercent` — RSD of per-run means. **This is the noise floor + checkbench uses for regression detection.** Present only when + `runs > 1`. +- `perRunMeanLatencyNs` — per-process means in registration order. + Present only when `runs > 1`. Lets you spot a single outlier process. +- `gcTotalNs` and `heapAvgBytes` are only present when mitata can observe + them (Node started with `--expose-gc` for GC stats; `node:v8` + `getHeapStatistics()` for heap). The `npm run bench` script already + passes `--expose-gc`. ## Benchmarks Each task mirrors the equivalent `Benchmark*` in `protovalidate-go` so deltas between languages stay meaningful. -| Task | What it measures | -| ------------------------------- | ------------------------------------------------------------------------------- | -| `Scalar` | One `int32` with `gt = 0`. Minimum-overhead baseline. | -| `Repeated/Scalar` | `repeated int32` with `max_items`. | -| `Repeated/Message` | `repeated` of nested messages. | -| `Repeated/Unique/Scalar` | `repeated float` with `unique = true` (hash-based dedup path). | -| `Repeated/Unique/Bytes` | `repeated bytes` with `unique = true`. | -| `Map` | `map` with `min_pairs`. | -| `ComplexSchema` | Broad message exercising scalars, repeated, maps, oneof, nested, self-ref. | -| `Int32GT` | Many numeric comparison rules (`gt`/`gte`/`lt`/`lte`/`const`/`in`/`not_in`). | -| `TestByteMatching` | `bytes.ip` / `bytes.ipv4` / `bytes.ipv6` / `bytes.uuid`. | -| `StringMatching` | `string.hostname` / `host_and_port` / `email` / `uuid`. | -| `WrapperTesting` | `google.protobuf.*Value` wrapper fields with rules. | -| `MultiRule/Error` | Multi-rule field that fails — drives violation accumulation. | -| `MultiRule/NoError` | Same schema, valid value — success path. | -| `Compile/ComplexSchema` | `createValidator()` + first validate on each iteration. Plan-build cost. | -| `Compile/Int32GT` | Same, simpler schema. | -| `StandardSchema/Scalar` | Standard Schema adapter, scalar message. TS-only — no Go analogue. | -| `StandardSchema/ComplexSchema` | Standard Schema adapter, complex message. | +| Task | What it measures | +|--------------------------------|------------------------------------------------------------------------------| +| `Scalar` | One `int32` with `gt = 0`. Minimum-overhead baseline. | +| `Repeated/Scalar` | `repeated int32` with `max_items`. | +| `Repeated/Message` | `repeated` of nested messages. | +| `Repeated/Unique/Scalar` | `repeated float` with `unique = true` (hash-based dedup path). | +| `Repeated/Unique/Bytes` | `repeated bytes` with `unique = true`. | +| `Map` | `map` with `min_pairs`. | +| `ComplexSchema` | Broad message exercising scalars, repeated, maps, oneof, nested, self-ref. | +| `Int32GT` | Many numeric comparison rules (`gt`/`gte`/`lt`/`lte`/`const`/`in`/`not_in`). | +| `TestByteMatching` | `bytes.ip` / `bytes.ipv4` / `bytes.ipv6` / `bytes.uuid`. | +| `StringMatching` | `string.hostname` / `host_and_port` / `email` / `uuid`. | +| `WrapperTesting` | `google.protobuf.*Value` wrapper fields with rules. | +| `MultiRule/Error` | Multi-rule field that fails — drives violation accumulation. | +| `MultiRule/NoError` | Same schema, valid value — success path. | +| `Compile/ComplexSchema` | `createValidator()` + first validate on each iteration. Plan-build cost. | +| `Compile/Int32GT` | Same, simpler schema. | +| `StandardSchema/Scalar` | Standard Schema adapter, scalar message. TS-only — no Go analogue. | +| `StandardSchema/ComplexSchema` | Standard Schema adapter, complex message. | ## Comparing runs @@ -117,17 +149,21 @@ Output is per task: baseline mean, current mean, `min Δ`, `heap Δ` (when available), an optional `gc Δ` (when both runs have GC stats), and `mean Δ` with a marker — `REGRESS (mean|min|heap|...)`, `faster (...)`, or `(noise)`. A delta is treated as noise if it falls inside the combined -relative standard deviation of the two runs. +noise floor of the two files: the sum of each side's `crossRunRsdPercent` +(when present) or `rmePercent` (fallback for schemaVersion-1 files, which +overstates real signal). The tool gates on three signals: **mean latency, min latency, and heap allocation per iteration**. A task fails if any of these deltas exceeds `--threshold` and falls outside the noise floor. - **Mean** catches the typical-case slowdown. -- **Min** is the JIT-warm floor — immune to GC pauses, so it surfaces real - CPU regressions that mean might hide in tail noise. +- **Min** is the raw fastest sample across all runs — sensitive to JIT + warmth and immune to GC pauses. With multi-run aggregation the min is + taken across every process's samples, so it's also stable against single-process + JIT variance. - **Heap Δ** is bytes allocated per iteration (via `node:v8` - `getHeapStatistics()`). Catches allocation regressions even when wall-clock + `getHeapStatistics()`). Catches allocation regressions even when wall-clock time is flat — those still hurt in production because they amplify GC pressure. The heap signal is mostly deterministic for short benches; for long-running alloc-heavy benches (`Compile/*`) it can drift with GC scheduling, which @@ -135,16 +171,13 @@ allocation per iteration**. A task fails if any of these deltas exceeds - **GC Δ** is informational only (no gating) and only appears when both runs were produced with `--expose-gc` so mitata can observe gc time. -Note on `rmePercent`: it's the relative standard deviation of the -samples (`stddev / mean × 100`). - ### Options -| Flag | Default | Description | -| --------------------- | ------- | ---------------------------------------------------------------- | -| `--threshold ` | `5` | Regression bar. Slowdowns above this AND outside noise fail. | -| `--dir ` | `.tmp/bench` | Directory the `latest` / `previous` shortcuts look in. | -| `--quiet`, `-q` | _(off)_ | Print summary line only. | +| Flag | Default | Description | +|---------------------|--------------|--------------------------------------------------------------| +| `--threshold ` | `5` | Regression bar. Slowdowns above this AND outside noise fail. | +| `--dir ` | `.tmp/bench` | Directory the `latest` / `previous` shortcuts look in. | +| `--quiet`, `-q` | _(off)_ | Print summary line only. | The script exits **1** if any task regresses past `--threshold`, otherwise **0** — drop it into a pre-commit hook or CI step to gate PRs on performance. @@ -161,10 +194,10 @@ npm run bench # produces .tmp/bench/.json (current) node scripts/checkbench.ts latest # diff vs previous ``` -Heads up: bench-to-bench wall-time numbers are sensitive to other load on the +Bench-to-bench wall-time numbers are sensitive to other loads on the machine. For meaningful comparison, run baseline and current on the same -hardware, close other CPU-heavy apps, and prefer longer runs -(`--time 5000`) when the deltas you care about are within a few percent. +hardware, close other CPU-heavy apps, and bump `--runs` if you need a +tighter noise floor than the default 5 runs gives. ## Regenerating proto code diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index de2eeaa..8d29ddf 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -12,21 +12,34 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { run } from "mitata"; +import { measure } from "mitata"; +import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { register as registerValidate } from "./suites/validate.bench.js"; import { register as registerCompile } from "./suites/compile.bench.js"; +import { getSpecs } from "./suites/registry.js"; import { register as registerStandardSchema } from "./suites/standard-schema.bench.js"; +import { register as registerValidate } from "./suites/validate.bench.js"; + +// Bumped to 2 when the schema added schemaVersion / runs / crossRunRsdPercent +// and changed minLatencyNs from mitata's trimmed min (3rd-lowest sample) to +// the actual raw minimum sample. Old files without schemaVersion are still +// readable by checkbench (treated as v1). +const SCHEMA_VERSION = 2; +const DEFAULT_RUNS = 5; interface CliOptions { filter: string | undefined; outDir: string; + runs: number; + worker: boolean; } function parseArgs(argv: readonly string[]): CliOptions { let filter: string | undefined; let outDir = ".tmp/bench"; + let runs = DEFAULT_RUNS; + let worker = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; switch (a) { @@ -36,6 +49,22 @@ function parseArgs(argv: readonly string[]): CliOptions { case "--out": outDir = String(argv[++i]); break; + case "--runs": { + const raw = argv[++i]; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) { + console.error(`--runs must be a positive integer: ${raw}`); + process.exit(2); + } + runs = n; + break; + } + case "--worker": + // Internal: marks this process as a child worker. The coordinator + // spawns one Node process per run with this flag, and reads the + // worker's JSON payload from stdout. + worker = true; + break; case "--help": case "-h": printUsage(); @@ -48,7 +77,7 @@ function parseArgs(argv: readonly string[]): CliOptions { } } } - return { filter, outDir }; + return { filter, outDir, runs, worker }; } function printUsage(): void { @@ -59,6 +88,8 @@ function printUsage(): void { "Options:", " --filter Only run benchmarks whose name contains ", " --out Output directory for JSON results (default: .tmp/bench)", + ` --runs Run N independent Node processes and aggregate (default: ${DEFAULT_RUNS}).`, + " N=1 runs inline with no aggregation.", "", ].join("\n"), ); @@ -68,9 +99,8 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// Subset of mitata's trial/stats shape that we actually consume. Mitata's -// declarations expose these types as anonymous interfaces, so we restate the -// fields we read. +// Subset of mitata's stats shape we read. Mitata declares these inline; we +// restate the fields we touch. interface MitataStats { avg: number; min: number; @@ -81,51 +111,33 @@ interface MitataStats { heap?: { avg: number }; } -interface MitataTrial { - alias: string; - runs: { stats?: MitataStats; error?: unknown; name: string }[]; +// The shape a worker prints to stdout (or that runs=1 builds inline). One +// entry per bench. Latency stats are computed by us from the raw, untrimmed +// samples mitata returns when samples_threshold is set to a very large value. +interface WorkerTask { + name: string; + meanLatencyNs: number; + minLatencyNs: number; + medianLatencyNs: number; + p99LatencyNs: number; + rmePercent: number; + samples: number; + gcTotalNs?: number; + heapAvgBytes?: number; } -const opts = parseArgs(process.argv.slice(2)); - -registerValidate(); -registerCompile(); -registerStandardSchema(); - -console.log(`# protovalidate-es bench`); -console.log(`# node ${process.version} ${process.platform}/${process.arch}`); - -const result = (await run( - opts.filter !== undefined - ? { filter: new RegExp(escapeRegExp(opts.filter)) } - : {}, -)) as { benchmarks: MitataTrial[] }; - -console.log(`# tasks: ${result.benchmarks.length}`); -if (result.benchmarks.length === 0) { - console.error("no tasks matched filter"); - process.exit(2); +interface WorkerPayload { + schemaVersion: number; + node: string; + platform: string; + timestamp: string; + tasks: WorkerTask[]; } -function relativeStddevPercent(samples: number[], mean: number): number { - if (samples.length === 0 || mean === 0) return 0; - let sumSq = 0; - for (const s of samples) { - const d = s - mean; - sumSq += d * d; - } - return (Math.sqrt(sumSq / samples.length) / mean) * 100; -} - -const stamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .replace(/T/, "_") - .replace(/Z$/, ""); -mkdirSync(opts.outDir, { recursive: true }); -const outPath = join(opts.outDir, `${stamp}.json`); - -interface TaskPayload { +// What the coordinator writes to disk. When runs=1, this is just the worker +// stats with no cross-run fields. When runs>1, fields are aggregated across +// runs and crossRunRsdPercent / perRunMeanLatencyNs are populated. +interface AggregatedTask { name: string; meanLatencyNs: number; minLatencyNs: number; @@ -133,36 +145,346 @@ interface TaskPayload { p99LatencyNs: number; throughputOpsPerSec: number; rmePercent: number; + crossRunRsdPercent?: number; samples: number; + runs: number; + perRunMeanLatencyNs?: number[]; gcTotalNs?: number; heapAvgBytes?: number; } -const tasks: TaskPayload[] = []; -for (const trial of result.benchmarks) { - const r = trial.runs[0]; - if (!r || r.error !== undefined || !r.stats) continue; - const s = r.stats; - const task: TaskPayload = { - name: trial.alias, - meanLatencyNs: s.avg, - minLatencyNs: s.min, - medianLatencyNs: s.p50, - p99LatencyNs: s.p99, - throughputOpsPerSec: 1e9 / s.avg, - rmePercent: relativeStddevPercent(s.samples, s.avg), - samples: s.samples.length, +interface AggregatedPayload { + schemaVersion: number; + node: string; + platform: string; + timestamp: string; + runs: number; + tasks: AggregatedTask[]; +} + +// ---- Stats helpers ---- + +function median(sorted: readonly number[]): number { + if (sorted.length === 0) return 0; + const mid = sorted.length >> 1; + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function medianOf(arr: readonly number[]): number { + if (arr.length === 0) return 0; + return median([...arr].sort((a, b) => a - b)); +} + +// Sample stddev / mean. Returns a fraction (multiply by 100 for percent). +function rsdFraction(arr: readonly number[]): number { + if (arr.length < 2) return 0; + let sum = 0; + for (const v of arr) sum += v; + const m = sum / arr.length; + if (m === 0) return 0; + let sumSq = 0; + for (const v of arr) { + const d = v - m; + sumSq += d * d; + } + return Math.sqrt(sumSq / (arr.length - 1)) / m; +} + +// Mean over samples with the lowest 2 and highest 2 dropped. Mirrors the +// trimming mitata used to do by default, but applied by us so we can keep +// the raw samples accessible too. +function trimmedMean(sortedSamples: readonly number[]): number { + if (sortedSamples.length === 0) return 0; + const slice = + sortedSamples.length > 4 ? sortedSamples.slice(2, -2) : sortedSamples; + let sum = 0; + for (const v of slice) sum += v; + return sum / slice.length; +} + +function trimmedRsdPercent(sortedSamples: readonly number[]): number { + const slice = + sortedSamples.length > 4 ? sortedSamples.slice(2, -2) : sortedSamples; + return rsdFraction(slice) * 100; +} + +function fmtNs(n: number): string { + if (n < 1000) return `${n.toFixed(0)} ns`; + if (n < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; + return `${(n / 1_000_000).toFixed(2)} ms`; +} + +// ---- Worker (single-process bench run) ---- + +async function makeHeapFn(): Promise<(() => number) | undefined> { + try { + const v8 = await import("node:v8"); + v8.getHeapStatistics(); + return () => { + const m = v8.getHeapStatistics(); + return m.used_heap_size + m.malloced_memory; + }; + } catch { + return undefined; + } +} + +async function collectWorkerTasks( + filterRe: RegExp | undefined, +): Promise { + registerValidate(); + registerCompile(); + registerStandardSchema(); + + const specs = getSpecs(filterRe); + if (specs.length === 0) { + process.stderr.write("no tasks matched filter\n"); + process.exit(2); + } + + const heapFn = await makeHeapFn(); + const tasks: WorkerTask[] = []; + for (const spec of specs) { + process.stderr.write(` ${spec.name} ...`); + const t0 = performance.now(); + // gc is left undefined so mitata uses its default gc function (which + // calls globalThis.gc() under --expose-gc). Passing `gc: true` makes + // mitata try to call `true()` as the gc function. + const stats = (await measure(spec.fn, { + inner_gc: spec.gc === "inner", + heap: heapFn, + // Disable mitata's built-in symmetric trim so we can see the raw min + // and compute our own trimmed mean. Any positive number larger than the + // sample count works; MAX_SAFE_INTEGER is the cleanest. + samples_threshold: Number.MAX_SAFE_INTEGER, + })) as MitataStats; + const t1 = performance.now(); + const samples = stats.samples; + const meanNs = trimmedMean(samples); + const task: WorkerTask = { + name: spec.name, + meanLatencyNs: meanNs, + minLatencyNs: stats.min, + medianLatencyNs: stats.p50, + p99LatencyNs: stats.p99, + rmePercent: trimmedRsdPercent(samples), + samples: samples.length, + }; + if (stats.gc !== undefined) task.gcTotalNs = stats.gc.total; + if (stats.heap !== undefined) task.heapAvgBytes = stats.heap.avg; + tasks.push(task); + process.stderr.write( + ` ${fmtNs(meanNs)} (rsd ${task.rmePercent.toFixed(1)}%, n=${samples.length}, ${(t1 - t0).toFixed(0)}ms)\n`, + ); + } + return tasks; +} + +async function runWorker(filterRe: RegExp | undefined): Promise { + const tasks = await collectWorkerTasks(filterRe); + const payload: WorkerPayload = { + schemaVersion: SCHEMA_VERSION, + node: process.version, + platform: `${process.platform}/${process.arch}`, + timestamp: new Date().toISOString(), + tasks, }; - if (s.gc !== undefined) task.gcTotalNs = s.gc.total; - if (s.heap !== undefined) task.heapAvgBytes = s.heap.avg; - tasks.push(task); -} - -const payload = { - node: process.version, - platform: `${process.platform}/${process.arch}`, - timestamp: new Date().toISOString(), - tasks, -}; -writeFileSync(outPath, JSON.stringify(payload, null, 2)); -console.log(`wrote ${outPath}`); + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +// ---- Coordinator (multi-process aggregation) ---- + +function spawnWorker(filter: string | undefined): Promise { + // Re-invoke the same script in a fresh Node process. process.execArgv + // carries the original flags (--expose-gc, any tsx loader hooks), so the + // child runs in the same environment as the parent. + const args = [...process.execArgv, process.argv[1], "--worker"]; + if (filter !== undefined) args.push("--filter", filter); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ["ignore", "pipe", "inherit"], + }); + let stdout = ""; + child.stdout.setEncoding("utf-8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code !== 0) { + reject(new Error(`worker exited with code ${code}`)); + return; + } + try { + const payload = JSON.parse(stdout.trim()) as WorkerPayload; + resolve(payload); + } catch (e) { + reject( + new Error( + `failed to parse worker output: ${(e as Error).message}\n--- stdout ---\n${stdout}`, + ), + ); + } + }); + }); +} + +function aggregate(workers: WorkerPayload[]): AggregatedPayload { + if (workers.length === 0) { + throw new Error("aggregate: no worker payloads"); + } + const first = workers[0]; + const multiRun = workers.length > 1; + + // Group per-task across workers, preserving the order the first worker saw. + const byName = new Map(); + const order: string[] = []; + for (const w of workers) { + for (const t of w.tasks) { + let arr = byName.get(t.name); + if (arr === undefined) { + arr = []; + byName.set(t.name, arr); + order.push(t.name); + } + arr.push(t); + } + } + + const aggTasks: AggregatedTask[] = []; + for (const name of order) { + const ts = byName.get(name); + if (ts === undefined) continue; // unreachable: name was pushed when arr was created + const meanArr = ts.map((t) => t.meanLatencyNs); + const meanLatencyNs = multiRun ? medianOf(meanArr) : meanArr[0]; + const minLatencyNs = Math.min(...ts.map((t) => t.minLatencyNs)); + const medianLatencyNs = multiRun + ? medianOf(ts.map((t) => t.medianLatencyNs)) + : ts[0].medianLatencyNs; + const p99LatencyNs = multiRun + ? medianOf(ts.map((t) => t.p99LatencyNs)) + : ts[0].p99LatencyNs; + const rmePercent = multiRun + ? medianOf(ts.map((t) => t.rmePercent)) + : ts[0].rmePercent; + const samples = ts.reduce((a, t) => a + t.samples, 0); + + const agg: AggregatedTask = { + name, + meanLatencyNs, + minLatencyNs, + medianLatencyNs, + p99LatencyNs, + throughputOpsPerSec: meanLatencyNs > 0 ? 1e9 / meanLatencyNs : 0, + rmePercent, + samples, + runs: ts.length, + }; + if (multiRun) { + agg.crossRunRsdPercent = rsdFraction(meanArr) * 100; + agg.perRunMeanLatencyNs = meanArr; + } + const gcArr = ts + .map((t) => t.gcTotalNs) + .filter((v): v is number => v !== undefined); + if (gcArr.length === ts.length && gcArr.length > 0) { + agg.gcTotalNs = medianOf(gcArr); + } + const heapArr = ts + .map((t) => t.heapAvgBytes) + .filter((v): v is number => v !== undefined); + if (heapArr.length === ts.length && heapArr.length > 0) { + agg.heapAvgBytes = medianOf(heapArr); + } + aggTasks.push(agg); + } + + return { + schemaVersion: SCHEMA_VERSION, + node: first.node, + platform: first.platform, + timestamp: new Date().toISOString(), + runs: workers.length, + tasks: aggTasks, + }; +} + +function printSummary(payload: AggregatedPayload): void { + const nameW = Math.max(4, ...payload.tasks.map((t) => t.name.length)); + console.log(""); + console.log( + `${"task".padEnd(nameW)} ${"mean".padEnd(12)} ${"min".padEnd(12)} rsd ${ + payload.runs > 1 ? "cross-run" : "" + }`, + ); + for (const t of payload.tasks) { + const cross = + payload.runs > 1 && t.crossRunRsdPercent !== undefined + ? `${t.crossRunRsdPercent.toFixed(2)}%` + : ""; + console.log( + `${t.name.padEnd(nameW)} ${fmtNs(t.meanLatencyNs).padEnd(12)} ${fmtNs(t.minLatencyNs).padEnd(12)} ${t.rmePercent.toFixed(2).padStart(5)}% ${cross}`, + ); + } + console.log(""); +} + +async function runCoordinator(opts: CliOptions): Promise { + const filterRe = + opts.filter !== undefined + ? new RegExp(escapeRegExp(opts.filter)) + : undefined; + + console.log(`# protovalidate-es bench`); + console.log(`# node ${process.version} ${process.platform}/${process.arch}`); + console.log(`# runs ${opts.runs}`); + + let payload: AggregatedPayload; + if (opts.runs === 1) { + const tasks = await collectWorkerTasks(filterRe); + payload = aggregate([ + { + schemaVersion: SCHEMA_VERSION, + node: process.version, + platform: `${process.platform}/${process.arch}`, + timestamp: new Date().toISOString(), + tasks, + }, + ]); + } else { + const workers: WorkerPayload[] = []; + for (let i = 0; i < opts.runs; i++) { + console.log(`run ${i + 1}/${opts.runs} ...`); + workers.push(await spawnWorker(opts.filter)); + } + payload = aggregate(workers); + } + + const stamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .replace(/T/, "_") + .replace(/Z$/, ""); + mkdirSync(opts.outDir, { recursive: true }); + const outPath = join(opts.outDir, `${stamp}.json`); + writeFileSync(outPath, JSON.stringify(payload, null, 2)); + console.log(`wrote ${outPath}`); + printSummary(payload); +} + +// ---- Entry ---- + +const opts = parseArgs(process.argv.slice(2)); + +if (opts.worker) { + const filterRe = + opts.filter !== undefined + ? new RegExp(escapeRegExp(opts.filter)) + : undefined; + await runWorker(filterRe); +} else { + await runCoordinator(opts); +} diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index dda5321..10f139d 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -48,6 +48,8 @@ type FileInfo = { platform: string; timestamp: string; path: string; + runs: number; + schemaVersion: number; }; byName: Map; }; @@ -60,7 +62,13 @@ type Task = { p99LatencyNs: number; throughputOpsPerSec: number; rmePercent: number; + // Present only for files written with schemaVersion >= 2 from a multi-run + // invocation. When present this is the relative stddev across per-run means + // — i.e. the actual run-to-run noise — and should be used as the noise + // floor in preference to rmePercent (which is within-run sample spread). + crossRunRsdPercent?: number; samples: number; + runs?: number; gcTotalNs?: number; heapAvgBytes?: number; }; @@ -77,6 +85,12 @@ function load(path: string): FileInfo { platform: data.platform, timestamp: data.timestamp, path, + // Older files (schemaVersion absent or 1) don't have a runs field at + // the top level, but they also lack crossRunRsdPercent on tasks, so + // checkbench falls back to rmePercent for them. + runs: typeof data.runs === "number" ? data.runs : 1, + schemaVersion: + typeof data.schemaVersion === "number" ? data.schemaVersion : 1, }, byName, }; @@ -231,11 +245,11 @@ const current = load(currentPath); console.log(`baseline: ${baseline.meta.path}`); console.log( - ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform}`, + ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform} runs=${baseline.meta.runs}`, ); console.log(`current: ${current.meta.path}`); console.log( - ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform}`, + ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform} runs=${current.meta.runs}`, ); console.log(""); @@ -255,6 +269,22 @@ if (baseline.meta.node !== current.meta.node) { ), ); } +if (baseline.meta.runs !== current.meta.runs) { + console.log( + color( + `! runs count differs (${baseline.meta.runs} vs ${current.meta.runs}) — noise floor uses the looser of the two`, + "33", + ), + ); +} +if (baseline.meta.schemaVersion < 2 || current.meta.schemaVersion < 2) { + console.log( + color( + `! one or both files use schemaVersion 1 — falling back to within-run rmePercent as the noise floor (overstates real signal)`, + "33", + ), + ); +} const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); let regressions = 0; @@ -274,7 +304,12 @@ function classify( } function fmtDelta(deltaPct: number, verdict: SignalVerdict): string { - const base = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; + let base: string; + if (!Number.isFinite(deltaPct)) { + base = deltaPct > 0 ? "+∞%" : "-∞%"; + } else { + base = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; + } switch (verdict) { case "regress": return color(base, "31"); @@ -336,8 +371,17 @@ for (const name of [...names].sort()) { const meanDelta = ((c.meanLatencyNs - b.meanLatencyNs) / b.meanLatencyNs) * 100; const minDelta = ((c.minLatencyNs - b.minLatencyNs) / b.minLatencyNs) * 100; - // Combined relative stddev; deltas inside this are treated as noise. - const noiseFloor = (b.rmePercent ?? 0) + (c.rmePercent ?? 0); + // Prefer the cross-run RSD when both files have it (schemaVersion >= 2, + // runs > 1). That measures actual between-process variance and is the + // honest noise floor for comparing two separate bench invocations. Within + // -run rmePercent describes sample spread inside a single process; using + // it as a noise floor across processes systematically under-estimates the + // noise, which is what produced spurious "regress" markers on unchanged + // code. Falling back to rmePercent for v1 files keeps old comparisons + // working at the cost of accuracy. + const bNoise = b.crossRunRsdPercent ?? b.rmePercent ?? 0; + const cNoise = c.crossRunRsdPercent ?? c.rmePercent ?? 0; + const noiseFloor = bNoise + cNoise; const meanV = classify(meanDelta, noiseFloor, args.threshold); const minV = classify(minDelta, noiseFloor, args.threshold); diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts index 17723b2..7f11181 100644 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -12,22 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { bench } from "mitata"; import { createValidator } from "@bufbuild/protovalidate"; import { caseByName } from "./cases.js"; +import { registerSpec } from "./registry.js"; // Compile-time benchmarks: build a fresh validator on each iteration and run // one validate() call so the plan is forced. Mirrors Go's BenchmarkCompile, // which calls New() in the hot loop. +// +// gc: "inner" — allocation cost is the signal here. Forcing a full GC +// between batch samples keeps each sample's heap state comparable and stops +// background gc from inflating individual samples. const compileTargets = ["ComplexSchema", "Int32GT"] as const; export function register(): void { for (const name of compileTargets) { const c = caseByName(name); - bench(`Compile/${c.name}`, () => { - const v = createValidator(); - v.validate(c.schema, c.fixture); - }).gc("inner"); + registerSpec( + `Compile/${c.name}`, + () => { + const v = createValidator(); + v.validate(c.schema, c.fixture); + }, + "inner", + ); } } diff --git a/packages/protovalidate-bench/src/suites/registry.ts b/packages/protovalidate-bench/src/suites/registry.ts new file mode 100644 index 0000000..dbdaa24 --- /dev/null +++ b/packages/protovalidate-bench/src/suites/registry.ts @@ -0,0 +1,36 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// "once": one GC before measurement starts. Use for fast benches where forcing +// a full GC between batches would add more variance than it removes. +// "inner": GC between every batch sample. Use when allocation cost is the +// signal under test (e.g. Compile/*) — pays for itself in stability. +export type GcMode = "once" | "inner"; + +export interface BenchSpec { + name: string; + fn: () => void; + gc: GcMode; +} + +const specs: BenchSpec[] = []; + +export function registerSpec(name: string, fn: () => void, gc: GcMode): void { + specs.push({ name, fn, gc }); +} + +export function getSpecs(filter?: RegExp): BenchSpec[] { + if (filter === undefined) return specs.slice(); + return specs.filter((s) => filter.test(s.name)); +} diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index dec57eb..d700c95 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -12,13 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { bench } from "mitata"; import { createStandardSchema } from "@bufbuild/protovalidate"; import { caseByName } from "./cases.js"; +import { registerSpec } from "./registry.js"; // Standard Schema adapter overhead — TS-only surface, no Go analogue. Compares // directly with the matching Scalar/ComplexSchema validate benches to surface // the cost of the adapter's path→Issue translation and unknown→typed narrowing. +// +// gc: "once" — same reasoning as validate.bench.ts: the adapter is a thin +// wrapper around validate(), so the GC strategy should match. const adapterTargets = ["Scalar", "ComplexSchema"] as const; @@ -27,8 +30,12 @@ export function register(): void { const c = caseByName(name); const adapter = createStandardSchema(c.schema); adapter["~standard"].validate(c.fixture); // warm - bench(`StandardSchema/${c.name}`, () => { - adapter["~standard"].validate(c.fixture); - }).gc("inner"); + registerSpec( + `StandardSchema/${c.name}`, + () => { + adapter["~standard"].validate(c.fixture); + }, + "once", + ); } } diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts index 1a52f73..5a904cc 100644 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -12,20 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { bench } from "mitata"; import { createValidator } from "@bufbuild/protovalidate"; import { cases } from "./cases.js"; +import { registerSpec } from "./registry.js"; // Validate-time benches: a single validator is warmed once per case and then // reused across iterations, matching Go's BenchmarkValidate*. The set of // cases lives in cases.ts — add a row there to add a benchmark. +// +// gc: "once" — these benches are short-lived and reuse the same validator/ +// fixture, so forcing a full GC between every batch sample (as gc:"inner" +// would) churns the heap layout enough to add more run-to-run variance than +// it removes. Multi-run aggregation in the driver captures the residual +// noise across processes. export function register(): void { const validator = createValidator(); for (const c of cases) { validator.validate(c.schema, c.fixture); // warm the planner cache - bench(c.name, () => { - validator.validate(c.schema, c.fixture); - }).gc("inner"); + registerSpec( + c.name, + () => { + validator.validate(c.schema, c.fixture); + }, + "once", + ); } } From 52ede797aba6ca9a47f8fb2858f71d7cb6768cb9 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 28 May 2026 13:41:58 -0400 Subject: [PATCH 23/38] additional tweaks to improve noise Signed-off-by: Jon Bodner --- packages/protovalidate-bench/README.md | 5 ++- packages/protovalidate-bench/src/bench.ts | 35 ++++++++++++++----- .../protovalidate-bench/src/checkbench.ts | 26 ++++++++++---- .../src/suites/registry.ts | 25 ++++++++++--- .../src/suites/standard-schema.bench.ts | 5 ++- .../src/suites/validate.bench.ts | 35 +++++++++++++++---- 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index aa78f3f..88ea464 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -179,7 +179,10 @@ allocation per iteration**. A task fails if any of these deltas exceeds | `--dir ` | `.tmp/bench` | Directory the `latest` / `previous` shortcuts look in. | | `--quiet`, `-q` | _(off)_ | Print summary line only. | -The script exits **1** if any task regresses past `--threshold`, otherwise +Pass a larger `--threshold` if you're working on noisier hardware or want to allow +small regressions through. + +The script exits **2** for bad parameter values (invalid threshold, directory, or files), **1** if any task regresses past `--threshold`, otherwise **0** — drop it into a pre-commit hook or CI step to gate PRs on performance. ### Typical workflow diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index 8d29ddf..1034720 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -61,7 +61,7 @@ function parseArgs(argv: readonly string[]): CliOptions { } case "--worker": // Internal: marks this process as a child worker. The coordinator - // spawns one Node process per run with this flag, and reads the + // spawns one Node process per run with this flag and reads the // worker's JSON payload from stdout. worker = true; break; @@ -69,7 +69,6 @@ function parseArgs(argv: readonly string[]): CliOptions { case "-h": printUsage(); process.exit(0); - break; default: if (a?.startsWith("--")) { console.error(`unknown flag: ${a}`); @@ -99,7 +98,7 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// Subset of mitata's stats shape we read. Mitata declares these inline; we +// Subset of the mitata stats shape that we read. Mitata declares these inline; we // restate the fields we touch. interface MitataStats { avg: number; @@ -134,8 +133,8 @@ interface WorkerPayload { tasks: WorkerTask[]; } -// What the coordinator writes to disk. When runs=1, this is just the worker -// stats with no cross-run fields. When runs>1, fields are aggregated across +// What the coordinator writes to disk. When runs = 1, this is just the worker +// stats with no cross-run fields. When runs > 1, fields are aggregated across // runs and crossRunRsdPercent / perRunMeanLatencyNs are populated. interface AggregatedTask { name: string; @@ -248,18 +247,38 @@ async function collectWorkerTasks( const tasks: WorkerTask[] = []; for (const spec of specs) { process.stderr.write(` ${spec.name} ...`); + // Pre-warm: drive the function past V8's cold-start state so mitata's + // warmup sees steady-state timing. Without this, a cold first iter on a + // ~40µs bench can exceed mitata's 500µs warmup_threshold, which silently + // disables batching and collapses sample quality (within-run RSD jumps + // from <1% to >10%). + for (let i = 0; i < 20; i++) spec.fn(); const t0 = performance.now(); - // gc is left undefined so mitata uses its default gc function (which + // gc is left undefined, so mitata uses its default gc function (which // calls globalThis.gc() under --expose-gc). Passing `gc: true` makes // mitata try to call `true()` as the gc function. - const stats = (await measure(spec.fn, { + const measureOpts: Record = { inner_gc: spec.gc === "inner", heap: heapFn, // Disable mitata's built-in symmetric trim so we can see the raw min // and compute our own trimmed mean. Any positive number larger than the // sample count works; MAX_SAFE_INTEGER is the cleanest. samples_threshold: Number.MAX_SAFE_INTEGER, - })) as MitataStats; + // mitata defaults warmup_threshold to 500µs: if the first iter exceeds + // that, the inner warmup loop is skipped and batching is disabled. + // For 10-40µs benches a cold first iter routinely lands above 500µs, + // making the batch decision non-deterministic across processes. Bumped + // to 5ms, so any sub-millisecond bench reliably enters the inner warmup + // (and the batch_threshold of 65µs then makes the actual batch choice). + warmup_threshold: 5_000_000, + }; + if (spec.minSamples !== undefined) { + measureOpts.min_samples = spec.minSamples; + } + if (spec.minCpuTimeMs !== undefined) { + measureOpts.min_cpu_time = spec.minCpuTimeMs * 1e6; + } + const stats = (await measure(spec.fn, measureOpts)) as MitataStats; const t1 = performance.now(); const samples = stats.samples; const meanNs = trimmedMean(samples); diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index 10f139d..7749e7b 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -63,8 +63,8 @@ type Task = { throughputOpsPerSec: number; rmePercent: number; // Present only for files written with schemaVersion >= 2 from a multi-run - // invocation. When present this is the relative stddev across per-run means - // — i.e. the actual run-to-run noise — and should be used as the noise + // invocation. When present, this is the relative stddev across per-run means + // — i.e., the actual run-to-run noise — and should be used as the noise // floor in preference to rmePercent (which is within-run sample spread). crossRunRsdPercent?: number; samples: number; @@ -331,11 +331,15 @@ type Row = { minText: string; heapText: string; gcText: string; + // Combined per-task noise floor used to classify deltas. Empty for new/gone + // rows where one side is missing. Suffixed with "*" when at least one side + // fell back to within-run rmePercent (schemaVersion < 2 or runs == 1). + noiseText: string; }; const rows: Row[] = []; // Track whether any row has heap/gc info so we can skip those columns entirely -// when neither file has them (e.g. comparing against a pre-mitata JSON). +// when neither file has them (e.g., comparing against a pre-mitata JSON). let anyHeap = false; let anyGc = false; @@ -352,6 +356,7 @@ for (const name of [...names].sort()) { minText: "", heapText: "", gcText: "", + noiseText: "", }); continue; } @@ -365,6 +370,7 @@ for (const name of [...names].sort()) { minText: "", heapText: "", gcText: "", + noiseText: "", }); continue; } @@ -373,15 +379,18 @@ for (const name of [...names].sort()) { const minDelta = ((c.minLatencyNs - b.minLatencyNs) / b.minLatencyNs) * 100; // Prefer the cross-run RSD when both files have it (schemaVersion >= 2, // runs > 1). That measures actual between-process variance and is the - // honest noise floor for comparing two separate bench invocations. Within - // -run rmePercent describes sample spread inside a single process; using - // it as a noise floor across processes systematically under-estimates the + // honest noise floor for comparing two separate bench invocations. + // Within-run rmePercent describes sample spread inside a single process; using + // it as a noise floor across processes systematically underestimates the // noise, which is what produced spurious "regress" markers on unchanged // code. Falling back to rmePercent for v1 files keeps old comparisons // working at the cost of accuracy. const bNoise = b.crossRunRsdPercent ?? b.rmePercent ?? 0; const cNoise = c.crossRunRsdPercent ?? c.rmePercent ?? 0; const noiseFloor = bNoise + cNoise; + const noiseFellBack = + b.crossRunRsdPercent === undefined || c.crossRunRsdPercent === undefined; + const noiseText = `${noiseFloor.toFixed(2)}%${noiseFellBack ? "*" : ""}`; const meanV = classify(meanDelta, noiseFloor, args.threshold); const minV = classify(minDelta, noiseFloor, args.threshold); @@ -463,6 +472,7 @@ for (const name of [...names].sort()) { minText, heapText, gcText, + noiseText, }); } @@ -476,6 +486,7 @@ function padVisible(s: string, n: number): string { if (!args.quiet) { const nameW = Math.max(4, ...rows.map((r) => r.name.length)); + const noiseW = 8; const cols = [ `${pad("task", nameW)}`, pad("baseline", 12), @@ -496,6 +507,8 @@ if (!args.quiet) { cols.push(pad("gc Δ", 10)); seps.push("-".repeat(10)); } + cols.push(pad("noise", noiseW)); + seps.push("-".repeat(noiseW)); cols.push("mean Δ"); seps.push("-".repeat(28)); console.log(cols.join(" ")); @@ -507,6 +520,7 @@ if (!args.quiet) { const cells = [pad(r.name, nameW), pad(b, 12), pad(c, 12), minCell]; if (anyHeap) cells.push(padVisible(r.heapText || "—", 10)); if (anyGc) cells.push(padVisible(r.gcText || "—", 10)); + cells.push(padVisible(r.noiseText || "—", noiseW)); cells.push(r.meanText); console.log(cells.join(" ")); } diff --git a/packages/protovalidate-bench/src/suites/registry.ts b/packages/protovalidate-bench/src/suites/registry.ts index dbdaa24..b7fb545 100644 --- a/packages/protovalidate-bench/src/suites/registry.ts +++ b/packages/protovalidate-bench/src/suites/registry.ts @@ -15,10 +15,22 @@ // "once": one GC before measurement starts. Use for fast benches where forcing // a full GC between batches would add more variance than it removes. // "inner": GC between every batch sample. Use when allocation cost is the -// signal under test (e.g. Compile/*) — pays for itself in stability. +// signal under test, or when opportunistic GC inside a batch is responsible +// for a high within-run RSD. export type GcMode = "once" | "inner"; -export interface BenchSpec { +export interface SpecOptions { + // Override mitata's min_samples default (12). Use when a slow per-iter + // bench naturally hits min_samples before min_cpu_time and you want + // tighter within-run stats. + minSamples?: number; + // Override mitata's min_cpu_time default (642ms). The runtime budget for + // the measurement loop; the bench keeps sampling until both + // (samples >= minSamples) and (elapsed >= minCpuTimeMs) are satisfied. + minCpuTimeMs?: number; +} + +export interface BenchSpec extends SpecOptions { name: string; fn: () => void; gc: GcMode; @@ -26,8 +38,13 @@ export interface BenchSpec { const specs: BenchSpec[] = []; -export function registerSpec(name: string, fn: () => void, gc: GcMode): void { - specs.push({ name, fn, gc }); +export function registerSpec( + name: string, + fn: () => void, + gc: GcMode, + options?: SpecOptions, +): void { + specs.push({ name, fn, gc, ...options }); } export function getSpecs(filter?: RegExp): BenchSpec[] { diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index d700c95..2931656 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -20,9 +20,8 @@ import { registerSpec } from "./registry.js"; // directly with the matching Scalar/ComplexSchema validate benches to surface // the cost of the adapter's path→Issue translation and unknown→typed narrowing. // -// gc: "once" — same reasoning as validate.bench.ts: the adapter is a thin -// wrapper around validate(), so the GC strategy should match. - +// Uses gc: "once" to match the corresponding validate-time benches; see the +// note in validate.bench.ts for why gc: "inner" was rejected here. const adapterTargets = ["Scalar", "ComplexSchema"] as const; export function register(): void { diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts index 5a904cc..e8fabe0 100644 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -14,28 +14,51 @@ import { createValidator } from "@bufbuild/protovalidate"; import { cases } from "./cases.js"; -import { registerSpec } from "./registry.js"; +import { registerSpec, type SpecOptions } from "./registry.js"; // Validate-time benches: a single validator is warmed once per case and then // reused across iterations, matching Go's BenchmarkValidate*. The set of // cases lives in cases.ts — add a row there to add a benchmark. // -// gc: "once" — these benches are short-lived and reuse the same validator/ -// fixture, so forcing a full GC between every batch sample (as gc:"inner" -// would) churns the heap layout enough to add more run-to-run variance than -// it removes. Multi-run aggregation in the driver captures the residual -// noise across processes. +// All cases run with gc: "once". gc: "inner" was tried for the alloc-heavy +// cases (ComplexSchema, Int32GT) to tame their ~15% within-run RSD, but it +// shifts the measured mean ~3× upward (it removes opportunistic concurrent +// GC, which is part of real-world cost) and degraded cross-run stability — +// the metric that actually matters for regression detection. The high +// within-run RSD is informational only: the trimmed mean is robust to the +// in-batch GC outliers, and checkbench gates on cross-run RSD, not rmePercent. + +// Slow per-iter benches naturally hit mitata's 12-sample minimum before the +// 642ms time budget runs out, so the within-run stats are computed from very +// few samples. Targeting ~30 samples with a 1.5s budget tightens within-run +// RSD enough that single-process numbers stay informative; cross-run +// aggregation already handles between-process noise. +const slowPerIterOptions: SpecOptions = { + minSamples: 30, + minCpuTimeMs: 1500, +}; +const slowPerIterCases: ReadonlySet = new Set([ + "Repeated/Message", + "TestByteMatching", + "StringMatching", + "WrapperTesting", + "MultiRule/Error", +]); export function register(): void { const validator = createValidator(); for (const c of cases) { validator.validate(c.schema, c.fixture); // warm the planner cache + const options = slowPerIterCases.has(c.name) + ? slowPerIterOptions + : undefined; registerSpec( c.name, () => { validator.validate(c.schema, c.fixture); }, "once", + options, ); } } From cf1c101dac4b327235eaa823e385cc705be038e2 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 28 May 2026 15:16:07 -0400 Subject: [PATCH 24/38] don't include EvalExtendedRulesCel when there are no rules and don't include EvalStandardRulesCel if there are native rules for every field. --- packages/protovalidate/src/planner.ts | 37 +++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 5b28663..96bfcf2 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -454,11 +454,13 @@ export class Planner { forMapKey, wrappedValueField, }); - const evalStandard = new EvalStandardRulesCel( - this.celMan, - rules, - forMapKey, - ); + // Standard CEL plans: enroll every set field whose rule isn't claimed by + // the native dispatcher. When native handles every set rule (e.g., a pure + // numeric/bytes message after the phase-3 port) the CEL evaluator stays + // empty — leave it out of the tree entirely so its per-iteration setEnv() + // calls don't run. The handledFields set from tryBuildNative is what + // tells us which plans the native path took. + let evalStandard: EvalStandardRulesCel | undefined; for (const plan of prepared.standard) { if (!isFieldSet(rules, plan.field)) { continue; @@ -466,16 +468,19 @@ export class Planner { if (native?.handledFields.has(plan.field)) { continue; } + evalStandard ??= new EvalStandardRulesCel(this.celMan, rules, forMapKey); evalStandard.add( plan.compiled, rulePath.clone().field(plan.field).toPath(), ); } - const evalExtended = new EvalExtendedRulesCel( - this.celMan, - rules, - forMapKey, - ); + // Extended CEL plans: only allocate the wrapper when an $unknown + // extension actually contributes a rule. Native handlers bail on rules + // with $unknown fields, so this evaluator is mutually exclusive with + // the native path — but constructing it eagerly costs ~13-34% per + // validate across the suite because EvalExtendedRulesCel.eval() still + // runs two setEnv calls per field even when empty. + let evalExtended: EvalExtendedRulesCel | undefined; if (rules.$unknown) { for (const uf of rules.$unknown) { const plans = prepared.extensions.get(uf.no); @@ -485,6 +490,11 @@ export class Planner { ); } for (const plan of plans) { + evalExtended ??= new EvalExtendedRulesCel( + this.celMan, + rules, + forMapKey, + ); evalExtended.add( plan.compiled, rulePath.clone().extension(plan.ext).toPath(), @@ -494,10 +504,9 @@ export class Planner { } } } - const combined = new EvalMany( - evalStandard, - evalExtended, - ); + const combined = new EvalMany(); + if (evalStandard !== undefined) combined.add(evalStandard); + if (evalExtended !== undefined) combined.add(evalExtended); if (native !== undefined) { combined.add(native.eval); } From b29b0adf737264657f7288b12bfa971efe53a660 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 29 May 2026 18:48:10 -0400 Subject: [PATCH 25/38] add --metric flag and drop min from regression gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bench.ts accepts --metric cpu|memory|both (default both). cpu skips the heap probe to save ~10-20% per run. The chosen metric is recorded in the JSON output and forwarded to spawned worker processes. checkbench.ts accepts --metric cpu|memory|both (default both) to control which signals can trigger REGRESS markers. cpu gates on mean latency only, memory on heap only, both on mean+heap. Files produced with one metric mode but compared under another emit a warning. Min latency is no longer a regression gate — it remains in the table as an informational signal but never fails the run. Min is too sensitive to JIT warmth and per-process scheduling to be a reliable gate; anything genuinely regressed shows up in mean as well. --- packages/protovalidate-bench/README.md | 25 +++--- packages/protovalidate-bench/src/bench.ts | 64 ++++++++++++--- .../protovalidate-bench/src/checkbench.ts | 79 ++++++++++++++++--- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index 88ea464..743c023 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -37,11 +37,13 @@ The runner prints a table of results and writes a JSON file to `.tmp/bench/` | `--filter ` | _(none)_ | Only run tasks whose name contains `` | | `--out ` | `.tmp/bench` | Output directory for JSON results | | `--runs ` | `5` | Number of fresh Node processes to spawn and aggregate. `--runs 1` runs inline. | +| `--metric ` | `both` | What to measure: `cpu` skips the heap probe (~10-20% faster per run); `memory` and `both` collect heap stats. | A 5-run pass over the full suite takes a few minutes. For quick iteration, drop to `--runs 1` and accept the wider noise floor, or combine `--runs 1 --filter ` to only re-measure the tasks you're -changing. +changing. `--metric cpu` is the right pick when you're iterating on a CPU +optimization and don't care about allocation deltas. Example: @@ -153,21 +155,22 @@ noise floor of the two files: the sum of each side's `crossRunRsdPercent` (when present) or `rmePercent` (fallback for schemaVersion-1 files, which overstates real signal). -The tool gates on three signals: **mean latency, min latency, and heap -allocation per iteration**. A task fails if any of these deltas exceeds -`--threshold` and falls outside the noise floor. +The tool gates on two signals: **mean latency** and **heap allocation per +iteration**. A task fails if either delta exceeds `--threshold` and falls +outside the noise floor. - **Mean** catches the typical-case slowdown. -- **Min** is the raw fastest sample across all runs — sensitive to JIT - warmth and immune to GC pauses. With multi-run aggregation the min is - taken across every process's samples, so it's also stable against single-process - JIT variance. - **Heap Δ** is bytes allocated per iteration (via `node:v8` `getHeapStatistics()`). Catches allocation regressions even when wall-clock time is flat — those still hurt in production because they amplify GC pressure. The heap signal is mostly deterministic for short benches; for long-running alloc-heavy benches (`Compile/*`) it can drift with GC scheduling, which the noise floor absorbs. +- **Min Δ** is the raw fastest sample across all runs — sensitive to JIT + warmth and immune to GC pauses. **Informational only** — shown in the + table to help diagnose unexpected mean shifts, but never gates a + regression because it's too sensitive to per-process JIT variance to be + reliable on its own; anything genuinely regressed shows up in mean. - **GC Δ** is informational only (no gating) and only appears when both runs were produced with `--expose-gc` so mitata can observe gc time. @@ -177,10 +180,14 @@ allocation per iteration**. A task fails if any of these deltas exceeds |---------------------|--------------|--------------------------------------------------------------| | `--threshold ` | `5` | Regression bar. Slowdowns above this AND outside noise fail. | | `--dir ` | `.tmp/bench` | Directory the `latest` / `previous` shortcuts look in. | +| `--metric ` | `both` | Which signals gate a regression: `cpu` (mean latency only), `memory` (heap only), or `both` (mean+heap). Non-gated signals still appear in the table — they just can't fail the run. | | `--quiet`, `-q` | _(off)_ | Print summary line only. | Pass a larger `--threshold` if you're working on noisier hardware or want to allow -small regressions through. +small regressions through. The bench-side `--metric` (what gets measured) and +the checkbench-side `--metric` (what gates) are independent — a file produced +with `--metric cpu` has no heap data, so `checkbench --metric memory` against +it has nothing to gate on and warns. The script exits **2** for bad parameter values (invalid threshold, directory, or files), **1** if any task regresses past `--threshold`, otherwise **0** — drop it into a pre-commit hook or CI step to gate PRs on performance. diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index 1034720..6690951 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -28,11 +28,25 @@ import { register as registerValidate } from "./suites/validate.bench.js"; const SCHEMA_VERSION = 2; const DEFAULT_RUNS = 5; +// What's being measured. "cpu" skips the heap probe entirely (no +// getHeapStatistics() per sample) — about 10-20% faster per run. "memory" +// and "both" are identical on the bench side since mitata always measures +// time; the distinction only matters in checkbench, where it controls what +// gates a regression. +export type Metric = "cpu" | "memory" | "both"; + interface CliOptions { filter: string | undefined; outDir: string; runs: number; worker: boolean; + metric: Metric; +} + +function parseMetric(raw: string | undefined): Metric { + if (raw === "cpu" || raw === "memory" || raw === "both") return raw; + console.error(`--metric must be cpu|memory|both: ${raw}`); + process.exit(2); } function parseArgs(argv: readonly string[]): CliOptions { @@ -40,6 +54,7 @@ function parseArgs(argv: readonly string[]): CliOptions { let outDir = ".tmp/bench"; let runs = DEFAULT_RUNS; let worker = false; + let metric: Metric = "both"; for (let i = 0; i < argv.length; i++) { const a = argv[i]; switch (a) { @@ -59,6 +74,9 @@ function parseArgs(argv: readonly string[]): CliOptions { runs = n; break; } + case "--metric": + metric = parseMetric(argv[++i]); + break; case "--worker": // Internal: marks this process as a child worker. The coordinator // spawns one Node process per run with this flag and reads the @@ -76,7 +94,7 @@ function parseArgs(argv: readonly string[]): CliOptions { } } } - return { filter, outDir, runs, worker }; + return { filter, outDir, runs, worker, metric }; } function printUsage(): void { @@ -89,6 +107,10 @@ function printUsage(): void { " --out Output directory for JSON results (default: .tmp/bench)", ` --runs Run N independent Node processes and aggregate (default: ${DEFAULT_RUNS}).`, " N=1 runs inline with no aggregation.", + " --metric Which signals to measure: cpu|memory|both (default both).", + " cpu skips the heap probe and runs ~10-20% faster.", + " memory and both are identical on the bench side; they", + " differ only in checkbench's regression gating.", "", ].join("\n"), ); @@ -130,6 +152,9 @@ interface WorkerPayload { node: string; platform: string; timestamp: string; + // Recorded so checkbench knows whether heap stats are absent because they + // weren't measured (--metric cpu) vs because mitata couldn't observe them. + metric: Metric; tasks: WorkerTask[]; } @@ -158,6 +183,7 @@ interface AggregatedPayload { platform: string; timestamp: string; runs: number; + metric: Metric; tasks: AggregatedTask[]; } @@ -232,6 +258,7 @@ async function makeHeapFn(): Promise<(() => number) | undefined> { async function collectWorkerTasks( filterRe: RegExp | undefined, + metric: Metric, ): Promise { registerValidate(); registerCompile(); @@ -243,7 +270,9 @@ async function collectWorkerTasks( process.exit(2); } - const heapFn = await makeHeapFn(); + // metric=cpu skips the heap probe — no getHeapStatistics() per sample. + // memory and both keep it. + const heapFn = metric === "cpu" ? undefined : await makeHeapFn(); const tasks: WorkerTask[] = []; for (const spec of specs) { process.stderr.write(` ${spec.name} ...`); @@ -301,13 +330,17 @@ async function collectWorkerTasks( return tasks; } -async function runWorker(filterRe: RegExp | undefined): Promise { - const tasks = await collectWorkerTasks(filterRe); +async function runWorker( + filterRe: RegExp | undefined, + metric: Metric, +): Promise { + const tasks = await collectWorkerTasks(filterRe, metric); const payload: WorkerPayload = { schemaVersion: SCHEMA_VERSION, node: process.version, platform: `${process.platform}/${process.arch}`, timestamp: new Date().toISOString(), + metric, tasks, }; process.stdout.write(`${JSON.stringify(payload)}\n`); @@ -315,11 +348,20 @@ async function runWorker(filterRe: RegExp | undefined): Promise { // ---- Coordinator (multi-process aggregation) ---- -function spawnWorker(filter: string | undefined): Promise { +function spawnWorker( + filter: string | undefined, + metric: Metric, +): Promise { // Re-invoke the same script in a fresh Node process. process.execArgv // carries the original flags (--expose-gc, any tsx loader hooks), so the // child runs in the same environment as the parent. - const args = [...process.execArgv, process.argv[1], "--worker"]; + const args = [ + ...process.execArgv, + process.argv[1], + "--worker", + "--metric", + metric, + ]; if (filter !== undefined) args.push("--filter", filter); return new Promise((resolve, reject) => { @@ -427,6 +469,7 @@ function aggregate(workers: WorkerPayload[]): AggregatedPayload { platform: first.platform, timestamp: new Date().toISOString(), runs: workers.length, + metric: first.metric, tasks: aggTasks, }; } @@ -459,17 +502,18 @@ async function runCoordinator(opts: CliOptions): Promise { console.log(`# protovalidate-es bench`); console.log(`# node ${process.version} ${process.platform}/${process.arch}`); - console.log(`# runs ${opts.runs}`); + console.log(`# runs ${opts.runs} metric ${opts.metric}`); let payload: AggregatedPayload; if (opts.runs === 1) { - const tasks = await collectWorkerTasks(filterRe); + const tasks = await collectWorkerTasks(filterRe, opts.metric); payload = aggregate([ { schemaVersion: SCHEMA_VERSION, node: process.version, platform: `${process.platform}/${process.arch}`, timestamp: new Date().toISOString(), + metric: opts.metric, tasks, }, ]); @@ -477,7 +521,7 @@ async function runCoordinator(opts: CliOptions): Promise { const workers: WorkerPayload[] = []; for (let i = 0; i < opts.runs; i++) { console.log(`run ${i + 1}/${opts.runs} ...`); - workers.push(await spawnWorker(opts.filter)); + workers.push(await spawnWorker(opts.filter, opts.metric)); } payload = aggregate(workers); } @@ -503,7 +547,7 @@ if (opts.worker) { opts.filter !== undefined ? new RegExp(escapeRegExp(opts.filter)) : undefined; - await runWorker(filterRe); + await runWorker(filterRe, opts.metric); } else { await runCoordinator(opts); } diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts index 7749e7b..ddcd8d9 100755 --- a/packages/protovalidate-bench/src/checkbench.ts +++ b/packages/protovalidate-bench/src/checkbench.ts @@ -21,6 +21,13 @@ import { parseArgs } from "node:util"; const BENCH_DIR = ".tmp/bench"; const DEFAULT_THRESHOLD = 5; +// Which signal categories gate a regression. "cpu" only gates on mean +// latency. "memory" only gates on heap allocation. "both" gates on either. +// Min latency is shown but never gates (too sensitive to JIT warmth/ +// scheduling jitter; mean catches anything genuinely regressed). +type Metric = "cpu" | "memory" | "both"; +const DEFAULT_METRIC: Metric = "both"; + function usage() { process.stdout.write( [ @@ -33,6 +40,9 @@ function usage() { "Options:", " --threshold regression threshold percent (default: 5)", " --dir bench results directory (default: .tmp/bench)", + " --metric which signals gate a regression: cpu|memory|both (default both)", + " cpu: mean latency only; memory: heap only; both: mean+heap", + " (min latency is always shown but never gates)", " --quiet, -q only print summary line", " --help, -h show this help and exit", "", @@ -50,6 +60,9 @@ type FileInfo = { path: string; runs: number; schemaVersion: number; + // What the bench was told to measure. Default "both" for files written + // before --metric existed. + metric: Metric; }; byName: Map; }; @@ -91,6 +104,10 @@ function load(path: string): FileInfo { runs: typeof data.runs === "number" ? data.runs : 1, schemaVersion: typeof data.schemaVersion === "number" ? data.schemaVersion : 1, + metric: + data.metric === "cpu" || data.metric === "memory" + ? data.metric + : "both", }, byName, }; @@ -159,6 +176,7 @@ function getSecondNewestFile(dir: string): string { type ParsedValues = { threshold?: string; dir?: string; + metric?: string; quiet?: boolean; help?: boolean; }; @@ -190,7 +208,16 @@ function buildArgs(values: ParsedValues) { } threshold = n; } - return { threshold, dir, quiet: values.quiet ?? false }; + let metric: Metric = DEFAULT_METRIC; + if (values.metric !== undefined) { + const raw = values.metric.trim(); + if (raw !== "cpu" && raw !== "memory" && raw !== "both") { + console.error(`--metric must be cpu|memory|both: ${values.metric}`); + process.exit(2); + } + metric = raw; + } + return { threshold, dir, metric, quiet: values.quiet ?? false }; } const options = { @@ -200,6 +227,9 @@ const options = { dir: { type: "string", }, + metric: { + type: "string", + }, quiet: { type: "boolean", short: "q", @@ -245,12 +275,15 @@ const current = load(currentPath); console.log(`baseline: ${baseline.meta.path}`); console.log( - ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform} runs=${baseline.meta.runs}`, + ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform} runs=${baseline.meta.runs} metric=${baseline.meta.metric}`, ); console.log(`current: ${current.meta.path}`); console.log( - ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform} runs=${current.meta.runs}`, + ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform} runs=${current.meta.runs} metric=${current.meta.metric}`, ); +if (args.metric !== DEFAULT_METRIC) { + console.log(`gating: --metric ${args.metric}`); +} console.log(""); if (baseline.meta.platform !== current.meta.platform) { @@ -285,6 +318,25 @@ if (baseline.meta.schemaVersion < 2 || current.meta.schemaVersion < 2) { ), ); } +if (baseline.meta.metric !== current.meta.metric) { + console.log( + color( + `! recorded metric differs (${baseline.meta.metric} vs ${current.meta.metric}) — one side may be missing heap data`, + "33", + ), + ); +} +if ( + args.metric === "memory" && + (baseline.meta.metric === "cpu" || current.meta.metric === "cpu") +) { + console.log( + color( + `! --metric memory requested but one file was produced with --metric cpu (no heap data); nothing to gate on`, + "33", + ), + ); +} const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); let regressions = 0; @@ -429,14 +481,23 @@ for (const name of [...names].sort()) { } } + // --metric controls which signals can trigger REGRESS/faster markers. + // Non-gated signals still appear in the table (with their colored delta); + // they just can't fail the run. cpu → mean only, memory → heap only, + // both → mean+heap. + // + // The min column is informational. Even though it's a real signal — the + // fastest sample observed across all runs — it's too sensitive to JIT + // warmth and scheduling jitter to be a reliable gate on its own, and + // anything genuinely worth flagging will also show up in mean. + const gateCpu = args.metric === "cpu" || args.metric === "both"; + const gateMem = args.metric === "memory" || args.metric === "both"; const tags: string[] = []; - if (meanV === "regress") tags.push("mean"); - if (minV === "regress") tags.push("min"); - if (heapV === "regress") tags.push("heap"); + if (gateCpu && meanV === "regress") tags.push("mean"); + if (gateMem && heapV === "regress") tags.push("heap"); const fasterTags: string[] = []; - if (meanV === "improve") fasterTags.push("mean"); - if (minV === "improve") fasterTags.push("min"); - if (heapV === "improve") fasterTags.push("heap"); + if (gateCpu && meanV === "improve") fasterTags.push("mean"); + if (gateMem && heapV === "improve") fasterTags.push("heap"); let kind: Row["kind"] = "ok"; let meanText = fmtDelta(meanDelta, meanV); From 58ae4d2c14a9ba0f3028f7a659aa72ac8bf25def Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 2 Jun 2026 11:24:04 -0400 Subject: [PATCH 26/38] define simpler benchmarking and checkbench --- package-lock.json | 12 +- packages/protovalidate-bench/package.json | 7 +- .../src/{suites => }/cases.ts | 6 +- packages/protovalidate-bench/src/new_bench.ts | 139 ++++++++ .../protovalidate-bench/src/new_checkbench.ts | 323 ++++++++++++++++++ .../src/suites/compile.bench.ts | 2 +- .../src/suites/standard-schema.bench.ts | 2 +- .../src/suites/validate.bench.ts | 2 +- 8 files changed, 483 insertions(+), 10 deletions(-) rename packages/protovalidate-bench/src/{suites => }/cases.ts (97%) create mode 100644 packages/protovalidate-bench/src/new_bench.ts create mode 100644 packages/protovalidate-bench/src/new_checkbench.ts diff --git a/package-lock.json b/package-lock.json index d706323..89a602c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1711,6 +1711,15 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.2.tgz", + "integrity": "sha512-FlHoQpcFvCzeXK5kVPvV7IVgW/hs/B36QWTz876iSdeJguBDfdTSRQmYmaHX+fQNt4hp+gEFB2XXw+8hT4/y8A==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1885,7 +1894,8 @@ "@bufbuild/protobuf": "^2.11.0", "@bufbuild/protovalidate": "^1.2.0", "@mitata/counters": "^0.0.8", - "mitata": "^1.0.34" + "mitata": "^1.0.34", + "tinybench": "^6.0.2" }, "devDependencies": { "@bufbuild/buf": "^1.62.1", diff --git a/packages/protovalidate-bench/package.json b/packages/protovalidate-bench/package.json index fb0fa5c..ed5f4da 100644 --- a/packages/protovalidate-bench/package.json +++ b/packages/protovalidate-bench/package.json @@ -6,8 +6,8 @@ "scripts": { "generate": "buf generate", "postgenerate": "license-header src/gen", - "bench": "tsx --expose-gc src/bench.ts", - "checkbench": "tsx src/checkbench.ts", + "bench": "tsx --expose-gc src/new_bench.ts", + "checkbench": "tsx src/new_checkbench.ts", "format": "biome format --write", "lint": "biome lint --error-on-warnings && buf lint", "license-header": "license-header" @@ -18,7 +18,8 @@ "@bufbuild/protobuf": "^2.11.0", "@bufbuild/protovalidate": "^1.2.0", "@mitata/counters": "^0.0.8", - "mitata": "^1.0.34" + "mitata": "^1.0.34", + "tinybench": "^6.0.2" }, "devDependencies": { "@bufbuild/buf": "^1.62.1", diff --git a/packages/protovalidate-bench/src/suites/cases.ts b/packages/protovalidate-bench/src/cases.ts similarity index 97% rename from packages/protovalidate-bench/src/suites/cases.ts rename to packages/protovalidate-bench/src/cases.ts index 830bdc9..426a567 100644 --- a/packages/protovalidate-bench/src/suites/cases.ts +++ b/packages/protovalidate-bench/src/cases.ts @@ -21,14 +21,14 @@ import { BenchRepeatedScalarSchema, BenchRepeatedScalarUniqueSchema, BenchScalarSchema, -} from "../gen/bench/v1/bench_pb.js"; +} from "./gen/bench/v1/bench_pb.js"; import { BenchGTSchema, MultiRuleSchema, StringMatchingSchema, TestByteMatchingSchema, WrapperTestingSchema, -} from "../gen/bench/v1/native_pb.js"; +} from "./gen/bench/v1/native_pb.js"; import { benchComplexSchema, benchGT, @@ -43,7 +43,7 @@ import { stringMatching, testByteMatching, wrapperTesting, -} from "../fixtures.js"; +} from "./fixtures.js"; /** * One bench case: a schema, a fixture, and the name to record under. diff --git a/packages/protovalidate-bench/src/new_bench.ts b/packages/protovalidate-bench/src/new_bench.ts new file mode 100644 index 0000000..0ba3131 --- /dev/null +++ b/packages/protovalidate-bench/src/new_bench.ts @@ -0,0 +1,139 @@ +// Copyright 2021-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Bench} from "tinybench"; +import * as console from "node:console"; +import type {DescMessage, Message} from "@bufbuild/protobuf"; +import { createValidator } from "@bufbuild/protovalidate"; +import {cases} from "./cases.js"; +import {writeFileSync} from "node:fs"; + +/* eslint-disable no-console, import/no-named-as-default-member */ + +const outPath = ".tmp/bench"; + +async function main(args: string[]): Promise { + function filterTests(regexp: string): Test[] { + const tests = setupTests(); + const re = new RegExp(regexp); + return tests.filter((test) => re.test(test.name)); + } + switch (args.shift()) { + case "list": + if (args.length > 1) { + exitUsage(1); + break; + } + for (const test of filterTests(args.length == 1 ? args[0] : ".*")) { + console.log(test.name); + } + break; + case "benchmark": + if (args.length > 1) { + exitUsage(1); + break; + } + await bench(filterTests(args.length == 1 ? args[0] : ".*")); + break; + case "run": { + if (args.length > 1) { + exitUsage(1); + break; + } + const tests = filterTests(args.length == 1 ? args[0] : ".*"); + run(tests); + break; + } + default: + exitUsage(1); + } + + function exitUsage(exitCode = 0) { + const out = exitCode === 0 ? process.stdout : process.stderr; + out.write( + [ + `USAGE: ${process.argv[1]} [list|benchmark|run] [regex] [iteration]`, + ``, + `benchmark '.*'`, + `Run tests with the npm package "tinybench", and print results to standard out.`, + ``, + `run '.*'`, + `Run each test.`, + ``, + `list '.*':`, + `List tests.`, + ``, + ].join("\n"), + () => process.exit(exitCode), + ); + } +} + +interface Test { + name: string; + schema: DescMessage; + fixture: Message; +} + +function setupTests(): Test[] { + const tests: Test[] = []; + tests.push(...cases); + return tests; +} +/** + * Run given tests consecutively. + */ +function run(tests: Test[]): void { + const validator = createValidator(); + for (const test of tests) { + console.log(`Running "${test.name}"`); + validator.validate(test.schema, test.fixture); + } +} + +/** + * Benchmark tests with the npm package "tinybench". Results are printed to + * standard out. + */ +async function bench(tests: Test[]): Promise { + const bench = new Bench({name: 'protovalidate benchmarks', time: 100}) + const validator = createValidator(); + + for (const test of tests) { + bench.add(test.name, ()=> { + validator.validate(test.schema, test.fixture); + }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + await bench.run() + + const payload = { + timestamp: timestamp, + node: process.version, + platform: `${process.platform}/${process.arch}`, + tasks: bench.tasks.map((t) => ({ + name: t.name, + // t.result is undefined if the task errored + result: t.result, + })), + }; + writeFileSync(`${outPath}/${timestamp}.json`, JSON.stringify(payload, null, 2)); + + console.log(bench.name) + console.table(bench.table()) +} + +await main(process.argv.slice(2)); diff --git a/packages/protovalidate-bench/src/new_checkbench.ts b/packages/protovalidate-bench/src/new_checkbench.ts new file mode 100644 index 0000000..e865b94 --- /dev/null +++ b/packages/protovalidate-bench/src/new_checkbench.ts @@ -0,0 +1,323 @@ +#!/usr/bin/env node + +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +const BENCH_DIR = ".tmp/bench"; + +function usage() { + process.stdout.write( + [ + "Usage: tsx src/new_checkbench.ts [baseline] [current] [options]", + "", + "Arguments are paths to JSON files relative to the benchmark directory (default: .tmp/bench/).", + "If no arguments are given, the two most recent files are used with the older file as baseline.", + "If one argument is given, it is used as the baseline and the most recent file is used as current.", + "If two arguments are given, the first is the baseline and the second is the current.", + "", + "Options:", + " --dir bench results directory (default: .tmp/bench)", + " --help, -h show this help and exit", + "", + ].join("\n"), + ); +} + +// Shape of each task in a tinybench-produced JSON file. Only the latency +// fields we read are required; the rest of result.* is ignored. +type TinybenchTask = { + name: string; + result?: { + latency?: { + mean: number; + p50: number; + }; + }; +}; + +type FileInfo = { + path: string; + timestamp: string; + node: string; + platform: string; + byName: Map; +}; + +function load(path: string): FileInfo { + const data = JSON.parse(readFileSync(path, "utf-8")); + const byName = new Map(); + for (const task of data.tasks ?? []) { + byName.set(task.name, task); + } + return { + path, + timestamp: data.timestamp ?? "", + node: data.node ?? "", + platform: data.platform ?? "", + byName, + }; +} + +function getFile(dir: string, arg: string): string { + const path = resolve(dir, arg); + try { + if (!statSync(path).isFile()) { + console.error(`not a file: ${path}`); + process.exit(2); + } + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + console.error(`file does not exist: ${path}`); + process.exit(2); + } + throw err; + } + return path; +} + +type DirEntry = { f: string; mtime: number }; + +function getSortedDirEntries(dir: string): DirEntry[] { + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); +} + +function getNewestFile(dir: string): string { + const entries = getSortedDirEntries(dir); + if (entries.length === 0) { + console.error(`no JSON files in ${dir}`); + process.exit(2); + } + return getFile(dir, entries[0].f); +} + +function getSecondNewestFile(dir: string): string { + const entries = getSortedDirEntries(dir); + if (entries.length < 2) { + console.error(`not enough JSON files in ${dir} to resolve previous file`); + process.exit(2); + } + return getFile(dir, entries[1].f); +} + +// tinybench reports latency in milliseconds. Convert to ns once at the +// boundary so the rest of the code (and fmtNs) works in a single unit. +function msToNs(ms: number): number { + return ms * 1e6; +} + +function fmtNs(n: number): string { + const abs = Math.abs(n); + if (abs < 1000) return `${n.toFixed(0)} ns`; + if (abs < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; + return `${(n / 1_000_000).toFixed(2)} ms`; +} + +function fmtSignedNs(n: number): string { + const s = fmtNs(n); + return n >= 0 && !s.startsWith("-") ? `+${s}` : s; +} + +function fmtPct(pct: number): string { + if (!Number.isFinite(pct)) return pct > 0 ? "+∞%" : "-∞%"; + return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`; +} + +function pad(s: string, n: number): string { + return String(s).padEnd(n); +} + +type ParsedValues = { + dir?: string; + help?: boolean; +}; + +function buildArgs(values: ParsedValues) { + const dir = values.dir ?? BENCH_DIR; + try { + if (!statSync(dir).isDirectory()) { + console.error(`--dir is not a directory: ${dir}`); + process.exit(2); + } + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + console.error(`--dir does not exist: ${dir}`); + process.exit(2); + } + throw err; + } + return { dir }; +} + +const options = { + dir: { + type: "string", + }, + help: { + type: "boolean", + short: "h", + }, +} as const; +const { values, positionals } = parseArgs({ + options, + allowPositionals: true, +}); +if (values.help) { + usage(); + process.exit(0); +} +if (positionals.length > 2) { + usage(); + process.exit(2); +} + +const args = buildArgs(values); + +const baselinePath = + positionals.length > 0 + ? getFile(args.dir, positionals[0]) + : getSecondNewestFile(args.dir); +const currentPath = + positionals.length === 2 + ? getFile(args.dir, positionals[1]) + : getNewestFile(args.dir); + +if (baselinePath === currentPath) { + console.error( + `baseline and current resolve to the same file: ${baselinePath}`, + ); + process.exit(2); +} + +const baseline = load(baselinePath); +const current = load(currentPath); + +console.log(`baseline: ${baseline.path}`); +console.log(` ${baseline.timestamp} node ${baseline.node} ${baseline.platform}`); +console.log(`current: ${current.path}`); +console.log(` ${current.timestamp} node ${current.node} ${current.platform}`); +console.log(""); + +// Render one row per task name present in either file. Tasks that errored +// (no result.latency) are reported as "—" cells so the row layout stays +// consistent. +type Row = { + name: string; + baseMean: string; + curMean: string; + meanDeltaNs: string; + meanDeltaPct: string; + baseP50: string; + curP50: string; + p50DeltaNs: string; + p50DeltaPct: string; +}; + +function deltaCells( + baseMs: number | undefined, + curMs: number | undefined, +): { base: string; cur: string; deltaNs: string; deltaPct: string } { + if (baseMs === undefined || curMs === undefined) { + return { + base: baseMs === undefined ? "—" : fmtNs(msToNs(baseMs)), + cur: curMs === undefined ? "—" : fmtNs(msToNs(curMs)), + deltaNs: "—", + deltaPct: "—", + }; + } + const baseNs = msToNs(baseMs); + const curNs = msToNs(curMs); + const deltaNs = curNs - baseNs; + const deltaPct = baseNs === 0 ? Number.POSITIVE_INFINITY : (deltaNs / baseNs) * 100; + return { + base: fmtNs(baseNs), + cur: fmtNs(curNs), + deltaNs: fmtSignedNs(deltaNs), + deltaPct: fmtPct(deltaPct), + }; +} + +const rows: Row[] = []; +const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); +for (const name of [...names].sort()) { + const b = baseline.byName.get(name); + const c = current.byName.get(name); + const meanCells = deltaCells(b?.result?.latency?.mean, c?.result?.latency?.mean); + const p50Cells = deltaCells(b?.result?.latency?.p50, c?.result?.latency?.p50); + rows.push({ + name, + baseMean: meanCells.base, + curMean: meanCells.cur, + meanDeltaNs: meanCells.deltaNs, + meanDeltaPct: meanCells.deltaPct, + baseP50: p50Cells.base, + curP50: p50Cells.cur, + p50DeltaNs: p50Cells.deltaNs, + p50DeltaPct: p50Cells.deltaPct, + }); +} + +const nameW = Math.max(4, ...rows.map((r) => r.name.length)); +const cellW = 12; +const deltaW = 12; +const pctW = 9; +console.log( + [ + pad("task", nameW), + pad("base mean", cellW), + pad("cur mean", cellW), + pad("mean Δ", deltaW), + pad("mean %", pctW), + pad("base p50", cellW), + pad("cur p50", cellW), + pad("p50 Δ", deltaW), + pad("p50 %", pctW), + ].join(" "), +); +console.log( + [ + "-".repeat(nameW), + "-".repeat(cellW), + "-".repeat(cellW), + "-".repeat(deltaW), + "-".repeat(pctW), + "-".repeat(cellW), + "-".repeat(cellW), + "-".repeat(deltaW), + "-".repeat(pctW), + ].join(" "), +); +for (const r of rows) { + console.log( + [ + pad(r.name, nameW), + pad(r.baseMean, cellW), + pad(r.curMean, cellW), + pad(r.meanDeltaNs, deltaW), + pad(r.meanDeltaPct, pctW), + pad(r.baseP50, cellW), + pad(r.curP50, cellW), + pad(r.p50DeltaNs, deltaW), + pad(r.p50DeltaPct, pctW), + ].join(" "), + ); +} \ No newline at end of file diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts index 7f11181..c78b7af 100644 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ b/packages/protovalidate-bench/src/suites/compile.bench.ts @@ -13,7 +13,7 @@ // limitations under the License. import { createValidator } from "@bufbuild/protovalidate"; -import { caseByName } from "./cases.js"; +import { caseByName } from "../cases.js"; import { registerSpec } from "./registry.js"; // Compile-time benchmarks: build a fresh validator on each iteration and run diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts index 2931656..9da93ab 100644 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts @@ -13,7 +13,7 @@ // limitations under the License. import { createStandardSchema } from "@bufbuild/protovalidate"; -import { caseByName } from "./cases.js"; +import { caseByName } from "../cases.js"; import { registerSpec } from "./registry.js"; // Standard Schema adapter overhead — TS-only surface, no Go analogue. Compares diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts index e8fabe0..e2d9524 100644 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ b/packages/protovalidate-bench/src/suites/validate.bench.ts @@ -13,7 +13,7 @@ // limitations under the License. import { createValidator } from "@bufbuild/protovalidate"; -import { cases } from "./cases.js"; +import { cases } from "../cases.js"; import { registerSpec, type SpecOptions } from "./registry.js"; // Validate-time benches: a single validator is warmed once per case and then From 48eb3b30d752ce38c8d6e360e83789e5433f1688 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 28 May 2026 15:16:07 -0400 Subject: [PATCH 27/38] don't include EvalExtendedRulesCel when there are no rules and don't include EvalStandardRulesCel if there are native rules for every field. --- packages/protovalidate/src/planner.ts | 37 +++++++++++++++++---------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index fc9d9db..ff57720 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -459,11 +459,13 @@ export class Planner { listField, regexMatch: this.regexMatch, }); - const evalStandard = new EvalStandardRulesCel( - this.celMan, - rules, - forMapKey, - ); + // Standard CEL plans: enroll every set field whose rule isn't claimed by + // the native dispatcher. When native handles every set rule (e.g., a pure + // numeric/bytes message after the phase-3 port) the CEL evaluator stays + // empty — leave it out of the tree entirely so its per-iteration setEnv() + // calls don't run. The handledFields set from tryBuildNative is what + // tells us which plans the native path took. + let evalStandard: EvalStandardRulesCel | undefined; for (const plan of prepared.standard) { if (!isFieldSet(rules, plan.field)) { continue; @@ -471,16 +473,19 @@ export class Planner { if (native?.handledFields.has(plan.field)) { continue; } + evalStandard ??= new EvalStandardRulesCel(this.celMan, rules, forMapKey); evalStandard.add( plan.compiled, rulePath.clone().field(plan.field).toPath(), ); } - const evalExtended = new EvalExtendedRulesCel( - this.celMan, - rules, - forMapKey, - ); + // Extended CEL plans: only allocate the wrapper when an $unknown + // extension actually contributes a rule. Native handlers bail on rules + // with $unknown fields, so this evaluator is mutually exclusive with + // the native path — but constructing it eagerly costs ~13-34% per + // validate across the suite because EvalExtendedRulesCel.eval() still + // runs two setEnv calls per field even when empty. + let evalExtended: EvalExtendedRulesCel | undefined; if (rules.$unknown) { for (const uf of rules.$unknown) { const plans = prepared.extensions.get(uf.no); @@ -490,6 +495,11 @@ export class Planner { ); } for (const plan of plans) { + evalExtended ??= new EvalExtendedRulesCel( + this.celMan, + rules, + forMapKey, + ); evalExtended.add( plan.compiled, rulePath.clone().extension(plan.ext).toPath(), @@ -499,10 +509,9 @@ export class Planner { } } } - const combined = new EvalMany( - evalStandard, - evalExtended, - ); + const combined = new EvalMany(); + if (evalStandard !== undefined) combined.add(evalStandard); + if (evalExtended !== undefined) combined.add(evalExtended); if (native !== undefined) { combined.add(native.eval); } From 4829305f8a240867b5bd9bfae51df4dfa9cd26c6 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 5 Jun 2026 16:09:45 -0400 Subject: [PATCH 28/38] remove obsolete benchmark code --- .../protovalidate-bench/scripts/checkbench.js | 259 ------------------ .../protovalidate-bench/src/suites/cases.ts | 130 --------- .../src/suites/compile.bench.ts | 33 --- .../src/suites/standard-schema.bench.ts | 34 --- .../src/suites/validate.bench.ts | 31 --- 5 files changed, 487 deletions(-) delete mode 100755 packages/protovalidate-bench/scripts/checkbench.js delete mode 100644 packages/protovalidate-bench/src/suites/cases.ts delete mode 100644 packages/protovalidate-bench/src/suites/compile.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/standard-schema.bench.ts delete mode 100644 packages/protovalidate-bench/src/suites/validate.bench.ts diff --git a/packages/protovalidate-bench/scripts/checkbench.js b/packages/protovalidate-bench/scripts/checkbench.js deleted file mode 100755 index c1cb840..0000000 --- a/packages/protovalidate-bench/scripts/checkbench.js +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env node - -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Compare two bench JSON files written by src/bench.ts. -// -// Usage: -// node scripts/checkbench.js [--threshold 5] -// -// "latest" / "previous" shortcuts pick the most recent files in .tmp/bench/: -// node scripts/checkbench.js latest -// node scripts/checkbench.js previous latest -// -// Exits non-zero if any task regresses by more than --threshold percent -// (default 5%). A regression is defined as a slower mean latency where the -// delta exceeds both the threshold AND the combined RME of the two samples -// (so we don't flag noise as a regression). - -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; - -const BENCH_DIR = ".tmp/bench"; -const DEFAULT_THRESHOLD = 5; - -function parseArgs(argv) { - const positional = []; - let threshold = DEFAULT_THRESHOLD; - let dir = BENCH_DIR; - let quiet = false; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === "--threshold") { - threshold = Number(argv[++i]); - } else if (a === "--dir") { - dir = argv[++i]; - } else if (a === "--quiet" || a === "-q") { - quiet = true; - } else if (a === "-h" || a === "--help") { - usage(); - process.exit(0); - } else if (a.startsWith("--")) { - console.error(`unknown flag: ${a}`); - process.exit(2); - } else { - positional.push(a); - } - } - return { positional, threshold, dir, quiet }; -} - -function usage() { - process.stdout.write( - [ - "Usage: node scripts/checkbench.js [options]", - "", - "Arguments may be paths to JSON files or one of the shortcuts:", - " latest most recent file in .tmp/bench/", - " previous second-most recent file in .tmp/bench/", - "", - "Options:", - " --threshold regression threshold percent (default: 5)", - " --dir bench results directory (default: .tmp/bench)", - " --quiet, -q only print summary line", - "", - "Exit code: 0 if no regressions past threshold, 1 otherwise.", - "", - ].join("\n"), - ); -} - -function resolveFile(arg, dir) { - if (arg === "latest" || arg === "previous") { - const entries = readdirSync(dir) - .filter((f) => f.endsWith(".json")) - .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); - const idx = arg === "latest" ? 0 : 1; - if (entries.length <= idx) { - throw new Error(`not enough JSON files in ${dir} to resolve "${arg}"`); - } - return resolve(dir, entries[idx].f); - } - return resolve(arg); -} - -function load(path) { - const data = JSON.parse(readFileSync(path, "utf-8")); - const byName = new Map(); - for (const task of data.tasks) { - byName.set(task.name, task); - } - return { - meta: { - node: data.node, - platform: data.platform, - timestamp: data.timestamp, - path, - }, - byName, - }; -} - -function pad(s, n) { - return String(s).padEnd(n); -} - -function fmtNs(n) { - if (n < 1000) return `${n.toFixed(0)} ns`; - if (n < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; - return `${(n / 1_000_000).toFixed(2)} ms`; -} - -function color(s, code) { - if (!process.stdout.isTTY) return s; - return `\x1b[${code}m${s}\x1b[0m`; -} - -const args = parseArgs(process.argv.slice(2)); -if (args.positional.length === 0 || args.positional.length > 2) { - usage(); - process.exit(2); -} - -const baselineArg = - args.positional.length === 2 ? args.positional[0] : "previous"; -const currentArg = - args.positional.length === 2 ? args.positional[1] : args.positional[0]; - -const baselinePath = resolveFile(baselineArg, args.dir); -const currentPath = resolveFile(currentArg, args.dir); - -if (baselinePath === currentPath) { - console.error( - `baseline and current resolve to the same file: ${baselinePath}`, - ); - process.exit(2); -} - -const baseline = load(baselinePath); -const current = load(currentPath); - -console.log(`baseline: ${baseline.meta.path}`); -console.log( - ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform}`, -); -console.log(`current: ${current.meta.path}`); -console.log( - ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform}`, -); -console.log(""); - -if (baseline.meta.platform !== current.meta.platform) { - console.log( - color( - `! platform differs (${baseline.meta.platform} vs ${current.meta.platform}) — numbers may not be comparable`, - "33", - ), - ); -} -if (baseline.meta.node !== current.meta.node) { - console.log( - color( - `! node version differs (${baseline.meta.node} vs ${current.meta.node})`, - "33", - ), - ); -} - -const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); -let regressions = 0; -let improvements = 0; - -const rows = []; -for (const name of [...names].sort()) { - const b = baseline.byName.get(name); - const c = current.byName.get(name); - if (!b) { - rows.push({ - name, - kind: "new", - text: color("NEW", "36"), - bMean: undefined, - cMean: c.meanLatencyNs, - delta: undefined, - }); - continue; - } - if (!c) { - rows.push({ - name, - kind: "gone", - text: color("GONE", "90"), - bMean: b.meanLatencyNs, - cMean: undefined, - delta: undefined, - }); - continue; - } - const deltaPct = - ((c.meanLatencyNs - b.meanLatencyNs) / b.meanLatencyNs) * 100; - // Combined relative margin of error; deltas inside this are noise. - const noiseFloor = (b.rmePercent ?? 0) + (c.rmePercent ?? 0); - let kind = "ok"; - let text = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; - if (deltaPct > args.threshold && Math.abs(deltaPct) > noiseFloor) { - kind = "regress"; - text = color(`${text} REGRESS`, "31"); - regressions++; - } else if (deltaPct < -args.threshold && Math.abs(deltaPct) > noiseFloor) { - kind = "improve"; - text = color(`${text} faster`, "32"); - improvements++; - } else if (Math.abs(deltaPct) <= noiseFloor) { - text = color(`${text} (noise)`, "90"); - } - rows.push({ - name, - kind, - text, - bMean: b.meanLatencyNs, - cMean: c.meanLatencyNs, - delta: deltaPct, - }); -} - -if (!args.quiet) { - const nameW = Math.max(4, ...rows.map((r) => r.name.length)); - console.log( - `${pad("task", nameW)} ${pad("baseline", 12)} ${pad("current", 12)} delta`, - ); - console.log( - `${pad("", nameW).replaceAll(" ", "-")} ${"-".repeat(12)} ${"-".repeat(12)} ${"-".repeat(20)}`, - ); - for (const r of rows) { - const b = r.bMean !== undefined ? fmtNs(r.bMean) : "—"; - const c = r.cMean !== undefined ? fmtNs(r.cMean) : "—"; - console.log( - `${pad(r.name, nameW)} ${pad(b, 12)} ${pad(c, 12)} ${r.text}`, - ); - } - console.log(""); -} - -console.log( - `summary: ${regressions} regression(s), ${improvements} improvement(s), threshold ${args.threshold}%`, -); -process.exit(regressions > 0 ? 1 : 0); diff --git a/packages/protovalidate-bench/src/suites/cases.ts b/packages/protovalidate-bench/src/suites/cases.ts deleted file mode 100644 index 830bdc9..0000000 --- a/packages/protovalidate-bench/src/suites/cases.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { DescMessage, Message } from "@bufbuild/protobuf"; -import { - BenchComplexSchemaSchema, - BenchMapSchema, - BenchRepeatedBytesUniqueSchema, - BenchRepeatedMessageSchema, - BenchRepeatedScalarSchema, - BenchRepeatedScalarUniqueSchema, - BenchScalarSchema, -} from "../gen/bench/v1/bench_pb.js"; -import { - BenchGTSchema, - MultiRuleSchema, - StringMatchingSchema, - TestByteMatchingSchema, - WrapperTestingSchema, -} from "../gen/bench/v1/native_pb.js"; -import { - benchComplexSchema, - benchGT, - benchMap, - benchRepeatedBytesUnique, - benchRepeatedMessage, - benchRepeatedScalar, - benchRepeatedScalarUnique, - benchScalar, - multiRuleError, - multiRuleNoError, - stringMatching, - testByteMatching, - wrapperTesting, -} from "../fixtures.js"; - -/** - * One bench case: a schema, a fixture, and the name to record under. - */ -export type BenchCase = { - name: string; - schema: DescMessage; - fixture: Message; -}; - -/** - * Every (schema, fixture) pair used by the validate-time benches. - * - * To add a benchmark, add the fixture to fixtures.ts and append a row here. - * `validate.bench.ts` iterates this list; `compile.bench.ts` and - * `standard-schema.bench.ts` reference individual entries by name. - */ -export const cases: readonly BenchCase[] = [ - { name: "Scalar", schema: BenchScalarSchema, fixture: benchScalar }, - { - name: "Repeated/Scalar", - schema: BenchRepeatedScalarSchema, - fixture: benchRepeatedScalar, - }, - { - name: "Repeated/Message", - schema: BenchRepeatedMessageSchema, - fixture: benchRepeatedMessage, - }, - { - name: "Repeated/Unique/Scalar", - schema: BenchRepeatedScalarUniqueSchema, - fixture: benchRepeatedScalarUnique, - }, - { - name: "Repeated/Unique/Bytes", - schema: BenchRepeatedBytesUniqueSchema, - fixture: benchRepeatedBytesUnique, - }, - { name: "Map", schema: BenchMapSchema, fixture: benchMap }, - { - name: "ComplexSchema", - schema: BenchComplexSchemaSchema, - fixture: benchComplexSchema, - }, - { name: "Int32GT", schema: BenchGTSchema, fixture: benchGT }, - { - name: "TestByteMatching", - schema: TestByteMatchingSchema, - fixture: testByteMatching, - }, - { - name: "StringMatching", - schema: StringMatchingSchema, - fixture: stringMatching, - }, - { - name: "WrapperTesting", - schema: WrapperTestingSchema, - fixture: wrapperTesting, - }, - { - name: "MultiRule/Error", - schema: MultiRuleSchema, - fixture: multiRuleError, - }, - { - name: "MultiRule/NoError", - schema: MultiRuleSchema, - fixture: multiRuleNoError, - }, -]; - -/** - * Look up a single case by name. Throws if no case matches — used by suites - * that pick a curated subset (e.g. compile, standard-schema benches). - */ -export function caseByName(name: string): BenchCase { - const c = cases.find((c) => c.name === name); - if (!c) { - throw new Error(`no bench case named "${name}"`); - } - return c; -} diff --git a/packages/protovalidate-bench/src/suites/compile.bench.ts b/packages/protovalidate-bench/src/suites/compile.bench.ts deleted file mode 100644 index 401783c..0000000 --- a/packages/protovalidate-bench/src/suites/compile.bench.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { caseByName } from "./cases.js"; - -// Compile-time benchmarks: build a fresh validator on each iteration and run -// one validate() call so the plan is forced. Mirrors Go's BenchmarkCompile, -// which calls New() in the hot loop. - -const compileTargets = ["ComplexSchema", "Int32GT"] as const; - -export function register(bench: Bench): void { - for (const name of compileTargets) { - const c = caseByName(name); - bench.add(`Compile/${c.name}`, () => { - const v = createValidator(); - v.validate(c.schema, c.fixture); - }); - } -} diff --git a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts b/packages/protovalidate-bench/src/suites/standard-schema.bench.ts deleted file mode 100644 index ee14308..0000000 --- a/packages/protovalidate-bench/src/suites/standard-schema.bench.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createStandardSchema } from "@bufbuild/protovalidate"; -import { caseByName } from "./cases.js"; - -// Standard Schema adapter overhead — TS-only surface, no Go analogue. Compares -// directly with the matching Scalar/ComplexSchema validate benches to surface -// the cost of the adapter's path→Issue translation and unknown→typed narrowing. - -const adapterTargets = ["Scalar", "ComplexSchema"] as const; - -export function register(bench: Bench): void { - for (const name of adapterTargets) { - const c = caseByName(name); - const adapter = createStandardSchema(c.schema); - adapter["~standard"].validate(c.fixture); // warm - bench.add(`StandardSchema/${c.name}`, () => { - adapter["~standard"].validate(c.fixture); - }); - } -} diff --git a/packages/protovalidate-bench/src/suites/validate.bench.ts b/packages/protovalidate-bench/src/suites/validate.bench.ts deleted file mode 100644 index 5c25266..0000000 --- a/packages/protovalidate-bench/src/suites/validate.bench.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { Bench } from "tinybench"; -import { createValidator } from "@bufbuild/protovalidate"; -import { cases } from "./cases.js"; - -// Validate-time benches: a single validator is warmed once per case and then -// reused across iterations, matching Go's BenchmarkValidate*. The set of -// cases lives in cases.ts — add a row there to add a benchmark. - -export function register(bench: Bench): void { - const validator = createValidator(); - for (const c of cases) { - validator.validate(c.schema, c.fixture); // warm the planner cache - bench.add(c.name, () => { - validator.validate(c.schema, c.fixture); - }); - } -} From f7fa861e35e46e7b7e85e3642e28cfb476f8ca18 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Mon, 8 Jun 2026 13:24:18 -0400 Subject: [PATCH 29/38] respond to code review feedback --- packages/protovalidate/src/native/bool.ts | 14 ++- .../protovalidate/src/native/dispatcher.ts | 2 +- .../protovalidate/src/native/numeric.test.ts | 1 - packages/protovalidate/src/native/numeric.ts | 55 +++++---- packages/protovalidate/src/native/sites.ts | 108 ------------------ packages/protovalidate/src/planner.ts | 11 +- 6 files changed, 40 insertions(+), 151 deletions(-) delete mode 100644 packages/protovalidate/src/native/sites.ts diff --git a/packages/protovalidate/src/native/bool.ts b/packages/protovalidate/src/native/bool.ts index 49c1d25..c1fb909 100644 --- a/packages/protovalidate/src/native/bool.ts +++ b/packages/protovalidate/src/native/bool.ts @@ -20,8 +20,10 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { BoolRules } from "../gen/buf/validate/validate_pb.js"; -import { boolConstDesc } from "./sites.js"; +import { + type BoolRules, + BoolRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; /** @@ -38,7 +40,7 @@ class EvalNativeBoolRules implements Eval { ) {} eval(val: ScalarValue, cursor: Cursor): void { - if ((val as boolean) !== this.constVal) { + if (val !== this.constVal) { cursor.violate( `must equal ${this.constVal}`, "bool.const", @@ -65,12 +67,12 @@ export function tryBuildNativeBoolRules( if (rules.$unknown && rules.$unknown.length > 0) { return undefined; } - if (!isFieldSet(rules, boolConstDesc)) { + if (!isFieldSet(rules, BoolRulesSchema.field.const)) { return undefined; } - const path = rulePath.clone().field(boolConstDesc).toPath(); + const path = rulePath.clone().field(BoolRulesSchema.field.const).toPath(); return { eval: new EvalNativeBoolRules(forMapKey, rules.const, path), - handledFields: new Set([boolConstDesc]), + handledFields: new Set([BoolRulesSchema.field.const]), }; } diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index bb0e0b7..634d65d 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -39,7 +39,7 @@ export type NativeDispatchResult = { /** * Internal dispatch result used by the per-rules-type builders. They produce - * a scalar-typed eval; {@link tryBuildNative} either lifts it directly into + * a scalar-typed eval; tryBuildNative either lifts it directly into * `Eval` (the scalar case) or wraps it in a * `WrappedValueEval` for WKT wrapper messages. */ diff --git a/packages/protovalidate/src/native/numeric.test.ts b/packages/protovalidate/src/native/numeric.test.ts index e7a4dc3..d012a03 100644 --- a/packages/protovalidate/src/native/numeric.test.ts +++ b/packages/protovalidate/src/native/numeric.test.ts @@ -334,7 +334,6 @@ void suite("native numeric rules", () => { }); }); - // Review follow-up: gaps surfaced by the code review. void suite("review gap coverage", () => { void test("T1: NaN value with float.in list", () => { // NaN is never === to any list element, so the violation must fire. diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index f08f4d0..350b058 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { isFieldSet, type Message } from "@bufbuild/protobuf"; +import { type DescField, isFieldSet, type Message } from "@bufbuild/protobuf"; import type { Path, PathBuilder, @@ -36,21 +36,18 @@ import { } from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { printFloat } from "./format.js"; -import { - doubleDescs, - fixed32Descs, - fixed64Descs, - floatDescs, - int32Descs, - int64Descs, - type NumericRulesDescs, - sfixed32Descs, - sfixed64Descs, - sint32Descs, - sint64Descs, - uint32Descs, - uint64Descs, -} from "./sites.js"; + +type NumericRulesDescs = { + readonly const: DescField; + readonly gt: DescField; + readonly gte: DescField; + readonly lt: DescField; + readonly lte: DescField; + readonly in: DescField; + readonly notIn: DescField; + /** Only present on FloatRulesSchema and DoubleRulesSchema. */ + readonly finite?: DescField; +}; /** * Per-scalar configuration for the numeric native evaluator. @@ -71,73 +68,73 @@ const floatFormat = (v: number): string => printFloat(v); const int32Config: NumericConfig = { typeName: "int32", - descs: int32Descs, + descs: Int32RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const int64Config: NumericConfig = { typeName: "int64", - descs: int64Descs, + descs: Int64RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const uint32Config: NumericConfig = { typeName: "uint32", - descs: uint32Descs, + descs: UInt32RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const uint64Config: NumericConfig = { typeName: "uint64", - descs: uint64Descs, + descs: UInt64RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const sint32Config: NumericConfig = { typeName: "sint32", - descs: sint32Descs, + descs: SInt32RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const sint64Config: NumericConfig = { typeName: "sint64", - descs: sint64Descs, + descs: SInt64RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const fixed32Config: NumericConfig = { typeName: "fixed32", - descs: fixed32Descs, + descs: Fixed32RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const fixed64Config: NumericConfig = { typeName: "fixed64", - descs: fixed64Descs, + descs: Fixed64RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const sfixed32Config: NumericConfig = { typeName: "sfixed32", - descs: sfixed32Descs, + descs: SFixed32RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const sfixed64Config: NumericConfig = { typeName: "sfixed64", - descs: sfixed64Descs, + descs: SFixed64RulesSchema.field, format: stringFormat, nanFailsRange: false, }; const floatConfig: NumericConfig = { typeName: "float", - descs: floatDescs, + descs: FloatRulesSchema.field, format: floatFormat, nanFailsRange: true, }; const doubleConfig: NumericConfig = { typeName: "double", - descs: doubleDescs, + descs: DoubleRulesSchema.field, format: floatFormat, nanFailsRange: true, }; @@ -353,7 +350,7 @@ function build( return undefined; } - const handled = new Set(); + const handled = new Set(); let constRule: ConstRule | undefined; if (isFieldSet(rules, config.descs.const)) { diff --git a/packages/protovalidate/src/native/sites.ts b/packages/protovalidate/src/native/sites.ts deleted file mode 100644 index 02d32fd..0000000 --- a/packages/protovalidate/src/native/sites.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import type { DescField } from "@bufbuild/protobuf"; -import { - BoolRulesSchema, - DoubleRulesSchema, - Fixed32RulesSchema, - Fixed64RulesSchema, - FloatRulesSchema, - Int32RulesSchema, - Int64RulesSchema, - SFixed32RulesSchema, - SFixed64RulesSchema, - SInt32RulesSchema, - SInt64RulesSchema, - UInt32RulesSchema, - UInt64RulesSchema, -} from "../gen/buf/validate/validate_pb.js"; - -/** - * Leaf-field references for the numeric rules schemas. - * - * The dispatcher uses these to (a) consult `isFieldSet(rules, descs.const)` - * for presence and (b) build leaf rule paths via - * `rulePath.clone().field(descs.const).toPath()` at plan time. - */ -export type NumericRulesDescs = { - readonly const: DescField; - readonly gt: DescField; - readonly gte: DescField; - readonly lt: DescField; - readonly lte: DescField; - readonly in: DescField; - readonly notIn: DescField; - /** Only present on FloatRulesSchema and DoubleRulesSchema. */ - readonly finite?: DescField; -}; - -function descs( - schema: - | typeof Int32RulesSchema - | typeof Int64RulesSchema - | typeof UInt32RulesSchema - | typeof UInt64RulesSchema - | typeof SInt32RulesSchema - | typeof SInt64RulesSchema - | typeof Fixed32RulesSchema - | typeof Fixed64RulesSchema - | typeof SFixed32RulesSchema - | typeof SFixed64RulesSchema, -): NumericRulesDescs { - return { - const: schema.field.const, - gt: schema.field.gt, - gte: schema.field.gte, - lt: schema.field.lt, - lte: schema.field.lte, - in: schema.field.in, - notIn: schema.field.notIn, - }; -} - -export const int32Descs: NumericRulesDescs = descs(Int32RulesSchema); -export const int64Descs: NumericRulesDescs = descs(Int64RulesSchema); -export const uint32Descs: NumericRulesDescs = descs(UInt32RulesSchema); -export const uint64Descs: NumericRulesDescs = descs(UInt64RulesSchema); -export const sint32Descs: NumericRulesDescs = descs(SInt32RulesSchema); -export const sint64Descs: NumericRulesDescs = descs(SInt64RulesSchema); -export const fixed32Descs: NumericRulesDescs = descs(Fixed32RulesSchema); -export const fixed64Descs: NumericRulesDescs = descs(Fixed64RulesSchema); -export const sfixed32Descs: NumericRulesDescs = descs(SFixed32RulesSchema); -export const sfixed64Descs: NumericRulesDescs = descs(SFixed64RulesSchema); - -export const floatDescs: NumericRulesDescs = { - const: FloatRulesSchema.field.const, - gt: FloatRulesSchema.field.gt, - gte: FloatRulesSchema.field.gte, - lt: FloatRulesSchema.field.lt, - lte: FloatRulesSchema.field.lte, - in: FloatRulesSchema.field.in, - notIn: FloatRulesSchema.field.notIn, - finite: FloatRulesSchema.field.finite, -}; - -export const doubleDescs: NumericRulesDescs = { - const: DoubleRulesSchema.field.const, - gt: DoubleRulesSchema.field.gt, - gte: DoubleRulesSchema.field.gte, - lt: DoubleRulesSchema.field.lt, - lte: DoubleRulesSchema.field.lte, - in: DoubleRulesSchema.field.in, - notIn: DoubleRulesSchema.field.notIn, - finite: DoubleRulesSchema.field.finite, -}; - -export const boolConstDesc: DescField = BoolRulesSchema.field.const; diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 96bfcf2..bcb5b9f 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -454,12 +454,11 @@ export class Planner { forMapKey, wrappedValueField, }); - // Standard CEL plans: enroll every set field whose rule isn't claimed by - // the native dispatcher. When native handles every set rule (e.g., a pure - // numeric/bytes message after the phase-3 port) the CEL evaluator stays - // empty — leave it out of the tree entirely so its per-iteration setEnv() - // calls don't run. The handledFields set from tryBuildNative is what - // tells us which plans the native path took. + // Standard CEL plans: enroll every field whose rule isn't claimed by + // the native dispatcher. In situations where native rules handle every specified + // rule, the CEL evaluator stays empty. Leave it out of the tree entirely so + // its per-iteration setEnv() calls don't run. The handledFields set from + // tryBuildNative is what tells us which plans the native path took. let evalStandard: EvalStandardRulesCel | undefined; for (const plan of prepared.standard) { if (!isFieldSet(rules, plan.field)) { From 8122b83755d4ba396a82cfd70f75a2ec05830ca3 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 11 Jun 2026 11:26:51 -0400 Subject: [PATCH 30/38] remove sites.ts from maps/enum/repeated --- packages/protovalidate/src/native/enum.ts | 17 +++++++------- packages/protovalidate/src/native/map.ts | 15 ++++++------ packages/protovalidate/src/native/repeated.ts | 23 +++++++++---------- 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts index 816d008..1b7c86e 100644 --- a/packages/protovalidate/src/native/enum.ts +++ b/packages/protovalidate/src/native/enum.ts @@ -20,10 +20,9 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { EnumRules } from "../gen/buf/validate/validate_pb.js"; +import {type EnumRules, EnumRulesSchema} from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { formatList } from "./format.js"; -import { enumDescs } from "./sites.js"; type ConstRule = { readonly val: number; readonly path: Path }; type ListRule = { readonly vals: readonly number[]; readonly path: Path }; @@ -103,30 +102,30 @@ export function tryBuildNativeEnumRules( const handled = new Set(); let constRule: ConstRule | undefined; - if (isFieldSet(rules, enumDescs.const)) { + if (isFieldSet(rules, EnumRulesSchema.field.const)) { constRule = { val: rules.const, - path: rulePath.clone().field(enumDescs.const).toPath(), + path: rulePath.clone().field(EnumRulesSchema.field.const).toPath(), }; - handled.add(enumDescs.const); + handled.add(EnumRulesSchema.field.const); } let inRule: ListRule | undefined; if (rules.in.length > 0) { inRule = { vals: rules.in, - path: rulePath.clone().field(enumDescs.in).toPath(), + path: rulePath.clone().field(EnumRulesSchema.field.in).toPath(), }; - handled.add(enumDescs.in); + handled.add(EnumRulesSchema.field.in); } let notInRule: ListRule | undefined; if (rules.notIn.length > 0) { notInRule = { vals: rules.notIn, - path: rulePath.clone().field(enumDescs.notIn).toPath(), + path: rulePath.clone().field(EnumRulesSchema.field.notIn).toPath(), }; - handled.add(enumDescs.notIn); + handled.add(EnumRulesSchema.field.notIn); } if (handled.size === 0) { diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts index 6a3d000..655b809 100644 --- a/packages/protovalidate/src/native/map.ts +++ b/packages/protovalidate/src/native/map.ts @@ -16,8 +16,7 @@ import { type DescField, isFieldSet } from "@bufbuild/protobuf"; import type { Path, PathBuilder, ReflectMap } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { MapRules } from "../gen/buf/validate/validate_pb.js"; -import { mapDescs } from "./sites.js"; +import {type MapRules, MapRulesSchema} from "../gen/buf/validate/validate_pb.js"; /** * Internal dispatch result for map-shaped native handlers. @@ -77,21 +76,21 @@ export function tryBuildNativeMapRules( const handled = new Set(); let minPairsRule: SizeRule | undefined; - if (isFieldSet(rules, mapDescs.minPairs)) { + if (isFieldSet(rules, MapRulesSchema.field.minPairs)) { minPairsRule = { val: rules.minPairs, - path: rulePath.clone().field(mapDescs.minPairs).toPath(), + path: rulePath.clone().field(MapRulesSchema.field.minPairs).toPath(), }; - handled.add(mapDescs.minPairs); + handled.add(MapRulesSchema.field.minPairs); } let maxPairsRule: SizeRule | undefined; - if (isFieldSet(rules, mapDescs.maxPairs)) { + if (isFieldSet(rules, MapRulesSchema.field.maxPairs)) { maxPairsRule = { val: rules.maxPairs, - path: rulePath.clone().field(mapDescs.maxPairs).toPath(), + path: rulePath.clone().field(MapRulesSchema.field.maxPairs).toPath(), }; - handled.add(mapDescs.maxPairs); + handled.add(MapRulesSchema.field.maxPairs); } if (handled.size === 0) { diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts index 26cb02b..25c2513 100644 --- a/packages/protovalidate/src/native/repeated.ts +++ b/packages/protovalidate/src/native/repeated.ts @@ -20,8 +20,7 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import type { RepeatedRules } from "../gen/buf/validate/validate_pb.js"; -import { repeatedDescs } from "./sites.js"; +import {type RepeatedRules, RepeatedRulesSchema} from "../gen/buf/validate/validate_pb.js"; /** * Internal dispatch result for list-shaped native handlers. @@ -151,38 +150,38 @@ export function tryBuildNativeRepeatedRules( const handled = new Set(); let minItemsRule: SizeRule | undefined; - if (isFieldSet(rules, repeatedDescs.minItems)) { + if (isFieldSet(rules, RepeatedRulesSchema.field.minItems)) { minItemsRule = { val: rules.minItems, - path: rulePath.clone().field(repeatedDescs.minItems).toPath(), + path: rulePath.clone().field(RepeatedRulesSchema.field.minItems).toPath(), }; - handled.add(repeatedDescs.minItems); + handled.add(RepeatedRulesSchema.field.minItems); } let maxItemsRule: SizeRule | undefined; - if (isFieldSet(rules, repeatedDescs.maxItems)) { + if (isFieldSet(rules, RepeatedRulesSchema.field.maxItems)) { maxItemsRule = { val: rules.maxItems, - path: rulePath.clone().field(repeatedDescs.maxItems).toPath(), + path: rulePath.clone().field(RepeatedRulesSchema.field.maxItems).toPath(), }; - handled.add(repeatedDescs.maxItems); + handled.add(RepeatedRulesSchema.field.maxItems); } let uniqueRule: UniqueRule | undefined; - if (isFieldSet(rules, repeatedDescs.unique)) { + if (isFieldSet(rules, RepeatedRulesSchema.field.unique)) { if (!rules.unique) { // Explicit `unique: false` is a no-op rule. Claim the field so CEL // doesn't bother re-evaluating it. Matches numeric.ts's treatment of // `finite: false`. - handled.add(repeatedDescs.unique); + handled.add(RepeatedRulesSchema.field.unique); } else if (listField !== undefined) { const kind = uniqueKindForListField(listField); if (kind !== undefined) { uniqueRule = { kind, - path: rulePath.clone().field(repeatedDescs.unique).toPath(), + path: rulePath.clone().field(RepeatedRulesSchema.field.unique).toPath(), }; - handled.add(repeatedDescs.unique); + handled.add(RepeatedRulesSchema.field.unique); } // When `kind === undefined` (message-element list with unique:true) we // deliberately do NOT claim the unique field; CEL handles it. From 4fe448ae65594666809e531e7ced11ded23c79c3 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 11 Jun 2026 11:48:51 -0400 Subject: [PATCH 31/38] remove obsolete benchmark code. make sure benchmarks run against latest code. update README --- packages/protovalidate-bench/README.md | 7 +- .../protovalidate-bench/src/checkbench.ts | 594 ------------------ packages/protovalidate-bench/src/new_bench.ts | 139 ---- .../protovalidate-bench/src/new_checkbench.ts | 323 ---------- turbo.json | 4 + 5 files changed, 9 insertions(+), 1058 deletions(-) delete mode 100755 packages/protovalidate-bench/src/checkbench.ts delete mode 100644 packages/protovalidate-bench/src/new_bench.ts delete mode 100644 packages/protovalidate-bench/src/new_checkbench.ts diff --git a/packages/protovalidate-bench/README.md b/packages/protovalidate-bench/README.md index 8a9451e..94c0ed7 100644 --- a/packages/protovalidate-bench/README.md +++ b/packages/protovalidate-bench/README.md @@ -9,10 +9,13 @@ so that runtime cost can be tracked across changes and compared cross-language. From the repo root: ```shell -npx turbo run bench [regex] -d dir +npx turbo run bench [regex] --filter=@bufbuild/protovalidate-bench ``` -Or from this directory: +This command will rebuild the `protovalidate` package and run the benchmarks. This is preferred to make sure +the latest changes are reflected in the results. + +You can also run the benchmarks directly from the `protovalidate-bench` package: ```shell npm run bench diff --git a/packages/protovalidate-bench/src/checkbench.ts b/packages/protovalidate-bench/src/checkbench.ts deleted file mode 100755 index ddcd8d9..0000000 --- a/packages/protovalidate-bench/src/checkbench.ts +++ /dev/null @@ -1,594 +0,0 @@ -#!/usr/bin/env node - -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { parseArgs } from "node:util"; - -const BENCH_DIR = ".tmp/bench"; -const DEFAULT_THRESHOLD = 5; - -// Which signal categories gate a regression. "cpu" only gates on mean -// latency. "memory" only gates on heap allocation. "both" gates on either. -// Min latency is shown but never gates (too sensitive to JIT warmth/ -// scheduling jitter; mean catches anything genuinely regressed). -type Metric = "cpu" | "memory" | "both"; -const DEFAULT_METRIC: Metric = "both"; - -function usage() { - process.stdout.write( - [ - "Usage: tsx src/checkbench.ts [options]", - "", - "Arguments are paths to JSON files relative to the benchmark directory (default: .tmp/bench/).", - "If neither argument is present, the two most recent files are used, with the older file being the baseline.", - "If one argument is present, the named file in the benchmark directory is used as the baseline and the most recent file is used as the current.", - "", - "Options:", - " --threshold regression threshold percent (default: 5)", - " --dir bench results directory (default: .tmp/bench)", - " --metric which signals gate a regression: cpu|memory|both (default both)", - " cpu: mean latency only; memory: heap only; both: mean+heap", - " (min latency is always shown but never gates)", - " --quiet, -q only print summary line", - " --help, -h show this help and exit", - "", - "Exit code: 0 if no regressions past threshold, 1 for regressions, 2 for other errors.", - "", - ].join("\n"), - ); -} - -type FileInfo = { - meta: { - node: string; - platform: string; - timestamp: string; - path: string; - runs: number; - schemaVersion: number; - // What the bench was told to measure. Default "both" for files written - // before --metric existed. - metric: Metric; - }; - byName: Map; -}; - -type Task = { - name: string; - meanLatencyNs: number; - minLatencyNs: number; - medianLatencyNs: number; - p99LatencyNs: number; - throughputOpsPerSec: number; - rmePercent: number; - // Present only for files written with schemaVersion >= 2 from a multi-run - // invocation. When present, this is the relative stddev across per-run means - // — i.e., the actual run-to-run noise — and should be used as the noise - // floor in preference to rmePercent (which is within-run sample spread). - crossRunRsdPercent?: number; - samples: number; - runs?: number; - gcTotalNs?: number; - heapAvgBytes?: number; -}; - -function load(path: string): FileInfo { - const data = JSON.parse(readFileSync(path, "utf-8")); - const byName = new Map(); - for (const task of data.tasks) { - byName.set(task.name, task); - } - return { - meta: { - node: data.node, - platform: data.platform, - timestamp: data.timestamp, - path, - // Older files (schemaVersion absent or 1) don't have a runs field at - // the top level, but they also lack crossRunRsdPercent on tasks, so - // checkbench falls back to rmePercent for them. - runs: typeof data.runs === "number" ? data.runs : 1, - schemaVersion: - typeof data.schemaVersion === "number" ? data.schemaVersion : 1, - metric: - data.metric === "cpu" || data.metric === "memory" - ? data.metric - : "both", - }, - byName, - }; -} - -function pad(s: string, n: number): string { - return String(s).padEnd(n); -} - -function fmtNs(n: number): string { - if (n < 1000) return `${n.toFixed(0)} ns`; - if (n < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; - return `${(n / 1_000_000).toFixed(2)} ms`; -} - -function color(s: string, code: string): string { - if (!process.stdout.isTTY) return s; - return `\x1b[${code}m${s}\x1b[0m`; -} - -function getFile(dir: string, arg: string): string { - const path = resolve(dir, arg); - try { - if (!statSync(path).isFile()) { - console.error(`not a file: ${path}`); - process.exit(2); - } - } catch (err) { - const e = err as NodeJS.ErrnoException; - if (e.code === "ENOENT") { - console.error(`file does not exist: ${path}`); - process.exit(2); - } - throw err; - } - return path; -} - -type DirEntry = { f: string; mtime: number }; - -function getSortedDirEntries(dir: string): DirEntry[] { - return readdirSync(dir) - .filter((f) => f.endsWith(".json")) - .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); -} - -function getNewestFile(dir: string): string { - const entries = getSortedDirEntries(dir); - if (entries.length === 0) { - console.error(`no JSON files in ${dir}`); - process.exit(2); - } - return getFile(dir, entries[0].f); -} - -function getSecondNewestFile(dir: string): string { - const entries = getSortedDirEntries(dir); - if (entries.length < 2) { - console.error(`not enough JSON files in ${dir} to resolve previous file`); - process.exit(2); - } - return getFile(dir, entries[1].f); -} - -type ParsedValues = { - threshold?: string; - dir?: string; - metric?: string; - quiet?: boolean; - help?: boolean; -}; - -function buildArgs(values: ParsedValues) { - const dir = values.dir ?? BENCH_DIR; - try { - if (!statSync(dir).isDirectory()) { - console.error(`--dir is not a directory: ${dir}`); - process.exit(2); - } - } catch (err) { - const e = err as NodeJS.ErrnoException; - if (e.code === "ENOENT") { - console.error(`--dir does not exist: ${dir}`); - process.exit(2); - } - throw err; - } - let threshold = DEFAULT_THRESHOLD; - if (values.threshold !== undefined) { - const raw = values.threshold.trim(); - const n = Number(raw); - if (raw === "" || !Number.isFinite(n) || n < 0) { - console.error( - `--threshold must be a non-negative number: ${values.threshold}`, - ); - process.exit(2); - } - threshold = n; - } - let metric: Metric = DEFAULT_METRIC; - if (values.metric !== undefined) { - const raw = values.metric.trim(); - if (raw !== "cpu" && raw !== "memory" && raw !== "both") { - console.error(`--metric must be cpu|memory|both: ${values.metric}`); - process.exit(2); - } - metric = raw; - } - return { threshold, dir, metric, quiet: values.quiet ?? false }; -} - -const options = { - threshold: { - type: "string", - }, - dir: { - type: "string", - }, - metric: { - type: "string", - }, - quiet: { - type: "boolean", - short: "q", - }, - help: { - type: "boolean", - short: "h", - }, -} as const; -const { values, positionals } = parseArgs({ - options, - allowPositionals: true, -}); -if (values.help) { - usage(); - process.exit(0); -} -if (positionals.length > 2) { - usage(); - process.exit(2); -} - -const args = buildArgs(values); - -const baselinePath = - positionals.length > 0 - ? getFile(args.dir, positionals[0]) - : getSecondNewestFile(args.dir); -const currentPath = - positionals.length === 2 - ? getFile(args.dir, positionals[1]) - : getNewestFile(args.dir); - -if (baselinePath === currentPath) { - console.error( - `baseline and current resolve to the same file: ${baselinePath}`, - ); - process.exit(2); -} - -const baseline = load(baselinePath); -const current = load(currentPath); - -console.log(`baseline: ${baseline.meta.path}`); -console.log( - ` ${baseline.meta.timestamp} node ${baseline.meta.node} ${baseline.meta.platform} runs=${baseline.meta.runs} metric=${baseline.meta.metric}`, -); -console.log(`current: ${current.meta.path}`); -console.log( - ` ${current.meta.timestamp} node ${current.meta.node} ${current.meta.platform} runs=${current.meta.runs} metric=${current.meta.metric}`, -); -if (args.metric !== DEFAULT_METRIC) { - console.log(`gating: --metric ${args.metric}`); -} -console.log(""); - -if (baseline.meta.platform !== current.meta.platform) { - console.log( - color( - `! platform differs (${baseline.meta.platform} vs ${current.meta.platform}) — numbers may not be comparable`, - "33", - ), - ); -} -if (baseline.meta.node !== current.meta.node) { - console.log( - color( - `! node version differs (${baseline.meta.node} vs ${current.meta.node})`, - "33", - ), - ); -} -if (baseline.meta.runs !== current.meta.runs) { - console.log( - color( - `! runs count differs (${baseline.meta.runs} vs ${current.meta.runs}) — noise floor uses the looser of the two`, - "33", - ), - ); -} -if (baseline.meta.schemaVersion < 2 || current.meta.schemaVersion < 2) { - console.log( - color( - `! one or both files use schemaVersion 1 — falling back to within-run rmePercent as the noise floor (overstates real signal)`, - "33", - ), - ); -} -if (baseline.meta.metric !== current.meta.metric) { - console.log( - color( - `! recorded metric differs (${baseline.meta.metric} vs ${current.meta.metric}) — one side may be missing heap data`, - "33", - ), - ); -} -if ( - args.metric === "memory" && - (baseline.meta.metric === "cpu" || current.meta.metric === "cpu") -) { - console.log( - color( - `! --metric memory requested but one file was produced with --metric cpu (no heap data); nothing to gate on`, - "33", - ), - ); -} - -const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); -let regressions = 0; -let improvements = 0; - -type SignalVerdict = "regress" | "improve" | "noise" | "ok"; - -function classify( - deltaPct: number, - noiseFloor: number, - threshold: number, -): SignalVerdict { - if (Math.abs(deltaPct) <= noiseFloor) return "noise"; - if (deltaPct > threshold) return "regress"; - if (deltaPct < -threshold) return "improve"; - return "ok"; -} - -function fmtDelta(deltaPct: number, verdict: SignalVerdict): string { - let base: string; - if (!Number.isFinite(deltaPct)) { - base = deltaPct > 0 ? "+∞%" : "-∞%"; - } else { - base = `${deltaPct >= 0 ? "+" : ""}${deltaPct.toFixed(2)}%`; - } - switch (verdict) { - case "regress": - return color(base, "31"); - case "improve": - return color(base, "32"); - case "noise": - return color(base, "90"); - default: - return base; - } -} - -type Row = { - name: string; - kind: "new" | "gone" | "regress" | "improve" | "ok"; - bMean: number | undefined; - cMean: number | undefined; - meanText: string; - minText: string; - heapText: string; - gcText: string; - // Combined per-task noise floor used to classify deltas. Empty for new/gone - // rows where one side is missing. Suffixed with "*" when at least one side - // fell back to within-run rmePercent (schemaVersion < 2 or runs == 1). - noiseText: string; -}; - -const rows: Row[] = []; -// Track whether any row has heap/gc info so we can skip those columns entirely -// when neither file has them (e.g., comparing against a pre-mitata JSON). -let anyHeap = false; -let anyGc = false; - -for (const name of [...names].sort()) { - const b = baseline.byName.get(name); - const c = current.byName.get(name); - if (!b) { - rows.push({ - name, - kind: "new", - bMean: undefined, - cMean: c?.meanLatencyNs, - meanText: color("NEW", "36"), - minText: "", - heapText: "", - gcText: "", - noiseText: "", - }); - continue; - } - if (!c) { - rows.push({ - name, - kind: "gone", - bMean: b.meanLatencyNs, - cMean: undefined, - meanText: color("GONE", "90"), - minText: "", - heapText: "", - gcText: "", - noiseText: "", - }); - continue; - } - const meanDelta = - ((c.meanLatencyNs - b.meanLatencyNs) / b.meanLatencyNs) * 100; - const minDelta = ((c.minLatencyNs - b.minLatencyNs) / b.minLatencyNs) * 100; - // Prefer the cross-run RSD when both files have it (schemaVersion >= 2, - // runs > 1). That measures actual between-process variance and is the - // honest noise floor for comparing two separate bench invocations. - // Within-run rmePercent describes sample spread inside a single process; using - // it as a noise floor across processes systematically underestimates the - // noise, which is what produced spurious "regress" markers on unchanged - // code. Falling back to rmePercent for v1 files keeps old comparisons - // working at the cost of accuracy. - const bNoise = b.crossRunRsdPercent ?? b.rmePercent ?? 0; - const cNoise = c.crossRunRsdPercent ?? c.rmePercent ?? 0; - const noiseFloor = bNoise + cNoise; - const noiseFellBack = - b.crossRunRsdPercent === undefined || c.crossRunRsdPercent === undefined; - const noiseText = `${noiseFloor.toFixed(2)}%${noiseFellBack ? "*" : ""}`; - const meanV = classify(meanDelta, noiseFloor, args.threshold); - const minV = classify(minDelta, noiseFloor, args.threshold); - - // Heap is mostly deterministic per code+fixture, but for long-running - // alloc-heavy benches (Compile/*) the GC scheduler can fire mid-iteration - // and make `getHeapStatistics()` snapshots noisy. Reuse the timing noise - // floor (combined rmePercent) as a soft upper bound on measurement noise — - // not exact, but it suppresses the same kind of jitter that timing sees. - let heapDelta: number | undefined; - let heapV: SignalVerdict = "ok"; - if (b.heapAvgBytes !== undefined && c.heapAvgBytes !== undefined) { - anyHeap = true; - const heapAbsDelta = c.heapAvgBytes - b.heapAvgBytes; - if (b.heapAvgBytes === 0) { - heapDelta = c.heapAvgBytes === 0 ? 0 : Number.POSITIVE_INFINITY; - } else { - heapDelta = (heapAbsDelta / b.heapAvgBytes) * 100; - } - if (Math.abs(heapAbsDelta) < 1) { - heapV = "ok"; - } else { - heapV = classify(heapDelta, noiseFloor, args.threshold); - } - } - - // GC time is reported only when the runtime exposes gc(). Informational - // only — we don't gate on it because per-iter GC cost is noisy and already - // captured (in a noisier form) by heap allocation. - let gcDelta: number | undefined; - if (b.gcTotalNs !== undefined && c.gcTotalNs !== undefined) { - anyGc = true; - if (b.gcTotalNs === 0) { - gcDelta = c.gcTotalNs === 0 ? 0 : Number.POSITIVE_INFINITY; - } else { - gcDelta = ((c.gcTotalNs - b.gcTotalNs) / b.gcTotalNs) * 100; - } - } - - // --metric controls which signals can trigger REGRESS/faster markers. - // Non-gated signals still appear in the table (with their colored delta); - // they just can't fail the run. cpu → mean only, memory → heap only, - // both → mean+heap. - // - // The min column is informational. Even though it's a real signal — the - // fastest sample observed across all runs — it's too sensitive to JIT - // warmth and scheduling jitter to be a reliable gate on its own, and - // anything genuinely worth flagging will also show up in mean. - const gateCpu = args.metric === "cpu" || args.metric === "both"; - const gateMem = args.metric === "memory" || args.metric === "both"; - const tags: string[] = []; - if (gateCpu && meanV === "regress") tags.push("mean"); - if (gateMem && heapV === "regress") tags.push("heap"); - const fasterTags: string[] = []; - if (gateCpu && meanV === "improve") fasterTags.push("mean"); - if (gateMem && heapV === "improve") fasterTags.push("heap"); - - let kind: Row["kind"] = "ok"; - let meanText = fmtDelta(meanDelta, meanV); - const minText = fmtDelta(minDelta, minV); - const heapText = heapDelta === undefined ? "" : fmtDelta(heapDelta, heapV); - const gcText = - gcDelta === undefined - ? "" - : Number.isFinite(gcDelta) - ? `${gcDelta >= 0 ? "+" : ""}${gcDelta.toFixed(2)}%` - : "∞"; - - if (tags.length > 0) { - kind = "regress"; - regressions++; - const marker = color(`REGRESS (${tags.join("+")})`, "31"); - meanText = `${meanText} ${marker}`; - } else if (fasterTags.length > 0) { - kind = "improve"; - improvements++; - const marker = color(`faster (${fasterTags.join("+")})`, "32"); - meanText = `${meanText} ${marker}`; - } else if (meanV === "noise" || minV === "noise") { - meanText = `${meanText} ${color("(noise)", "90")}`; - } - - rows.push({ - name, - kind, - bMean: b.meanLatencyNs, - cMean: c.meanLatencyNs, - meanText, - minText, - heapText, - gcText, - noiseText, - }); -} - -// padVisible pads s to width n based on its visible (ANSI-stripped) length. -const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); -function padVisible(s: string, n: number): string { - const visible = s.replace(ansiPattern, ""); - const padding = Math.max(0, n - visible.length); - return s + " ".repeat(padding); -} - -if (!args.quiet) { - const nameW = Math.max(4, ...rows.map((r) => r.name.length)); - const noiseW = 8; - const cols = [ - `${pad("task", nameW)}`, - pad("baseline", 12), - pad("current", 12), - pad("min Δ", 10), - ]; - const seps = [ - "-".repeat(nameW), - "-".repeat(12), - "-".repeat(12), - "-".repeat(10), - ]; - if (anyHeap) { - cols.push(pad("heap Δ", 10)); - seps.push("-".repeat(10)); - } - if (anyGc) { - cols.push(pad("gc Δ", 10)); - seps.push("-".repeat(10)); - } - cols.push(pad("noise", noiseW)); - seps.push("-".repeat(noiseW)); - cols.push("mean Δ"); - seps.push("-".repeat(28)); - console.log(cols.join(" ")); - console.log(seps.join(" ")); - for (const r of rows) { - const b = r.bMean !== undefined ? fmtNs(r.bMean) : "—"; - const c = r.cMean !== undefined ? fmtNs(r.cMean) : "—"; - const minCell = padVisible(r.minText, 10); - const cells = [pad(r.name, nameW), pad(b, 12), pad(c, 12), minCell]; - if (anyHeap) cells.push(padVisible(r.heapText || "—", 10)); - if (anyGc) cells.push(padVisible(r.gcText || "—", 10)); - cells.push(padVisible(r.noiseText || "—", noiseW)); - cells.push(r.meanText); - console.log(cells.join(" ")); - } - console.log(""); -} - -console.log( - `summary: ${regressions} regression(s), ${improvements} improvement(s), threshold ${args.threshold}%`, -); -process.exit(regressions > 0 ? 1 : 0); diff --git a/packages/protovalidate-bench/src/new_bench.ts b/packages/protovalidate-bench/src/new_bench.ts deleted file mode 100644 index 0ba3131..0000000 --- a/packages/protovalidate-bench/src/new_bench.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2021-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import {Bench} from "tinybench"; -import * as console from "node:console"; -import type {DescMessage, Message} from "@bufbuild/protobuf"; -import { createValidator } from "@bufbuild/protovalidate"; -import {cases} from "./cases.js"; -import {writeFileSync} from "node:fs"; - -/* eslint-disable no-console, import/no-named-as-default-member */ - -const outPath = ".tmp/bench"; - -async function main(args: string[]): Promise { - function filterTests(regexp: string): Test[] { - const tests = setupTests(); - const re = new RegExp(regexp); - return tests.filter((test) => re.test(test.name)); - } - switch (args.shift()) { - case "list": - if (args.length > 1) { - exitUsage(1); - break; - } - for (const test of filterTests(args.length == 1 ? args[0] : ".*")) { - console.log(test.name); - } - break; - case "benchmark": - if (args.length > 1) { - exitUsage(1); - break; - } - await bench(filterTests(args.length == 1 ? args[0] : ".*")); - break; - case "run": { - if (args.length > 1) { - exitUsage(1); - break; - } - const tests = filterTests(args.length == 1 ? args[0] : ".*"); - run(tests); - break; - } - default: - exitUsage(1); - } - - function exitUsage(exitCode = 0) { - const out = exitCode === 0 ? process.stdout : process.stderr; - out.write( - [ - `USAGE: ${process.argv[1]} [list|benchmark|run] [regex] [iteration]`, - ``, - `benchmark '.*'`, - `Run tests with the npm package "tinybench", and print results to standard out.`, - ``, - `run '.*'`, - `Run each test.`, - ``, - `list '.*':`, - `List tests.`, - ``, - ].join("\n"), - () => process.exit(exitCode), - ); - } -} - -interface Test { - name: string; - schema: DescMessage; - fixture: Message; -} - -function setupTests(): Test[] { - const tests: Test[] = []; - tests.push(...cases); - return tests; -} -/** - * Run given tests consecutively. - */ -function run(tests: Test[]): void { - const validator = createValidator(); - for (const test of tests) { - console.log(`Running "${test.name}"`); - validator.validate(test.schema, test.fixture); - } -} - -/** - * Benchmark tests with the npm package "tinybench". Results are printed to - * standard out. - */ -async function bench(tests: Test[]): Promise { - const bench = new Bench({name: 'protovalidate benchmarks', time: 100}) - const validator = createValidator(); - - for (const test of tests) { - bench.add(test.name, ()=> { - validator.validate(test.schema, test.fixture); - }); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - - await bench.run() - - const payload = { - timestamp: timestamp, - node: process.version, - platform: `${process.platform}/${process.arch}`, - tasks: bench.tasks.map((t) => ({ - name: t.name, - // t.result is undefined if the task errored - result: t.result, - })), - }; - writeFileSync(`${outPath}/${timestamp}.json`, JSON.stringify(payload, null, 2)); - - console.log(bench.name) - console.table(bench.table()) -} - -await main(process.argv.slice(2)); diff --git a/packages/protovalidate-bench/src/new_checkbench.ts b/packages/protovalidate-bench/src/new_checkbench.ts deleted file mode 100644 index e865b94..0000000 --- a/packages/protovalidate-bench/src/new_checkbench.ts +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node - -// Copyright 2024-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { parseArgs } from "node:util"; - -const BENCH_DIR = ".tmp/bench"; - -function usage() { - process.stdout.write( - [ - "Usage: tsx src/new_checkbench.ts [baseline] [current] [options]", - "", - "Arguments are paths to JSON files relative to the benchmark directory (default: .tmp/bench/).", - "If no arguments are given, the two most recent files are used with the older file as baseline.", - "If one argument is given, it is used as the baseline and the most recent file is used as current.", - "If two arguments are given, the first is the baseline and the second is the current.", - "", - "Options:", - " --dir bench results directory (default: .tmp/bench)", - " --help, -h show this help and exit", - "", - ].join("\n"), - ); -} - -// Shape of each task in a tinybench-produced JSON file. Only the latency -// fields we read are required; the rest of result.* is ignored. -type TinybenchTask = { - name: string; - result?: { - latency?: { - mean: number; - p50: number; - }; - }; -}; - -type FileInfo = { - path: string; - timestamp: string; - node: string; - platform: string; - byName: Map; -}; - -function load(path: string): FileInfo { - const data = JSON.parse(readFileSync(path, "utf-8")); - const byName = new Map(); - for (const task of data.tasks ?? []) { - byName.set(task.name, task); - } - return { - path, - timestamp: data.timestamp ?? "", - node: data.node ?? "", - platform: data.platform ?? "", - byName, - }; -} - -function getFile(dir: string, arg: string): string { - const path = resolve(dir, arg); - try { - if (!statSync(path).isFile()) { - console.error(`not a file: ${path}`); - process.exit(2); - } - } catch (err) { - const e = err as NodeJS.ErrnoException; - if (e.code === "ENOENT") { - console.error(`file does not exist: ${path}`); - process.exit(2); - } - throw err; - } - return path; -} - -type DirEntry = { f: string; mtime: number }; - -function getSortedDirEntries(dir: string): DirEntry[] { - return readdirSync(dir) - .filter((f) => f.endsWith(".json")) - .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); -} - -function getNewestFile(dir: string): string { - const entries = getSortedDirEntries(dir); - if (entries.length === 0) { - console.error(`no JSON files in ${dir}`); - process.exit(2); - } - return getFile(dir, entries[0].f); -} - -function getSecondNewestFile(dir: string): string { - const entries = getSortedDirEntries(dir); - if (entries.length < 2) { - console.error(`not enough JSON files in ${dir} to resolve previous file`); - process.exit(2); - } - return getFile(dir, entries[1].f); -} - -// tinybench reports latency in milliseconds. Convert to ns once at the -// boundary so the rest of the code (and fmtNs) works in a single unit. -function msToNs(ms: number): number { - return ms * 1e6; -} - -function fmtNs(n: number): string { - const abs = Math.abs(n); - if (abs < 1000) return `${n.toFixed(0)} ns`; - if (abs < 1_000_000) return `${(n / 1000).toFixed(2)} µs`; - return `${(n / 1_000_000).toFixed(2)} ms`; -} - -function fmtSignedNs(n: number): string { - const s = fmtNs(n); - return n >= 0 && !s.startsWith("-") ? `+${s}` : s; -} - -function fmtPct(pct: number): string { - if (!Number.isFinite(pct)) return pct > 0 ? "+∞%" : "-∞%"; - return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`; -} - -function pad(s: string, n: number): string { - return String(s).padEnd(n); -} - -type ParsedValues = { - dir?: string; - help?: boolean; -}; - -function buildArgs(values: ParsedValues) { - const dir = values.dir ?? BENCH_DIR; - try { - if (!statSync(dir).isDirectory()) { - console.error(`--dir is not a directory: ${dir}`); - process.exit(2); - } - } catch (err) { - const e = err as NodeJS.ErrnoException; - if (e.code === "ENOENT") { - console.error(`--dir does not exist: ${dir}`); - process.exit(2); - } - throw err; - } - return { dir }; -} - -const options = { - dir: { - type: "string", - }, - help: { - type: "boolean", - short: "h", - }, -} as const; -const { values, positionals } = parseArgs({ - options, - allowPositionals: true, -}); -if (values.help) { - usage(); - process.exit(0); -} -if (positionals.length > 2) { - usage(); - process.exit(2); -} - -const args = buildArgs(values); - -const baselinePath = - positionals.length > 0 - ? getFile(args.dir, positionals[0]) - : getSecondNewestFile(args.dir); -const currentPath = - positionals.length === 2 - ? getFile(args.dir, positionals[1]) - : getNewestFile(args.dir); - -if (baselinePath === currentPath) { - console.error( - `baseline and current resolve to the same file: ${baselinePath}`, - ); - process.exit(2); -} - -const baseline = load(baselinePath); -const current = load(currentPath); - -console.log(`baseline: ${baseline.path}`); -console.log(` ${baseline.timestamp} node ${baseline.node} ${baseline.platform}`); -console.log(`current: ${current.path}`); -console.log(` ${current.timestamp} node ${current.node} ${current.platform}`); -console.log(""); - -// Render one row per task name present in either file. Tasks that errored -// (no result.latency) are reported as "—" cells so the row layout stays -// consistent. -type Row = { - name: string; - baseMean: string; - curMean: string; - meanDeltaNs: string; - meanDeltaPct: string; - baseP50: string; - curP50: string; - p50DeltaNs: string; - p50DeltaPct: string; -}; - -function deltaCells( - baseMs: number | undefined, - curMs: number | undefined, -): { base: string; cur: string; deltaNs: string; deltaPct: string } { - if (baseMs === undefined || curMs === undefined) { - return { - base: baseMs === undefined ? "—" : fmtNs(msToNs(baseMs)), - cur: curMs === undefined ? "—" : fmtNs(msToNs(curMs)), - deltaNs: "—", - deltaPct: "—", - }; - } - const baseNs = msToNs(baseMs); - const curNs = msToNs(curMs); - const deltaNs = curNs - baseNs; - const deltaPct = baseNs === 0 ? Number.POSITIVE_INFINITY : (deltaNs / baseNs) * 100; - return { - base: fmtNs(baseNs), - cur: fmtNs(curNs), - deltaNs: fmtSignedNs(deltaNs), - deltaPct: fmtPct(deltaPct), - }; -} - -const rows: Row[] = []; -const names = new Set([...baseline.byName.keys(), ...current.byName.keys()]); -for (const name of [...names].sort()) { - const b = baseline.byName.get(name); - const c = current.byName.get(name); - const meanCells = deltaCells(b?.result?.latency?.mean, c?.result?.latency?.mean); - const p50Cells = deltaCells(b?.result?.latency?.p50, c?.result?.latency?.p50); - rows.push({ - name, - baseMean: meanCells.base, - curMean: meanCells.cur, - meanDeltaNs: meanCells.deltaNs, - meanDeltaPct: meanCells.deltaPct, - baseP50: p50Cells.base, - curP50: p50Cells.cur, - p50DeltaNs: p50Cells.deltaNs, - p50DeltaPct: p50Cells.deltaPct, - }); -} - -const nameW = Math.max(4, ...rows.map((r) => r.name.length)); -const cellW = 12; -const deltaW = 12; -const pctW = 9; -console.log( - [ - pad("task", nameW), - pad("base mean", cellW), - pad("cur mean", cellW), - pad("mean Δ", deltaW), - pad("mean %", pctW), - pad("base p50", cellW), - pad("cur p50", cellW), - pad("p50 Δ", deltaW), - pad("p50 %", pctW), - ].join(" "), -); -console.log( - [ - "-".repeat(nameW), - "-".repeat(cellW), - "-".repeat(cellW), - "-".repeat(deltaW), - "-".repeat(pctW), - "-".repeat(cellW), - "-".repeat(cellW), - "-".repeat(deltaW), - "-".repeat(pctW), - ].join(" "), -); -for (const r of rows) { - console.log( - [ - pad(r.name, nameW), - pad(r.baseMean, cellW), - pad(r.curMean, cellW), - pad(r.meanDeltaNs, deltaW), - pad(r.meanDeltaPct, pctW), - pad(r.baseP50, cellW), - pad(r.curP50, cellW), - pad(r.p50DeltaNs, deltaW), - pad(r.p50DeltaPct, pctW), - ].join(" "), - ); -} \ No newline at end of file diff --git a/turbo.json b/turbo.json index ce63b99..22e054b 100644 --- a/turbo.json +++ b/turbo.json @@ -32,6 +32,10 @@ "dependsOn": ["format", "^build", "generate"], "cache": false }, + "bench": { + "dependsOn": ["^build"], + "cache": false + }, "attw": { "dependsOn": ["build"], "outputLogs": "new-only" From 3a721c080cbfeca16b690199f01122b477e15709 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 11 Jun 2026 11:53:05 -0400 Subject: [PATCH 32/38] fix formatting --- packages/protovalidate/src/native/enum.ts | 5 ++++- packages/protovalidate/src/native/map.ts | 5 ++++- packages/protovalidate/src/native/numeric.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/protovalidate/src/native/enum.ts b/packages/protovalidate/src/native/enum.ts index 16c5b9e..545df02 100644 --- a/packages/protovalidate/src/native/enum.ts +++ b/packages/protovalidate/src/native/enum.ts @@ -20,7 +20,10 @@ import type { } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import {type EnumRules, EnumRulesSchema} from "../gen/buf/validate/validate_pb.js"; +import { + type EnumRules, + EnumRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; import { formatList } from "./format.js"; diff --git a/packages/protovalidate/src/native/map.ts b/packages/protovalidate/src/native/map.ts index 7ecb276..df4a945 100644 --- a/packages/protovalidate/src/native/map.ts +++ b/packages/protovalidate/src/native/map.ts @@ -16,7 +16,10 @@ import { type DescField, isFieldSet } from "@bufbuild/protobuf"; import type { Path, PathBuilder, ReflectMap } from "@bufbuild/protobuf/reflect"; import type { Cursor } from "../cursor.js"; import type { Eval } from "../eval.js"; -import {type MapRules, MapRulesSchema} from "../gen/buf/validate/validate_pb.js"; +import { + type MapRules, + MapRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; const F = MapRulesSchema.field; diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index 82cd20e..d9ae427 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -35,7 +35,7 @@ import { UInt64RulesSchema, } from "../gen/buf/validate/validate_pb.js"; import type { ScalarNativeResult } from "./dispatcher.js"; -import {formatList, printFloat } from "./format.js"; +import { formatList, printFloat } from "./format.js"; type NumericRulesDescs = { readonly const: DescField; From b3f864f465b5816b2d5581822697d400b86b252b Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 11 Jun 2026 12:07:28 -0400 Subject: [PATCH 33/38] add support for disabling native tests in benchmark --- packages/protovalidate-bench/src/bench.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index 73850b7..cad6d83 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -15,7 +15,10 @@ import * as console from "node:console"; import { writeFileSync } from "node:fs"; import { parseArgs } from "node:util"; -import { createValidator } from "@bufbuild/protovalidate"; +import { + createValidator, + type ValidatorOptions, +} from "@bufbuild/protovalidate"; import { Bench, type Task } from "tinybench"; import { cases } from "./cases.js"; @@ -62,7 +65,10 @@ if (tests.length == 0) { } const bench = new Bench({ name: "protovalidate benchmarks", time: 100 }); -const validator = createValidator(); +const disableNative = process.env.DISABLE_NATIVE_RULES; +const opts: ValidatorOptions = + disableNative !== undefined ? { disableNativeRules: true } : {}; +const validator = createValidator(opts); for (const test of tests) { bench.add(test.name, () => { validator.validate(test.schema, test.fixture); From 7fe6e8f601734f9bc502646bbd51c74518a6c4ec Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 11 Jun 2026 16:15:03 -0400 Subject: [PATCH 34/38] string support completed --- README.md | 4 +- package-lock.json | 9 +- .../protovalidate-testing/src/executor.ts | 8 +- packages/protovalidate/package.json | 3 +- .../protovalidate/src/native/dispatcher.ts | 12 + .../protovalidate/src/native/format.test.ts | 40 +- packages/protovalidate/src/native/format.ts | 35 + .../protovalidate/src/native/string.test.ts | 517 ++++++++++++++ packages/protovalidate/src/native/string.ts | 665 ++++++++++++++++++ packages/protovalidate/src/regex.ts | 44 ++ packages/protovalidate/src/validator.ts | 16 +- 11 files changed, 1341 insertions(+), 12 deletions(-) create mode 100644 packages/protovalidate/src/native/string.test.ts create mode 100644 packages/protovalidate/src/native/string.ts create mode 100644 packages/protovalidate/src/regex.ts diff --git a/README.md b/README.md index 4a66ae4..36e371a 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ if (result.kind !== "valid") { > > The `string.pattern` rule supports regular expressions with CEL's standard [RE2 syntax](https://github.com/google/re2/wiki/syntax). > -> Protovalidate translates RE2 to ECMAScript's regular expressions. This works except for some RE2 flags, but it cannot support RE2's most important property: Execution in linear time, which guards against [ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS). +> Protovalidate evaluates patterns with [@bufbuild/re2](https://www.npmjs.com/package/@bufbuild/re2), an RE2-compatible engine that executes in linear time, guarding against [ReDoS](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS). > -> If you need full support for RE2, you can bring your own RE2 implementation: +> If you prefer a different engine, you can bring your own RE2 implementation: > > ```ts > const validator = createValidator({ diff --git a/package-lock.json b/package-lock.json index 4675af8..177d393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -525,6 +525,12 @@ "resolved": "packages/protovalidate-testing", "link": true }, + "node_modules/@bufbuild/re2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@bufbuild/re2/-/re2-0.6.0.tgz", + "integrity": "sha512-HrSjnBjk71LpKDlUnbxdN9DD24Hr5O92kCxRVHA0sEa35z6tEUjBS7YQVYBMS9npbllLtPW7tGSAWwfUKBs1Hw==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1862,7 +1868,8 @@ "version": "1.2.0", "license": "Apache-2.0", "dependencies": { - "@bufbuild/cel": "0.4.0" + "@bufbuild/cel": "0.4.0", + "@bufbuild/re2": "^0.6.0" }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/packages/protovalidate-testing/src/executor.ts b/packages/protovalidate-testing/src/executor.ts index e77fd85..cfc0168 100644 --- a/packages/protovalidate-testing/src/executor.ts +++ b/packages/protovalidate-testing/src/executor.ts @@ -38,7 +38,13 @@ if (!request.fdset) { throw new Error(`Empty request field "fdset"`); } const registry = createFileRegistry(request.fdset); -const validator = createValidator({ registry }); +// Set PROTOVALIDATE_DISABLE_NATIVE_RULES=1 to run the conformance suite +// against the pure-CEL path, proving equivalence with the (default-on) +// native rules. +const validator = createValidator({ + registry, + disableNativeRules: process.env.PROTOVALIDATE_DISABLE_NATIVE_RULES === "1", +}); const response = create(TestConformanceResponseSchema); for (const [name, any] of Object.entries(request.cases)) { const testResult = create(TestResultSchema); diff --git a/packages/protovalidate/package.json b/packages/protovalidate/package.json index 6483a86..825a228 100644 --- a/packages/protovalidate/package.json +++ b/packages/protovalidate/package.json @@ -42,7 +42,8 @@ } }, "dependencies": { - "@bufbuild/cel": "0.4.0" + "@bufbuild/cel": "0.4.0", + "@bufbuild/re2": "^0.6.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.8.0" diff --git a/packages/protovalidate/src/native/dispatcher.ts b/packages/protovalidate/src/native/dispatcher.ts index f03f463..42b6544 100644 --- a/packages/protovalidate/src/native/dispatcher.ts +++ b/packages/protovalidate/src/native/dispatcher.ts @@ -25,6 +25,7 @@ import type { FieldRules, MapRules, RepeatedRules, + StringRules, } from "../gen/buf/validate/validate_pb.js"; import { BoolRulesSchema, @@ -32,6 +33,7 @@ import { EnumRulesSchema, MapRulesSchema, RepeatedRulesSchema, + StringRulesSchema, } from "../gen/buf/validate/validate_pb.js"; import type { Eval } from "../eval.js"; import type { RegexMatcher } from "../func.js"; @@ -41,6 +43,7 @@ import { tryBuildNativeEnumRules } from "./enum.js"; import { tryBuildNativeMapRules } from "./map.js"; import { tryBuildNativeNumericRules } from "./numeric.js"; import { tryBuildNativeRepeatedRules } from "./repeated.js"; +import { tryBuildNativeStringRules } from "./string.js"; import { WrappedValueEval } from "./wrapper.js"; /** @@ -123,6 +126,15 @@ export function tryBuildNative( ); return liftScalar(r, wrappedValueField); } + case StringRulesSchema.typeName: { + const r = tryBuildNativeStringRules( + rules as StringRules, + rulePath, + forMapKey, + regexMatch, + ); + return liftScalar(r, wrappedValueField); + } case BytesRulesSchema.typeName: { const r = tryBuildNativeBytesRules( rules as BytesRules, diff --git a/packages/protovalidate/src/native/format.test.ts b/packages/protovalidate/src/native/format.test.ts index 4f71d6a..40c8f88 100644 --- a/packages/protovalidate/src/native/format.test.ts +++ b/packages/protovalidate/src/native/format.test.ts @@ -14,7 +14,7 @@ import { suite, test } from "node:test"; import * as assert from "node:assert/strict"; -import { codepointLength, printFloat } from "./format.js"; +import { codepointLength, printFloat, utf8ByteLength } from "./format.js"; void suite("codepointLength", () => { void test("counts ASCII as one per char", () => { @@ -44,6 +44,44 @@ void suite("codepointLength", () => { }); }); +void suite("utf8ByteLength", () => { + void test("counts ASCII as one byte per char", () => { + assert.strictEqual(utf8ByteLength(""), 0); + assert.strictEqual(utf8ByteLength("a"), 1); + assert.strictEqual(utf8ByteLength("abc"), 3); + }); + + void test("counts 2-, 3-, and 4-byte sequences", () => { + assert.strictEqual(utf8ByteLength("é"), 2); // U+00E9 + assert.strictEqual(utf8ByteLength("€"), 3); // U+20AC + assert.strictEqual(utf8ByteLength("𝑎"), 4); // U+1D44E, surrogate pair + }); + + void test("counts unpaired surrogates as U+FFFD (3 bytes)", () => { + assert.strictEqual(utf8ByteLength("\ud800"), 3); // lone high surrogate + assert.strictEqual(utf8ByteLength("\udc00"), 3); // lone low surrogate + assert.strictEqual(utf8ByteLength("\ud800x"), 4); // high surrogate + ASCII + }); + + void test("matches TextEncoder for mixed inputs", () => { + const cases = [ + "", + "x", + "𝑎b", + "🇺🇸", + "héllo", + "߿ࠀ", + "\ud800", + "\udc00😀", + "a𐀀b", + ]; + const encoder = new TextEncoder(); + for (const s of cases) { + assert.strictEqual(utf8ByteLength(s), encoder.encode(s).length); + } + }); +}); + void suite("printFloat", () => { void test("formats finite numbers via toString", () => { assert.strictEqual(printFloat(0), "0"); diff --git a/packages/protovalidate/src/native/format.ts b/packages/protovalidate/src/native/format.ts index a38f1cf..18c24cb 100644 --- a/packages/protovalidate/src/native/format.ts +++ b/packages/protovalidate/src/native/format.ts @@ -27,6 +27,41 @@ export function codepointLength(s: string): number { return n; } +/** + * Number of bytes in the UTF-8 encoding of a string. + * + * Matches CEL's `bytes(string).size()` semantics — i.e. what + * `new TextEncoder().encode(s).length` returns, including the replacement of + * unpaired surrogates with U+FFFD (3 bytes) — without allocating the encoded + * buffer. + */ +export function utf8ByteLength(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c < 0x80) { + n += 1; + } else if (c < 0x800) { + n += 2; + } else if (c >= 0xd800 && c < 0xdc00 && i + 1 < s.length) { + const d = s.charCodeAt(i + 1); + if (d >= 0xdc00 && d < 0xe000) { + // Surrogate pair: one code point above U+FFFF, 4 bytes. + n += 4; + i++; + } else { + // Unpaired high surrogate: encoded as U+FFFD, 3 bytes. + n += 3; + } + } else { + // BMP code point at U+0800 and above, or an unpaired surrogate + // (encoded as U+FFFD) — 3 bytes either way. + n += 3; + } + } + return n; +} + /** * Format a finite double for inclusion in a violation message. * diff --git a/packages/protovalidate/src/native/string.test.ts b/packages/protovalidate/src/native/string.test.ts new file mode 100644 index 0000000..b92624a --- /dev/null +++ b/packages/protovalidate/src/native/string.test.ts @@ -0,0 +1,517 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { suite, test } from "node:test"; +import * as assert from "node:assert/strict"; +import { create, createRegistry } from "@bufbuild/protobuf"; +import { pathToString } from "@bufbuild/protobuf/reflect"; +import { compileFile } from "@bufbuild/protocompile"; +import { bufCompileOptions, cel, compile, diff, native } from "./testing.js"; +import { RuntimeError } from "../error.js"; +import { createValidator } from "../validator.js"; + +void suite("native string rules", () => { + void test("string.const passes and fails", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.const = "hello"]; }`, + ); + diff(s, create(s, { v: "hello" })); + diff(s, create(s, { v: "world" })); + diff(s, create(s, { v: "" })); + }); + + void test("string.len counts code points, not UTF-16 units", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.len = 2]; }`, + ); + diff(s, create(s, { v: "ab" })); + // "𝑎" is a surrogate pair: .length is 2, but it is 1 code point. + diff(s, create(s, { v: "𝑎" })); + diff(s, create(s, { v: "𝑎b" })); // 2 code points — passes + diff(s, create(s, { v: "abc" })); + }); + + void test("string.min_len + max_len", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string = { min_len: 2, max_len: 4 }]; }`, + ); + diff(s, create(s, { v: "ab" })); + diff(s, create(s, { v: "abcd" })); + diff(s, create(s, { v: "a" })); + diff(s, create(s, { v: "abcde" })); + diff(s, create(s, { v: "𝑎𝑏" })); // 2 code points — passes + }); + + void test("string.len_bytes / min_bytes / max_bytes count UTF-8 bytes", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string = { min_bytes: 2, max_bytes: 4 }]; }`, + ); + diff(s, create(s, { v: "ab" })); + diff(s, create(s, { v: "a" })); // 1 byte — too short + diff(s, create(s, { v: "é" })); // 2 bytes — passes + diff(s, create(s, { v: "𝑎" })); // 4 bytes — passes + diff(s, create(s, { v: "𝑎b" })); // 5 bytes — too long + const exact = compile( + `message M { string v = 1 [(buf.validate.field).string.len_bytes = 3]; }`, + ); + diff(exact, create(exact, { v: "abc" })); + diff(exact, create(exact, { v: "€" })); // 3 bytes — passes + diff(exact, create(exact, { v: "ab" })); + }); + + void suite("string.pattern", () => { + void test("valid match passes, mismatch fails", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = "^[a-z]+$"]; }`, + ); + diff(s, create(s, { v: "hello" })); + diff(s, create(s, { v: "HELLO" })); + diff(s, create(s, { v: "" })); + }); + + void test("RE2-only syntax works under the default engine", () => { + // "(?i)" mid-pattern is valid RE2 but invalid ECMAScript — this is + // the engine swap in action. + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = "(?i)^hello$"]; }`, + ); + diff(s, create(s, { v: "HELLO" })); + diff(s, create(s, { v: "nope" })); + assert.equal(native.validate(s, create(s, { v: "HeLLo" })).kind, "valid"); + }); + + void test("pattern invalid under RE2 errors on both paths", () => { + // Lookahead is valid ECMAScript but not RE2. The native handler + // bails to CEL, whose matches() hits the same engine failure. + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = "(?=a)a"]; }`, + ); + diff(s, create(s, { v: "a" })); + assert.equal(native.validate(s, create(s, { v: "a" })).kind, "error"); + assert.equal(cel.validate(s, create(s, { v: "a" })).kind, "error"); + }); + + void test("empty pattern matches any input", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = ""]; }`, + ); + diff(s, create(s, { v: "" })); + diff(s, create(s, { v: "hello" })); + }); + + void test("custom regexMatch override is honored", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = ".+"]; }`, + ); + let calledWith: { pattern: string; against: string } | undefined; + const v = createValidator({ + regexMatch: (pattern, against) => { + if (against !== "") { + calledWith = { pattern, against }; + } + return false; // always fail + }, + }); + const r = v.validate(s, create(s, { v: "x" })); + assert.equal(r.kind, "invalid"); + assert.equal(calledWith?.pattern, ".+"); + assert.equal(calledWith?.against, "x"); + }); + + void test("custom regexMatch that throws at eval is a RuntimeError", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = ".+"]; }`, + ); + let probing = true; + const v = createValidator({ + regexMatch: () => { + if (probing) { + return true; // pass the plan-time probe + } + throw new Error("synthetic engine failure"); + }, + }); + probing = false; + const r = v.validate(s, create(s, { v: "x" })); + assert.equal(r.kind, "error"); + assert.ok(r.error instanceof RuntimeError); + }); + }); + + void test("string.prefix", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.prefix = "ab"]; }`, + ); + diff(s, create(s, { v: "abc" })); + diff(s, create(s, { v: "bc" })); + diff(s, create(s, { v: "" })); + }); + + void test("string.suffix", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.suffix = "yz"]; }`, + ); + diff(s, create(s, { v: "xyz" })); + diff(s, create(s, { v: "xy" })); + }); + + void test("string.contains / not_contains", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + contains: "needle", + not_contains: "thorn" + }]; + }`, + ); + diff(s, create(s, { v: "a needle here" })); + diff(s, create(s, { v: "no match" })); // contains fails + diff(s, create(s, { v: "needle and thorn" })); // not_contains fails + diff(s, create(s, { v: "just a thorn" })); // both fail + }); + + void test("string.in / not_in", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + in: ["foo", "bar"], + not_in: ["baz"] + }]; + }`, + ); + diff(s, create(s, { v: "foo" })); + diff(s, create(s, { v: "bar" })); + diff(s, create(s, { v: "baz" })); // violates both + diff(s, create(s, { v: "qux" })); // violates in only + // Lock the bare-CSV list formatting in the message. + const r = native.validate(s, create(s, { v: "qux" })); + assert.equal(r.kind, "invalid"); + assert.equal(r.violations?.[0]?.message, "must be in list [foo, bar]"); + }); + + void test("empty in / not_in lists are no-ops", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { in: [], not_in: [] }]; + }`, + ); + diff(s, create(s, { v: "" })); + diff(s, create(s, { v: "anything" })); + }); + + void test("combined rules fire in CEL order", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + const: "abcd", min_len: 10, pattern: "^[a-z]+$", suffix: "zz" + }]; + }`, + ); + diff(s, create(s, { v: "XY" })); // all four fail + diff(s, create(s, { v: "abcd" })); // min_len + suffix fail + }); + + void suite("StringValue wrapper", () => { + void test("inner value validated against string.min_len", () => { + const s = compile( + `message M { + google.protobuf.StringValue v = 1 [(buf.validate.field).string.min_len = 3]; + }`, + ); + diff(s, create(s, { v: "abc" })); + diff(s, create(s, { v: "ab" })); + }); + void test("inner value validated against string.email", () => { + const s = compile( + `message M { + google.protobuf.StringValue v = 1 [(buf.validate.field).string.email = true]; + }`, + ); + diff(s, create(s, { v: "foo@example.com" })); + diff(s, create(s, { v: "nope" })); + }); + void test("absent wrapper skips validation", () => { + const s = compile( + `message M { + google.protobuf.StringValue v = 1 [(buf.validate.field).string.min_len = 3]; + }`, + ); + diff(s, create(s, {})); + assert.equal(native.validate(s, create(s, {})).kind, "valid"); + }); + }); + + void test("map keys validated with forMapKey", () => { + const s = compile( + `message M { + map m = 1 [(buf.validate.field).map.keys.string.min_len = 3]; + }`, + ); + diff(s, create(s, { m: { abc: "x" } })); + diff(s, create(s, { m: { ab: "x" } })); + }); + + void suite("rule path assertions", () => { + void test("path lands at string.const", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.const = "a"]; }`, + ); + const r = native.validate(s, create(s, { v: "b" })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "string.const"); + }); + void test("path lands at string.pattern", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.pattern = "^x$"]; }`, + ); + const r = native.validate(s, create(s, { v: "y" })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "string.pattern"); + }); + void test("path lands at string.uuid (well-known oneof)", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.uuid = true]; }`, + ); + const r = native.validate(s, create(s, { v: "nope" })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(pathToString(v.rule), "string.uuid"); + assert.equal(v.ruleId, "string.uuid"); + }); + }); + + void suite("well-known formats", () => { + // Each entry: [proto rule name, valid input, invalid input]. + // Empty input is exercised separately to cover the *_empty rule ids. + const cases: [string, string, string][] = [ + ["email", "foo@example.com", "not-an-email"], + ["hostname", "example.com", "-bad-.example"], + ["ip", "192.168.0.1", "999.0.0.1"], + ["ipv4", "192.168.0.1", "::1"], + ["ipv6", "::1", "192.168.0.1"], + ["uri", "https://example.com/path", "not a uri"], + ["uri_ref", "./relative/path", "::"], + ["address", "example.com", "!!!"], + ["uuid", "8badf0d8-2cab-4dcb-94ee-fa6f4e5d4a4a", "not-a-uuid"], + ["tuuid", "8badf0d82cab4dcb94eefa6f4e5d4a4a", "not-a-tuuid"], + ["ulid", "01ARZ3NDEKTSV4RRFFQ69G5FAV", "not-a-ulid"], + ["ip_with_prefixlen", "192.168.1.5/24", "192.168.1.5"], + ["ipv4_with_prefixlen", "192.168.1.5/24", "2001:db8::1/64"], + ["ipv6_with_prefixlen", "2001:db8::1/64", "192.168.1.5/24"], + ["ip_prefix", "192.168.1.0/24", "192.168.1.5/24"], + ["ipv4_prefix", "192.168.1.0/24", "2001:db8::/64"], + ["ipv6_prefix", "2001:db8::/64", "2001:db8::1/64"], + ["host_and_port", "example.com:8080", "example.com"], + ["protobuf_fqn", "buf.validate.StringRules", ".leading.dot"], + ["protobuf_dot_fqn", ".buf.validate.StringRules", "no.leading.dot"], + ]; + for (const [rule, valid, invalid] of cases) { + void test(`string.${rule} valid, invalid, and empty`, () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.${rule} = true]; }`, + ); + diff(s, create(s, { v: valid })); + diff(s, create(s, { v: invalid })); + diff(s, create(s, { v: "" })); + }); + } + + void test("host_and_port accepts bracketed IPv6 with port", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.host_and_port = true]; }`, + ); + diff(s, create(s, { v: "[::1]:8080" })); + diff(s, create(s, { v: "::1" })); // no port + }); + + void test("empty input emits the *_empty rule id", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.email = true]; }`, + ); + const r = native.validate(s, create(s, { v: "" })); + assert.equal(r.kind, "invalid"); + const v = r.violations?.[0]; + assert.ok(v); + assert.equal(v.ruleId, "string.email_empty"); + assert.equal( + v.message, + "value is empty, which is not a valid email address", + ); + assert.equal(pathToString(v.rule), "string.email"); + }); + + void test("uri_ref has no *_empty rule", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.uri_ref = true]; }`, + ); + diff(s, create(s, { v: "" })); + const r = native.validate(s, create(s, { v: "" })); + // Whatever the verdict, an empty uri_ref never produces a + // "string.uri_ref_empty" id — there is no such rule. + if (r.kind === "invalid") { + assert.equal(r.violations?.[0]?.ruleId, "string.uri_ref"); + } + }); + + void test("explicit email=false is a no-op claim — never emits a violation", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.email = false]; }`, + ); + diff(s, create(s, { v: "" })); + diff(s, create(s, { v: "not-an-email" })); + assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid"); + assert.equal( + native.validate(s, create(s, { v: "not-an-email" })).kind, + "valid", + ); + }); + }); + + void suite("string.well_known_regex", () => { + void test("header name, strict by default", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; }`, + ); + diff(s, create(s, { v: "Content-Type" })); + diff(s, create(s, { v: ":authority" })); + diff(s, create(s, { v: "bad name" })); // space not allowed + diff(s, create(s, { v: "bad\u0000name" })); + }); + + void test("header name empty emits header_name_empty", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; }`, + ); + diff(s, create(s, { v: "" })); + const r = native.validate(s, create(s, { v: "" })); + assert.equal(r.kind, "invalid"); + assert.equal( + r.violations?.[0]?.ruleId, + "string.well_known_regex.header_name_empty", + ); + }); + + void test("header name with strict=false uses the loose pattern", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + well_known_regex: KNOWN_REGEX_HTTP_HEADER_NAME, strict: false + }]; + }`, + ); + diff(s, create(s, { v: "anything goes ()" })); // loose allows spaces + diff(s, create(s, { v: "bad\u0000name" })); // NUL still rejected + diff(s, create(s, { v: "bad\nname" })); // LF still rejected + diff(s, create(s, { v: "" })); + }); + + void test("header value, strict by default", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; }`, + ); + diff(s, create(s, { v: "application/json" })); + diff(s, create(s, { v: "tab\tis fine" })); + diff(s, create(s, { v: "bad\u0000value" })); + diff(s, create(s, { v: "bad\u001fvalue" })); + diff(s, create(s, { v: "" })); // empty matches the * pattern + assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid"); + }); + + void test("header value with strict=false uses the loose * pattern", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + well_known_regex: KNOWN_REGEX_HTTP_HEADER_VALUE, strict: false + }]; + }`, + ); + // The loose header-value pattern is anchored with * (unlike header + // name's +), so empty input is valid — a spot where CEL and + // protovalidate-go's native path historically diverged. + diff(s, create(s, { v: "" })); + diff(s, create(s, { v: "ctl\u0001is fine when loose" })); + diff(s, create(s, { v: "bad\u0000value" })); + diff(s, create(s, { v: "bad\rvalue" })); + assert.equal(native.validate(s, create(s, { v: "" })).kind, "valid"); + }); + + void test("explicit strict=true behaves like the default", () => { + const s = compile( + `message M { + string v = 1 [(buf.validate.field).string = { + well_known_regex: KNOWN_REGEX_HTTP_HEADER_VALUE, strict: true + }]; + }`, + ); + diff(s, create(s, { v: "good value" })); + diff(s, create(s, { v: "bad\u001fvalue" })); + }); + + void test("KNOWN_REGEX_UNSPECIFIED is a no-op claim", () => { + const s = compile( + `message M { string v = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_UNSPECIFIED]; }`, + ); + diff(s, create(s, { v: "" })); + diff(s, create(s, { v: "anything\u0000at all" })); + assert.equal( + native.validate(s, create(s, { v: "anything\u0000at all" })).kind, + "valid", + ); + }); + }); + + void suite("fallthrough", () => { + void test("custom predefined extension falls back to CEL entirely", () => { + const descFile = compileFile( + ` + syntax = "proto2"; + import "buf/validate/validate.proto"; + message M { + optional string v = 1 [ + (buf.validate.field).string.min_len = 5, + (buf.validate.field).string.(starts_x) = true + ]; + } + extend buf.validate.StringRules { + optional bool starts_x = 81048953 [(buf.validate.predefined).cel = { + id: "string.starts_x" + message: "value must start with x" + expression: "!rules.starts_x || this.startsWith('x')" + }]; + } + `, + bufCompileOptions, + ); + const s = descFile.messages[0]; + const ext = descFile.extensions[0]; + const nativeV = createValidator({ registry: createRegistry(ext) }); + const celV = createValidator({ + registry: createRegistry(ext), + disableNativeRules: true, + }); + const fmt = (v: { toString(): string }) => v.toString(); + for (const input of ["xhello", "hello", "x", "abc"]) { + const a = nativeV.validate(s, create(s, { v: input })); + const b = celV.validate(s, create(s, { v: input })); + assert.equal(a.kind, b.kind, `kind mismatch for ${input}`); + assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); + } + }); + }); +}); diff --git a/packages/protovalidate/src/native/string.ts b/packages/protovalidate/src/native/string.ts new file mode 100644 index 0000000..0f76b74 --- /dev/null +++ b/packages/protovalidate/src/native/string.ts @@ -0,0 +1,665 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type DescField, isFieldSet } from "@bufbuild/protobuf"; +import type { + Path, + PathBuilder, + ScalarValue, +} from "@bufbuild/protobuf/reflect"; +import type { Cursor } from "../cursor.js"; +import type { Eval } from "../eval.js"; +import { RuntimeError } from "../error.js"; +import { + KnownRegex, + type StringRules, + StringRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; +import { + isEmail, + isHostAndPort, + isHostname, + isIp, + isIpPrefix, + isUri, + isUriRef, +} from "../lib.js"; +import type { RegexMatcher } from "../func.js"; +import type { ScalarNativeResult } from "./dispatcher.js"; +import { codepointLength, formatList, utf8ByteLength } from "./format.js"; + +const F = StringRulesSchema.field; + +/** A rule with a string operand: const, prefix, suffix, contains, not_contains. */ +type StrRule = { readonly val: string; readonly path: Path }; +/** A rule with a numeric size operand: len, min_len, max_len, len_bytes, min_bytes, max_bytes. */ +type SizeRule = { readonly val: bigint; readonly path: Path }; +/** A rule with a string list operand: in, not_in. */ +type StrListRule = { readonly vals: readonly string[]; readonly path: Path }; +type PatternRule = { + readonly src: string; + readonly test: (against: string) => boolean; + readonly path: Path; +}; + +/** + * Spec carried alongside an active well-known constraint, so `eval()` does + * no per-call table lookups. + */ +type WellKnownRule = { + readonly check: (s: string) => boolean; + readonly ruleId: string; + readonly msg: string; + /** + * When set, an empty input emits this violation instead of running + * `check`, matching the dedicated `*_empty` CEL rules. Unset for + * `uri_ref` and `well_known_regex.header_value`, which validate the + * empty string like any other input. + */ + readonly empty?: { readonly ruleId: string; readonly msg: string }; + readonly path: Path; +}; + +/** + * The boolean members of the StringRules `well_known` oneof — + * everything except `well_known_regex`, which carries a KnownRegex enum + * and is dispatched separately. + */ +type BoolWellKnownCase = Exclude< + StringRules["wellKnown"]["case"], + "wellKnownRegex" | undefined +>; + +/** + * Per-kind specs for the boolean well-known string formats. Messages and + * rule ids mirror the predefined CEL annotations on the corresponding + * `StringRules` fields. Kinds backed by a fixed regex carry the exact + * pattern string the CEL expression compiles, so both paths share one + * compiled regex and one behavior under any engine; the rest call the same + * `lib.ts` helpers CEL's custom functions are built on. + */ +const WELL_KNOWN: Record< + BoolWellKnownCase, + { + readonly msg: string; + /** Unset for uri_ref, which has no `*_empty` CEL rule. */ + readonly emptyMsg?: string; + /** Exactly one of check / pattern is set. */ + readonly check?: (s: string) => boolean; + readonly pattern?: string; + } +> = { + email: { + msg: "must be a valid email address", + emptyMsg: "value is empty, which is not a valid email address", + check: (s) => isEmail.call(s), + }, + hostname: { + msg: "must be a valid hostname", + emptyMsg: "value is empty, which is not a valid hostname", + check: (s) => isHostname.call(s), + }, + ip: { + msg: "must be a valid IP address", + emptyMsg: "value is empty, which is not a valid IP address", + check: (s) => isIp.call(s), + }, + ipv4: { + msg: "must be a valid IPv4 address", + emptyMsg: "value is empty, which is not a valid IPv4 address", + check: (s) => isIp.call(s, 4), + }, + ipv6: { + msg: "must be a valid IPv6 address", + emptyMsg: "value is empty, which is not a valid IPv6 address", + check: (s) => isIp.call(s, 6), + }, + uri: { + msg: "must be a valid URI", + emptyMsg: "value is empty, which is not a valid URI", + check: (s) => isUri.call(s), + }, + uriRef: { + msg: "must be a valid URI Reference", + check: (s) => isUriRef.call(s), + }, + address: { + msg: "must be a valid hostname, or ip address", + emptyMsg: "value is empty, which is not a valid hostname, or ip address", + check: (s) => isHostname.call(s) || isIp.call(s), + }, + uuid: { + msg: "must be a valid UUID", + emptyMsg: "value is empty, which is not a valid UUID", + pattern: + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + }, + tuuid: { + msg: "must be a valid trimmed UUID", + emptyMsg: "value is empty, which is not a valid trimmed UUID", + pattern: "^[0-9a-fA-F]{32}$", + }, + ipWithPrefixlen: { + msg: "must be a valid IP prefix", + emptyMsg: "value is empty, which is not a valid IP prefix", + check: (s) => isIpPrefix.call(s), + }, + ipv4WithPrefixlen: { + msg: "must be a valid IPv4 address with prefix length", + emptyMsg: + "value is empty, which is not a valid IPv4 address with prefix length", + check: (s) => isIpPrefix.call(s, 4), + }, + ipv6WithPrefixlen: { + msg: "must be a valid IPv6 address with prefix length", + emptyMsg: + "value is empty, which is not a valid IPv6 address with prefix length", + check: (s) => isIpPrefix.call(s, 6), + }, + ipPrefix: { + msg: "must be a valid IP prefix", + emptyMsg: "value is empty, which is not a valid IP prefix", + check: (s) => isIpPrefix.call(s, undefined, true), + }, + ipv4Prefix: { + msg: "must be a valid IPv4 prefix", + emptyMsg: "value is empty, which is not a valid IPv4 prefix", + check: (s) => isIpPrefix.call(s, 4, true), + }, + ipv6Prefix: { + msg: "must be a valid IPv6 prefix", + emptyMsg: "value is empty, which is not a valid IPv6 prefix", + check: (s) => isIpPrefix.call(s, 6, true), + }, + hostAndPort: { + msg: "must be a valid host (hostname or IP address) and port pair", + emptyMsg: "value is empty, which is not a valid host and port pair", + check: (s) => isHostAndPort.call(s, true), + }, + ulid: { + msg: "must be a valid ULID", + emptyMsg: "value is empty, which is not a valid ULID", + pattern: "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", + }, + protobufFqn: { + msg: "must be a valid fully-qualified Protobuf name", + emptyMsg: + "value is empty, which is not a valid fully-qualified Protobuf name", + pattern: "^[A-Za-z_][A-Za-z_0-9]*(\\.[A-Za-z_][A-Za-z_0-9]*)*$", + }, + protobufDotFqn: { + msg: "must be a valid fully-qualified Protobuf name with a leading dot", + emptyMsg: + "value is empty, which is not a valid fully-qualified Protobuf name with a leading dot", + pattern: "^\\.[A-Za-z_][A-Za-z_0-9]*(\\.[A-Za-z_][A-Za-z_0-9]*)*$", + }, +}; + +// The `well_known_regex` patterns, byte-identical to the strings the CEL +// expressions on `StringRules.well_known_regex` compile (after CEL string +// unescaping — `\\x60` becomes a literal backtick, `\\u0000` a literal NUL). +// The loose patterns differ between header name (`+`) and header value +// (`*`); CEL is the source of truth here, not protovalidate-go's shared +// loose regex. +const headerNameStrictPattern = "^:?[0-9a-zA-Z!#$%&'*+-.^_|~`]+$"; +const headerNameLoosePattern = "^[^\\u0000\\u000A\\u000D]+$"; +const headerValueStrictPattern = "^[^\\u0000-\\u0008\\u000A-\\u001F\\u007F]*$"; +const headerValueLoosePattern = "^[^\\u0000\\u000A\\u000D]*$"; + +/** + * Configuration for {@link EvalNativeStringRules}. Bundled into a single + * object so callers don't have to track ~15 positional constructor args. + */ +type StringRulesConfig = { + readonly forMapKey: boolean; + readonly constRule?: StrRule; + readonly exactLen?: SizeRule; + readonly minLen?: SizeRule; + readonly maxLen?: SizeRule; + readonly exactBytes?: SizeRule; + readonly minBytes?: SizeRule; + readonly maxBytes?: SizeRule; + readonly pattern?: PatternRule; + readonly prefix?: StrRule; + readonly suffix?: StrRule; + readonly containsRule?: StrRule; + readonly notContainsRule?: StrRule; + readonly inRule?: StrListRule; + readonly notInRule?: StrListRule; + readonly wellKnown?: WellKnownRule; +}; + +// Checks run in the declaration order of the StringRules fields, which is +// the order the CEL path evaluates the predefined rules in — keeping the +// violation order identical between the two paths. +class EvalNativeStringRules implements Eval { + constructor(private readonly cfg: StringRulesConfig) {} + + eval(val: ScalarValue, cursor: Cursor): void { + const v = val as string; + const c = this.cfg; + + if (c.constRule !== undefined && v !== c.constRule.val) { + cursor.violate( + `must equal \`${c.constRule.val}\``, + "string.const", + c.constRule.path, + c.forMapKey, + ); + } + + if ( + c.exactLen !== undefined || + c.minLen !== undefined || + c.maxLen !== undefined + ) { + const len = BigInt(codepointLength(v)); + if (c.exactLen !== undefined && len !== c.exactLen.val) { + cursor.violate( + `must be ${c.exactLen.val} characters`, + "string.len", + c.exactLen.path, + c.forMapKey, + ); + } + if (c.minLen !== undefined && len < c.minLen.val) { + cursor.violate( + `must be at least ${c.minLen.val} characters`, + "string.min_len", + c.minLen.path, + c.forMapKey, + ); + } + if (c.maxLen !== undefined && len > c.maxLen.val) { + cursor.violate( + `must be at most ${c.maxLen.val} characters`, + "string.max_len", + c.maxLen.path, + c.forMapKey, + ); + } + } + + if ( + c.exactBytes !== undefined || + c.minBytes !== undefined || + c.maxBytes !== undefined + ) { + const len = BigInt(utf8ByteLength(v)); + if (c.exactBytes !== undefined && len !== c.exactBytes.val) { + cursor.violate( + `must be ${c.exactBytes.val} bytes`, + "string.len_bytes", + c.exactBytes.path, + c.forMapKey, + ); + } + if (c.minBytes !== undefined && len < c.minBytes.val) { + cursor.violate( + `must be at least ${c.minBytes.val} bytes`, + "string.min_bytes", + c.minBytes.path, + c.forMapKey, + ); + } + if (c.maxBytes !== undefined && len > c.maxBytes.val) { + cursor.violate( + `must be at most ${c.maxBytes.val} bytes`, + "string.max_bytes", + c.maxBytes.path, + c.forMapKey, + ); + } + } + + if (c.pattern !== undefined) { + // Wrap test() — if a user-supplied regexMatch throws, surface it as a + // RuntimeError so CEL's behavior is preserved end-to-end. The default + // RE2 engine doesn't throw at match time. + let matched: boolean; + try { + matched = c.pattern.test(v); + } catch (cause) { + throw new RuntimeError(`regex match failed for ${c.pattern.src}`, { + cause, + }); + } + if (!matched) { + cursor.violate( + `does not match regex pattern \`${c.pattern.src}\``, + "string.pattern", + c.pattern.path, + c.forMapKey, + ); + } + } + + if (c.prefix !== undefined && !v.startsWith(c.prefix.val)) { + cursor.violate( + `does not have prefix \`${c.prefix.val}\``, + "string.prefix", + c.prefix.path, + c.forMapKey, + ); + } + + if (c.suffix !== undefined && !v.endsWith(c.suffix.val)) { + cursor.violate( + `does not have suffix \`${c.suffix.val}\``, + "string.suffix", + c.suffix.path, + c.forMapKey, + ); + } + + if (c.containsRule !== undefined && !v.includes(c.containsRule.val)) { + cursor.violate( + `does not contain substring \`${c.containsRule.val}\``, + "string.contains", + c.containsRule.path, + c.forMapKey, + ); + } + + if (c.notContainsRule !== undefined && v.includes(c.notContainsRule.val)) { + cursor.violate( + `contains substring \`${c.notContainsRule.val}\``, + "string.not_contains", + c.notContainsRule.path, + c.forMapKey, + ); + } + + if (c.inRule !== undefined && !c.inRule.vals.includes(v)) { + cursor.violate( + `must be in list ${formatList(c.inRule.vals, (s) => s)}`, + "string.in", + c.inRule.path, + c.forMapKey, + ); + } + + if (c.notInRule?.vals.includes(v)) { + cursor.violate( + `must not be in list ${formatList(c.notInRule.vals, (s) => s)}`, + "string.not_in", + c.notInRule.path, + c.forMapKey, + ); + } + + if (c.wellKnown !== undefined) { + const wk = c.wellKnown; + if (wk.empty !== undefined && v === "") { + cursor.violate(wk.empty.msg, wk.empty.ruleId, wk.path, c.forMapKey); + } else if (!wk.check(v)) { + cursor.violate(wk.msg, wk.ruleId, wk.path, c.forMapKey); + } + } + } + + prune(): boolean { + return false; + } +} + +/** + * Build a match predicate for a pattern under the active regex engine. + * Returns `undefined` if the pattern doesn't compile — the caller falls + * through to CEL, which surfaces the same failure the way it already does + * today. + */ +function makePatternTest( + src: string, + regexMatch: RegexMatcher | undefined, +): ((against: string) => boolean) | undefined { + try { + if (regexMatch) { + // Probe the engine at plan time so an invalid pattern surfaces here, + // symmetric with the default engine's eager compile. Empty input is + // the contract-safe probe — a regex engine must be able to test any + // pattern against the empty string. + regexMatch(src, ""); + return (against) => regexMatch(src, against); + } + const re = new RegExp(src); + return (against) => re.test(against); + } catch { + return undefined; + } +} + +/** + * Try to build a native evaluator for StringRules. Returns `undefined` if + * no native handler applies (no fields set, unknown extensions, or an + * uncompilable pattern that we let CEL surface). + * + * String rules are handled all-or-nothing: a single set field the native + * path can't take falls the entire rules message through to CEL, so the + * violation order always matches the pure-CEL path. + */ +export function tryBuildNativeStringRules( + rules: StringRules, + rulePath: PathBuilder, + forMapKey: boolean, + regexMatch: RegexMatcher | undefined, +): ScalarNativeResult | undefined { + if (rules.$unknown && rules.$unknown.length > 0) { + return undefined; + } + + const handled = new Set(); + const cfg: { + -readonly [K in keyof StringRulesConfig]: StringRulesConfig[K]; + } = { forMapKey }; + + if (isFieldSet(rules, F.const)) { + cfg.constRule = { + val: rules.const, + path: rulePath.clone().field(F.const).toPath(), + }; + handled.add(F.const); + } + + if (isFieldSet(rules, F.len)) { + cfg.exactLen = { + val: rules.len, + path: rulePath.clone().field(F.len).toPath(), + }; + handled.add(F.len); + } + + if (isFieldSet(rules, F.minLen)) { + cfg.minLen = { + val: rules.minLen, + path: rulePath.clone().field(F.minLen).toPath(), + }; + handled.add(F.minLen); + } + + if (isFieldSet(rules, F.maxLen)) { + cfg.maxLen = { + val: rules.maxLen, + path: rulePath.clone().field(F.maxLen).toPath(), + }; + handled.add(F.maxLen); + } + + if (isFieldSet(rules, F.lenBytes)) { + cfg.exactBytes = { + val: rules.lenBytes, + path: rulePath.clone().field(F.lenBytes).toPath(), + }; + handled.add(F.lenBytes); + } + + if (isFieldSet(rules, F.minBytes)) { + cfg.minBytes = { + val: rules.minBytes, + path: rulePath.clone().field(F.minBytes).toPath(), + }; + handled.add(F.minBytes); + } + + if (isFieldSet(rules, F.maxBytes)) { + cfg.maxBytes = { + val: rules.maxBytes, + path: rulePath.clone().field(F.maxBytes).toPath(), + }; + handled.add(F.maxBytes); + } + + if (isFieldSet(rules, F.pattern)) { + const test = makePatternTest(rules.pattern, regexMatch); + if (test === undefined) { + return undefined; + } + cfg.pattern = { + src: rules.pattern, + test, + path: rulePath.clone().field(F.pattern).toPath(), + }; + handled.add(F.pattern); + } + + if (isFieldSet(rules, F.prefix)) { + cfg.prefix = { + val: rules.prefix, + path: rulePath.clone().field(F.prefix).toPath(), + }; + handled.add(F.prefix); + } + + if (isFieldSet(rules, F.suffix)) { + cfg.suffix = { + val: rules.suffix, + path: rulePath.clone().field(F.suffix).toPath(), + }; + handled.add(F.suffix); + } + + if (isFieldSet(rules, F.contains)) { + cfg.containsRule = { + val: rules.contains, + path: rulePath.clone().field(F.contains).toPath(), + }; + handled.add(F.contains); + } + + if (isFieldSet(rules, F.notContains)) { + cfg.notContainsRule = { + val: rules.notContains, + path: rulePath.clone().field(F.notContains).toPath(), + }; + handled.add(F.notContains); + } + + if (rules.in.length > 0) { + cfg.inRule = { + vals: rules.in, + path: rulePath.clone().field(F.in).toPath(), + }; + handled.add(F.in); + } + + if (rules.notIn.length > 0) { + cfg.notInRule = { + vals: rules.notIn, + path: rulePath.clone().field(F.notIn).toPath(), + }; + handled.add(F.notIn); + } + + const wk = rules.wellKnown; + if (wk.case === "wellKnownRegex") { + // Claim the field on isFieldSet regardless of value — for UNSPECIFIED + // (or an unrecognized enum number) the CEL predicates `!= 1` / `!= 2` + // never fire, so the claim is a no-op, matching `bytes.ip: false`. + handled.add(F.wellKnownRegex); + const path = rulePath.clone().field(F.wellKnownRegex).toPath(); + // strict is on by default; only an explicit `strict: false` loosens. + const strict = !isFieldSet(rules, F.strict) || rules.strict; + if (wk.value === KnownRegex.HTTP_HEADER_NAME) { + const test = makePatternTest( + strict ? headerNameStrictPattern : headerNameLoosePattern, + regexMatch, + ); + if (test === undefined) { + return undefined; + } + cfg.wellKnown = { + check: test, + ruleId: "string.well_known_regex.header_name", + msg: "must be a valid HTTP header name", + empty: { + ruleId: "string.well_known_regex.header_name_empty", + msg: "value is empty, which is not a valid HTTP header name", + }, + path, + }; + } else if (wk.value === KnownRegex.HTTP_HEADER_VALUE) { + const test = makePatternTest( + strict ? headerValueStrictPattern : headerValueLoosePattern, + regexMatch, + ); + if (test === undefined) { + return undefined; + } + cfg.wellKnown = { + check: test, + ruleId: "string.well_known_regex.header_value", + msg: "must be a valid HTTP header value", + path, + }; + } + } else if (wk.case !== undefined) { + // Claim the leaf field on isFieldSet regardless of value — explicit + // `email: false` is a no-op rule, matching `bytes.ip: false` + // (`bytes.ts`) and `float.finite: false` (`numeric.ts`). + const desc = F[wk.case]; + handled.add(desc); + if (wk.value) { + const spec = WELL_KNOWN[wk.case]; + let check = spec.check; + if (check === undefined) { + check = makePatternTest(spec.pattern as string, regexMatch); + if (check === undefined) { + return undefined; + } + } + cfg.wellKnown = { + check, + ruleId: `string.${desc.name}`, + msg: spec.msg, + empty: + spec.emptyMsg === undefined + ? undefined + : { + ruleId: `string.${desc.name}_empty`, + msg: spec.emptyMsg, + }, + path: rulePath.clone().field(desc).toPath(), + }; + } + } + + if (handled.size === 0) { + return undefined; + } + + return { + eval: new EvalNativeStringRules(cfg), + handledFields: handled, + }; +} diff --git a/packages/protovalidate/src/regex.ts b/packages/protovalidate/src/regex.ts new file mode 100644 index 0000000..d02727b --- /dev/null +++ b/packages/protovalidate/src/regex.ts @@ -0,0 +1,44 @@ +// Copyright 2024-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { RE2JS } from "@bufbuild/re2"; +import type { RegexMatcher } from "./func.js"; + +// Most validators see a fixed set of patterns (they come from schema rules), +// so a simple compile cache makes repeat matches cheap. User CEL rules can +// construct patterns from input data, though, so the cache is reset when it +// grows past this bound to keep pathological inputs from leaking memory. +const cacheLimit = 1024; + +const cache = new Map(); + +/** + * The default {@link RegexMatcher}, backed by an RE2 engine. + * + * Patterns are compiled with RE2 syntax and matched in linear time, + * fulfilling protovalidate's RE2 contract for `string.pattern`, + * `bytes.pattern`, and the CEL `matches()` function. Throws on patterns + * that are not valid RE2 syntax. + */ +export function re2RegexMatch(pattern: string, against: string): boolean { + let re = cache.get(pattern); + if (re === undefined) { + if (cache.size >= cacheLimit) { + cache.clear(); + } + re = RE2JS.compile(pattern); + cache.set(pattern, re); + } + return re.test(against); +} diff --git a/packages/protovalidate/src/validator.ts b/packages/protovalidate/src/validator.ts index 33c2a94..0138a34 100644 --- a/packages/protovalidate/src/validator.ts +++ b/packages/protovalidate/src/validator.ts @@ -33,6 +33,7 @@ import { import { Planner } from "./planner.js"; import { CelManager } from "./cel.js"; import type { RegexMatcher } from "./func.js"; +import { re2RegexMatch } from "./regex.js"; import { file_buf_validate_validate } from "./gen/buf/validate/validate_pb.js"; /** @@ -60,12 +61,15 @@ export type ValidatorOptions = { /** * RE2 compliant regex matcher to use. * - * ECMAScript supports most, but not all RE expressions. You can use a custom - * regex engine to support the unsupported features of RE2. + * By default, regular expressions are evaluated with an RE2 engine + * (the @bufbuild/re2 package), matching protovalidate's documented RE2 + * contract: RE2 syntax, linear-time matching, no backreferences or + * lookaround. * - * Know limitations of default RE (ECMAScript) matcher: - * * Cannot change flags mid-sequence e.g. 'John(?i)Doe'. - * * Doesn't support the 'U' flag. + * This option is the bring-your-own-engine hook: a matcher supplied here + * replaces the default for every regex the validator evaluates — the + * `string.pattern` and `bytes.pattern` rules, and the CEL `matches()` + * function. */ regexMatch?: RegexMatcher; @@ -147,7 +151,7 @@ export function createValidator(opt?: ValidatorOptions): Validator { ? createMutableRegistry(opt.registry, file_buf_validate_validate) : createMutableRegistry(file_buf_validate_validate); const failFast = opt?.failFast ?? false; - const regexMatch = opt?.regexMatch; + const regexMatch = opt?.regexMatch ?? re2RegexMatch; const celMan = new CelManager(registry, regexMatch); const planner = new Planner( celMan, From 5358f9e0f1b2c853c2a2d63fd5e5d30ab6f8336e Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 2 Sep 2026 16:28:10 -0400 Subject: [PATCH 35/38] don't test Number.isNan; Number.isFinite covers it. Standardize on PROTOVALIDATE_DISABLE_NATIVE_RULES. Ensure that PROTOVALIDATE_DISABLE_NATIVE_RULES is passed through turbo to tests. Fix regexps so they use \x and not \u. add tests to validate that regexps are valid re2 syntax. simplify code calling isWrapperDesc (it already ensures there's a value field). --- .github/workflows/ci.yaml | 6 +++ packages/protovalidate-bench/src/bench.ts | 2 +- packages/protovalidate/src/native/numeric.ts | 2 +- .../protovalidate/src/native/string.test.ts | 39 ++++++++++++++++++- packages/protovalidate/src/native/string.ts | 16 +++++--- packages/protovalidate/src/planner.ts | 12 ++---- turbo.json | 1 + 7 files changed, 60 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f88c468..9fd7633 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -75,3 +75,9 @@ jobs: restore-keys: ${{ runner.os }}/test - run: npm ci - run: npx turbo run test + # Re-run the conformance suite against the pure-CEL path to prove it + # stays equivalent to the (default-on) native rules. + - name: conformance (native rules disabled) + run: npx turbo run test --filter=@bufbuild/protovalidate-testing + env: + PROTOVALIDATE_DISABLE_NATIVE_RULES: "1" diff --git a/packages/protovalidate-bench/src/bench.ts b/packages/protovalidate-bench/src/bench.ts index cad6d83..8966bc0 100644 --- a/packages/protovalidate-bench/src/bench.ts +++ b/packages/protovalidate-bench/src/bench.ts @@ -65,7 +65,7 @@ if (tests.length == 0) { } const bench = new Bench({ name: "protovalidate benchmarks", time: 100 }); -const disableNative = process.env.DISABLE_NATIVE_RULES; +const disableNative = process.env.PROTOVALIDATE_DISABLE_NATIVE_RULES; const opts: ValidatorOptions = disableNative !== undefined ? { disableNativeRules: true } : {}; const validator = createValidator(opts); diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index d9ae427..f525cd4 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -222,7 +222,7 @@ class EvalNativeNumericRules if ( this.finitePath !== undefined && typeof v === "number" && - (Number.isNaN(v) || !Number.isFinite(v)) + !Number.isFinite(v) ) { cursor.violate( "must be finite", diff --git a/packages/protovalidate/src/native/string.test.ts b/packages/protovalidate/src/native/string.test.ts index b92624a..51526ac 100644 --- a/packages/protovalidate/src/native/string.test.ts +++ b/packages/protovalidate/src/native/string.test.ts @@ -15,11 +15,17 @@ import { suite, test } from "node:test"; import * as assert from "node:assert/strict"; import { create, createRegistry } from "@bufbuild/protobuf"; -import { pathToString } from "@bufbuild/protobuf/reflect"; +import { buildPath, pathToString } from "@bufbuild/protobuf/reflect"; import { compileFile } from "@bufbuild/protocompile"; import { bufCompileOptions, cel, compile, diff, native } from "./testing.js"; import { RuntimeError } from "../error.js"; import { createValidator } from "../validator.js"; +import { + KnownRegex, + StringRulesSchema, +} from "../gen/buf/validate/validate_pb.js"; +import { re2RegexMatch } from "../regex.js"; +import { tryBuildNativeStringRules } from "./string.js"; void suite("native string rules", () => { void test("string.const passes and fails", () => { @@ -474,6 +480,37 @@ void suite("native string rules", () => { "valid", ); }); + // Regression: these header patterns originally spelled their control + // characters as `backslash-u` escapes, which RE2 rejects as an invalid + // escape sequence. `makePatternTest` swallows that error and returns + // undefined, so the entire rules message fell through to CEL — output + // stayed correct (so `diff` above still passed) while the native path + // silently never ran. Assert engagement directly, against the default + // RE2 engine. + for (const [label, wk] of [ + ["header name", KnownRegex.HTTP_HEADER_NAME], + ["header value", KnownRegex.HTTP_HEADER_VALUE], + ] as const) { + for (const strict of [true, false]) { + void test(`${label} (strict=${strict}) is valid RE2 and stays native`, () => { + const rules = create(StringRulesSchema, { + wellKnown: { case: "wellKnownRegex", value: wk }, + strict, + }); + const built = tryBuildNativeStringRules( + rules, + buildPath(StringRulesSchema), + false, + re2RegexMatch, + ); + assert.notEqual( + built, + undefined, + "native path fell back to CEL — the well_known_regex pattern is not valid RE2", + ); + }); + } + } }); void suite("fallthrough", () => { diff --git a/packages/protovalidate/src/native/string.ts b/packages/protovalidate/src/native/string.ts index 0f76b74..2ebdfdb 100644 --- a/packages/protovalidate/src/native/string.ts +++ b/packages/protovalidate/src/native/string.ts @@ -206,16 +206,20 @@ const WELL_KNOWN: Record< }, }; -// The `well_known_regex` patterns, byte-identical to the strings the CEL -// expressions on `StringRules.well_known_regex` compile (after CEL string -// unescaping — `\\x60` becomes a literal backtick, `\\u0000` a literal NUL). +// The `well_known_regex` patterns, matching the regexes the CEL expressions +// on `StringRules.well_known_regex` build. CEL unescapes its string literals +// before handing them to the regex engine (`\\x60` becomes a literal +// backtick, `\\u0000` a literal NUL), so the CEL path's control characters +// arrive raw. RE2 rejects a regex-level `\u` as an invalid escape sequence, +// so the equivalent `\xHH` escapes are used here — they denote the same +// codepoints and are valid in both RE2 and ECMAScript. // The loose patterns differ between header name (`+`) and header value // (`*`); CEL is the source of truth here, not protovalidate-go's shared // loose regex. const headerNameStrictPattern = "^:?[0-9a-zA-Z!#$%&'*+-.^_|~`]+$"; -const headerNameLoosePattern = "^[^\\u0000\\u000A\\u000D]+$"; -const headerValueStrictPattern = "^[^\\u0000-\\u0008\\u000A-\\u001F\\u007F]*$"; -const headerValueLoosePattern = "^[^\\u0000\\u000A\\u000D]*$"; +const headerNameLoosePattern = "^[^\\x00\\x0A\\x0D]+$"; +const headerValueStrictPattern = "^[^\\x00-\\x08\\x0A-\\x1F\\x7F]*$"; +const headerValueLoosePattern = "^[^\\x00\\x0A\\x0D]*$"; /** * Configuration for {@link EvalNativeStringRules}. Bundled into a single diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 0763aae..932137b 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -426,15 +426,9 @@ export class Planner { if (isMessage(rules, AnyRulesSchema)) { evals.add(new EvalAnyRules(rulePath, rules)); } - let wrappedValueField: DescField | undefined; - if (isWrapperDesc(descMessage)) { - wrappedValueField = descMessage.fields.find((f) => f.name === "value"); - if (wrappedValueField === undefined) { - throw new CompilationError( - `wrapper ${descMessage.typeName} has no "value" field`, - ); - } - } + const wrappedValueField = isWrapperDesc(descMessage) + ? descMessage.field.value + : undefined; evals.add(this.rules(rules, rulePath, false, wrappedValueField)); } return evals; diff --git a/turbo.json b/turbo.json index 22e054b..51afd93 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turbo.build/schema.json", + "globalPassThroughEnv": ["PROTOVALIDATE_DISABLE_NATIVE_RULES"], "tasks": { "build": { "dependsOn": ["^build", "generate"], From 317afae559457b6bb49c2a59cc9b485d44cfc408 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 2 Sep 2026 16:49:51 -0400 Subject: [PATCH 36/38] fix formatting in planner. update format from violation to include all fields. --- packages/protovalidate/src/native/testing.ts | 19 ++++++++++++++++--- packages/protovalidate/src/planner.ts | 4 ++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/protovalidate/src/native/testing.ts b/packages/protovalidate/src/native/testing.ts index 9c5f18c..3c742c8 100644 --- a/packages/protovalidate/src/native/testing.ts +++ b/packages/protovalidate/src/native/testing.ts @@ -15,6 +15,7 @@ import { readFileSync } from "node:fs"; import * as assert from "node:assert/strict"; import type { DescMessage, Message } from "@bufbuild/protobuf"; +import { pathToString } from "@bufbuild/protobuf/reflect"; import { compileFile } from "@bufbuild/protocompile"; import { createValidator } from "../validator.js"; import type { Violation } from "../error.js"; @@ -40,8 +41,14 @@ export const cel = createValidator({ disableNativeRules: true }); /** * Validate a fixture under both the native and CEL paths and assert their - * Violation arrays are byte-identical (message + ruleId + rule path + field - * path, via `Violation.toString()`). + * Violation arrays are identical across every field: message, ruleId, + * field path, rule path, and forKey. + * + * This compares the whole Violation rather than `Violation.toString()`, + * which renders only `: []` and so is blind to + * `rule` and `forKey`. Two violations that differ only in those — a map with + * the same rule on its key and value, say — stringify identically, and a + * native handler that mixed them up would slip through. * * This is the workhorse assertion for native-rule unit tests — every native * handler must reproduce CEL output exactly, and the simplest way to prove @@ -51,7 +58,13 @@ export function diff(schema: DescMessage, msg: Message): void { const a = native.validate(schema, msg); const b = cel.validate(schema, msg); assert.equal(a.kind, b.kind, "kind mismatch"); - const fmt = (v: Violation) => v.toString(); + // Paths are arrays of descriptors; render them so deepEqual compares the + // paths themselves rather than descriptor object identity. + const fmt = (v: Violation) => ({ + ...v, + field: pathToString(v.field), + rule: pathToString(v.rule), + }); assert.deepEqual(a.violations?.map(fmt), b.violations?.map(fmt)); } diff --git a/packages/protovalidate/src/planner.ts b/packages/protovalidate/src/planner.ts index 932137b..d290f9a 100644 --- a/packages/protovalidate/src/planner.ts +++ b/packages/protovalidate/src/planner.ts @@ -427,8 +427,8 @@ export class Planner { evals.add(new EvalAnyRules(rulePath, rules)); } const wrappedValueField = isWrapperDesc(descMessage) - ? descMessage.field.value - : undefined; + ? descMessage.field.value + : undefined; evals.add(this.rules(rules, rulePath, false, wrappedValueField)); } return evals; From 403d2636be360767ed98133ca91b9dd0a01dfb9f Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 2 Sep 2026 17:05:25 -0400 Subject: [PATCH 37/38] fix package dependencies. --- package-lock.json | 3 ++- packages/protovalidate/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c13ade..75159ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,7 +1850,8 @@ "version": "1.2.0", "license": "Apache-2.0", "dependencies": { - "@bufbuild/cel": "^0.6.1" + "@bufbuild/cel": "0.6.1", + "@bufbuild/re2": "0.6.1" }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/packages/protovalidate/package.json b/packages/protovalidate/package.json index 05a68f8..87784c9 100644 --- a/packages/protovalidate/package.json +++ b/packages/protovalidate/package.json @@ -42,7 +42,8 @@ } }, "dependencies": { - "@bufbuild/cel": "0.6.1" + "@bufbuild/cel": "0.6.1", + "@bufbuild/re2": "0.6.1" }, "peerDependencies": { "@bufbuild/protobuf": "^2.8.0" From aa93748944fbb55d5b8a1ae4f947d9221a7c1bd2 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 3 Sep 2026 16:03:03 -0400 Subject: [PATCH 38/38] make the order of numeric checks the same in native and CEL. if unique can't be handled for a repeated type, don't handle min/max items. don't publish native/testing.ts. --- .github/workflows/ci.yaml | 1 + package.json | 2 +- packages/protovalidate/package.json | 1 + packages/protovalidate/src/native/numeric.ts | 4 ++-- packages/protovalidate/src/native/repeated.ts | 11 +++-------- packages/protovalidate/tsconfig.json | 7 +++++-- turbo.json | 4 ++++ 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b9aa328..cfe5e06 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,7 @@ jobs: task: - format - lint + - typecheck - attw - build - license-header diff --git a/package.json b/package.json index d9788a9..7b4d9c2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "scripts": { "clean": "git clean -Xdf", - "all": "turbo run --ui tui build format test lint attw license-header update-readme", + "all": "turbo run --ui tui build format test typecheck lint attw license-header update-readme", "setversion": "node scripts/set-workspace-version.js", "postsetversion": "npm run all", "format": "biome format --write", diff --git a/packages/protovalidate/package.json b/packages/protovalidate/package.json index 87784c9..cdeb9ea 100644 --- a/packages/protovalidate/package.json +++ b/packages/protovalidate/package.json @@ -21,6 +21,7 @@ "generate": "buf generate", "postgenerate": "license-header src/gen", "test": "npx tsx --test ./src/*.test.ts ./src/**/*.test.ts", + "typecheck": "tsc --project tsconfig.test.json", "prebuild": "rm -rf ./dist/*", "build": "npm run build:cjs && npm run build:esm", "build:cjs": "tsc --project tsconfig.json --module commonjs --verbatimModuleSyntax false --moduleResolution node10 --outDir ./dist/cjs && echo >./dist/cjs/package.json '{\"type\":\"commonjs\"}'", diff --git a/packages/protovalidate/src/native/numeric.ts b/packages/protovalidate/src/native/numeric.ts index f525cd4..add2260 100644 --- a/packages/protovalidate/src/native/numeric.ts +++ b/packages/protovalidate/src/native/numeric.ts @@ -201,6 +201,8 @@ class EvalNativeNumericRules ); } + this.evalRange(v, cursor); + if (this.inRule !== undefined && !includesT(this.inRule.vals, v)) { cursor.violate( `must be in list ${formatList(this.inRule.vals, this.config.format)}`, @@ -231,8 +233,6 @@ class EvalNativeNumericRules this.forMapKey, ); } - - this.evalRange(v, cursor); } prune(): boolean { diff --git a/packages/protovalidate/src/native/repeated.ts b/packages/protovalidate/src/native/repeated.ts index d41e890..ef1d067 100644 --- a/packages/protovalidate/src/native/repeated.ts +++ b/packages/protovalidate/src/native/repeated.ts @@ -187,15 +187,10 @@ export function tryBuildNativeRepeatedRules( path: rulePath.clone().field(F.unique).toPath(), }; handled.add(F.unique); + } else { + // if we can't handle unique, don't partially handle repeated rules + return undefined; } - // When `kind === undefined` (message-element list with unique:true) we - // deliberately do NOT claim the unique field; CEL handles it. - // - // protovalidate-go bails the entire RepeatedRules handler in this case - // — releasing min/max_items back to CEL too — to keep ownership - // all-or-nothing. We split ownership instead because in TS the - // partial-claim cost is zero and unique on message elements is - // uncommon. Conformance with the CEL path holds in both shapes. } } diff --git a/packages/protovalidate/tsconfig.json b/packages/protovalidate/tsconfig.json index 485440c..96384cd 100644 --- a/packages/protovalidate/tsconfig.json +++ b/packages/protovalidate/tsconfig.json @@ -1,9 +1,12 @@ { - "include": ["src/index.ts", "src/**/*.test.ts"], + // The emitted program: only what is reachable from the public entry point. + // Tests and their helpers are typechecked by tsconfig.test.json instead, so + // they never land in dist/ (and therefore never in the published tarball). + "include": ["src/index.ts"], "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": [ - // For Error.cause in error.ts and error.test.ts + // For Error.cause in error.ts "ES2022.Error" ] } diff --git a/turbo.json b/turbo.json index 51afd93..9ceb38f 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,10 @@ "dependsOn": ["^build", "generate"], "cache": false }, + "typecheck": { + "dependsOn": ["^build", "generate"], + "outputLogs": "new-only" + }, "format": { "outputLogs": "new-only" },