diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0333a247f..516c05ee6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: filter: ${{ steps.scope.outputs.filter }} standard-packages: ${{ steps.scope.outputs.standard-packages }} coverage-other-packages: ${{ steps.scope.outputs.coverage-other-packages }} + mutation-targets: ${{ steps.scope.outputs.mutation-targets }} sdk: ${{ steps.scope.outputs.sdk }} did: ${{ steps.scope.outputs.did }} wallet: ${{ steps.scope.outputs.wallet }} @@ -103,16 +104,28 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" CHANGED=$(git diff --name-only "${BASE_SHA}"...HEAD) - if echo "$CHANGED" | grep -Eq '^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.base.json|codecov.yml|sonar-project.properties|\.sonarcloud\.properties|\.github/workflows/(ci|property-tests)\.yml|governance/(repository-health|test-quality)/|scripts/(check-package-artifact|check-wallet-toolbox-platform|repository-health|run-ci-tests|normalize-lcov-paths|run-governed-test|test-governance|sync-service-(rate-limit|edge)-policy)\.(mjs|test\.mjs)$)'; then + if echo "$CHANGED" | grep -Eq '^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.base.json|codecov.yml|sonar-project.properties|\.sonarcloud\.properties|\.github/workflows/(ci|property-tests|mutation-tests)\.yml|governance/(repository-health|test-quality|mutation-testing)/|scripts/(check-package-artifact|check-wallet-toolbox-platform|repository-health|run-ci-tests|normalize-lcov-paths|run-governed-test|test-governance|mutation-testing|sync-service-(rate-limit|edge)-policy)\.(mjs|test\.mjs)$)'; then FILTER="" else FILTER="...[${BASE_SHA}]" fi else FILTER="" + BEFORE_SHA="${{ github.event.before }}" + if [ -z "$BEFORE_SHA" ] || + [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ] || + ! git cat-file -e "$BEFORE_SHA^{commit}" 2>/dev/null; then + CHANGED=$(git ls-files) + else + CHANGED=$(git diff --name-only "$BEFORE_SHA"...HEAD) + fi fi + printf '%s\n' "$CHANGED" > .ci-changed-files.txt echo "filter=$FILTER" >> "$GITHUB_OUTPUT" + MUTATION_TARGETS=$(node scripts/mutation-testing.mjs \ + --affected-file .ci-changed-files.txt) + echo "mutation-targets=$MUTATION_TARGETS" >> "$GITHUB_OUTPUT" if [ -n "$FILTER" ]; then PROJECTS=$(pnpm -r --filter "$FILTER" list --depth -1 --json) else @@ -209,6 +222,67 @@ jobs: if-no-files-found: error retention-days: 1 + mutation-tests: + name: Mutation / ${{ matrix.target }} + if: needs.prepare.outputs.mutation-targets != '[]' + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 6 + matrix: + target: ${{ fromJSON(needs.prepare.outputs.mutation-targets) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + - name: Install frozen dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Rebuild the audited workspace build tool + run: pnpm rebuild esbuild + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: build-outputs + path: .ci-artifacts + - name: Restore build outputs + run: tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz + - name: Enforce the affected mutation-quality ratchet + run: node scripts/mutation-testing.mjs --target "${{ matrix.target }}" + - name: Preserve the machine-readable mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-${{ matrix.target }} + path: artifacts/mutation/${{ matrix.target }}/mutation.json + if-no-files-found: ignore + retention-days: 30 + + mutation-quality: + name: Mutation quality + if: always() + needs: + - prepare + - mutation-tests + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Verify the affected mutation targets + env: + PREPARE_RESULT: ${{ needs.prepare.result }} + MUTATION_RESULT: ${{ needs.mutation-tests.result }} + run: | + if [[ "$PREPARE_RESULT" != "success" || + ( "$MUTATION_RESULT" != "success" && "$MUTATION_RESULT" != "skipped" ) ]]; then + echo "::error::Mutation quality failed: prepare=$PREPARE_RESULT mutation=$MUTATION_RESULT" + exit 1 + fi + standard-tests: name: Tests / non-coverage packages needs: prepare @@ -897,6 +971,7 @@ jobs: needs: - repository-health - build-and-test + - mutation-quality - infra - docs-validate - conformance @@ -908,6 +983,7 @@ jobs: env: HEALTH_RESULT: ${{ needs.repository-health.result }} BUILD_RESULT: ${{ needs.build-and-test.result }} + MUTATION_RESULT: ${{ needs.mutation-quality.result }} INFRA_RESULT: ${{ needs.infra.result }} DOCS_RESULT: ${{ needs.docs-validate.result }} CONFORMANCE_RESULT: ${{ needs.conformance.result }} @@ -915,10 +991,11 @@ jobs: run: | if [[ "$HEALTH_RESULT" != "success" || "$BUILD_RESULT" != "success" || + "$MUTATION_RESULT" != "success" || "$INFRA_RESULT" != "success" || "$DOCS_RESULT" != "success" || "$CONFORMANCE_RESULT" != "success" ]]; then - echo "::error::Required CI failed: health=$HEALTH_RESULT build=$BUILD_RESULT infra=$INFRA_RESULT docs=$DOCS_RESULT conformance=$CONFORMANCE_RESULT" + echo "::error::Required CI failed: health=$HEALTH_RESULT build=$BUILD_RESULT mutation=$MUTATION_RESULT infra=$INFRA_RESULT docs=$DOCS_RESULT conformance=$CONFORMANCE_RESULT" exit 1 fi if [[ "$DEPENDENCY_RESULT" != "success" && "$DEPENDENCY_RESULT" != "skipped" ]]; then diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml new file mode 100644 index 0000000000..31f7fc2cc4 --- /dev/null +++ b/.github/workflows/mutation-tests.yml @@ -0,0 +1,140 @@ +name: Mutation quality + +on: + schedule: + - cron: '17 8 * * 0' + workflow_dispatch: + inputs: + target: + description: Optional exact governed target ID; blank runs all 25 + required: false + type: string + +permissions: {} + +concurrency: + group: mutation-quality-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + name: Build mutation campaign + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + targets: ${{ steps.targets.outputs.targets }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + + - name: Install frozen dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Rebuild the audited workspace build tool + run: pnpm rebuild esbuild + + - name: Select the full campaign or one exact target + id: targets + env: + REQUESTED_TARGET: ${{ inputs.target || '' }} + run: | + GOVERNED_TARGETS=$(node scripts/mutation-testing.mjs --list) + if [ -n "$REQUESTED_TARGET" ]; then + if ! grep -Fxq "$REQUESTED_TARGET" <<<"$GOVERNED_TARGETS"; then + echo "::error::Unknown mutation target: $REQUESTED_TARGET" + exit 1 + fi + TARGETS=$(jq -cn --arg target "$REQUESTED_TARGET" '[$target]') + else + TARGETS=$(jq -R -s -c 'split("\n") | map(select(length > 0))' <<<"$GOVERNED_TARGETS") + fi + echo "targets=$TARGETS" >> "$GITHUB_OUTPUT" + + - name: Build the governed package graph + run: pnpm build:property + + - name: Archive build outputs + run: | + { + find packages -type d \( -name dist -o -name out \) -prune -print0 + find packages -type f -name '*.tsbuildinfo' -print0 + } | tar --null --create --gzip --file build-outputs.tar.gz --files-from=- + + - name: Share build outputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-build-outputs + path: build-outputs.tar.gz + if-no-files-found: error + retention-days: 1 + + mutation-tests: + name: Mutation / ${{ matrix.target }} + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 6 + matrix: + target: ${{ fromJSON(needs.prepare.outputs.targets) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + + - name: Install frozen dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Rebuild the audited workspace build tool + run: pnpm rebuild esbuild + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: mutation-build-outputs + path: .ci-artifacts + + - name: Restore build outputs + run: tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz + + - name: Enforce the governed mutation-quality ratchet + run: node scripts/mutation-testing.mjs --target "${{ matrix.target }}" + + - name: Preserve the machine-readable mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-${{ matrix.target }}-${{ github.run_id }} + path: artifacts/mutation/${{ matrix.target }}/mutation.json + if-no-files-found: ignore + retention-days: 30 + + mutation-quality: + name: Full mutation campaign + if: always() + needs: + - prepare + - mutation-tests + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Verify the campaign + env: + PREPARE_RESULT: ${{ needs.prepare.result }} + MUTATION_RESULT: ${{ needs.mutation-tests.result }} + run: | + if [[ "$PREPARE_RESULT" != "success" || "$MUTATION_RESULT" != "success" ]]; then + echo "::error::Mutation campaign failed: prepare=$PREPARE_RESULT mutation=$MUTATION_RESULT" + exit 1 + fi diff --git a/.gitignore b/.gitignore index ebc8a5c67d..1b9540603b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ build/ !.env.example coverage/ .nyc_output/ +.stryker-tmp/ +artifacts/mutation/ *.log .DS_Store .venv/ diff --git a/docs/about/contributing.md b/docs/about/contributing.md index 6bcf46c00f..94c94000e8 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -114,7 +114,21 @@ resource-intensive, and intended conformance gaps follow the Run `pnpm test:governance` before submitting test-classification changes. New untrusted parsers, codecs, serializers, signature framing, or network destination logic should include a registered property suite; use -`pnpm test:property` to run the complete reproducible campaign. +`pnpm test:property` to run the complete reproducible campaign. Every property +suite also owns a focused mutation target so its assertions are proven capable +of detecting semantic defects: + +```bash +# List the governed targets +pnpm test:mutation --list + +# Run the target for the boundary being changed +pnpm test:mutation --target p2p-messages +``` + +When a mutant survives, add the missing observable assertion or correlated +input case when it represents a real behavior gap. Do not add broad mutation +exclusions or weaken the ratchet to make a pull request pass. ### Lint Code @@ -282,6 +296,7 @@ Run before submitting: ```bash pnpm test +pnpm test:property pnpm conformance pnpm lint pnpm format:check @@ -289,6 +304,10 @@ pnpm health:check pnpm typecheck ``` +Run the affected mutation target while iterating. Root toolchain, SDK, +governance, and CI changes require the complete `pnpm test:mutation --all` +campaign; CI selects this automatically. + ## Reporting Issues When reporting a bug, include: diff --git a/docs/reference/repository-health.md b/docs/reference/repository-health.md index 29664b68fe..4cdc67fee0 100644 --- a/docs/reference/repository-health.md +++ b/docs/reference/repository-health.md @@ -2,7 +2,7 @@ id: repository-health title: 'Repository Health Controls' kind: reference -version: '1.2.0' +version: '1.3.0' last_updated: '2026-07-26' last_verified: '2026-07-26' review_cadence_days: 30 @@ -79,6 +79,9 @@ contract inventories required skips, classifies every manual/live/resource suite, rejects empty tests, ratchets all conformance skip groups, and requires every property-fuzz suite to declare its package, trust boundary, concrete invariants, minimum PR budget, and reproducible long-run campaign. +`governance/mutation-testing/policy.json` then pairs all 25 property suites with +focused implementation mutation targets, per-boundary score ratchets, zero +uncovered mutants, and zero invalid mutants. ## Commands @@ -88,6 +91,7 @@ Run the blocking control and its unit tests: pnpm health:check pnpm test:governance pnpm test:property +pnpm test:mutation --all pnpm typecheck ``` @@ -157,6 +161,13 @@ checks: 6. current package-contract findings exactly match the ratcheted snapshot; and 7. the health implementation’s unit tests pass. +Affected package changes also select their matching mutation targets. The +parallel mutation lane restores the shared workspace build, executes only the +focused property and regression tests for each selected implementation +boundary, retains its JSON report, and feeds the required merge gate. SDK, +toolchain, CI, and governance changes deliberately fan out to the full target +registry. A separate scheduled workflow validates all targets weekly. + The job writes a rule-by-rule and project-by-project report to the GitHub Actions step summary and feeds the required `merge-gate`. Known findings stay visible until their remediation PR removes them from both the code and baseline. diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 1a41648d51..4d60ad38d6 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -2,7 +2,7 @@ id: test-quality-governance title: 'Test Quality and Skip Governance' kind: reference -version: '1.0.0' +version: '1.1.0' last_updated: '2026-07-26' last_verified: '2026-07-26' review_cadence_days: 30 @@ -144,6 +144,59 @@ Changes that introduce a new parser, codec, serializer, cryptographic framing rule, network destination, or authorization decision must extend this registry when an arbitrary-input invariant can be stated. +## Mutation-validated fuzzing + +Property generation is only valuable when its assertions can detect a broken +invariant. Every one of the 25 registered property suites therefore has a +matching Stryker mutation target. The target mutates the implementation owned +by that property boundary and runs the smallest relevant combination of +property and deterministic regression tests. This catches weak round trips, +uncorrelated generators, assertions that only prove “did not throw,” and rare +boundaries that random generation is unlikely to reach. + +The machine-readable ratchet is +`governance/mutation-testing/policy.json`. For each target it records the same +package, risk, trust boundary, and property suite as the property policy, plus +its measured minimum mutation score. All targets reject: + +- any `NoCoverage` mutant in the selected implementation boundary; +- any compile-error or runtime-error mutant, which does not prove test + detection; and +- a mutation-score regression below the target's reviewed baseline. + +Surviving mutants are not automatically ignored. Review them to decide whether +they reveal a missing negative case, an insufficiently correlated generator, +an unasserted observable result, redundant implementation logic, or a genuinely +equivalent transformation. Prefer a deterministic regression plus the general +property when a survivor identifies a concrete boundary. Narrow a mutation +scope only to the implementation contract described by the registered +property; never exclude product code merely to raise the score. + +Pull requests run the targets owned by changed packages. Changes to the SDK, +root toolchain, mutation governance, or CI fan out to all 25 targets. The +independent `Mutation quality` workflow runs the full matrix every Sunday and +can run one exact target manually. Targets execute in parallel, reuse one +workspace build, and preserve machine-readable reports for 30 days. Mutation +runs use the policy's fixed 300-case fast-check seed by default so the dry run +and every mutant see the same generated campaign. `FAST_CHECK_NUM_RUNS`, +`FAST_CHECK_SEED`, and `FAST_CHECK_PATH` remain available for an explicit +replay or deeper local investigation. + +List and run targets locally: + +```sh +pnpm test:mutation --list +pnpm test:mutation --target p2p-messages +pnpm test:mutation --all +``` + +The mutation score follows Stryker's valid-mutant definition: killed and timed +out mutants are detected; survived and uncovered mutants are undetected; +compile and runtime errors are excluded from the score but rejected separately +by this repository's policy. A score is a ratchet, not a substitute for +reviewing the surviving-mutant report and the semantic quality of each +generator. + Before running a governed non-PR suite, read its policy prerequisites. After the run, perform its cleanup and attach evidence to the tracker or release record. No private credential, production wallet data, or secret output belongs in CI diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json new file mode 100644 index 0000000000..4fef99a289 --- /dev/null +++ b/governance/mutation-testing/policy.json @@ -0,0 +1,271 @@ +{ + "schemaVersion": 1, + "lastReviewed": "2026-07-26", + "reviewBy": "2026-08-26", + "owner": "ts-stack-maintainers", + "tool": { + "package": "@stryker-mutator/core", + "version": "9.6.1", + "config": "governance/mutation-testing/stryker.config.mjs", + "targets": "governance/mutation-testing/targets.mjs", + "runner": "scripts/mutation-testing.mjs", + "rootCommand": "pnpm test:mutation --all", + "ciWorkflow": ".github/workflows/ci.yml", + "scheduledWorkflow": ".github/workflows/mutation-tests.yml", + "reportRetentionDays": 30, + "propertyRuns": 300, + "propertySeed": 3242026 + }, + "targets": [ + { + "id": "sdk-codecs", + "manifest": "packages/sdk/package.json", + "propertyTest": "packages/sdk/src/primitives/__tests/utils.property.test.ts", + "risk": "critical", + "boundary": "SDK binary-to-text codecs used by keys, addresses, and protocol payloads", + "minimumScore": 86, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-action-batch", + "manifest": "packages/wallet/wallet-toolbox/package.json", + "propertyTest": "packages/wallet/wallet-toolbox/src/utility/__tests/actionBatchPack.property.test.ts", + "risk": "critical", + "boundary": "Wallet authenticated action-batch binary framing", + "minimumScore": 88, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "overlay-linkage", + "manifest": "packages/overlays/topics/package.json", + "propertyTest": "packages/overlays/topics/src/mandala/__tests/types.property.test.ts", + "risk": "critical", + "boundary": "Overlay Mandala linkage payloads and issuer authorization", + "minimumScore": 82, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "did-codecs", + "manifest": "packages/helpers/did/package.json", + "propertyTest": "packages/helpers/did/tests/codec.property.test.ts", + "risk": "critical", + "boundary": "DID, multibase, base64url, JSON, and SD-JWT compact encodings", + "minimumScore": 82, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "mandala-encoding", + "manifest": "packages/helpers/ts-templates/package.json", + "propertyTest": "packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts", + "risk": "critical", + "boundary": "Bitcoin script-number and on-chain asset outpoint encodings", + "minimumScore": 92, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "paymail-address", + "manifest": "packages/messaging/ts-paymail/package.json", + "propertyTest": "packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts", + "risk": "high", + "boundary": "Public Paymail alias and DNS-name parser", + "minimumScore": 91, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "p2p-messages", + "manifest": "packages/network/ts-p2p/package.json", + "propertyTest": "packages/network/ts-p2p/test/messages.property.test.ts", + "risk": "critical", + "boundary": "Untrusted Teranode GossipSub UTF-8, JSON, and base64 envelopes", + "minimumScore": 88, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "auth", + "manifest": "packages/middleware/auth/package.json", + "propertyTest": "packages/middleware/auth/src/__tests__/core.property.test.ts", + "risk": "critical", + "boundary": "Authentication signature canonicalization, body binding, and freshness validation", + "minimumScore": 86, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "reorg-stream", + "manifest": "packages/overlays/overlay-express/package.json", + "propertyTest": "packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts", + "risk": "critical", + "boundary": "Public chain-reorg SSE framing and height/hash normalization", + "minimumScore": 82, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "message-box-host", + "manifest": "packages/messaging/message-box-client/package.json", + "propertyTest": "packages/messaging/message-box-client/src/__tests/host.property.test.ts", + "risk": "critical", + "boundary": "Untrusted overlay-advertised Message Box network destinations", + "minimumScore": 81, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-pairing", + "manifest": "packages/wallet/ts-wallet-relay/package.json", + "propertyTest": "packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts", + "risk": "critical", + "boundary": "Mobile/deep-link wallet pairing URI trust bootstrap", + "minimumScore": 88, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "overlay-advertisement", + "manifest": "packages/overlays/overlay-discovery-services/package.json", + "propertyTest": "packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts", + "risk": "critical", + "boundary": "BRC-87 names and BRC-101 public service advertisements", + "minimumScore": 86, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "overlay-integrity", + "manifest": "packages/overlays/overlay/package.json", + "propertyTest": "packages/overlays/overlay/src/__tests/BASM.property.test.ts", + "risk": "critical", + "boundary": "Overlay BASM/TAC integrity hashing and untrusted log serialization", + "minimumScore": 85, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "verifast-batch", + "manifest": "packages/verifast/package.json", + "propertyTest": "packages/verifast/src/__tests/BdkBatch.property.test.ts", + "risk": "critical", + "boundary": "Native BDK batch memory framing, offsets, flags, and result decoding", + "minimumScore": 95, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "btms-helpers", + "manifest": "packages/wallet/btms/package.json", + "propertyTest": "packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts", + "risk": "critical", + "boundary": "BTMS wallet metadata, message bodies, labels, and key-derivation instructions", + "minimumScore": 79, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "amount-format", + "manifest": "packages/helpers/amountinator/package.json", + "propertyTest": "packages/helpers/amountinator/tests/amountFormat.property.test.ts", + "risk": "high", + "boundary": "Wallet-facing currency conversion, decimal formatting, and safe-integer display", + "minimumScore": 83, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "fund-wallet-cli", + "manifest": "packages/helpers/fund-wallet/package.json", + "propertyTest": "packages/helpers/fund-wallet/src/cli.property.test.ts", + "risk": "critical", + "boundary": "Operator-supplied wallet keys, funding amounts, networks, and storage endpoints", + "minimumScore": 89, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "payment-402", + "manifest": "packages/middleware/402-pay/package.json", + "propertyTest": "packages/middleware/402-pay/src/server.property.test.ts", + "risk": "critical", + "boundary": "Public BRC-121 payment challenge identity and price headers", + "minimumScore": 83, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "auth-express-bytes", + "manifest": "packages/middleware/auth-express-middleware/package.json", + "propertyTest": "packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts", + "risk": "critical", + "boundary": "Authenticated Express request and response canonical byte serialization", + "minimumScore": 81, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "payment-replay", + "manifest": "packages/middleware/payment-express-middleware/package.json", + "propertyTest": "packages/middleware/payment-express-middleware/src/__tests/PaymentReplayStore.property.test.ts", + "risk": "critical", + "boundary": "Public payment replay admission and bounded in-memory capacity", + "minimumScore": 94, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "wallet-script-encoding", + "manifest": "packages/helpers/bsv-wallet-helper/package.json", + "propertyTest": "packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts", + "risk": "critical", + "boundary": "Wallet transaction script classification and arbitrary OP_RETURN binary payload framing", + "minimumScore": 87, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "create-app-config", + "manifest": "packages/helpers/create-bsv-app/package.json", + "propertyTest": "packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts", + "risk": "critical", + "boundary": "Untrusted project-scaffolder configuration and filesystem-relative output destinations", + "minimumScore": 82, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "gasp-inputs", + "manifest": "packages/overlays/gasp-core/package.json", + "propertyTest": "packages/overlays/gasp-core/src/__tests/GASP.property.test.ts", + "risk": "critical", + "boundary": "Public Graph Aware Sync Protocol request, response, pagination, and UTXO-set reconciliation", + "minimumScore": 88, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "btms-topic", + "manifest": "packages/overlays/btms-backend/package.json", + "propertyTest": "packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.property.test.ts", + "risk": "critical", + "boundary": "Untrusted BTMS overlay token amount and asset identifier fields", + "minimumScore": 99, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "btms-permission", + "manifest": "packages/wallet/btms-permission-module/package.json", + "propertyTest": "packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts", + "risk": "critical", + "boundary": "BTMS wallet authorization sessions, request shapes, and Bitcoin varint framing", + "minimumScore": 82, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + } + ] +} diff --git a/governance/mutation-testing/stryker.config.mjs b/governance/mutation-testing/stryker.config.mjs new file mode 100644 index 0000000000..69667ec4da --- /dev/null +++ b/governance/mutation-testing/stryker.config.mjs @@ -0,0 +1,56 @@ +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' +import { statSync } from 'node:fs' +import { buildMutationTargets } from './targets.mjs' + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const targetName = process.env.TS_STACK_MUTATION_TARGET +const targets = buildMutationTargets(repositoryRoot) + +const target = targets[targetName] + +if (target === undefined) { + throw new Error( + `Unknown TS_STACK_MUTATION_TARGET ${JSON.stringify(targetName)}. Expected one of: ${Object.keys( + targets + ).join(', ')}` + ) +} + +const expectedDirectory = resolve(repositoryRoot, target.packageDirectory) +const expectedStat = statSync(expectedDirectory) +const currentStat = statSync(process.cwd()) +if (expectedStat.dev !== currentStat.dev || expectedStat.ino !== currentStat.ino) { + throw new Error( + `Mutation target ${targetName} must run from ${expectedDirectory}; received ${process.cwd()}` + ) +} + +const reportDirectory = resolve(repositoryRoot, 'artifacts/mutation', targetName) + +export default { + testRunner: target.testRunner, + plugins: [ + resolve( + repositoryRoot, + `node_modules/@stryker-mutator/${target.testRunner}-runner/dist/src/index.js` + ) + ], + coverageAnalysis: 'perTest', + mutate: target.mutate, + concurrency: 4, + timeoutMS: 2_000, + timeoutFactor: 3, + cleanTempDir: 'always', + ignorePatterns: ['coverage', 'dist', 'reports', 'artifacts', '.stryker-tmp'], + reporters: ['clear-text', 'json'], + jsonReporter: { + fileName: resolve(reportDirectory, 'mutation.json') + }, + thresholds: { + high: 80, + low: 60, + break: null + }, + ...target.runnerOptions +} diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs new file mode 100644 index 0000000000..7822d52454 --- /dev/null +++ b/governance/mutation-testing/targets.mjs @@ -0,0 +1,308 @@ +import { resolve } from 'node:path' + +function jestTarget(configFile, testMatch, { esm = false, config = {}, findRelated = false } = {}) { + return { + testRunner: 'jest', + runnerOptions: { + jest: { + projectType: 'custom', + configFile, + enableFindRelatedTests: findRelated, + ...(testMatch === undefined + ? {} + : { + config: { + ...config, + testMatch + } + }) + }, + ...(esm ? { testRunnerNodeArgs: ['--experimental-vm-modules'] } : {}) + } + } +} + +function vitestTarget(configFile) { + return { + testRunner: 'vitest', + runnerOptions: { + vitest: { + configFile, + related: true + } + } + } +} + +export function buildMutationTargets(repositoryRoot) { + return { + 'sdk-codecs': { + packageDirectory: 'packages/sdk', + manifest: 'packages/sdk/package.json', + propertyTest: 'packages/sdk/src/primitives/__tests/utils.property.test.ts', + mutate: ['src/primitives/utils.ts:255-370'], + ...jestTarget('jest.config.js', ['/src/primitives/__tests/utils.property.test.ts'], { + esm: true + }) + }, + 'wallet-action-batch': { + packageDirectory: 'packages/wallet/wallet-toolbox', + manifest: 'packages/wallet/wallet-toolbox/package.json', + propertyTest: + 'packages/wallet/wallet-toolbox/src/utility/__tests/actionBatchPack.property.test.ts', + mutate: ['src/utility/actionBatchPack.ts:38-132'], + ...jestTarget( + 'jest.config.ts', + [ + '/src/utility/__tests/actionBatchPack*.test.ts', + '/src/utility/__tests__/actionBatchPack*.test.ts' + ], + { + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + } + ) + }, + 'overlay-linkage': { + packageDirectory: 'packages/overlays/topics', + manifest: 'packages/overlays/topics/package.json', + propertyTest: 'packages/overlays/topics/src/mandala/__tests/types.property.test.ts', + mutate: ['src/mandala/types.ts:72-78', 'src/admission/issuerPolicy.ts:36-39'], + ...jestTarget('jest.config.js', ['/src/mandala/__tests/types*.test.ts'], { + esm: true + }) + }, + 'did-codecs': { + packageDirectory: 'packages/helpers/did', + manifest: 'packages/helpers/did/package.json', + propertyTest: 'packages/helpers/did/tests/codec.property.test.ts', + mutate: ['src/utils/base64url.ts', 'src/utils/multibase.ts'], + ...jestTarget( + 'jest.config.js', + ['/tests/codec.property.test.ts', '/tests/did.test.ts'], + { esm: true } + ) + }, + 'mandala-encoding': { + packageDirectory: 'packages/helpers/ts-templates', + manifest: 'packages/helpers/ts-templates/package.json', + propertyTest: 'packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts', + mutate: ['src/mandala-encoding.ts'], + ...jestTarget('jest.config.js', ['/src/__tests/mandala-encoding*.test.ts']) + }, + 'paymail-address': { + packageDirectory: 'packages/messaging/ts-paymail', + manifest: 'packages/messaging/ts-paymail/package.json', + propertyTest: 'packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts', + mutate: ['src/paymailAddress.ts'], + ...jestTarget('jest.config.js', ['/src/__tests/paymailAddress*.test.ts'], { + esm: true + }) + }, + 'p2p-messages': { + packageDirectory: 'packages/network/ts-p2p', + manifest: 'packages/network/ts-p2p/package.json', + propertyTest: 'packages/network/ts-p2p/test/messages.property.test.ts', + mutate: ['src/messages.ts:139-231'], + ...jestTarget('jest.config.js', ['/test/messages*.test.ts'], { esm: true }) + }, + auth: { + packageDirectory: 'packages/middleware/auth', + manifest: 'packages/middleware/auth/package.json', + propertyTest: 'packages/middleware/auth/src/__tests__/core.property.test.ts', + mutate: ['src/core.ts'], + ...jestTarget('jest.config.js', [ + '/src/__tests__/authProof.test.ts', + '/src/__tests__/core.property.test.ts' + ]) + }, + 'reorg-stream': { + packageDirectory: 'packages/overlays/overlay-express', + manifest: 'packages/overlays/overlay-express/package.json', + propertyTest: 'packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts', + mutate: ['src/ReorgStream.ts'], + ...jestTarget( + 'jest.config.js', + [ + '/src/__tests/ReorgStream*.test.ts', + '/src/__tests__/ReorgStream*.test.ts' + ], + { + esm: true + } + ) + }, + 'message-box-host': { + packageDirectory: 'packages/messaging/message-box-client', + manifest: 'packages/messaging/message-box-client/package.json', + propertyTest: 'packages/messaging/message-box-client/src/__tests/host.property.test.ts', + mutate: ['src/host.ts'], + ...jestTarget('jest.config.ts', ['/src/__tests/host*.test.ts'], { esm: true }) + }, + 'wallet-pairing': { + packageDirectory: 'packages/wallet/ts-wallet-relay', + manifest: 'packages/wallet/ts-wallet-relay/package.json', + propertyTest: 'packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts', + mutate: ['src/shared/pairingUri.ts'], + ...jestTarget('jest.mutation.config.cjs', undefined) + }, + 'overlay-advertisement': { + packageDirectory: 'packages/overlays/overlay-discovery-services', + manifest: 'packages/overlays/overlay-discovery-services/package.json', + propertyTest: + 'packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts', + mutate: ['src/utils/isAdvertisableURI.ts', 'src/utils/isValidTopicOrServiceName.ts'], + ...jestTarget( + 'jest.config.js', + [ + '/src/utils/__tests/isAdvertisableURI*.test.ts', + '/src/utils/__tests/isValidTopicOrServiceName*.test.ts' + ], + { esm: true } + ) + }, + 'overlay-integrity': { + packageDirectory: 'packages/overlays/overlay', + manifest: 'packages/overlays/overlay/package.json', + propertyTest: 'packages/overlays/overlay/src/__tests/BASM.property.test.ts', + mutate: ['src/BASM.ts:154-190', 'src/SafeLog.ts'], + ...jestTarget('jest.config.js', [ + '/src/__tests/BASM.test.ts', + '/src/__tests/BASM.property.test.ts', + '/src/__tests/SafeLog.test.ts' + ]) + }, + 'verifast-batch': { + packageDirectory: 'packages/verifast', + manifest: 'packages/verifast/package.json', + propertyTest: 'packages/verifast/src/__tests/BdkBatch.property.test.ts', + mutate: ['src/BdkBatch.ts'], + ...jestTarget('jest.config.js', ['/src/__tests/BdkBatch*.test.ts'], { + esm: true, + config: { + moduleNameMapper: { + '^@bsv/sdk$': resolve(repositoryRoot, 'packages/sdk/mod.ts'), + '^(\\.{1,2}/.*)\\.js$': '$1' + } + } + }) + }, + 'btms-helpers': { + packageDirectory: 'packages/wallet/btms', + manifest: 'packages/wallet/btms/package.json', + propertyTest: 'packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts', + mutate: [ + 'src/BTMSHelpers.ts:24-27', + 'src/BTMSHelpers.ts:195-203', + 'src/BTMSHelpers.ts:238-256', + 'src/utils.ts:28-67' + ], + ...jestTarget( + 'jest.config.cjs', + ['/src/__tests/BTMSHelpers*.test.ts', '/src/__tests/utils*.test.ts'], + { esm: true } + ) + }, + 'amount-format': { + packageDirectory: 'packages/helpers/amountinator', + manifest: 'packages/helpers/amountinator/package.json', + propertyTest: 'packages/helpers/amountinator/tests/amountFormat.property.test.ts', + mutate: ['src/utils/amountFormatHelpers.ts', 'src/utils/currencyConverter.ts:208-260'], + ...jestTarget('jest.config.cjs', [ + '/tests/amountFormat.property.test.ts', + '/tests/formatAmountWithCurrency.test.ts' + ]) + }, + 'fund-wallet-cli': { + packageDirectory: 'packages/helpers/fund-wallet', + manifest: 'packages/helpers/fund-wallet/package.json', + propertyTest: 'packages/helpers/fund-wallet/src/cli.property.test.ts', + mutate: ['src/cli.ts:188-256'], + ...vitestTarget('vitest.config.ts') + }, + 'payment-402': { + packageDirectory: 'packages/middleware/402-pay', + manifest: 'packages/middleware/402-pay/package.json', + propertyTest: 'packages/middleware/402-pay/src/server.property.test.ts', + mutate: ['src/server.ts'], + ...vitestTarget('vitest.config.ts') + }, + 'auth-express-bytes': { + packageDirectory: 'packages/middleware/auth-express-middleware', + manifest: 'packages/middleware/auth-express-middleware/package.json', + propertyTest: + 'packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts', + mutate: ['src/authMiddlewareHelpers.ts:96-103', 'src/authMiddlewareHelpers.ts:186-213'], + ...jestTarget('jest.config.js', ['/src/__tests/authMiddlewareHelpers*.test.ts']) + }, + 'payment-replay': { + packageDirectory: 'packages/middleware/payment-express-middleware', + manifest: 'packages/middleware/payment-express-middleware/package.json', + propertyTest: + 'packages/middleware/payment-express-middleware/src/__tests/PaymentReplayStore.property.test.ts', + mutate: ['src/index.ts:27-44'], + ...jestTarget('jest.config.js', ['/src/__tests/PaymentReplayStore*.test.ts']) + }, + 'wallet-script-encoding': { + packageDirectory: 'packages/helpers/bsv-wallet-helper', + manifest: 'packages/helpers/bsv-wallet-helper/package.json', + propertyTest: + 'packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts', + mutate: [ + 'src/utils/opreturn.ts:51-111', + 'src/utils/scriptValidation.ts:102-145', + 'src/utils/scriptValidation.ts:705-749' + ], + ...jestTarget('jest.config.js', [ + '/src/utils/__tests__/scriptEncoding*.test.ts', + '/src/utils/__tests__/opreturn*.test.ts', + '/src/utils/__tests__/scriptValidation*.test.ts' + ]) + }, + 'create-app-config': { + packageDirectory: 'packages/helpers/create-bsv-app', + manifest: 'packages/helpers/create-bsv-app/package.json', + propertyTest: + 'packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts', + mutate: ['src/config/validate.ts'], + ...jestTarget('jest.config.cjs', ['/src/config/__tests__/validate*.test.ts']) + }, + 'gasp-inputs': { + packageDirectory: 'packages/overlays/gasp-core', + manifest: 'packages/overlays/gasp-core/package.json', + propertyTest: 'packages/overlays/gasp-core/src/__tests/GASP.property.test.ts', + mutate: ['src/GASP.ts:304-314', 'src/GASP.ts:452-509'], + ...jestTarget('jest.config.js', ['/src/__tests/GASP*.test.ts']) + }, + 'btms-topic': { + packageDirectory: 'packages/overlays/btms-backend', + manifest: 'packages/overlays/btms-backend/package.json', + propertyTest: + 'packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.property.test.ts', + mutate: ['src/topic-managers/BTMSTopicManager.ts:70-88'], + ...jestTarget( + 'jest.config.js', + ['/src/topic-managers/__tests/BTMSTopicManager*.test.ts'], + { esm: true } + ) + }, + 'btms-permission': { + packageDirectory: 'packages/wallet/btms-permission-module', + manifest: 'packages/wallet/btms-permission-module/package.json', + propertyTest: + 'packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts', + mutate: [ + 'src/BasicTokenModule.ts:141-172', + 'src/BasicTokenModule.ts:778-818', + 'src/BasicTokenModule.ts:1082-1138' + ], + ...jestTarget('jest.config.cjs', ['/src/__tests__/BasicTokenModule*.test.ts'], { + esm: true + }) + } + } +} diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 046c997ca0..4cfcb22c46 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -45,10 +45,11 @@ "manifest": "packages/sdk/package.json", "risk": "critical", "boundary": "SDK binary-to-text codecs used by keys, addresses, and protocol payloads", - "target": "Base58 and Base58Check arbitrary-byte round trips plus checksum tamper rejection", + "target": "Base58 and Base58Check arbitrary-byte round trips, canonical alphabet enforcement, and checksum tamper rejection", "invariants": [ "Every non-empty byte sequence round-trips through Base58.", "Every prefix and payload round-trips through Base58Check.", + "Characters outside the exact Base58 alphabet are rejected.", "A deterministic checksum mutation is rejected." ] }, @@ -93,9 +94,10 @@ "manifest": "packages/helpers/ts-templates/package.json", "risk": "critical", "boundary": "Bitcoin script-number and on-chain asset outpoint encodings", - "target": "Safe-integer script numbers, minimal chunks, uint32 outpoints, and truncation rejection", + "target": "Safe-integer script numbers, exact minimal-push boundaries, uint32 outpoints, and truncation rejection", "invariants": [ "Every safe integer round-trips through script-number bytes and minimal chunks.", + "Zero, negative one, small integers, sign-bit boundaries, and every push-length transition use their exact minimal opcode.", "Every 32-byte transaction ID and uint32 output index round-trips.", "Non-hex IDs and overflowing output indexes are rejected." ] @@ -145,6 +147,7 @@ "target": "Reorg normalization, bounded depth fallback, numeric validation, chunked SSE delivery, and parser totality", "invariants": [ "Valid reorg hashes are canonicalized and common-ancestor heights are honored.", + "Only exact 32-byte hexadecimal orphan hashes survive normalization.", "A common ancestor cannot exceed the new chain tip.", "Depth fallback never produces a negative rebuild height.", "Non-safe heights are rejected.", @@ -162,7 +165,7 @@ "invariants": [ "Public HTTPS destinations canonicalize idempotently.", "Endpoint paths cannot replace the chosen authority.", - "Reserved IPv4 and IPv6 destinations are rejected.", + "Reserved IPv4 and IPv6 destinations are rejected without blocking adjacent public ranges.", "Arbitrary advertisements never throw at the tolerant boundary." ] }, @@ -235,11 +238,12 @@ "manifest": "packages/helpers/amountinator/package.json", "risk": "high", "boundary": "Wallet-facing currency conversion, decimal formatting, and safe-integer display", - "target": "Currency-pair round trips, safe-integer digit preservation, and finite formatting totality", + "target": "Currency-pair round trips, safe-integer digit preservation, canonical symbols, and finite formatting totality", "invariants": [ "Grouping separators never alter arbitrary safe-integer digits.", "Every supported currency pair round-trips bounded finite amounts.", - "Bounded finite amounts and decimal policies always produce non-NaN text." + "Bounded finite amounts and decimal policies always produce non-NaN text.", + "Known fiat currencies retain their canonical marker and unavailable rates fail closed." ] }, { @@ -271,11 +275,12 @@ "manifest": "packages/middleware/auth-express-middleware/package.json", "risk": "critical", "boundary": "Authenticated Express request and response canonical byte serialization", - "target": "Unicode field framing, typed-array slice fidelity, and deterministic JSON response bytes", + "target": "Unicode field framing, typed-array slice fidelity, response-value classification, and deterministic JSON bytes", "invariants": [ "Arbitrary Unicode header pairs round-trip without framing ambiguity.", "Typed-array views serialize only their selected bytes.", - "Arbitrary JSON response values serialize deterministically with canonical content type." + "Arbitrary JSON response values serialize deterministically with canonical content type.", + "Only integer arrays bounded to byte values bypass JSON serialization; every primitive family has a deterministic byte form." ] }, { @@ -307,10 +312,11 @@ "manifest": "packages/helpers/create-bsv-app/package.json", "risk": "critical", "boundary": "Untrusted project-scaffolder configuration and filesystem-relative output destinations", - "target": "Safe path preservation, traversal rejection, structured-input totality, and governed validation errors", + "target": "Safe path preservation, absolute/traversal rejection, structured-input totality, and governed validation errors", "invariants": [ "Arbitrary safe relative BSV and target paths retain their exact destination.", - "Absolute and traversal-bearing BSV paths are rejected before filesystem use.", + "Absolute and traversal-bearing BSV and target paths are rejected before filesystem use.", + "Non-string capabilities and unregistered starters are rejected with governed configuration errors.", "Arbitrary structured configuration either resolves safely or raises the dedicated configuration error." ] }, diff --git a/package.json b/package.json index a5283b325e..87e832b5b4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "typecheck": "pnpm -r --workspace-concurrency=4 --filter '!@bsv/ts-stack' --if-present run typecheck", "test": "pnpm -r --filter '!@bsv/ts-stack' run test", "lint": "oxlint scripts --deny-warnings && pnpm -r --filter '!@bsv/ts-stack' run lint", - "format:root": "prettier --check \"package.json\" \"pnpm-workspace.yaml\" \"tsconfig.base.json\" \".prettierrc.json\" \"scripts/**/*.{mjs,json}\" \"governance/**/*.{json,md}\" \".github/**/*.{md,yml,yaml}\"", + "format:root": "prettier --check \"package.json\" \"pnpm-workspace.yaml\" \"tsconfig.base.json\" \".prettierrc.json\" \"scripts/**/*.{mjs,json}\" \"governance/**/*.{json,md,mjs}\" \".github/**/*.{md,yml,yaml}\"", "format:check": "pnpm format:root && pnpm -r --filter '!@bsv/ts-stack' --if-present run format:check", "clean": "pnpm -r --filter '!@bsv/ts-stack' run clean", "sync-versions": "node scripts/sync-versions.mjs", @@ -23,6 +23,7 @@ "test:governance": "node scripts/test-governance.mjs", "build:property": "pnpm -r --filter '!@bsv/ts-stack' --filter '!docs-site' --filter '!example-paymail' run build", "test:property": "pnpm build:property && pnpm -r --workspace-concurrency=4 --if-present run test:property", + "test:mutation": "node scripts/mutation-testing.mjs", "audit:security": "pnpm audit --audit-level=high", "codegen": "node scripts/generate-openapi-types.mjs", "codegen:check": "node scripts/generate-openapi-types.mjs --check", @@ -34,7 +35,11 @@ }, "devDependencies": { "@arethetypeswrong/core": "0.18.5", + "@stryker-mutator/core": "9.6.1", + "@stryker-mutator/jest-runner": "9.6.1", + "@stryker-mutator/vitest-runner": "9.6.1", "esbuild": "0.28.1", + "jest-environment-node": "30.4.1", "oxlint": "1.75.0", "prettier": "3.9.6", "publint": "0.3.22", diff --git a/packages/helpers/amountinator/tests/amountFormat.property.test.ts b/packages/helpers/amountinator/tests/amountFormat.property.test.ts index 4d9e174366..e1a8590d3c 100644 --- a/packages/helpers/amountinator/tests/amountFormat.property.test.ts +++ b/packages/helpers/amountinator/tests/amountFormat.property.test.ts @@ -86,4 +86,27 @@ describe('currency boundary properties', () => { ) ) }) + + test('trims implicit decimals and fails closed for unavailable conversion rates', () => { + fc.assert( + fc.property( + fc.double({ min: 0.01, max: 1_000_000, noNaN: true, noDefaultInfinity: true }), + fc.constantFrom(...currencies), + (amount, currency) => { + const result = formatAmountWithCurrency(amount, currency) + expect(result.formattedAmount).toEqual(expect.any(String)) + expect(result.formattedAmount).not.toContain('NaN') + } + ) + ) + + const unavailable = new CurrencyConverter(0, { + get: async () => ({ currency: 'USD' }) + } as never) + expect(() => unavailable.convertCurrency(1, 'USD', 'BSV')).toThrow('usdPerBsv') + unavailable.exchangeRates = { usdPerBsv: 50, fiatPerUsd: { ...rates, EUR: 0 } } + expect(() => unavailable.convertCurrency(1, 'EUR', 'USD')).toThrow('EUR per USD') + expect(() => unavailable.convertCurrency(1, 'USD', 'EUR')).toThrow('EUR per USD') + expect(() => unavailable.convertCurrency(1, 'UNKNOWN', 'USD')).toThrow('Currency not supported') + }) }) diff --git a/packages/helpers/amountinator/tests/formatAmountWithCurrency.test.ts b/packages/helpers/amountinator/tests/formatAmountWithCurrency.test.ts index b7793498b3..11f0a252f4 100644 --- a/packages/helpers/amountinator/tests/formatAmountWithCurrency.test.ts +++ b/packages/helpers/amountinator/tests/formatAmountWithCurrency.test.ts @@ -22,6 +22,28 @@ describe('formatAmountWithCurrency', () => { expect(formatAmountWithCurrency(1234.56, 'USD').formattedAmount).toBe('$1,234.56') }) + test.each([ + ['USD', '$'], + ['GBP', '£'], + ['EUR', '€'], + ['JPY', '¥'], + ['CNY', '¥'], + ['INR', '₹'], + ['AUD', 'A$'], + ['CAD', 'C$'], + ['CHF', 'CHF '], + ['HKD', 'HK$'], + ['SGD', 'S$'], + ['NZD', 'NZ$'], + ['SEK', 'SEK '], + ['NOK', 'NOK '], + ['MXN', 'MX$'] + ])('uses the canonical %s currency marker', (currency, marker) => { + expect(formatAmountWithCurrency(1234.5, currency, { decimalPlaces: 1 }).formattedAmount).toBe( + `${marker}1,234.5` + ) + }) + test('formats EUR with no decimals', () => { expect(formatAmountWithCurrency(1234, 'EUR', { decimalPlaces: 0 }).formattedAmount).toBe( '€1,234' @@ -51,6 +73,26 @@ describe('formatAmountWithCurrency', () => { ) }) + test('keeps the exact small-amount threshold and implicit decimal boundaries', () => { + expect(formatAmountWithCurrency(0, 'USD')).toEqual({ + formattedAmount: '< $0.01', + hoverText: '$0' + }) + expect(formatAmountWithCurrency(0.009, 'USD')).toEqual({ + formattedAmount: '< $0.01', + hoverText: '$0.009' + }) + expect(formatAmountWithCurrency(0.01, 'USD')).toEqual({ + formattedAmount: '$0.01' + }) + expect(formatAmountWithCurrency(0.1, 'USD')).toEqual({ + formattedAmount: '$0.1' + }) + expect(formatAmountWithCurrency(1, 'USD')).toEqual({ + formattedAmount: '$1' + }) + }) + test('formats very small amounts for BSV and currencies without a known symbol', () => { expect(formatAmountWithCurrency(0.001, 'BSV')).toEqual({ formattedAmount: '< 0.01 BSV', diff --git a/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts b/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts index ba5fb03feb..1abb2baf1b 100644 --- a/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts +++ b/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptEncoding.property.test.ts @@ -61,6 +61,7 @@ describe('wallet script encoding properties', () => { expect(isP2PKH(canonical)).toBe(true) expect(isP2PKH(`75${canonical.slice(2)}`)).toBe(false) + expect(isP2PKH(`${canonical.slice(0, 4)}13${canonical.slice(6)}`)).toBe(false) expect(isP2PKH(`${canonical.slice(0, -2)}ad`)).toBe(false) }) ) diff --git a/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptValidation.test.ts b/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptValidation.test.ts index 07fc6a48c9..4132bdbb09 100644 --- a/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptValidation.test.ts +++ b/packages/helpers/bsv-wallet-helper/src/utils/__tests__/scriptValidation.test.ts @@ -70,6 +70,11 @@ describe('Script Validation Functions', () => { expect(isP2PKH(hex)).toBe(false) }) + it('should return false for the right total length with a wrong hash-length opcode', () => { + const hex = '76a913' + 'ab'.repeat(20) + '88ac' + expect(isP2PKH(hex)).toBe(false) + }) + it('should return false for hex string with wrong prefix', () => { const hex = '76a9' + 'ab'.repeat(20) + '88ac' // Wrong prefix expect(isP2PKH(hex)).toBe(false) diff --git a/packages/helpers/bsv-wallet-helper/src/utils/scriptValidation.ts b/packages/helpers/bsv-wallet-helper/src/utils/scriptValidation.ts index 2bd788e43d..9db07d04d0 100644 --- a/packages/helpers/bsv-wallet-helper/src/utils/scriptValidation.ts +++ b/packages/helpers/bsv-wallet-helper/src/utils/scriptValidation.ts @@ -136,12 +136,6 @@ export function isP2PKH(input: ScriptInput): boolean { return false } - // Check length byte (should be 0x14 = 20 bytes) - const lengthByte = hex.substring(4, 6) - if (lengthByte !== '14') { - return false - } - // Check suffix (OP_EQUALVERIFY OP_CHECKSIG) if (!hex.endsWith(suffix)) { return false @@ -731,9 +725,6 @@ export function extractOpReturnData(input: ScriptInput): string[] | null { // Find OP_RETURN chunk (opcode 0x6a = 106) const opReturnIndex = chunks.findIndex(chunk => chunk.op === 0x6a) - if (opReturnIndex === -1) { - return null - } // Extract all data chunks after OP_RETURN const dataFields: string[] = [] diff --git a/packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts b/packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts index a9986a4307..bc8aac9e81 100644 --- a/packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts +++ b/packages/helpers/create-bsv-app/src/config/__tests__/validate.property.test.ts @@ -63,6 +63,44 @@ describe('project configuration properties', () => { ) }) + test('enforces exact empty, absolute, type, default, and registry boundaries', () => { + expect(validBsvDir('')).toBe(false) + + const emptyTarget = resolveConfig({ + mode: 'add', + name: 'app', + dir: '', + bsvDir: 'src/bsv', + targets: { client: '', server: '' } + }) + expect(emptyTarget.dir).toBe('.') + expect(emptyTarget.targets).toEqual({ client: '', server: '' }) + + for (const target of ['/absolute', 'C:\\absolute', '../escape']) { + expect(() => + resolveConfig({ + mode: 'add', + name: 'app', + targets: { client: target } + }) + ).toThrow('safe relative path') + } + expect(() => + resolveConfig({ + mode: 'add', + name: 'app', + capabilities: [1] + }) + ).toThrow('capabilities must be strings') + expect(() => + resolveConfig({ + mode: 'add', + name: 'app', + starter: 'not-registered' + }) + ).toThrow('unknown starter: not-registered') + }) + test('handles arbitrary structured configuration as a resolved config or a governed error', () => { fc.assert( fc.property(fc.jsonValue(), input => { diff --git a/packages/helpers/did/src/utils/multibase.ts b/packages/helpers/did/src/utils/multibase.ts index 24a9274055..f0f546f555 100644 --- a/packages/helpers/did/src/utils/multibase.ts +++ b/packages/helpers/did/src/utils/multibase.ts @@ -37,9 +37,6 @@ export function decodeBase58Multibase(value: string): number[] { // https://w3c-ccg.github.io/did-key-spec/#did-key-identifier-syntax export function publicKeyToDidKey(publicKey: PublicKeyInput | PublicKey): string { const compressed = normalizePublicKey(publicKey) - if (compressed.length !== 33) { - throw new Error('secp256k1 did:key requires a 33-byte compressed public key') - } const multibaseValue = encodeBase58Multibase([...SECP256K1_PUB_MULTICODEC_PREFIX, ...compressed]) return `did:key:${multibaseValue}` } diff --git a/packages/helpers/did/tests/did.test.ts b/packages/helpers/did/tests/did.test.ts index c567ecd44e..08036c8f70 100644 --- a/packages/helpers/did/tests/did.test.ts +++ b/packages/helpers/did/tests/did.test.ts @@ -4,9 +4,15 @@ import { decodeDidKey, publicKeyFromDid, publicKeyToDidKey, - publicKeyToJwk + publicKeyToJwk, + verificationMethodForDid } from '../src/index.js' import { sha256Base64Url } from '../src/utils/crypto.js' +import { + didFromVerificationMethod, + encodeBase58Multibase, + SECP256K1_PUB_MULTICODEC_PREFIX +} from '../src/utils/multibase.js' describe('BsvDid', () => { test('creates a secp256k1 did:key and DID Document', () => { @@ -27,14 +33,43 @@ describe('BsvDid', () => { }) test('normalizes hexadecimal and byte-array public-key inputs', () => { - const publicKey = PrivateKey.fromRandom().toPublicKey().toDER() as number[] + const publicKeyObject = PrivateKey.fromRandom().toPublicKey() + const publicKey = publicKeyObject.toDER() as number[] const publicKeyHex = publicKey.map(byte => byte.toString(16).padStart(2, '0')).join('') const publicKeyBytes = new Uint8Array(publicKey) expect(publicKeyToDidKey(publicKeyHex)).toBe(publicKeyToDidKey(publicKeyBytes)) + expect(publicKeyToDidKey(publicKeyObject)).toBe(publicKeyToDidKey(publicKeyBytes)) expect(publicKeyToJwk(publicKeyHex)).toEqual(publicKeyToJwk(publicKeyBytes)) }) + test('round-trips the canonical verification method and rejects forged fragments', () => { + const did = publicKeyToDidKey(new PrivateKey(1).toPublicKey()) + const verificationMethod = verificationMethodForDid(did) + + expect(didFromVerificationMethod(verificationMethod)).toBe(did) + expect(() => didFromVerificationMethod(did)).toThrow('with a fragment') + expect(() => didFromVerificationMethod(`${did}#`)).toThrow('with a fragment') + expect(() => didFromVerificationMethod(`${did}#wrong`)).toThrow('does not match') + }) + + test('rejects malformed DID grammar, multicodecs, key lengths, and curve points', () => { + const validKey = new PrivateKey(1).toPublicKey().toDER() as number[] + const invalidInputs = [ + 'did:key', + 'did:web:example.com', + 'did:key:not-multibase', + `did:key:${encodeBase58Multibase([0xe8, 0x01, ...validKey])}`, + `did:key:${encodeBase58Multibase([...SECP256K1_PUB_MULTICODEC_PREFIX, ...validKey.slice(1)])}`, + `did:key:${encodeBase58Multibase([ + ...SECP256K1_PUB_MULTICODEC_PREFIX, + ...Array.from({ length: 33 }, () => 0) + ])}` + ] + + for (const did of invalidInputs) expect(() => decodeDidKey(did)).toThrow() + }) + test('hashes both text and byte-array values', () => { expect(sha256Base64Url('abc')).toBe(sha256Base64Url(new TextEncoder().encode('abc'))) }) diff --git a/packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts b/packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts index ce48306551..10aadde40b 100644 --- a/packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts +++ b/packages/helpers/ts-templates/src/__tests/mandala-encoding.property.test.ts @@ -27,6 +27,39 @@ const hashHex = fc .map(bytes => Buffer.from(bytes).toString('hex')) describe('Mandala encoding properties', () => { + test('selects exact minimal push opcodes at every length boundary', () => { + for (const [length, op] of [ + [0, 0], + [75, 75], + [76, 0x4c], + [255, 0x4c], + [256, 0x4d], + [65535, 0x4d], + [65536, 0x4e] + ] as const) { + expect(createMinimallyEncodedScriptChunk(Array.from({ length }, () => 0x42)).op).toBe(op) + } + const scriptNumberVectors: Array< + [value: number, encoded: number[], chunk: { op: number; data?: number[] }] + > = [ + [0, [], { op: 0 }], + [-1, [0x81], { op: 0x4f }], + [1, [0x01], { op: 0x51 }], + [16, [0x10], { op: 0x60 }], + [17, [0x11], { op: 1, data: [0x11] }], + [-17, [0x91], { op: 1, data: [0x91] }], + [127, [0x7f], { op: 1, data: [0x7f] }], + [128, [0x80, 0x00], { op: 2, data: [0x80, 0x00] }] + ] + for (const [value, encoded, chunk] of scriptNumberVectors) { + expect(encodeScriptNum(value)).toEqual(encoded) + expect(createMinimallyEncodedScriptChunk([...encoded])).toEqual(chunk) + expect(decodeScriptNumChunk(chunk)).toBe(value) + } + expect(createMinimallyEncodedScriptChunk([0])).toEqual({ op: 0 }) + expect(decodeScriptNumChunk({ op: 0x4c })).toBe(0) + }) + test('round-trips every arbitrary safe script number through bytes and minimal chunks', () => { fc.assert( fc.property( @@ -63,6 +96,8 @@ describe('Mandala encoding properties', () => { } ) ) + expect(() => encodeAssetId('missing-separator')).toThrow('"."') expect(() => encodeAssetId(`${'00'.repeat(32)}.4294967296`)).toThrow('unsigned 32-bit') + expect(() => decodeAssetId(Array.from({ length: 35 }, () => 0))).toThrow('exactly 36 bytes') }) }) diff --git a/packages/messaging/message-box-client/src/__tests/host.property.test.ts b/packages/messaging/message-box-client/src/__tests/host.property.test.ts index 6eee34d3be..5ec8600b6f 100644 --- a/packages/messaging/message-box-client/src/__tests/host.property.test.ts +++ b/packages/messaging/message-box-client/src/__tests/host.property.test.ts @@ -83,6 +83,56 @@ describe('Message Box host boundary properties', () => { } }) + test('blocks exact reserved-network edges without widening them into adjacent public space', () => { + const cases: Array<[host: string, blocked: boolean]> = [ + ['0.0.0.0', true], + ['1.0.0.0', false], + ['10.255.255.255', true], + ['11.0.0.0', false], + ['100.63.255.255', false], + ['100.64.0.0', true], + ['100.127.255.255', true], + ['100.128.0.0', false], + ['127.255.255.255', true], + ['128.0.0.0', false], + ['169.253.255.255', false], + ['169.254.0.0', true], + ['169.255.0.0', false], + ['172.15.255.255', false], + ['172.16.0.0', true], + ['172.31.255.255', true], + ['172.32.0.0', false], + ['192.0.0.1', true], + ['192.0.1.1', false], + ['192.0.2.1', true], + ['192.167.255.255', false], + ['192.168.0.0', true], + ['192.169.0.0', false], + ['198.17.255.255', false], + ['198.18.0.0', true], + ['198.19.255.255', true], + ['198.20.0.0', false], + ['198.51.99.255', false], + ['198.51.100.0', true], + ['198.51.101.0', false], + ['203.0.112.255', false], + ['203.0.113.0', true], + ['203.0.114.0', false], + ['223.255.255.255', false], + ['224.0.0.0', true], + ['[fe7f::1]', false], + ['[fe80::1]', true], + ['[febf::1]', true], + ['[fec0::1]', false] + ] + + for (const [host, blocked] of cases) { + const normalized = normalizeOverlayMessageBoxHost(`https://${host}`) + if (blocked) expect(normalized).toBeUndefined() + else expect(normalized).toBeDefined() + } + }) + test('never lets an arbitrary endpoint path replace the selected authority', () => { fc.assert( fc.property(publicHost, fc.string({ minLength: 1, maxLength: 512 }), (host, path) => { diff --git a/packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts b/packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts index 57db20ced4..baa4dc0971 100644 --- a/packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts +++ b/packages/messaging/ts-paymail/src/__tests/paymailAddress.property.test.ts @@ -43,6 +43,30 @@ describe('paymail address parser properties', () => { ) }) + test('enforces exact alias, host, and label boundaries', () => { + const aliasAtLimit = 'a'.repeat(64) + const domainAtLimit = ['a'.repeat(63), 'b'.repeat(63), 'c'.repeat(63), 'd'.repeat(61)].join('.') + + expect(parsePaymail(`${aliasAtLimit}@${domainAtLimit}`)).toEqual({ + name: aliasAtLimit, + domain: domainAtLimit + }) + for (const invalid of [ + 'example.com', + '@example.com', + 'name@', + 'name@@example.com', + `${'a'.repeat(65)}@example.com`, + `name@${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(63)}`, + 'name@example..com', + 'name@exa_mple.com', + '!name@example.com', + 'name!@example.com' + ]) { + expect(parsePaymail(invalid)).toBeUndefined() + } + }) + test('is total for arbitrary untrusted strings', () => { fc.assert( fc.property(fc.string({ maxLength: 1024 }), value => { diff --git a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts index dedc704a2b..d7f396c080 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/authMiddlewareHelpers.property.test.ts @@ -68,4 +68,30 @@ describe('auth transport serialization properties', () => { }) ) }) + + test('classifies every response-value family without treating malformed byte arrays as bytes', () => { + expect(convertValueToArray(undefined, {})).toEqual([]) + expect(convertValueToArray(null, {})).toEqual([]) + expect(convertValueToArray('é', {})).toEqual(Utils.toArray('é', 'utf8')) + expect(convertValueToArray(Buffer.from([0, 255]), {})).toEqual([0, 255]) + expect(convertValueToArray(new Uint8Array([0, 255]), {})).toEqual([0, 255]) + expect(convertValueToArray([0, 255], {})).toEqual([0, 255]) + + for (const malformed of [[-1], [256], [0.5], ['1']]) { + const headers: Record = {} + expect(convertValueToArray(malformed, headers)).toEqual( + Utils.toArray(JSON.stringify(malformed), 'utf8') + ) + expect(headers['content-type']).toBe('application/json') + } + + const existingHeaders = { 'content-type': 'application/custom' } + expect(convertValueToArray({ ok: true }, existingHeaders)).toEqual( + Utils.toArray('{"ok":true}', 'utf8') + ) + expect(existingHeaders['content-type']).toBe('application/custom') + expect(convertValueToArray(42, {})).toEqual(Utils.toArray('42', 'utf8')) + expect(convertValueToArray(false, {})).toEqual(Utils.toArray('false', 'utf8')) + expect(convertValueToArray(1n, {})).toEqual(Utils.toArray('1', 'utf8')) + }) }) diff --git a/packages/middleware/auth/src/__tests__/authProof.test.ts b/packages/middleware/auth/src/__tests__/authProof.test.ts index ee76d042bc..320a92e772 100644 --- a/packages/middleware/auth/src/__tests__/authProof.test.ts +++ b/packages/middleware/auth/src/__tests__/authProof.test.ts @@ -36,6 +36,9 @@ describe('standalone serializeAuthSigData / createAuthSigData', () => { it('serialization is deterministic and changes with any field', () => { const base = JSON.stringify(serializeAuthSigData(sigData())) expect(JSON.stringify(serializeAuthSigData(sigData()))).toBe(base) + expect(new TextDecoder().decode(new Uint8Array(serializeAuthSigData(sigData())))).toBe( + `login\n${IDENTITY}\n${String(NOW + WINDOW)}\ncmFuZG9tbm9uY2U=` + ) expect(JSON.stringify(serializeAuthSigData(sigData({ action: 'delete' })))).not.toBe(base) expect(JSON.stringify(serializeAuthSigData(sigData({ nonce: 'b3RoZXI=' })))).not.toBe(base) }) @@ -86,6 +89,10 @@ describe('AuthProofServer.checkAuthSigData', () => { it('accepts right up to the expiry boundary; rejects empty fields', () => { expect(server.checkAuthSigData(sigData({ expiresAt: NOW + 1 }), 'login', NOW).valid).toBe(true) + expect(server.checkAuthSigData(sigData({ expiresAt: NOW }), 'login', NOW)).toEqual({ + valid: false, + error: 'Proof expired' + }) expect(server.checkAuthSigData(sigData({ identityKey: '' }), 'login', NOW).error).toBe( 'Malformed proof' ) @@ -96,6 +103,21 @@ describe('AuthProofServer.checkAuthSigData', () => { 'Malformed proof' ) }) + + it('rejects every malformed field shape independently', () => { + for (const malformed of [ + sigData({ action: undefined as unknown as string }), + sigData({ identityKey: undefined as unknown as string }), + sigData({ nonce: undefined as unknown as string }), + sigData({ expiresAt: 'tomorrow' as unknown as number }), + sigData({ expiresAt: Number.POSITIVE_INFINITY }) + ]) { + expect(server.checkAuthSigData(malformed, 'login', NOW)).toEqual({ + valid: false, + error: 'Malformed proof' + }) + } + }) }) describe('AuthProofServer.verifyAuthProof', () => { @@ -163,7 +185,7 @@ describe('AuthProofServer.verifyAuthProof', () => { now: NOW, consumeNonce: consume }) - expect(result.error).toBe('Invalid signature') + expect(result).toEqual({ valid: false, error: 'Invalid signature' }) expect(consume).not.toHaveBeenCalled() }) @@ -181,7 +203,7 @@ describe('AuthProofServer.verifyAuthProof', () => { now: NOW, consumeNonce: consume }) - expect(result.error).toBe('Invalid signature') + expect(result).toEqual({ valid: false, error: 'Invalid signature' }) expect(consume).not.toHaveBeenCalled() }) @@ -193,7 +215,7 @@ describe('AuthProofServer.verifyAuthProof', () => { now: NOW, consumeNonce: () => false }) - expect(result.error).toBe('Proof already used') + expect(result).toEqual({ valid: false, error: 'Proof already used' }) }) it('verifies the signature against the proof identity key and nonce', async () => { @@ -209,6 +231,22 @@ describe('AuthProofServer.verifyAuthProof', () => { expect.objectContaining({ counterparty: IDENTITY, keyID: 'cmFuZG9tbm9uY2U=' }) ) }) + + it('passes a custom protocol directly to wallet signature operations', async () => { + const wallet = { + getPublicKey: jest.fn(async () => ({ publicKey: IDENTITY })), + createSignature: jest.fn(async () => ({ signature: [1, 2, 3] })) + } + await createAuthProof({ + wallet: wallet as never, + counterparty: 'server', + action: 'login', + ...OPTIONS + }) + expect(wallet.createSignature).toHaveBeenCalledWith( + expect.objectContaining({ protocolID: OPTIONS.protocol }) + ) + }) }) describe('normalizeBody', () => { diff --git a/packages/network/ts-p2p/test/messages.property.test.ts b/packages/network/ts-p2p/test/messages.property.test.ts index bbddca1508..b9aa660e24 100644 --- a/packages/network/ts-p2p/test/messages.property.test.ts +++ b/packages/network/ts-p2p/test/messages.property.test.ts @@ -28,6 +28,10 @@ function frame(sender: string, value: unknown): Uint8Array { return encoder.encode(JSON.stringify({ name: sender, data })) } +function envelopeFrame(name: unknown, data: unknown): Uint8Array { + return encoder.encode(JSON.stringify({ name, data })) +} + describe('Teranode message decoder properties', () => { test('round-trips arbitrary nested payloads through independent UTF-8 and base64 encoders', () => { fc.assert( @@ -52,19 +56,42 @@ describe('Teranode message decoder properties', () => { fc.string({ maxLength: 40 }), fc.constantFrom('!', '-', '_', ' ', 'A', 'AAA===', 'Zh==', 'Zm9='), (sender, data) => { - const malformed = encoder.encode(JSON.stringify({ name: sender, data })) + const malformed = envelopeFrame(sender, data) + expect(() => decodeMessage(malformed)).toThrow('Invalid canonical base64 payload') expect(tryDecodeMessage(malformed)).toBeNull() } ) ) + for (const data of ['AAAA!', '!AAAA', '=AAA', 'AAA=AAAA']) { + expect(() => decodeMessage(envelopeFrame('sender', data))).toThrow( + 'Invalid canonical base64 payload' + ) + } fc.assert( fc.property( fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)), primitive => { + expect(() => decodeMessage(frame('sender', primitive))).toThrow('Invalid message payload') expect(tryDecodeMessage(frame('sender', primitive))).toBeNull() } ) ) + + for (const envelope of [ + null, + [], + 'message', + 1, + {}, + { name: 1, data: 'e30=' }, + { name: 'sender', data: 1 } + ]) { + const malformed = encoder.encode(JSON.stringify(envelope)) + expect(() => decodeMessage(malformed)).toThrow('Invalid message envelope') + expect(tryDecodeMessage(malformed)).toBeNull() + } + expect(() => decodeMessage(frame('sender', []))).toThrow('Invalid message payload') + expect(tryDecodeMessage(frame('sender', []))).toBeNull() }) }) diff --git a/packages/overlays/btms-backend/package.json b/packages/overlays/btms-backend/package.json index 3e45ccbebf..a7e0939905 100644 --- a/packages/overlays/btms-backend/package.json +++ b/packages/overlays/btms-backend/package.json @@ -38,6 +38,7 @@ "author": "BSV Association", "license": "SEE LICENSE IN LICENSE.txt", "devDependencies": { + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@types/node": "^26.1.1", "fast-check": "^4.9.0", diff --git a/packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.test.ts b/packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.test.ts index 1caaa4f1d6..e4c946f36a 100644 --- a/packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.test.ts +++ b/packages/overlays/btms-backend/src/topic-managers/__tests/BTMSTopicManager.test.ts @@ -1,5 +1,8 @@ import BTMSTopicManager from '../BTMSTopicManager' import { Beef, LockingScript, PrivateKey, PublicKey, Script, Transaction, Utils } from '@bsv/sdk' +import { jest as jestRuntime } from '@jest/globals' + +const jestApi = jestRuntime as unknown as typeof jest /** * Helper to create a simple PushDrop-style locking script for testing. @@ -118,7 +121,7 @@ describe('BTMS Topic Manager', () => { }) afterEach(() => { - jest.restoreAllMocks() + jestApi.restoreAllMocks() }) describe('Issuance outputs', () => { @@ -785,7 +788,7 @@ describe('BTMS Topic Manager', () => { ] } as unknown as Transaction const beef = { - findTxid: jest.fn().mockReturnValue({ tx: sourceTx }) + findTxid: jestApi.fn().mockReturnValue({ tx: sourceTx }) } as unknown as Beef expect(internals(manager).collectPreviousUTXOs(transaction, beef, [0, 1, 2, 3])).toEqual([ @@ -800,13 +803,13 @@ describe('BTMS Topic Manager', () => { const second = new LockingScript() const malformed = new LockingScript() const subject = internals(manager) - subject.decodeToken = jest.fn(lockingScript => { + subject.decodeToken = jestApi.fn(lockingScript => { if (lockingScript === skip) return undefined if (lockingScript === first) return { assetIdField: 'asset.0', amount: 4, metadata: 'm' } if (lockingScript === second) return { assetIdField: 'asset.0', amount: 6, metadata: 'm' } throw new Error('malformed token') }) - const log = jest.spyOn(console, 'log').mockImplementation() + const log = jestApi.spyOn(console, 'log').mockImplementation() expect( subject.buildAssetAllowances( @@ -827,7 +830,7 @@ describe('BTMS Topic Manager', () => { it('admits only valid issuance and allowance-preserving outputs', () => { const scripts = Array.from({ length: 7 }, () => new LockingScript()) const subject = internals(manager) - subject.decodeToken = jest.fn(lockingScript => { + subject.decodeToken = jestApi.fn(lockingScript => { const index = scripts.indexOf(lockingScript) if (index === 0) return undefined if (index === 1) return { assetIdField: 'ISSUE', amount: 20 } @@ -837,7 +840,7 @@ describe('BTMS Topic Manager', () => { if (index === 5) return { assetIdField: 'other.0', amount: 1, metadata: 'wrong' } throw new Error('malformed output') }) - const debug = jest.spyOn(console, 'debug').mockImplementation() + const debug = jestApi.spyOn(console, 'debug').mockImplementation() const transaction = { outputs: scripts.map(lockingScript => ({ lockingScript })) } as unknown as Transaction @@ -857,13 +860,13 @@ describe('BTMS Topic Manager', () => { const skipped = new LockingScript() const malformed = new LockingScript() const subject = internals(manager) - subject.decodeToken = jest.fn(lockingScript => { + subject.decodeToken = jestApi.fn(lockingScript => { if (lockingScript === admitted) return { assetIdField: 'asset.0', amount: 1 } if (lockingScript === ignored) return { assetIdField: 'other.0', amount: 1 } if (lockingScript === skipped) return undefined throw new Error('malformed previous coin') }) - const debug = jest.spyOn(console, 'debug').mockImplementation() + const debug = jestApi.spyOn(console, 'debug').mockImplementation() const previousUTXOs = [admitted, ignored, skipped, malformed].map( (lockingScript, coinIndex) => ({ coinIndex, @@ -882,7 +885,7 @@ describe('BTMS Topic Manager', () => { const undecodable = new LockingScript() const malformed = new LockingScript() const subject = internals(manager) - subject.decodeToken = jest.fn(lockingScript => { + subject.decodeToken = jestApi.fn(lockingScript => { if (lockingScript === issuance) return { assetIdField: 'ISSUE', amount: 1 } if (lockingScript === undecodable) return undefined throw new Error('mutated output') @@ -902,10 +905,10 @@ describe('BTMS Topic Manager', () => { }) it('fails closed when a parsed transaction has no output array', async () => { - jest + jestApi .spyOn(Transaction, 'fromBEEF') .mockReturnValue({ outputs: undefined } as unknown as Transaction) - const warn = jest.spyOn(console, 'warn').mockImplementation() + const warn = jestApi.spyOn(console, 'warn').mockImplementation() await expect(manager.identifyAdmissibleOutputs([], [1])).resolves.toEqual({ outputsToAdmit: [], diff --git a/packages/overlays/gasp-core/src/__tests/GASP.property.test.ts b/packages/overlays/gasp-core/src/__tests/GASP.property.test.ts index ea20b57fef..1e6c84a6ee 100644 --- a/packages/overlays/gasp-core/src/__tests/GASP.property.test.ts +++ b/packages/overlays/gasp-core/src/__tests/GASP.property.test.ts @@ -132,6 +132,21 @@ describe('GASP protocol properties', () => { ) ) + const local = [ + { txid: 'a', outputIndex: 0, score: 1 }, + { txid: 'a', outputIndex: 1, score: 2 }, + { txid: 'b', outputIndex: 0, score: 3 } + ] + const remote = [ + { txid: 'a', outputIndex: 0, score: 10 }, + { txid: 'b', outputIndex: 1, score: 11 } + ] + await expect( + harness(local).gasp.getInitialReply({ UTXOList: remote, since: 0 }) + ).resolves.toEqual({ + UTXOList: [local[1], local[2]] + }) + await fc.assert( fc.asyncProperty( fc.jsonValue().filter(value => !Array.isArray(value)), diff --git a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts index d2e037de33..a331b4b96f 100644 --- a/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts +++ b/packages/overlays/overlay-discovery-services/src/utils/__tests/isAdvertisableURI.property.test.ts @@ -73,6 +73,22 @@ describe('overlay discovery boundary properties', () => { ) }) + test('rejects malformed transports and exact geographic boundary violations', () => { + for (const invalid of [ + 'https://%', + 'https+bsvauth://%', + 'wss://%', + 'https://localhost', + 'wss://localhost', + 'https://example.org/path', + 'js8c+bsvauth+smf:?lat=91&long=0&freq=1Hz&radius=1km', + 'js8c+bsvauth+smf:?lat=0&long=181&freq=1Hz&radius=1km', + 'js8c+bsvauth+smf:?lat=0&long=NaN&freq=1Hz&radius=1km' + ]) { + expect(isAdvertisableURI(invalid)).toBe(false) + } + }) + test('is total for arbitrary untrusted strings', () => { fc.assert( fc.property(fc.string({ maxLength: 4096 }), value => { diff --git a/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts b/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts index e6fbca18c3..bf6690238c 100644 --- a/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts +++ b/packages/overlays/overlay-discovery-services/src/utils/isAdvertisableURI.ts @@ -63,7 +63,6 @@ export const isAdvertisableURI = (uri: string): boolean => { else if (uri.startsWith('wss://')) { try { const parsed = new URL(uri) - if (parsed.protocol !== 'wss:') return false if (parsed.hostname.toLowerCase() === 'localhost') return false return true } catch { diff --git a/packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts b/packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts index 492e1f4863..15c16a3264 100644 --- a/packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts +++ b/packages/overlays/overlay-express/src/__tests/ReorgStream.property.test.ts @@ -1,7 +1,9 @@ import fc from 'fast-check' +import { jest as jestRuntime } from '@jest/globals' import { extractSseFrames, parseReorgEvent, ReorgSseAdapter } from '../ReorgStream.js' +const jestApi = jestRuntime as unknown as typeof jest const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) @@ -156,6 +158,41 @@ describe('reorg stream parser properties', () => { } ) ) + + expect( + parseReorgEvent( + JSON.stringify({ + orphanedHashes: [], + commonAncestor: null, + newTip: { height: -1 }, + depth: 0 + }) + ) + ).toBeNull() + }) + + test('retains only exact 32-byte hexadecimal orphan hashes', () => { + const valid = 'ab'.repeat(32) + expect( + parseReorgEvent( + JSON.stringify({ + orphanedHashes: [ + valid.toUpperCase(), + valid.slice(1), + `${valid}00`, + 'g'.repeat(64), + 1, + null + ], + commonAncestor: { height: 9 }, + newTip: { height: 10 } + }) + ) + ).toEqual({ + orphanedBlockHashes: [valid], + rebuildFromHeight: 10, + newTipHeight: 10 + }) }) test('extracts arbitrary complete SSE data frames and preserves the exact partial suffix', () => { @@ -226,7 +263,7 @@ describe('reorg stream parser properties', () => { const complete = new Promise(resolve => { resolveComplete = resolve }) - const fetchImpl = jest.fn(async () => response) as unknown as typeof fetch + const fetchImpl = jestApi.fn(async () => response) as unknown as typeof fetch const adapter = new ReorgSseAdapter({ url: 'https://arcade.example/reorg', fetchImpl, @@ -256,9 +293,9 @@ describe('reorg stream parser properties', () => { }) test('reconnects after HTTP and clean-stream failures while isolating malformed and failed handlers', async () => { - const warn = jest.fn() - const error = jest.fn() - const fetchImpl = jest + const warn = jestApi.fn() + const error = jestApi.fn() + const fetchImpl = jestApi .fn() .mockResolvedValueOnce(new Response(null, { status: 503 })) .mockResolvedValueOnce(streamResponse([])) @@ -274,7 +311,7 @@ describe('reorg stream parser properties', () => { url: 'https://arcade.example/reorg', fetchImpl, reconnectDelayMs: 0, - logger: { log: jest.fn(), warn, error }, + logger: { log: jestApi.fn(), warn, error }, onReorg: async () => { handlerCalls += 1 if (handlerCalls === 1) throw new Error('expected handler failure') @@ -293,17 +330,17 @@ describe('reorg stream parser properties', () => { }) test('continues event delivery when reconnect catch-up fails', async () => { - const warn = jest.fn() + const warn = jestApi.fn() let resolveComplete: () => void const complete = new Promise(resolve => { resolveComplete = resolve }) const adapter = new ReorgSseAdapter({ url: 'https://arcade.example/reorg', - fetchImpl: jest.fn(async () => + fetchImpl: jestApi.fn(async () => streamResponse([encodedReorgFrame(20)]) ) as unknown as typeof fetch, - logger: { log: jest.fn(), warn, error: jest.fn() }, + logger: { log: jestApi.fn(), warn, error: jestApi.fn() }, onConnect: async () => { throw new Error('expected catch-up failure') }, diff --git a/packages/overlays/overlay-express/src/__tests__/setup.ts b/packages/overlays/overlay-express/src/__tests__/setup.ts index 1086c42732..1afe1b1e67 100644 --- a/packages/overlays/overlay-express/src/__tests__/setup.ts +++ b/packages/overlays/overlay-express/src/__tests__/setup.ts @@ -1,4 +1,5 @@ // Jest setup file for global test configuration +import { jest } from '@jest/globals' // Mock uuid to avoid ES module issues jest.mock('uuid', () => ({ diff --git a/packages/sdk/src/primitives/__tests/utils.property.test.ts b/packages/sdk/src/primitives/__tests/utils.property.test.ts index 9a020afaf7..681d63bb4d 100644 --- a/packages/sdk/src/primitives/__tests/utils.property.test.ts +++ b/packages/sdk/src/primitives/__tests/utils.property.test.ts @@ -49,4 +49,22 @@ describe('base58 property tests', () => { }) ) }) + + test('matches independent vectors and enforces malformed-input and hex-output boundaries', () => { + expect(fromBase58('111z')).toEqual([0, 0, 0, 57]) + expect(toBase58([0, 0, 0, 57])).toBe('111z') + const base58Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + const invalidAscii = Array.from({ length: 128 }, (_, codePoint) => + String.fromCodePoint(codePoint) + ).filter(character => !base58Alphabet.includes(character)) + for (const invalid of ['', ...invalidAscii, 'é', '🚀']) { + expect(() => fromBase58(invalid)).toThrow() + } + + const encoded = toBase58Check([1, 2]) + expect(fromBase58Check(encoded, 'hex')).toEqual({ + prefix: '00', + data: '0102' + }) + }) }) diff --git a/packages/sdk/src/primitives/utils.ts b/packages/sdk/src/primitives/utils.ts index 349416e142..d5257ce3b2 100644 --- a/packages/sdk/src/primitives/utils.ts +++ b/packages/sdk/src/primitives/utils.ts @@ -256,7 +256,7 @@ export const fromBase58 = (str: string): number[] => { if (str === '' || typeof str !== 'string') { throw new Error(`Expected base58 string but got “${str}”`) } - const match: string[] | null = str.match(/[IOl0]/gmu) + const match: string[] | null = str.match(/[^1-9A-HJ-NP-Za-km-z]/gmu) if (match !== null) { throw new Error(`Invalid base58 character “${match.join('')}”`) @@ -267,7 +267,7 @@ export const fromBase58 = (str: string): number[] => { const uint8 = new Uint8Array([ ...new Uint8Array(psz), - ...(str.match(/./gmu) ?? []) // ✅ Safe Fix: If null, use [] + ...Array.from(str) .map(i => base58chars.indexOf(i)) .reduce((acc, i) => { acc = acc.map(j => { diff --git a/packages/wallet/btms-permission-module/package.json b/packages/wallet/btms-permission-module/package.json index 5dd83c79b6..24331c923f 100644 --- a/packages/wallet/btms-permission-module/package.json +++ b/packages/wallet/btms-permission-module/package.json @@ -57,6 +57,7 @@ "@bsv/btms": "workspace:^", "@bsv/sdk": "workspace:^", "@bsv/wallet-toolbox-client": "workspace:^", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@types/node": "^26.1.1", "fast-check": "^4.9.0", diff --git a/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts b/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts index 8ab2dfbf99..f1940503a4 100644 --- a/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts +++ b/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.property.test.ts @@ -1,9 +1,11 @@ import { PushDrop, Utils } from '@bsv/sdk' import fc from 'fast-check' +import { jest as jestRuntime } from '@jest/globals' import { BasicTokenModule } from '../index.js' import type { ParsedTokenInfo } from '../types.js' +const jestApi = jestRuntime as unknown as typeof jest const MIN_PROPERTY_RUNS = 300 const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) @@ -35,7 +37,7 @@ describe('BTMS permission boundary properties', () => { test('decodes the full unsigned Bitcoin uint32 varint domain', () => { fc.assert( fc.property(fc.integer({ min: 0, max: 0xffffffff }), value => { - const module = new BasicTokenModule(jest.fn()) as unknown as ModuleInternals + const module = new BasicTokenModule(jestApi.fn()) as unknown as ModuleInternals expect(module.readVarint([0xfe, ...encodeUint32(value)], 0)).toEqual({ value, nextOffset: 5 @@ -45,8 +47,8 @@ describe('BTMS permission boundary properties', () => { }) test('accepts exactly canonical positive safe-integer token amounts', () => { - const module = new BasicTokenModule(jest.fn()) as unknown as ModuleInternals - const decode = jest.spyOn(PushDrop, 'decode') + const module = new BasicTokenModule(jestApi.fn()) as unknown as ModuleInternals + const decode = jestApi.spyOn(PushDrop, 'decode') const amountText = fc.oneof( fc.string({ maxLength: 200 }), fc.integer({ min: 1, max: Number.MAX_SAFE_INTEGER }).map(value => value.toString()), @@ -70,6 +72,13 @@ describe('BTMS permission boundary properties', () => { } }) ) + + decode.mockReturnValue({ + fields: [Utils.toArray('', 'utf8'), Utils.toArray('1', 'utf8')] + } as ReturnType) + expect(module.parseTokenLockingScript('51')).toBeNull() + expect(module.parseTokenLockingScript('')).toBeNull() + expect(module.parseTokenLockingScript(undefined as unknown as string)).toBeNull() decode.mockRestore() }) @@ -81,7 +90,7 @@ describe('BTMS permission boundary properties', () => { { minLength: 1, maxLength: 30 } ), async originators => { - const prompt = jest.fn(async () => true) + const prompt = jestApi.fn(async () => true) const module = new BasicTokenModule(prompt) for (const originator of originators) { @@ -101,7 +110,7 @@ describe('BTMS permission boundary properties', () => { test('rejects arbitrary array-shaped request arguments at the authorization boundary', async () => { await fc.assert( fc.asyncProperty(fc.array(fc.jsonValue(), { maxLength: 30 }), async args => { - const module = new BasicTokenModule(jest.fn()) + const module = new BasicTokenModule(jestApi.fn()) await expect( module.onRequest({ method: 'getVersion', diff --git a/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.test.ts b/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.test.ts index a6d10c308a..cf293b091f 100644 --- a/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.test.ts +++ b/packages/wallet/btms-permission-module/src/__tests__/BasicTokenModule.test.ts @@ -11,10 +11,12 @@ import { type CreateActionResult, type WalletInterface } from '@bsv/sdk' +import { jest as jestRuntime } from '@jest/globals' import { BasicTokenModule, createBtmsModule } from '../index.js' import type { BTMS } from '@bsv/btms' import type { AuthorizedTransaction, ParsedTokenInfo, TokenSpendInfo } from '../types.js' +const jestApi = jestRuntime as unknown as typeof jest const ORIGINATOR = 'https://app.example' type AuthorizationState = { @@ -100,8 +102,8 @@ function createSignableTransaction(): Transaction { describe('BasicTokenModule authorization boundary', () => { afterEach(() => { - jest.restoreAllMocks() - jest.useRealTimers() + jestApi.restoreAllMocks() + jestApi.useRealTimers() }) it('requires a prompt callback', () => { @@ -111,7 +113,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('passes unknown wallet methods through without prompting', async () => { - const prompt = jest.fn, [string, string]>() + const prompt = jestApi.fn, [string, string]>() const module = new BasicTokenModule(prompt) const args = { value: 1 } @@ -120,7 +122,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('auto-authorizes only explicitly marked issuance actions', async () => { - const prompt = jest.fn, [string, string]>() + const prompt = jestApi.fn, [string, string]>() const module = new BasicTokenModule(prompt) const args = { description: 'Issue BTMS tokens', @@ -133,7 +135,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('prompts for an unmarked action even when it has no explicit inputs', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) const args = { description: 'Unmarked action', outputs: [] } @@ -142,7 +144,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('fails closed when an unmarked action is denied', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(false)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(false)) await expect( request(module, 'createAction', { description: 'Unmarked action', outputs: [] }) @@ -151,7 +153,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('prompts instead of treating a short signature payload as issuance', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) const args = { data: [1, 2, 3] } @@ -164,7 +166,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('fails closed on malformed data once a transaction commitment exists', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(true)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(true)) await request(module, 'listOutputs', { basket: 'p btms' }) state(module).authorizedTransactions.set(ORIGINATOR, { authorizedDigests: new Set(['11'.repeat(32)]), @@ -178,7 +180,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('rejects a digest that was not derived from the approved transaction', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(true)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(true)) await request(module, 'listOutputs', { basket: 'p btms' }) state(module).authorizedTransactions.set(ORIGINATOR, { authorizedDigests: new Set(['11'.repeat(32)]), @@ -192,7 +194,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('rejects a full preimage that was not derived from the approved transaction', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(true)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(true)) await request(module, 'listOutputs', { basket: 'p btms' }) state(module).authorizedTransactions.set(ORIGINATOR, { authorizedDigests: new Set(['11'.repeat(32)]), @@ -207,7 +209,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('invalidates session authorization when transaction binding fails', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) await request(module, 'createAction', { description: 'Spend', outputs: [] }) @@ -226,7 +228,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('clears authorization when createAction completes without a signable transaction', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(true)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(true)) await request(module, 'createAction', { description: 'Spend', outputs: [] }) expect(state(module).sessionAuthorizations.has(ORIGINATOR)).toBe(true) @@ -238,7 +240,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('clears authorization when a signable transaction omits required binding data', async () => { - const module = new BasicTokenModule(jest.fn().mockResolvedValue(true)) + const module = new BasicTokenModule(jestApi.fn().mockResolvedValue(true)) await request(module, 'createAction', { description: 'Spend', outputs: [] }) await expect( @@ -251,7 +253,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('captures a valid transaction commitment from createAction responses', async () => { - const module = new BasicTokenModule(jest.fn()) + const module = new BasicTokenModule(jestApi.fn()) const transaction = createSignableTransaction() const expectedDigest = Utils.toHex(Hash.sha256(transaction.preimage(0))) const response = { @@ -280,22 +282,22 @@ describe('BasicTokenModule authorization boundary', () => { }) it('caches access approval for sixty seconds and re-prompts after expiry', async () => { - jest.useFakeTimers() - jest.setSystemTime(new Date('2026-07-26T00:00:00.000Z')) - const prompt = jest.fn().mockResolvedValue(true) + jestApi.useFakeTimers() + jestApi.setSystemTime(new Date('2026-07-26T00:00:00.000Z')) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) await request(module, 'listOutputs', { basket: 'p btms asset.0' }) await request(module, 'listActions', { labels: ['p btms assetId asset.0'] }) expect(prompt).toHaveBeenCalledTimes(1) - jest.setSystemTime(new Date('2026-07-26T00:01:01.000Z')) + jestApi.setSystemTime(new Date('2026-07-26T00:01:01.000Z')) await request(module, 'listOutputs', { basket: 'p btms asset.0' }) expect(prompt).toHaveBeenCalledTimes(2) }) it('ignores unrelated and malformed labels before extracting a BTMS asset label', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) await request(module, 'listActions', { @@ -306,7 +308,7 @@ describe('BasicTokenModule authorization boundary', () => { }) it('clears sensitive authorization state when disposed', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = new BasicTokenModule(prompt) await request(module, 'listOutputs', { basket: 'p btms' }) @@ -319,9 +321,9 @@ describe('BasicTokenModule authorization boundary', () => { }) it('sweeps expired transaction state during subsequent requests', async () => { - jest.useFakeTimers() - jest.setSystemTime(new Date('2026-07-26T00:02:00.000Z')) - const module = new BasicTokenModule(jest.fn()) + jestApi.useFakeTimers() + jestApi.setSystemTime(new Date('2026-07-26T00:02:00.000Z')) + const module = new BasicTokenModule(jestApi.fn()) state(module).sessionAuthorizations.set( 'expired.example', new Date('2026-07-26T00:00:00.000Z').getTime() @@ -364,7 +366,7 @@ describe('BasicTokenModule security primitives', () => { } afterEach(() => { - jest.restoreAllMocks() + jestApi.restoreAllMocks() }) it.each([ @@ -375,12 +377,12 @@ describe('BasicTokenModule security primitives', () => { 'Invalid args' ] ])('rejects malformed permission requests', async (permissionRequest, message) => { - const module = new BasicTokenModule(jest.fn()) + const module = new BasicTokenModule(jestApi.fn()) await expect(module.onRequest(permissionRequest)).rejects.toThrow(message) }) it('parses every bounded Bitcoin varint form and rejects truncation', () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) expect(module.readVarint([0xfc], 0)).toEqual({ value: 0xfc, nextOffset: 1 }) expect(module.readVarint([0xfd, 0x34, 0x12], 0)).toEqual({ @@ -401,7 +403,7 @@ describe('BasicTokenModule security primitives', () => { }) it('handles create actions whose output list is absent', () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) expect(module.extractTokenSpendInfo({} as CreateActionArgs)).toMatchObject({ hasTokenOutputs: false, outputChangeAmount: 0, @@ -410,7 +412,7 @@ describe('BasicTokenModule security primitives', () => { }) it('reports no BEEF amount when inputs are present but none resolve to tokens', () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) expect( module.parseInputAmounts({ description: 'Inspect inputs', inputBEEF: [], inputs: [] }) ).toMatchObject({ @@ -420,7 +422,7 @@ describe('BasicTokenModule security primitives', () => { }) it('classifies pure burns and rejects mixed burn-and-send actions', () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) expect( module.classifyTokenAction({ @@ -445,7 +447,7 @@ describe('BasicTokenModule security primitives', () => { }) it('produces structured spend and burn prompts and honors denial', async () => { - const approve = jest.fn().mockResolvedValue(true) + const approve = jestApi.fn().mockResolvedValue(true) const approvedModule = state(new BasicTokenModule(approve)) await approvedModule.promptForTokenSpend(ORIGINATOR, defaultSpendInfo) @@ -466,7 +468,7 @@ describe('BasicTokenModule security primitives', () => { type: 'btms_burn' }) - const deniedModule = state(new BasicTokenModule(jest.fn().mockResolvedValue(false))) + const deniedModule = state(new BasicTokenModule(jestApi.fn().mockResolvedValue(false))) await expect(deniedModule.promptForTokenSpend(ORIGINATOR, defaultSpendInfo)).rejects.toThrow( 'User denied permission to spend tokens' ) @@ -482,9 +484,9 @@ describe('BasicTokenModule security primitives', () => { }) it('routes parsed spends and burns to their exact authorization prompts', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = state(new BasicTokenModule(prompt)) - const extract = jest.spyOn(module, 'extractTokenSpendInfo') + const extract = jestApi.spyOn(module, 'extractTokenSpendInfo') extract.mockReturnValueOnce(defaultSpendInfo) await module.handleCreateAction({ description: 'Send', inputs: [], outputs: [] }, ORIGINATOR) @@ -517,8 +519,8 @@ describe('BasicTokenModule security primitives', () => { }) it('parses valid token fields and rejects invalid token encodings', () => { - const module = state(new BasicTokenModule(jest.fn())) - const decode = jest.spyOn(PushDrop, 'decode') + const module = state(new BasicTokenModule(jestApi.fn())) + const decode = jestApi.spyOn(PushDrop, 'decode') decode.mockReturnValueOnce( decoded([ @@ -572,11 +574,11 @@ describe('BasicTokenModule security primitives', () => { }) it('recognizes explicit issuance markers in tags, scripts, and preimages', async () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) expect(module.outputIndicatesIssuance({ tags: ['btms_type_issue'] })).toBe(true) expect(module.outputIndicatesIssuance({ tags: ['other'] })).toBe(false) - jest + jestApi .spyOn(PushDrop, 'decode') .mockReturnValue(decoded([Array.from(Buffer.from('ISSUE')), Array.from(Buffer.from('10'))])) expect(module.outputIndicatesIssuance({ lockingScript: '00' })).toBe(true) @@ -593,7 +595,7 @@ describe('BasicTokenModule security primitives', () => { truncatedScript[104] = 0xfc expect(module.isIssuanceFromPreimage(truncatedScript)).toBe(false) - const prompt = jest.fn() + const prompt = jestApi.fn() await expect( request(new BasicTokenModule(prompt), 'createSignature', { data: preimage }) ).resolves.toEqual({ args: { data: preimage } }) @@ -601,7 +603,7 @@ describe('BasicTokenModule security primitives', () => { }) it('computes exact PushDrop signing digests for every transaction input', () => { - const module = state(new BasicTokenModule(jest.fn())) + const module = state(new BasicTokenModule(jestApi.fn())) const transaction = createSignableTransaction() expect(module.computeAuthorizedDigests(transaction)).toEqual( new Set([Utils.toHex(Hash.sha256(transaction.preimage(0)))]) @@ -614,7 +616,7 @@ describe('BasicTokenModule security primitives', () => { it('matches the 32-byte digest emitted by the SDK PushDrop signer', async () => { const transaction = createSignableTransaction() const signingKey = new PrivateKey(1) - const createSignature = jest.fn(async ({ data }: { data: number[] }) => ({ + const createSignature = jestApi.fn(async ({ data }: { data: number[] }) => ({ signature: signingKey.sign(data).toDER() as number[] })) const wallet = { createSignature } as unknown as WalletInterface @@ -628,7 +630,7 @@ describe('BasicTokenModule security primitives', () => { }) it('accepts only the exact approved transaction signing digest or preimage', async () => { - const prompt = jest.fn().mockResolvedValue(true) + const prompt = jestApi.fn().mockResolvedValue(true) const module = state(new BasicTokenModule(prompt)) const transaction = createSignableTransaction() const preimage = transaction.preimage(0) @@ -655,9 +657,9 @@ describe('BasicTokenModule security primitives', () => { }) it('expires transaction commitments and rejects missing verification data', () => { - jest.useFakeTimers() - jest.setSystemTime(new Date('2026-07-26T00:02:00.000Z')) - const module = state(new BasicTokenModule(jest.fn())) + jestApi.useFakeTimers() + jestApi.setSystemTime(new Date('2026-07-26T00:02:00.000Z')) + const module = state(new BasicTokenModule(jestApi.fn())) expect(() => module.verifyAuthorizedTransaction({ data: Array(32).fill(0) }, ORIGINATOR) ).toThrow('No approved transaction') @@ -683,7 +685,7 @@ describe('BasicTokenModule security primitives', () => { }) it('enriches prompts from BTMS metadata and tolerates lookup failure', async () => { - const getAssetInfo = jest + const getAssetInfo = jestApi .fn() .mockResolvedValueOnce({ metadata: { iconURL: 'https://example/icon' }, @@ -691,7 +693,7 @@ describe('BasicTokenModule security primitives', () => { }) .mockRejectedValueOnce(new Error('offline')) const module = state( - new BasicTokenModule(jest.fn(), { getAssetInfo } as Pick) + new BasicTokenModule(jestApi.fn(), { getAssetInfo } as Pick) ) await expect(module.getAssetMetadata('asset.0')).resolves.toEqual({ @@ -702,8 +704,8 @@ describe('BasicTokenModule security primitives', () => { }) it('extracts send/change totals and rejects mixed asset outputs', () => { - const module = state(new BasicTokenModule(jest.fn())) - const resolve = jest.spyOn(module, 'resolveTokenForOutput') + const module = state(new BasicTokenModule(jestApi.fn())) + const resolve = jestApi.spyOn(module, 'resolveTokenForOutput') resolve .mockReturnValueOnce({ amount: 40, @@ -761,8 +763,8 @@ describe('BasicTokenModule security primitives', () => { }) it('aggregates parsed input amounts and detects mixed input assets', () => { - const module = state(new BasicTokenModule(jest.fn())) - const resolveInput = jest + const module = state(new BasicTokenModule(jestApi.fn())) + const resolveInput = jestApi .spyOn( module as unknown as { resolveTokenForInput(input: { outpoint: string }, beef: number[]): ParsedTokenInfo | null diff --git a/packages/wallet/btms/package.json b/packages/wallet/btms/package.json index c4a8342ad5..11b9a4cfae 100644 --- a/packages/wallet/btms/package.json +++ b/packages/wallet/btms/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@bsv/sdk": "workspace:^", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@types/node": "^26.1.1", "fast-check": "^4.9.0", diff --git a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts index 95db541ba6..c3955516c3 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.property.test.ts @@ -41,6 +41,8 @@ describe('BTMS untrusted data properties', () => { } ) ) + expect(parseMetadata('{not-json')).toBeUndefined() + expect(parseIncomingMessage({ body: '{not-json' })).toBeNull() }) test('round-trips valid derivation instructions and rejects non-string derivation material', () => { @@ -77,6 +79,7 @@ describe('BTMS untrusted data properties', () => { } ) ) + expect(() => parseCustomInstructions(undefined, 'txid', 7)).toThrow('txid.7') }) test('strips exactly one governed label prefix and is total for arbitrary message text', () => { diff --git a/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts b/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts index 1db8425d9d..f152d3b11e 100644 --- a/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts +++ b/packages/wallet/btms/src/__tests/BTMSHelpers.test.ts @@ -1,4 +1,5 @@ import type { ListActionsResult } from '@bsv/sdk' +import { jest } from '@jest/globals' import { calcActionAmount, mapActionToTransaction, diff --git a/packages/wallet/ts-wallet-relay/jest.mutation.config.cjs b/packages/wallet/ts-wallet-relay/jest.mutation.config.cjs new file mode 100644 index 0000000000..cccc7a6d86 --- /dev/null +++ b/packages/wallet/ts-wallet-relay/jest.mutation.config.cjs @@ -0,0 +1,22 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + rootDir: '.', + testEnvironment: 'node', + testTimeout: 30000, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: { + module: 'commonjs', + moduleResolution: 'bundler' + } + } + ] + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + testMatch: ['/tests/pairingUri*.test.ts'] +} diff --git a/packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts b/packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts index 286da481b1..4762938c4e 100644 --- a/packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts +++ b/packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts @@ -134,4 +134,45 @@ describe('wallet pairing URI properties', () => { expect(parsePairingUri(uri).params).toBeNull() } }) + + test('rejects each missing field, ambiguous credentials, and malformed protocol tuples', () => { + const valid = buildPairingUri({ + sessionId: 'session', + backendIdentityKey: BACKEND_KEY, + protocolID: '[0,"pairing"]', + origin: 'https://wallet.example.org', + expiry: FUTURE_EXPIRY + }) + + for (const field of ['topic', 'backendIdentityKey', 'protocolID', 'origin', 'expiry']) { + const url = new URL(valid) + url.searchParams.delete(field) + expect(parsePairingUri(url.toString())).toEqual( + expect.objectContaining({ params: null, error: expect.any(String) }) + ) + } + + for (const invalidOrigin of [ + 'https://user@wallet.example.org', + 'https://:password@wallet.example.org' + ]) { + const url = new URL(valid) + url.searchParams.set('origin', invalidOrigin) + expect(parsePairingUri(url.toString()).params).toBeNull() + } + + for (const invalidProtocol of ['[]', '[0]', '[0,"pairing",true]', '["0","pairing"]', '[0,1]']) { + const url = new URL(valid) + url.searchParams.set('protocolID', invalidProtocol) + expect(parsePairingUri(url.toString()).params).toBeNull() + } + + for (const invalidExpiry of [` ${FUTURE_EXPIRY}`, `${FUTURE_EXPIRY} `]) { + const url = new URL(valid) + url.searchParams.set('expiry', invalidExpiry) + expect(parsePairingUri(url.toString()).params).toBeNull() + } + + expect(parsePairingUri('bsv-browser://%').params).toBeNull() + }) }) diff --git a/packages/wallet/wallet-toolbox/src/utility/__tests__/actionBatchPack.test.ts b/packages/wallet/wallet-toolbox/src/utility/__tests__/actionBatchPack.test.ts index 587340ed64..bf1dfc5b66 100644 --- a/packages/wallet/wallet-toolbox/src/utility/__tests__/actionBatchPack.test.ts +++ b/packages/wallet/wallet-toolbox/src/utility/__tests__/actionBatchPack.test.ts @@ -66,9 +66,38 @@ describe('action batch pack transport', () => { .toThrow('bounded action batch pack') expect(() => encodeActionBatchPack([], maxBytes, maxItems)) .toThrow('between 1 and') + expect(() => encodeActionBatchPack( + [item(Uint8Array.of(1))], + actionBatchPackLength([item(Uint8Array.of(1))]) - 1, + maxItems + )).toThrow('within the provider pack limit') expect(() => encodeActionBatchPack([ { digest: '00', bytes: new Uint8Array() } ], maxBytes, maxItems)).toThrow('32-byte hexadecimal') + + const wrongMagic = encoded.slice() + wrongMagic[0] ^= 0xff + expect(() => decodeActionBatchPack(wrongMagic, maxBytes, maxItems)) + .toThrow('bounded action batch pack') + + const zeroItems = encoded.slice(0, 8) + new DataView(zeroItems.buffer).setUint32(4, 0, true) + expect(() => decodeActionBatchPack(zeroItems, maxBytes, maxItems)) + .toThrow('between 1 and') + + const missingItemHeader = encoded.slice(0, 8) + new DataView(missingItemHeader.buffer).setUint32(4, 1, true) + expect(() => decodeActionBatchPack(missingItemHeader, maxBytes, maxItems)) + .toThrow('complete item headers') + }) + + test('rejects a mathematically unaddressable aggregate before allocation', () => { + const impossible = [{ + digest: '00'.repeat(32), + bytes: { length: Number.MAX_SAFE_INTEGER } as Uint8Array + }] + expect(() => actionBatchPackLength(impossible)) + .toThrow('addressable memory') }) test('bounds decompressed bytes before parsing a compression bomb', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67402e2337..561601db3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,21 @@ importers: '@arethetypeswrong/core': specifier: 0.18.5 version: 0.18.5 + '@stryker-mutator/core': + specifier: 9.6.1 + version: 9.6.1(@types/node@26.1.1) + '@stryker-mutator/jest-runner': + specifier: 9.6.1 + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.1.1)) + '@stryker-mutator/vitest-runner': + specifier: 9.6.1 + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.1.1))(vitest@4.1.10) esbuild: specifier: 0.28.1 version: 0.28.1 + jest-environment-node: + specifier: 30.4.1 + version: 30.4.1 oxlint: specifier: 1.75.0 version: 1.75.0 @@ -896,6 +908,9 @@ importers: specifier: ^7.5.0 version: 7.5.0 devDependencies: + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -1263,6 +1278,9 @@ importers: '@bsv/sdk': specifier: workspace:^ version: link:../../sdk + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -1302,6 +1320,9 @@ importers: '@bsv/wallet-toolbox-client': specifier: workspace:^ version: link:../wallet-toolbox/client + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -1764,6 +1785,12 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-export-default-from@7.29.7': resolution: {integrity: sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==} engines: {node: '>=6.9.0'} @@ -1791,6 +1818,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-dynamic-import@7.8.3': resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} peerDependencies: @@ -1914,6 +1947,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-flow-strip-types@7.29.7': resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} engines: {node: '>=6.9.0'} @@ -2016,6 +2055,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -2324,6 +2369,140 @@ packages: '@iconify/utils@3.1.4': resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3237,6 +3416,9 @@ packages: '@swc/helpers': optional: true + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@4.3.1': resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} engines: {node: '>=20'} @@ -3282,6 +3464,10 @@ packages: resolution: {integrity: sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -3294,6 +3480,35 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stryker-mutator/api@9.6.1': + resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/core@9.6.1': + resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@9.6.1': + resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/jest-runner@9.6.1': + resolution: {integrity: sha512-nIrIndfWwdweYkIcxJmyBTpl84nrXs9AE6A8vzEwwzUGvDb9kVvwBPfpEajtdAkjwPceH0pPuVrPkotvqcgYqQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@stryker-mutator/core': 9.6.1 + + '@stryker-mutator/util@9.6.1': + resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + + '@stryker-mutator/vitest-runner@9.6.1': + resolution: {integrity: sha512-eyUHTCf3Ui+SUn/tpFJwzw6MV391kyBLZk/cDHFUfKFELqKMLbvd7e81axArlApKqO6cOnLfrxlwED+2SRN0ow==} + engines: {node: '>=14.18.0'} + peerDependencies: + '@stryker-mutator/core': 9.6.1 + vitest: '>=2.0.0' + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -3993,9 +4208,16 @@ packages: peerDependencies: ajv: ^8.8.2 + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + angular-html-parser@10.4.0: + resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + engines: {node: '>= 14'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -4289,6 +4511,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -4323,6 +4548,10 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4708,6 +4937,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4725,6 +4957,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -4947,6 +5182,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + exit-x@0.2.2: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} @@ -5033,6 +5272,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -5148,6 +5391,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -5280,6 +5527,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5436,6 +5687,14 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5697,6 +5956,9 @@ packages: resolution: {integrity: sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==} engines: {node: '>= 20'} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5727,6 +5989,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-rpc-2.0@1.7.1: + resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -5887,6 +6152,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -6238,6 +6506,9 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -6378,6 +6649,23 @@ packages: multiformats@14.0.5: resolution: {integrity: sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==} + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.7.3: + resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + + mutation-testing-metrics@3.7.3: + resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + + mutation-testing-report-schema@3.7.3: + resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mysql2@3.23.1: resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==} engines: {node: '>= 8.0'} @@ -6465,6 +6753,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} @@ -6582,6 +6874,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -6604,6 +6900,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -6683,6 +6983,10 @@ packages: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + proc-log@6.1.0: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6690,6 +6994,10 @@ packages: progress-events@1.1.0: resolution: {integrity: sha512-82DVc5tI36neVB3IjdXR11ztwGuoBc98em9ijzubeZKxI47OlV2Znq6mlPqE5xPDzO2Uw98GHiQSjj2favBCRQ==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -6730,6 +7038,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -6968,6 +7280,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -6997,6 +7312,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -7195,6 +7515,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -7460,6 +7784,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -7476,9 +7804,17 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + typed-query-selector@2.12.2: resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typescript@5.6.1-rc: resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} @@ -7520,6 +7856,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -7546,6 +7885,10 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7730,6 +8073,9 @@ packages: weald@1.1.3: resolution: {integrity: sha512-vMWtNbYuPb58NeG2+0sKA0Een4VMDwzf+3oHqh68buWRSOMUBlUeRb11LhV28czV+DUpJHRykifijZDuS9bInA==} + weapon-regex@1.3.6: + resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + webdriver-bidi-protocol@0.4.2: resolution: {integrity: sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==} @@ -7941,6 +8287,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yuku-ast@0.8.0: resolution: {integrity: sha512-trBzFsSa6k32vzNUCH6pFhAoTzWD/NifSYOIQ/6v14vXKh7TRhd2vDNIwRguGdXvcyfBNEEGcsfxFHrnJbS+FQ==} @@ -7953,6 +8303,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8144,6 +8497,15 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8169,6 +8531,11 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8300,6 +8667,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -8423,6 +8798,17 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.29.7 + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -8696,6 +9082,125 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/confirm@6.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/core@11.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/editor@5.2.2(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/external-editor': 3.0.3(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/expand@5.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/external-editor@3.0.3(@types/node@26.1.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/number@4.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/password@5.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/prompts@8.5.2(@types/node@26.1.1)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@26.1.1) + '@inquirer/confirm': 6.1.1(@types/node@26.1.1) + '@inquirer/editor': 5.2.2(@types/node@26.1.1) + '@inquirer/expand': 5.1.1(@types/node@26.1.1) + '@inquirer/input': 5.1.2(@types/node@26.1.1) + '@inquirer/number': 4.1.1(@types/node@26.1.1) + '@inquirer/password': 5.1.1(@types/node@26.1.1) + '@inquirer/rawlist': 5.3.1(@types/node@26.1.1) + '@inquirer/search': 4.2.1(@types/node@26.1.1) + '@inquirer/select': 5.2.1(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/rawlist@5.3.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/search@4.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/select@5.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/type@4.0.7(@types/node@26.1.1)': + optionalDependencies: + '@types/node': 26.1.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -9816,6 +10321,8 @@ snapshots: dependencies: '@rspack/binding': 2.1.5 + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@4.3.1': dependencies: '@shikijs/primitive': 4.3.1 @@ -9871,6 +10378,8 @@ snapshots: '@sindresorhus/fnv1a@3.1.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -9883,6 +10392,81 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stryker-mutator/api@9.6.1': + dependencies: + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@9.6.1(@types/node@26.1.1)': + dependencies: + '@inquirer/prompts': 8.5.2(@types/node@26.1.1) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/instrumenter': 9.6.1 + '@stryker-mutator/util': 9.6.1 + ajv: 8.18.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.7.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.5 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.7.3 + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.8.5 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@stryker-mutator/instrumenter@9.6.1': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/util': 9.6.1 + angular-html-parser: 10.4.0 + semver: 7.7.4 + tslib: 2.8.1 + weapon-regex: 1.3.6 + transitivePeerDependencies: + - supports-color + + '@stryker-mutator/jest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.1.1))': + dependencies: + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/core': 9.6.1(@types/node@26.1.1) + '@stryker-mutator/util': 9.6.1 + semver: 7.7.4 + tslib: 2.8.1 + + '@stryker-mutator/util@9.6.1': {} + + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@26.1.1))(vitest@4.1.10)': + dependencies: + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/core': 9.6.1(@types/node@26.1.1) + '@stryker-mutator/util': 9.6.1 + semver: 7.8.5 + tslib: 2.8.1 + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(esbuild@0.28.1)(jsdom@26.1.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -10566,6 +11150,13 @@ snapshots: ajv: 8.20.0 fast-deep-equal: 3.1.3 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -10573,6 +11164,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + angular-html-parser@10.4.0: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -10863,6 +11456,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} + chownr@1.1.4: {} chownr@3.0.0: {} @@ -10885,6 +11480,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + cli-width@4.1.0: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -11264,6 +11861,11 @@ snapshots: dequal@2.0.3: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -11279,6 +11881,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + diff-match-patch@1.0.5: {} + diff@4.0.4: {} dijkstrajs@1.0.3: {} @@ -11526,6 +12130,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + exit-x@0.2.2: {} expand-template@2.0.3: {} @@ -11630,6 +12249,10 @@ snapshots: fflate@0.8.3: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -11752,6 +12375,11 @@ snapshots: get-stream@6.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -11943,6 +12571,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -12061,6 +12691,10 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + isexe@2.0.0: {} isexe@4.0.0: @@ -12660,6 +13294,8 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 + js-md4@0.3.2: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -12702,6 +13338,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-rpc-2.0@1.7.1: {} + json-schema-traverse@1.0.0: {} json5@2.2.3: {} @@ -12839,6 +13477,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.groupby@4.6.0: {} + lodash.memoize@4.1.2: {} lodash.throttle@4.1.1: {} @@ -13576,6 +14216,8 @@ snapshots: min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.8 @@ -13697,6 +14339,20 @@ snapshots: multiformats@14.0.5: {} + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.4.3 + + mutation-testing-elements@3.7.3: {} + + mutation-testing-metrics@3.7.3: + dependencies: + mutation-testing-report-schema: 3.7.3 + + mutation-testing-report-schema@3.7.3: {} + + mute-stream@3.0.0: {} + mysql2@3.23.1(@types/node@26.1.1): dependencies: '@types/node': 26.1.1 @@ -13774,6 +14430,11 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nullthrows@1.1.1: {} nwsapi@2.2.24: {} @@ -13903,6 +14564,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -13917,6 +14580,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -14000,11 +14665,17 @@ snapshots: react-is-18: react-is@18.3.1 react-is-19: react-is@19.2.8 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + proc-log@6.1.0: optional: true progress-events@1.1.0: {} + progress@2.0.3: {} + property-information@7.2.0: {} protons-runtime@5.6.0: @@ -14062,6 +14733,10 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + qs@6.15.1: + dependencies: + side-channel: 1.1.1 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -14420,6 +15095,10 @@ snapshots: rw@1.3.3: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + sade@1.8.1: dependencies: mri: 1.2.0 @@ -14448,6 +15127,8 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.5: {} send@1.2.1: @@ -14695,6 +15376,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@4.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -15003,6 +15686,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + type-detect@4.0.8: {} type-fest@0.21.3: {} @@ -15015,8 +15700,18 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 + typed-inject@5.0.0: {} + typed-query-selector@2.12.2: {} + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.15.1 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript@5.6.1-rc: {} typescript@5.9.3: {} @@ -15057,6 +15752,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + underscore@1.13.8: {} + undici-types@7.18.2: {} undici-types@8.3.0: {} @@ -15075,6 +15772,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15267,6 +15966,8 @@ snapshots: ms: 4.0.0-nightly.202508271359 supports-color: 10.2.2 + weapon-regex@1.3.6: {} + webdriver-bidi-protocol@0.4.2: {} webidl-conversions@3.0.1: {} @@ -15513,6 +16214,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + yuku-ast@0.8.0: dependencies: '@yuku-toolchain/types': 0.8.0 @@ -15552,4 +16255,6 @@ snapshots: zod@3.25.76: {} + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs new file mode 100644 index 0000000000..e1189c9fd2 --- /dev/null +++ b/scripts/mutation-testing.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' + +export const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const CONFIG_PATH = path.join(REPOSITORY_ROOT, 'governance/mutation-testing/stryker.config.mjs') +const POLICY_PATH = path.join(REPOSITORY_ROOT, 'governance/mutation-testing/policy.json') +const CONTROL_PATH_PREFIXES = [ + '.github/workflows/ci.yml', + '.github/workflows/mutation-tests.yml', + 'governance/mutation-testing/', + 'governance/test-quality/', + 'scripts/mutation-testing.mjs', + 'scripts/mutation-testing.test.mjs' +] +const CONTROL_PATHS = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.base.json' +]) + +function normalized(value) { + return value.split(path.sep).join('/').replace(/^\.\//, '') +} + +export function calculateMutationMetrics(mutants) { + const counts = {} + for (const mutant of mutants) counts[mutant.status] = (counts[mutant.status] ?? 0) + 1 + const detected = (counts.Killed ?? 0) + (counts.Timeout ?? 0) + const undetected = (counts.Survived ?? 0) + (counts.NoCoverage ?? 0) + const valid = detected + undetected + return { + counts, + detected, + undetected, + valid, + score: valid === 0 ? 100 : (detected / valid) * 100 + } +} + +export function selectAffectedMutationTargets(targets, changedFiles) { + const files = changedFiles.map(normalized).filter(Boolean) + const globalChange = files.some( + file => + CONTROL_PATHS.has(file) || + CONTROL_PATH_PREFIXES.some(prefix => + prefix.endsWith('/') ? file.startsWith(prefix) : file === prefix + ) || + file.startsWith('packages/sdk/') + ) + if (globalChange) return Object.keys(targets) + + return Object.entries(targets) + .filter(([, target]) => + files.some(file => file.startsWith(`${normalized(target.packageDirectory)}/`)) + ) + .map(([id]) => id) +} + +function readPolicy() { + if (!fs.existsSync(POLICY_PATH)) return undefined + return JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) +} + +function readReport(targetName) { + const reportPath = path.join(REPOSITORY_ROOT, 'artifacts/mutation', targetName, 'mutation.json') + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) + const mutants = Object.values(report.files).flatMap(file => file.mutants) + return { metrics: calculateMutationMetrics(mutants), reportPath } +} + +function policyTarget(policy, targetName) { + return policy?.targets?.find(target => target.id === targetName) +} + +export function evaluateMutationReport(targetName, metrics, policy) { + const targetPolicy = policyTarget(policy, targetName) + if (targetPolicy === undefined) { + return [`mutation target ${targetName} is absent from the governed policy`] + } + + const errors = [] + if (metrics.score + Number.EPSILON < targetPolicy.minimumScore) { + errors.push( + `${targetName} mutation score ${metrics.score.toFixed(2)} is below ${targetPolicy.minimumScore}` + ) + } + const noCoverage = metrics.counts.NoCoverage ?? 0 + if (noCoverage > targetPolicy.maximumNoCoverage) { + errors.push( + `${targetName} has ${noCoverage} no-coverage mutants; maximum is ${targetPolicy.maximumNoCoverage}` + ) + } + const invalid = (metrics.counts.RuntimeError ?? 0) + (metrics.counts.CompileError ?? 0) + if (invalid > targetPolicy.maximumInvalid) { + errors.push( + `${targetName} has ${invalid} invalid mutants; maximum is ${targetPolicy.maximumInvalid}` + ) + } + return errors +} + +function runCommand(command, arguments_, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { ...options, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (signal !== null) reject(new Error(`${command} exited from signal ${signal}`)) + else resolve(code ?? 1) + }) + }) +} + +async function runTarget(targetName, target, policy) { + const stryker = path.join(REPOSITORY_ROOT, 'node_modules/.bin/stryker') + const reporters = process.env.MUTATION_VERBOSE === '1' ? 'clear-text,json' : 'json' + const exitCode = await runCommand(stryker, ['run', CONFIG_PATH, '--reporters', reporters], { + cwd: path.join(REPOSITORY_ROOT, target.packageDirectory), + env: { + ...process.env, + FAST_CHECK_NUM_RUNS: process.env.FAST_CHECK_NUM_RUNS ?? String(policy.tool.propertyRuns), + FAST_CHECK_SEED: process.env.FAST_CHECK_SEED ?? String(policy.tool.propertySeed), + FAST_CHECK_PATH: process.env.FAST_CHECK_PATH ?? '', + TS_STACK_MUTATION_TARGET: targetName + } + }) + if (exitCode !== 0) throw new Error(`${targetName} mutation process exited ${exitCode}`) + + const { metrics, reportPath } = readReport(targetName) + const counts = Object.entries(metrics.counts) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([status, count]) => `${status}=${count}`) + .join(', ') + console.log(`${targetName}: ${metrics.score.toFixed(2)}% (${counts})`) + console.log(` report: ${path.relative(REPOSITORY_ROOT, reportPath)}`) + const errors = evaluateMutationReport(targetName, metrics, policy) + if (errors.length > 0) throw new Error(errors.join('\n')) +} + +export function parseArguments(arguments_) { + const result = { all: false, list: false, targets: [], affectedFile: undefined } + for (let index = 0; index < arguments_.length; index++) { + const argument = arguments_[index] + if (argument === '--all') result.all = true + else if (argument === '--list') result.list = true + else if (argument === '--target') { + const target = arguments_[++index] + if (target === undefined || target.startsWith('--')) { + throw new Error('--target requires an exact target ID') + } + result.targets.push(target) + } else if (argument === '--affected-file') { + const affectedFile = arguments_[++index] + if (affectedFile === undefined || affectedFile.startsWith('--')) { + throw new Error('--affected-file requires a path') + } + result.affectedFile = affectedFile + } else throw new Error(`Unknown argument ${argument}`) + } + const modes = [ + result.all, + result.list, + result.targets.length > 0, + result.affectedFile !== undefined + ].filter(Boolean).length + if (modes > 1) throw new Error('Select exactly one mutation command mode') + return result +} + +async function main() { + const options = parseArguments(process.argv.slice(2)) + const targets = buildMutationTargets(REPOSITORY_ROOT) + + if (options.list) { + console.log(Object.keys(targets).join('\n')) + return + } + if (options.affectedFile !== undefined) { + const changedFiles = fs.readFileSync(options.affectedFile, 'utf8').split(/\r?\n/) + console.log(JSON.stringify(selectAffectedMutationTargets(targets, changedFiles))) + return + } + + const selected = options.all ? Object.keys(targets) : options.targets + if (selected.length === 0) { + throw new Error('Select --all, --list, --affected-file , or at least one --target ') + } + if (new Set(selected).size !== selected.length) throw new Error('Duplicate mutation target') + + const policy = readPolicy() + if (policy === undefined) throw new Error('Mutation policy is missing') + for (const targetName of selected) { + const target = targets[targetName] + if (target === undefined) throw new Error(`Unknown mutation target ${targetName}`) + await runTarget(targetName, target, policy) + } +} + +const isMain = + process.argv[1] !== undefined && + fs.existsSync(process.argv[1]) && + fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +if (isMain) { + try { + await main() + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs new file mode 100644 index 0000000000..8961b95ee7 --- /dev/null +++ b/scripts/mutation-testing.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + calculateMutationMetrics, + evaluateMutationReport, + parseArguments, + selectAffectedMutationTargets +} from './mutation-testing.mjs' + +const targets = { + one: { packageDirectory: 'packages/one' }, + two: { packageDirectory: 'packages/two' } +} + +test('mutation metrics follow Stryker valid-mutant semantics', () => { + const metrics = calculateMutationMetrics([ + { status: 'Killed' }, + { status: 'Timeout' }, + { status: 'Survived' }, + { status: 'NoCoverage' }, + { status: 'RuntimeError' }, + { status: 'CompileError' }, + { status: 'Ignored' } + ]) + + assert.equal(metrics.detected, 2) + assert.equal(metrics.undetected, 2) + assert.equal(metrics.valid, 4) + assert.equal(metrics.score, 50) +}) + +test('mutation command parsing rejects missing values and conflicting modes', () => { + assert.deepEqual(parseArguments(['--target', 'one', '--target', 'two']), { + all: false, + list: false, + targets: ['one', 'two'], + affectedFile: undefined + }) + assert.throws(() => parseArguments(['--target']), /requires an exact target ID/) + assert.throws(() => parseArguments(['--affected-file']), /requires a path/) + assert.throws(() => parseArguments(['--all', '--target', 'one']), /exactly one/) +}) + +test('affected mutation selection is package-scoped with global control fan-out', () => { + assert.deepEqual(selectAffectedMutationTargets(targets, ['packages/one/src/index.ts']), ['one']) + assert.deepEqual(selectAffectedMutationTargets(targets, ['docs/about/contributing.md']), []) + assert.deepEqual(selectAffectedMutationTargets(targets, ['package.json']), ['one', 'two']) + assert.deepEqual(selectAffectedMutationTargets(targets, ['packages/sdk/src/index.ts']), [ + 'one', + 'two' + ]) +}) + +test('mutation report evaluation ratchets score, coverage, and invalid outcomes', () => { + const policy = { + targets: [ + { + id: 'one', + minimumScore: 75, + maximumNoCoverage: 0, + maximumInvalid: 0 + } + ] + } + assert.deepEqual( + evaluateMutationReport( + 'one', + { + score: 74.99, + counts: { Killed: 3, Survived: 1, NoCoverage: 1, RuntimeError: 1 } + }, + policy + ), + [ + 'one mutation score 74.99 is below 75', + 'one has 1 no-coverage mutants; maximum is 0', + 'one has 1 invalid mutants; maximum is 0' + ] + ) +}) diff --git a/scripts/test-governance.mjs b/scripts/test-governance.mjs index 9e227ce67d..b056cf834d 100644 --- a/scripts/test-governance.mjs +++ b/scripts/test-governance.mjs @@ -4,13 +4,25 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' + export const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const POLICY_PATH = 'governance/test-quality/policy.json' +const MUTATION_POLICY_PATH = 'governance/mutation-testing/policy.json' const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/ const PROPERTY_TEST_FILE_PATTERN = /\.property\.test\.[cm]?[jt]sx?$/ const MANUAL_SUFFIXES = ['.man.test.ts', '.live.test.ts'] -const SKIPPED_DIRECTORIES = new Set(['.git', 'coverage', 'dist', 'node_modules', 'out', 'reports']) +const SKIPPED_DIRECTORIES = new Set([ + '.git', + '.stryker-tmp', + 'artifacts', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'reports' +]) function normalizePath(value) { return value.split(path.sep).join('/') @@ -425,6 +437,189 @@ function validatePropertyTesting(root, requiredFiles, propertyTesting, policy, e return { excludedManifestPaths, manifestPaths } } +function validateMutationToolIdentity(tool, expectedTool, errors) { + if (tool.package !== expectedTool || typeof tool.version !== 'string') { + errors.push(`mutation testing policy must declare ${expectedTool} and its exact version`) + } + if (tool.rootCommand !== 'pnpm test:mutation --all') { + errors.push('mutation testing policy rootCommand must be "pnpm test:mutation --all"') + } +} + +function validateMutationToolManifest(root, tool, expectedTool, errors) { + const rootManifest = readJson(root, 'package.json') + if (rootManifest.scripts?.['test:mutation'] !== 'node scripts/mutation-testing.mjs') { + errors.push('root package.json must declare the governed test:mutation runner') + } + for (const dependency of [ + expectedTool, + '@stryker-mutator/jest-runner', + '@stryker-mutator/vitest-runner' + ]) { + if (rootManifest.devDependencies?.[dependency] !== tool.version) { + errors.push(`root package.json must pin ${dependency}@${tool.version}`) + } + } +} + +function validateMutationToolFiles(root, tool, errors) { + for (const field of ['config', 'targets', 'runner', 'ciWorkflow', 'scheduledWorkflow']) { + if (typeof tool[field] !== 'string' || !fs.existsSync(path.join(root, tool[field]))) { + errors.push(`mutation testing policy ${field} is missing`) + } + } +} + +function validateMutationToolBudget(tool, propertyTesting, errors) { + if (!Number.isSafeInteger(tool.reportRetentionDays) || tool.reportRetentionDays < 30) { + errors.push('mutation testing reports must be retained for at least 30 days') + } + if (tool.propertyRuns !== propertyTesting.minimumRuns) { + errors.push('mutation testing propertyRuns must match the required PR property budget') + } + if ( + !Number.isInteger(tool.propertySeed) || + tool.propertySeed < -2147483648 || + tool.propertySeed > 2147483647 + ) { + errors.push('mutation testing propertySeed must be a signed 32-bit integer') + } +} + +function validateMutationTool(root, tool, propertyTesting, errors) { + const expectedTool = '@stryker-mutator/core' + validateMutationToolIdentity(tool, expectedTool, errors) + validateMutationToolManifest(root, tool, expectedTool, errors) + validateMutationToolFiles(root, tool, errors) + validateMutationToolBudget(tool, propertyTesting, errors) +} + +function validateMutationWorkflow(root, workflowPath, fragments, errors) { + if (typeof workflowPath !== 'string' || !fs.existsSync(path.join(root, workflowPath))) return + const workflow = fs.readFileSync(path.join(root, workflowPath), 'utf8') + for (const fragment of fragments) { + if (!workflow.includes(fragment)) { + errors.push(`mutation testing workflow ${workflowPath} lacks ${fragment}`) + } + } +} + +function validateMutationWorkflows(root, tool, errors) { + const workflows = new Map([ + [tool.ciWorkflow, ['mutation-tests:', 'mutation-quality:', 'scripts/mutation-testing.mjs']], + [ + tool.scheduledWorkflow, + ['schedule:', 'workflow_dispatch:', 'matrix:', 'scripts/mutation-testing.mjs'] + ] + ]) + for (const [workflowPath, fragments] of workflows) { + validateMutationWorkflow(root, workflowPath, fragments, errors) + } +} + +function validateMutationSuite(target, suite, label, errors) { + if (suite === undefined) { + errors.push(`${label} references an unregistered property suite ${target.propertyTest}`) + return + } + for (const field of ['manifest', 'risk', 'boundary']) { + if (target[field] !== suite[field]) { + errors.push(`${label} ${field} must match its property suite`) + } + } +} + +function validateMutationThresholds(target, label, errors) { + if ( + !Number.isFinite(target.minimumScore) || + target.minimumScore < 60 || + target.minimumScore > 100 + ) { + errors.push(`${label} minimumScore must be between 60 and 100`) + } + if (target.maximumNoCoverage !== 0 || target.maximumInvalid !== 0) { + errors.push(`${label} must reject every no-coverage and invalid mutant`) + } +} + +function validateMutationScope(root, definition, label, errors) { + if (!Array.isArray(definition.mutate) || definition.mutate.length === 0) { + errors.push(`${label} must declare at least one mutation scope`) + } + for (const mutationScope of definition.mutate ?? []) { + const sourcePath = mutationScope.replace(/:\d+(?:-\d+)?$/, '') + if (!fs.existsSync(path.join(root, definition.packageDirectory, sourcePath))) { + errors.push(`${label} mutation scope is missing ${mutationScope}`) + } + } +} + +function validateMutationDefinition(root, target, definition, label, errors) { + if (definition.manifest !== target.manifest || definition.propertyTest !== target.propertyTest) { + errors.push(`${label} executable definition does not match its policy registration`) + } + validateMutationThresholds(target, label, errors) + validateMutationScope(root, definition, label, errors) + const configFile = + definition.runnerOptions?.jest?.configFile ?? definition.runnerOptions?.vitest?.configFile + if ( + typeof configFile !== 'string' || + !fs.existsSync(path.join(root, definition.packageDirectory, configFile)) + ) { + errors.push(`${label} test-runner config is missing`) + } +} + +function validateMutationTarget(root, target, context, errors) { + const label = `mutation target ${target.id}` + if (context.registeredIds.has(target.id)) errors.push(`duplicate ${label}`) + context.registeredIds.add(target.id) + if (context.registeredSuites.has(target.propertyTest)) { + errors.push(`duplicate mutation property suite ${target.propertyTest}`) + } + context.registeredSuites.add(target.propertyTest) + + const definition = context.definitions[target.id] + if (definition === undefined) { + errors.push(`${label} has no executable target definition`) + return + } + validateMutationSuite(target, context.suites.get(target.propertyTest), label, errors) + validateMutationDefinition(root, target, definition, label, errors) +} + +function validateMutationCompleteness(propertyTesting, context, errors) { + const { definitions, registeredIds, registeredSuites } = context + for (const id of Object.keys(definitions)) { + if (!registeredIds.has(id)) errors.push(`executable mutation target ${id} is unregistered`) + } + for (const suite of propertyTesting.suites) { + if (!registeredSuites.has(suite.path)) { + errors.push(`property suite ${suite.path} lacks mutation validation`) + } + } +} + +function validateMutationTesting(root, mutationPolicy, propertyTesting, policy, errors, today) { + validateDatedOwner(mutationPolicy, policy, 'mutation testing policy', errors, today) + const tool = mutationPolicy.tool ?? {} + validateMutationTool(root, tool, propertyTesting, errors) + validateMutationWorkflows(root, tool, errors) + + const context = { + definitions: buildMutationTargets(root), + registeredIds: new Set(), + registeredSuites: new Set(), + suites: new Map(propertyTesting.suites.map(suite => [suite.path, suite])) + } + for (const target of mutationPolicy.targets ?? []) { + validateMutationTarget(root, target, context, errors) + } + validateMutationCompleteness(propertyTesting, context, errors) + const { registeredIds } = context + return registeredIds.size +} + function validateManualPolicies(policy, errors, today) { const manualPolicies = new Map( policy.manualPolicies.map(manualPolicy => [manualPolicy.id, manualPolicy]) @@ -551,6 +746,7 @@ function validateRegisteredConformanceGroups(conformance, policy, errors, today) export function evaluateTestGovernance({ root = REPOSITORY_ROOT, policy = readJson(root, POLICY_PATH), + mutationPolicy = readJson(root, MUTATION_POLICY_PATH), today = new Date().toISOString().slice(0, 10) } = {}) { const errors = [] @@ -564,6 +760,14 @@ export function evaluateTestGovernance({ errors, today ) + const mutationTargets = validateMutationTesting( + root, + mutationPolicy, + propertyTesting, + policy, + errors, + today + ) const manualPolicies = validateManualPolicies(policy, errors, today) validateManualFiles(manualFiles, policy, manualPolicies, errors) const observedSkips = collectRequiredTestSkips(root, requiredFiles, errors) @@ -580,6 +784,7 @@ export function evaluateTestGovernance({ propertyPackages: manifestPaths.length, propertyExcludedPackages: excludedManifestPaths.length, propertyClassifiedPackages: manifestPaths.length + excludedManifestPaths.length, + mutationTargets, manualAndLiveFiles: manualFiles.length, conformanceSkipFiles: conformance.byFile.size, conformanceSkips: [...conformance.byFile.values()].reduce( @@ -606,6 +811,7 @@ function run() { `${summary.requiredTestFiles} required test files`, `${summary.requiredDirectSkips} governed direct skips`, `${summary.propertySuites} governed property suites across ${summary.propertyPackages} packages`, + `${summary.mutationTargets} governed mutation targets`, `${summary.propertyExcludedPackages} governed property exclusions`, `${summary.manualAndLiveFiles} classified manual/live files`, `${summary.conformanceSkips} governed conformance skips across ${summary.conformanceSkipFiles} files` diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 40178f25eb..e33e6833e1 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -28,11 +28,26 @@ test('current required, manual, live, resource, and conformance tests are govern assert.equal(result.summary.propertyPackages, 25) assert.equal(result.summary.propertyExcludedPackages, 8) assert.equal(result.summary.propertyClassifiedPackages, 33) + assert.equal(result.summary.mutationTargets, 25) assert.equal(result.summary.manualAndLiveFiles, 40) assert.equal(result.summary.conformanceSkipFiles, 19) assert.equal(result.summary.conformanceSkips, 211) }) +test('every property suite must retain an exact mutation-quality target', () => { + const mutationPolicyPath = path.join(REPOSITORY_ROOT, 'governance/mutation-testing/policy.json') + const mutationPolicy = JSON.parse(fs.readFileSync(mutationPolicyPath, 'utf8')) + mutationPolicy.targets.pop() + const result = evaluateTestGovernance({ + policy, + mutationPolicy, + today: '2026-07-26' + }) + + assert.match(result.errors.join('\n'), /lacks mutation validation/) + assert.match(result.errors.join('\n'), /executable mutation target .* is unregistered/) +}) + test('an unregistered required skip fails the exact inventory', () => { const changedPolicy = structuredClone(policy) changedPolicy.requiredSkips.pop()