diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index ee05a16..a6ca01d 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -119,59 +119,6 @@ jobs: path: .benchmark-output/results if-no-files-found: error - conformance: - name: Conformance (${{ matrix.shard }}/8) - runs-on: ubuntu-24.04 - timeout-minutes: 25 - strategy: - fail-fast: false - matrix: - shard: - - 1 - - 2 - - 3 - - 4 - - 5 - - 6 - - 7 - - 8 - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup - uses: ./.github/actions/setup - with: - playwright: 'true' - - - name: Run pull-request conformance shard - if: github.event_name == 'pull_request' - run: pnpm conformance:quick -- --shard=${{ matrix.shard }}/8 - - - name: Run production conformance shard - if: github.event_name != 'pull_request' - run: pnpm conformance -- --shard=${{ matrix.shard }}/8 - - - name: Publish conformance summary - if: success() - run: | - summaries=(.benchmark-output/conformance/results/*.md) - test -e "${summaries[0]}" - cat "${summaries[@]}" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload conformance - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: chart-library-conformance-${{ matrix.shard }}-${{ github.run_id }} - path: | - .benchmark-output/conformance/results - .benchmark-output/conformance/screenshots - if-no-files-found: error - stress: name: Stress (${{ matrix.name }}) runs-on: ubuntu-24.04 @@ -233,7 +180,6 @@ jobs: - static - bundle-baseline - compare - - conformance - stress runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -244,14 +190,12 @@ jobs: STATIC_RESULT: ${{ needs.static.result }} BUNDLE_RESULT: ${{ needs.bundle-baseline.result }} COMPARISON_RESULT: ${{ needs.compare.result }} - CONFORMANCE_RESULT: ${{ needs.conformance.result }} STRESS_RESULT: ${{ needs.stress.result }} run: | for result in \ "$STATIC_RESULT" \ "$BUNDLE_RESULT" \ "$COMPARISON_RESULT" \ - "$CONFORMANCE_RESULT" \ "$STRESS_RESULT" do test "$result" = success diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..3b092dd --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,124 @@ +name: Conformance monitoring + +on: + pull_request: + types: + - opened + - labeled + - synchronize + - reopened + - ready_for_review + schedule: + - cron: '43 7 * * *' + - cron: '13 9 * * 1' + workflow_dispatch: + inputs: + shard: + description: Standard conformance scope + required: true + type: choice + default: full + options: + - full + - '1' + - '2' + - '3' + - '4' + - '5' + - '6' + - '7' + - '8' + +permissions: + contents: read + +jobs: + select: + name: Select conformance shards + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'full-conformance') && + (github.event.action != 'labeled' || github.event.label.name == 'full-conformance')) + runs-on: ubuntu-24.04 + timeout-minutes: 2 + outputs: + mode: ${{ steps.selection.outputs.mode }} + shards: ${{ steps.selection.outputs.shards }} + + steps: + - id: selection + name: Resolve scope + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_SCHEDULE: ${{ github.event.schedule }} + REQUESTED_SHARD: ${{ inputs.shard }} + run: | + if [ "$EVENT_NAME" = schedule ] && [ "$EVENT_SCHEDULE" = '43 7 * * *' ]; then + epoch_day=$(( $(date -u +%s) / 86400 )) + shard=$(( epoch_day % 8 + 1 )) + mode=nightly + shards="[$shard]" + elif [ "$EVENT_NAME" = workflow_dispatch ] && [ "$REQUESTED_SHARD" != full ]; then + mode=manual + shards="[$REQUESTED_SHARD]" + elif [ "$EVENT_NAME" = schedule ]; then + mode=weekly + shards='[1,2,3,4,5,6,7,8]' + elif [ "$EVENT_NAME" = pull_request ]; then + mode=label + shards='[1,2,3,4,5,6,7,8]' + else + mode=manual + shards='[1,2,3,4,5,6,7,8]' + fi + + echo "mode=$mode" >> "$GITHUB_OUTPUT" + echo "shards=$shards" >> "$GITHUB_OUTPUT" + echo "Mode: $mode; shards: $shards; revision: $GITHUB_SHA" >> "$GITHUB_STEP_SUMMARY" + + conformance: + name: Conformance (${{ matrix.shard }}/8, ${{ needs.select.outputs.mode }}) + needs: select + runs-on: ubuntu-24.04 + timeout-minutes: 25 + concurrency: + group: charts-conformance-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}-${{ matrix.shard }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(needs.select.outputs.shards) }} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + playwright: 'true' + + - name: Run standard conformance shard + run: pnpm conformance -- --shard=${{ matrix.shard }}/8 + + - name: Publish conformance summary + if: success() + run: | + summaries=(.benchmark-output/conformance/results/*.md) + test -e "${summaries[0]}" + cat "${summaries[@]}" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload conformance + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: chart-library-conformance-${{ needs.select.outputs.mode }}-${{ matrix.shard }}-${{ github.run_id }} + path: | + .benchmark-output/conformance/results + .benchmark-output/conformance/screenshots + if-no-files-found: error + retention-days: 14 diff --git a/API-FRICTION.md b/API-FRICTION.md index 758713a..b271bd2 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -190,6 +190,7 @@ Each entry records: | F-152 | Version bumps invalidated workspace bundle evidence | Tooling/Release | resolved | | F-153 | Changesets left release-facing version claims behind | Tooling/Release | resolved | | F-154 | Root barrels crossed the browser host boundary | API/Tooling | resolved | +| F-155 | Conformance monitoring blocked unrelated changes | Tooling | resolved | ## Findings @@ -2816,13 +2817,15 @@ Each entry records: headers, cache policy, and content delivery behavior. - Decision: treat the catalog as generated structured content. Charts CI builds schema-v4 `catalog.json` plus only the recursively allowlisted implementation - modules, then replaces the generated `catalog-dist` branch after validation - and the unfiltered conformance matrix. TanStack.com's existing content - pipeline reads that branch, verifies hashes and limits, renders native routes - and embeds, and serves modules below an artifact-commit namespace. Charts - source and dependencies remain out of the site repository and default site - bundle. The previous Worker, staging tree, deployment scripts, credentials, - and route ownership are removed from the Charts workflow. + modules, then replaces the generated `catalog-dist` branch after the static, + package, bundle, comparison, and stress gates pass. Conformance is independent + regression monitoring rather than an artifact-integrity gate. TanStack.com's + existing content pipeline reads that branch, verifies hashes and limits, + renders native routes and embeds, and serves modules below an artifact-commit + namespace. Charts source and dependencies remain out of the site repository + and default site bundle. The previous Worker, staging tree, deployment + scripts, credentials, and route ownership are removed from the Charts + workflow. - Verification: the artifact generator records an exact Charts revision, deterministic SHA-256 allowlist, safe repository source paths, recursive imports, debug-only comparison roots, and role-aware authored-source @@ -3681,3 +3684,28 @@ Each entry records: export, reconciliation, renderer, and SVG surface modules. That full portable barrel measures 55.26 kB minified and 17.04 kB gzip; granular subpaths remain the bundle-sensitive option. + +### F-155 — Conformance monitoring blocked unrelated changes + +- Status: resolved +- Severity: high +- Owner: Tooling +- Observed in: the first pull-request and main runs after release automation + was simplified +- Friction: every pull request ran all 100 paired cases in the quick profile, + then every main commit reran all 100 in the standard profile. Release-only PR + `#17` therefore consumed 15.9 conformance runner-minutes before merge and + 21.6 after merge despite changing no chart source. The eight shards also + repeated the complete TypeScript program and 27 type-protection probes. npm + publication did not wait on either run, so the cost added no release gate. +- Decision: move conformance into a read-only monitoring workflow. Run one + deterministic standard shard nightly, all eight standard shards weekly, all + eight on manual request by default, and all eight for a pull request carrying + the `full-conformance` label. Manual dispatch may select one exact shard for + reproduction. Normal pull-request, main, catalog, and release paths do not + wait on conformance. +- Verification: workflow contracts require deterministic eight-day rotation, + complete weekly and labeled-PR matrices, exact manual shard selection, + read-only permissions, immutable action pins, and standard-profile browser + execution. The main CI contract rejects any conformance dependency while + retaining every exact-revision catalog publication guard. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4128def..07deea2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,9 +36,15 @@ Pull-request CI runs the cached Nx target graph, the locked comparison bundle gate, and these uncached browser matrices in parallel: - four chart-library comparison shards; -- eight conformance shards; - four stress-workload shards. +Conformance runs as regression monitoring outside normal validation. One +deterministically rotated standard shard runs nightly, all eight standard +shards run weekly, and a manual run can select all shards or reproduce one +exact shard. Add the `full-conformance` label to a risky pull request to run +the complete standard matrix against that pull request; later commits rerun +it while the label remains. + Browser measurements and the revision-stamped catalog artifact are not cached because their results depend on the browser environment or exact Git commit. Install Chromium before running a browser suite locally: @@ -87,9 +93,10 @@ Every push to `main` starts the release workflow: attestations, the workflow creates one annotated `vX.Y.Z` tag and GitHub release from the root changelog. -The complete chart, browser, and catalog workflow still runs on `main`, but it -is independent from npm publication. User-visible package work must pass that -workflow in its feature pull request before merging. +The chart comparison, stress, and catalog workflow still runs on `main`, but +it is independent from npm publication. Scheduled conformance monitoring is +also independent from release and catalog publication. User-visible package +work must pass normal validation in its feature pull request before merging. Never move or reuse a release tag. If publishing succeeds but tag or GitHub release creation fails, rerun the failed `Release` workflow; its registry diff --git a/README.md b/README.md index cffe0a2..d2bccf6 100644 --- a/README.md +++ b/README.md @@ -315,10 +315,11 @@ pnpm conformance:quick These measurements are development evidence, not release claims. See each suite's README for its protocol, output, and limitations. -Pull requests first gate formatting, types, tests, packed exports and -declarations, locked bundles, comparison bundles, and catalog metadata. -Browser comparison, all 100 conformance cases, and the quick five-library stress -matrix then run as separate artifact-producing jobs. +Pull requests gate formatting, types, tests, packed exports and declarations, +locked bundles, comparison bundles, catalog metadata, browser comparison, and +the quick five-library stress matrix. Conformance is monitored separately with +a rotating nightly shard, a complete weekly matrix, manual runs, and an +opt-in `full-conformance` pull-request label. ## License diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index 494f986..e8c3f9d 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -187,13 +187,15 @@ pnpm catalog:build pnpm catalog:loading:check ``` -Main-branch CI publishes a new generated commit only after validation and the -unfiltered standard conformance matrix pass. TanStack.com's existing content -pipeline reads that branch and verifies the schema, revision, module allowlist, -sizes, and hashes before serving it. It composes `site.assetBasePath`, the -resolved `catalog-dist` commit SHA, and each relative module path into the -immutable asset URL. A rollback points `catalog-dist` back to a prior generated -commit; the catalog has no mutable runtime state. +Main-branch CI publishes a new generated commit only after the static, +package, bundle, comparison, and stress gates pass. Conformance runs +independently as nightly rotating, weekly complete, manual, and labeled-PR +monitoring. TanStack.com's existing content pipeline reads the generated branch +and verifies the schema, revision, module allowlist, sizes, and hashes before +serving it. It composes `site.assetBasePath`, the resolved `catalog-dist` commit +SHA, and each relative module path into the immutable asset URL. A rollback +points `catalog-dist` back to a prior generated commit; the catalog has no +mutable runtime state. ## What is and is not equivalent diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 4dbe1fd..4e505ae 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -38,14 +38,8 @@ describe('CI workflow contract', () => { assert.doesNotMatch(setupAction, /playwright-\d+\.\d+\.\d+/) }) - test('runs static, bundle, comparison, conformance, and stress partitions independently', () => { - for (const name of [ - 'static', - 'bundle-baseline', - 'compare', - 'conformance', - 'stress', - ]) { + test('runs static, bundle, comparison, and stress partitions independently', () => { + for (const name of ['static', 'bundle-baseline', 'compare', 'stress']) { assert.doesNotMatch( job(name), /^\s*needs:/m, @@ -53,12 +47,14 @@ describe('CI workflow contract', () => { ) } - for (const name of ['compare', 'conformance', 'stress']) { + for (const name of ['compare', 'stress']) { assert.match(job(name), /uses:\s*\.\/\.github\/actions\/setup/) assert.match(job(name), /playwright:\s*['"]true['"]/) } assert.doesNotMatch(job('static'), /playwright:\s*['"]true['"]/) assert.doesNotMatch(job('bundle-baseline'), /playwright:\s*['"]true['"]/) + assert.doesNotMatch(workflow, /^\s{2}conformance:\s*$/m) + assert.doesNotMatch(workflow, /pnpm conformance/) }) test('runs the cached workspace graph and builds the main catalog fully', () => { @@ -75,7 +71,7 @@ describe('CI workflow contract', () => { assert.match(staticChecks, /path:\s*\.catalog-artifact/) }) - test('shards comparison by chart and conformance into eight partitions', () => { + test('shards comparison by chart', () => { const comparison = job('compare') assert.match(comparison, /fail-fast:\s*false/) assert.deepEqual(scalarList(comparison, 'chart'), [ @@ -93,39 +89,6 @@ describe('CI workflow contract', () => { comparison, /summaries=\(\.benchmark-output\/results\/\*\.md\)[\s\S]*test -e "\${summaries\[0\]}"[\s\S]*cat "\${summaries\[@\]}"/, ) - - const conformance = job('conformance') - assert.match(conformance, /fail-fast:\s*false/) - assert.deepEqual(scalarList(conformance, 'shard'), [ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - ]) - assert.match(conformance, /Conformance \(\${{ matrix\.shard }}\/8\)/) - assert.match( - conformance, - /pnpm conformance:quick -- --shard=\${{ matrix\.shard }}\/8/, - ) - assert.match(conformance, /if:\s*github\.event_name == 'pull_request'/) - assert.match( - conformance, - /pnpm conformance -- --shard=\${{ matrix\.shard }}\/8/, - ) - assert.match(conformance, /if:\s*github\.event_name != 'pull_request'/) - assert.doesNotMatch(conformance, /version_pr/) - assert.match( - conformance, - /name:\s*chart-library-conformance-\${{ matrix\.shard }}-\${{ github\.run_id }}/, - ) - assert.match( - conformance, - /summaries=\(\.benchmark-output\/conformance\/results\/\*\.md\)[\s\S]*test -e "\${summaries\[0\]}"[\s\S]*cat "\${summaries\[@\]}"/, - ) }) test('covers every stress workload exactly once across named shards', () => { @@ -188,14 +151,12 @@ describe('CI workflow contract', () => { 'static', 'bundle-baseline', 'compare', - 'conformance', 'stress', ]) for (const [variable, dependency] of [ ['STATIC_RESULT', 'static'], ['BUNDLE_RESULT', 'bundle-baseline'], ['COMPARISON_RESULT', 'compare'], - ['CONFORMANCE_RESULT', 'conformance'], ['STRESS_RESULT', 'stress'], ]) { assert.match( @@ -206,6 +167,7 @@ describe('CI workflow contract', () => { ) } assert.match(aggregate, /test "\$result" = success/) + assert.doesNotMatch(aggregate, /CONFORMANCE_RESULT|needs\.conformance/) assert.doesNotMatch(aggregate, /contents:\s*write/) }) diff --git a/scripts/conformance-workflow.test.mjs b/scripts/conformance-workflow.test.mjs new file mode 100644 index 0000000..763c33f --- /dev/null +++ b/scripts/conformance-workflow.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const { describe, test } = process.env.VITEST + ? await import('vitest') + : await import('node:test') + +const workflow = await readFile( + resolve(import.meta.dirname, '../.github/workflows/conformance.yml'), + 'utf8', +) + +describe('conformance monitoring workflow contract', () => { + test('runs outside ordinary pull-request and main validation', () => { + assert.match(workflow, /^name:\s*Conformance monitoring$/m) + assert.doesNotMatch(workflow, /^\s{2}push:\s*$/m) + assert.doesNotMatch(workflow, /pull_request_target/) + assert.match( + workflow, + /pull_request:\s*\n\s+types:\s*\n(?:\s+-\s+(?:opened|labeled|synchronize|reopened|ready_for_review)\s*\n){5}/, + ) + assert.match( + job('select'), + /contains\(github\.event\.pull_request\.labels\.\*\.name, 'full-conformance'\)/, + ) + assert.match( + job('select'), + /github\.event\.action != 'labeled' \|\| github\.event\.label\.name == 'full-conformance'/, + ) + }) + + test('rotates one nightly shard and runs all shards weekly', () => { + assert.deepEqual( + [...workflow.matchAll(/^\s+- cron:\s*'([^']+)'\s*$/gm)].map( + (match) => match[1], + ), + ['43 7 * * *', '13 9 * * 1'], + ) + + const select = job('select') + assert.match(select, /epoch_day=\$\(\( \$\(date -u \+%s\) \/ 86400 \)\)/) + assert.match(select, /shard=\$\(\( epoch_day % 8 \+ 1 \)\)/) + assert.match(select, /mode=nightly\s*\n\s+shards="\[\$shard\]"/) + assert.match(select, /mode=weekly\s*\n\s+shards='\[1,2,3,4,5,6,7,8\]'/) + assert.match(select, /mode:\s*\${{ steps\.selection\.outputs\.mode }}/) + assert.match(select, /shards:\s*\${{ steps\.selection\.outputs\.shards }}/) + }) + + test('supports a full manual run and exact-shard reproduction', () => { + assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:/) + assert.match( + workflow, + /shard:[\s\S]*description:\s*Standard conformance scope[\s\S]*default:\s*full/, + ) + assert.deepEqual(scalarList(workflow, 'options'), [ + 'full', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]) + + const select = job('select') + assert.match( + select, + /EVENT_NAME" = workflow_dispatch \] && \[ "\$REQUESTED_SHARD" != full/, + ) + assert.match(select, /shards="\[\$REQUESTED_SHARD\]"/) + assert.match(select, /mode=manual\s*\n\s+shards='\[1,2,3,4,5,6,7,8\]'/) + }) + + test('runs the standard browser profile through a dynamic shard matrix', () => { + const conformance = job('conformance') + assert.deepEqual(needs(conformance), ['select']) + assert.match( + conformance, + /shard:\s*\${{ fromJSON\(needs\.select\.outputs\.shards\) }}/, + ) + assert.match(conformance, /playwright:\s*['"]true['"]/) + assert.match( + conformance, + /pnpm conformance -- --shard=\${{ matrix\.shard }}\/8/, + ) + assert.doesNotMatch(conformance, /conformance:quick|--profile=full/) + assert.match( + conformance, + /group:\s*charts-conformance-\${{ github\.event_name == 'pull_request' && github\.event\.pull_request\.number \|\| github\.run_id }}-\${{ matrix\.shard }}/, + ) + assert.match( + conformance, + /cancel-in-progress:\s*\${{ github\.event_name == 'pull_request' }}/, + ) + assert.match( + conformance, + /name:\s*chart-library-conformance-\${{ needs\.select\.outputs\.mode }}-\${{ matrix\.shard }}-\${{ github\.run_id }}/, + ) + assert.match(conformance, /retention-days:\s*14/) + }) + + test('uses only read access and immutable external actions', () => { + assert.match(workflow, /^permissions:\s*\n\s+contents:\s*read\s*$/m) + assert.doesNotMatch( + workflow, + /contents:\s*write|id-token:\s*write|pull-requests:\s*write|secrets\.|NPM_TOKEN/, + ) + assert.doesNotMatch( + workflow, + /publish-catalog|changesets\/action|npm publish/, + ) + assertPinnedExternalActions(workflow) + }) +}) + +function job(name) { + const lines = workflow.split(/\r?\n/) + const jobsIndex = lines.findIndex((line) => /^\s*jobs:\s*$/.test(line)) + assert.notEqual(jobsIndex, -1, 'workflow must define jobs') + const jobsIndent = indentation(lines[jobsIndex]) + const start = lines.findIndex( + (line, index) => + index > jobsIndex && + indentation(line) > jobsIndent && + new RegExp(`^\\s*${escapeRegExp(name)}:\\s*$`).test(line), + ) + assert.notEqual(start, -1, `workflow must define job ${name}`) + const jobIndent = indentation(lines[start]) + let end = lines.length + for (let index = start + 1; index < lines.length; index += 1) { + if (!lines[index].trim() || lines[index].trimStart().startsWith('#')) { + continue + } + const indent = indentation(lines[index]) + if (indent < jobIndent) { + end = index + break + } + if (indent === jobIndent && /^\s*[A-Za-z0-9_-]+:\s*$/.test(lines[index])) { + end = index + break + } + } + return lines.slice(start, end).join('\n') +} + +function needs(block) { + const scalar = block.match(/^\s*needs:\s*([A-Za-z0-9_-]+)\s*$/m) + if (scalar) return [scalar[1]] + return scalarList(block, 'needs') +} + +function scalarList(block, key) { + const lines = block.split(/\r?\n/) + const start = lines.findIndex((line) => + new RegExp(`^\\s*${escapeRegExp(key)}:\\s*$`).test(line), + ) + assert.notEqual(start, -1, `expected ${key} list`) + const keyIndent = indentation(lines[start]) + const values = [] + for (let index = start + 1; index < lines.length; index += 1) { + if (!lines[index].trim() || lines[index].trimStart().startsWith('#')) { + continue + } + if (indentation(lines[index]) <= keyIndent) break + const item = lines[index].match(/^\s*-\s*([^#]+?)\s*$/) + if (item) values.push(item[1].replace(/^['"]|['"]$/g, '')) + } + return values +} + +function assertPinnedExternalActions(source) { + const uses = [...source.matchAll(/^\s*uses:\s*([^\s#]+)\s*(?:#.*)?$/gm)].map( + (match) => match[1], + ) + assert.ok(uses.length > 0, 'workflow must use actions') + for (const action of uses) { + if (action.startsWith('./')) continue + assert.match( + action, + /@[0-9a-f]{40}$/, + `${action} must be pinned to an immutable commit`, + ) + } +} + +function indentation(line) { + return line.match(/^\s*/)[0].length +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +}