diff --git a/.github/workflows/mutation-health-nightly.yml b/.github/workflows/mutation-health-nightly.yml deleted file mode 100644 index b44ece40f00..00000000000 --- a/.github/workflows/mutation-health-nightly.yml +++ /dev/null @@ -1,218 +0,0 @@ -name: 'Mutation Health (nightly)' -run-name: "${{ github.event_name == 'workflow_dispatch' && format('Mutation Health (mode={0}, top_n={1}, source_file={2})', inputs.mode, inputs.top_n, inputs.source_file) || '' }}" - -on: - schedule: - # 03:30 UTC daily — outside CI rush, before EU morning. Runs both passes. - - cron: '30 3 * * *' - workflow_dispatch: - inputs: - mode: - description: 'Which pass to run' - type: choice - options: - - both - - baseline # score files with no result yet (the `new` bucket) - - coverage # revisit the weakest scored files (`red`/`stale`, lowest first) - default: both - top_n: - description: 'How many top-ranked rows the global picker schedules for this run (--top-n).' - type: string - default: '6' - source_file: - description: 'Optional: re-score this exact repo-relative file, skipping the picker (e.g. packages/workflow/src/common/get-node-by-name.ts). Used by the lane:mutation-increase close handler to refresh the ledger.' - type: string - default: '' - bootstrap_packages: - description: | - Comma-separated ELIGIBLE_PACKAGES.name list to skip in the divergence guard for this run. - Use after onboarding a new entry to ELIGIBLE_PACKAGES (the guard otherwise refuses to schedule for a package with zero prior-status rows). Use '*' to acknowledge a genuine cold-start (empty ledger). Leave empty otherwise — empty ledger then fails loud on the scheduled path. - type: string - default: '' - -permissions: - contents: read - -env: - READER_URL: https://internal.users.n8n.cloud/webhook/mutation-health-ledger - -jobs: - # Build the matrix once. The package list is the picker's ELIGIBLE_PACKAGES - # export (single source of truth — DEVP-497); the workflow no longer - # redeclares it. fetch-depth: 0 here so signals.mjs has full git history - # for churn / fix-density when ranking globally. The picker's --global mode - # walks every eligible package, ranks rows by value, and returns the top-N - # picks across all packages; the matrix maps one job per pick. - setup: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.build.outputs.matrix }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Full history feeds signals.mjs (churn + fix-density). A shallow - # clone yields zero signals and falls back to a coverage-only - # ranking, which is fine but loses the value formula's leverage. - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 24.18.1 - - - name: Fetch read-all live ledger from BigQuery - # Read-all (no ?package= param): one curl returns every row across - # every eligible package. Per-package narrowing happens inside the - # picker. - run: | - mkdir -p .mutation-health - curl --fail -sS "$READER_URL" -o .mutation-health/live-ledger.json - - - name: Gather git-derived signals (churn + fix-density) - # Drives the value formula inside the global picker. Skipped when - # SOURCE_FILE is set — that path bypasses the picker. - if: ${{ github.event.inputs.source_file == '' }} - run: | - node scripts/mutation-health/signals.mjs \ - --since 180.days.ago \ - > .mutation-health/signals.json - - - name: Build matrix - id: build - env: - REQUESTED_MODE: ${{ github.event.inputs.mode || 'both' }} - # TOP_N intentionally has no `|| '6'` fallback here — the default lives - # in build-matrix.mjs so the script stays self-contained for local - # invocation. Scheduled runs (no inputs) hit the script default. - TOP_N: ${{ github.event.inputs.top_n }} - SOURCE_FILE: ${{ github.event.inputs.source_file || '' }} - BOOTSTRAP_PACKAGES: ${{ github.event.inputs.bootstrap_packages || '' }} - LEDGER_FILE: .mutation-health/live-ledger.json - SIGNALS_FILE: .mutation-health/signals.json - run: node scripts/mutation-health/build-matrix.mjs >> "$GITHUB_OUTPUT" - - - name: Upload picker artefacts - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: mutation-health-picker-${{ github.run_id }} - path: | - .mutation-health/live-ledger.json - .mutation-health/signals.json - retention-days: 14 - if-no-files-found: warn - - mutate: - needs: setup - # Gate on setup success so a die-loud failure in build-matrix.mjs (e.g. - # the divergence guard) surfaces cleanly via setup, instead of via - # `fromJSON('')` here. - if: ${{ needs.setup.result == 'success' && needs.setup.outputs.matrix && fromJSON(needs.setup.outputs.matrix).include[0] != null }} - name: ${{ matrix.mode }} · ${{ matrix.name }} · ${{ matrix.source_file }} - runs-on: blacksmith-4vcpu-ubuntu-2204 - timeout-minutes: 60 - strategy: - fail-fast: false # one leg's failure must not skip the others - matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} - # Concurrency-group key is per-row (mode + package slug + file slug) so - # multiple top-N picks within the same (mode, package) bucket each get - # their own group. With `cancel-in-progress: false` Actions otherwise - # keeps at most one running + one pending job per group and cancels any - # older pending — which would silently drop middle picks every nightly - # and leave their ledger rows stuck on `new`. The original double-write - # protection (scheduled vs manual on the same row) is unchanged because - # the same `file_slug` resolves to the same group key. - concurrency: - group: mutation-health-${{ matrix.mode }}-${{ matrix.slug }}-${{ matrix.file_slug }} - cancel-in-progress: false - env: - PACKAGE_NAME: ${{ matrix.name }} - PKG_DIR: ${{ matrix.dir }} - REPORTS_DIR: ${{ matrix.dir }}/reports/mutation - SOURCE_FILE: ${{ matrix.source_file || '' }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # Shallow clone is fine — the file to mutate has already been picked - # in the setup job, and Stryker only needs the working tree. - - - name: Setup Environment - uses: ./.github/actions/setup-nodejs - - - name: Download picker artefacts (signals.json) - # The setup job computed churn/fix-density over full history and uploaded - # signals.json. This shallow checkout can't recompute them from git, so - # emit-payload reads them from here instead — keeping the ledger's churn - # and fix_density columns populated and consistent with what the picker - # ranked on. Best-effort: the on-demand source_file path skips the gather - # step, so signals.json may be absent; emit-payload falls back gracefully. - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - continue-on-error: true - with: - name: mutation-health-picker-${{ github.run_id }} - path: .mutation-health - - - name: Mutate the picked source file - # Exit code semantics from mutate.mjs: - # 0 — gate passed: score >= threshold AND no unjustified survivors (green) - # 1 — gate failed: score < threshold OR >=1 Survived/NoCoverage mutant (red) - # — emit step still runs, this is normal - # 2 — usage error - # 3 — Stryker hard failure (no summary.json) — must fail the job - # We capture rc explicitly so we can distinguish "gate failed" from - # "Stryker crashed." continue-on-error: true would collapse them. - run: | - mkdir -p "$REPORTS_DIR" - src_rel="${SOURCE_FILE#"$PKG_DIR"/}" - set +e - node scripts/mutation-health/mutate.mjs "$src_rel" --package-dir "$PKG_DIR" - rc=$? - set -e - if [ "$rc" -gt 1 ]; then - echo "::error::Stryker hard-failed with exit code $rc." - exit "$rc" - fi - echo "Mutate exit $rc ($([ "$rc" = "0" ] && echo green || echo red))." - - - name: Emit BQ payload - # Pass --signals only when the setup job produced it (skipped on the - # source_file re-score path). Without it emit-payload falls back to git, - # which yields null churn/fix_density on this shallow clone. - run: | - signals_arg=() - if [ -f .mutation-health/signals.json ]; then - signals_arg=(--signals .mutation-health/signals.json) - else - echo "::notice::signals.json not present — churn/fix_density will fall back to git (null on shallow clone)." - fi - node scripts/mutation-health/emit-payload.mjs \ - --summary "$REPORTS_DIR/summary.json" \ - --package "$PACKAGE_NAME" \ - "${signals_arg[@]}" - - - name: POST result payload - env: - MUTATION_HEALTH_WEBHOOK: ${{ secrets.MUTATION_HEALTH_WEBHOOK }} - run: | - if [ -z "$MUTATION_HEALTH_WEBHOOK" ]; then - echo "::notice::MUTATION_HEALTH_WEBHOOK not set — dry-run, POST skipped (payload uploaded as artefact)." - exit 0 - fi - curl --fail -sS -X POST \ - -H 'Content-Type: application/json' \ - --data @"$REPORTS_DIR/bq-payload.json" \ - "$MUTATION_HEALTH_WEBHOOK" - - - name: Upload artefacts - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: mutation-health-${{ matrix.mode }}-${{ matrix.slug }}-${{ matrix.file_slug }}-${{ github.run_id }} - path: | - ${{ env.REPORTS_DIR }}/raw.json - ${{ env.REPORTS_DIR }}/raw.html - ${{ env.REPORTS_DIR }}/summary.json - ${{ env.REPORTS_DIR }}/bq-payload.json - retention-days: 14 - if-no-files-found: warn diff --git a/package.json b/package.json index 20a6f9c9740..e066da2625a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs", "start": "node scripts/os-normalize.mjs --dir packages/cli/bin n8n", "mutate": "node scripts/mutation-health/mutate.mjs", + "mutate:diff": "node scripts/mutation-health/mutate.mjs --diff", "test": "turbo run test", "test:ci": "turbo run test --continue --concurrency=1", "test:ci:frontend": "turbo run test --continue --filter='./packages/frontend/**' --filter='./packages/modules/**'", @@ -84,6 +85,8 @@ "@dotenvx/dotenvx": "^1.40.0", "@n8n/eslint-config": "workspace:*", "@n8n/module-cli": "workspace:*", + "@stryker-mutator/core": "catalog:", + "@stryker-mutator/vitest-runner": "catalog:", "@types/node": "*", "@types/supertest": "^6.0.3", "babel-plugin-transform-import-meta": "^2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 366f4dc770e..cc2245db097 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,11 +193,11 @@ catalogs: specifier: 3.0.5 version: 3.0.5 '@stryker-mutator/core': - specifier: 9.6.1 - version: 9.6.1 + specifier: 10.0.0 + version: 10.0.0 '@stryker-mutator/vitest-runner': - specifier: 9.6.1 - version: 9.6.1 + specifier: 10.0.0 + version: 10.0.0 '@supabase/supabase-js': specifier: 2.112.3 version: 2.112.3 @@ -845,7 +845,7 @@ overrides: undici@6: ^6.28.0 undici@7: ^7.29.0 node-gyp>undici: ^7.29.0 - '@babel/traverse': ^7.23.2 + '@babel/traverse@<7.23.2': ^7.23.2 '@vitest/browser@<4.1.10': 4.1.10 immutable: 5.1.8 nanoid@<3.3.18: 3.3.18 @@ -893,6 +893,12 @@ importers: '@n8n/module-cli': specifier: workspace:* version: link:packages/@n8n/module-cli + '@stryker-mutator/core': + specifier: 'catalog:' + version: 10.0.0(@types/node@20.19.41) + '@stryker-mutator/vitest-runner': + specifier: 'catalog:' + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@20.19.41))(vitest@4.1.9) '@types/node': specifier: ^20.17.50 version: 20.19.41 @@ -3637,10 +3643,10 @@ importers: version: link:../vitest-config '@stryker-mutator/core': specifier: 'catalog:' - version: 9.6.1(@types/node@20.19.41)(supports-color@5.5.0) + version: 10.0.0(@types/node@20.19.41) '@stryker-mutator/vitest-runner': specifier: 'catalog:' - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@5.5.0))(vitest@4.1.9) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@20.19.41))(vitest@4.1.9) '@types/luxon': specifier: 'catalog:' version: 3.2.0 @@ -7327,10 +7333,10 @@ importers: version: link:../@n8n/vitest-config '@stryker-mutator/core': specifier: 'catalog:' - version: 9.6.1(@types/node@20.19.41)(supports-color@8.1.1) + version: 10.0.0(@types/node@20.19.41) '@stryker-mutator/vitest-runner': specifier: 'catalog:' - version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@8.1.1))(vitest@4.1.9) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@20.19.41))(vitest@4.1.9) '@types/express': specifier: 'catalog:' version: 5.0.1 @@ -8153,32 +8159,62 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.0': + resolution: {integrity: sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.0': + resolution: {integrity: sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.1': + resolution: {integrity: sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@8.0.0': + resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.0': + resolution: {integrity: sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.28.6': resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@8.0.1': + resolution: {integrity: sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-create-regexp-features-plugin@7.28.5': resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} engines: {node: '>=6.9.0'} @@ -8194,28 +8230,56 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.0': + resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@8.0.0': + resolution: {integrity: sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.1': + resolution: {integrity: sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@8.0.0': + resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-remap-async-to-generator@7.27.1': resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} engines: {node: '>=6.9.0'} @@ -8228,10 +8292,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@8.0.1': + resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -8240,14 +8314,26 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-wrap-function@7.28.3': resolution: {integrity: sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==} engines: {node: '>=6.9.0'} @@ -8256,6 +8342,10 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.0': + resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} @@ -8266,6 +8356,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} engines: {node: '>=6.9.0'} @@ -8296,11 +8391,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/plugin-proposal-decorators@7.29.0': - resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} - engines: {node: '>=6.9.0'} + '@babel/plugin-proposal-decorators@8.0.2': + resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} @@ -8308,11 +8403,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-decorators@7.28.6': - resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-decorators@8.0.1': + resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/plugin-syntax-import-assertions@7.28.6': resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} @@ -8326,17 +8421,17 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} @@ -8404,6 +8499,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-destructuring@8.0.1': + resolution: {integrity: sha512-RtR8uLDl0QcCmqMNIkM8gmDeYZ3rS0ZH+sa+I6sfc09yFoqfp9AEPgBstq9KyfVb0lFCVSRFfJXCI70FIl5ccw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-dotall-regex@7.28.6': resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} engines: {node: '>=6.9.0'} @@ -8434,6 +8535,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-explicit-resource-management@8.0.1': + resolution: {integrity: sha512-VzDIYwBlLCpV6mJfloRdJm8HmYnMqs7O+bGha8yfg2kP7jAdxeCw6yZBVBeaKKQUThtSU52iy+3lB7DhYsbOBA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-exponentiation-operator@7.28.6': resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} engines: {node: '>=6.9.0'} @@ -8494,6 +8601,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-modules-systemjs@7.29.0': resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} engines: {node: '>=6.9.0'} @@ -8578,6 +8691,30 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-regenerator@7.29.0': resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} engines: {node: '>=6.9.0'} @@ -8626,11 +8763,11 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} - engines: {node: '>=6.9.0'} + '@babel/plugin-transform-typescript@8.0.1': + resolution: {integrity: sha512-0Svqp3413Eg0GElldykF/T7SNsxQO5YVGD70fZyAdZTnX8WRgcopmbiU7GTa5xY5ZnJcEpNbfns8/GjX+/1yeA==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 '@babel/plugin-transform-unicode-escapes@7.27.1': resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} @@ -8667,11 +8804,17 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': ^8.0.0 + + '@babel/preset-typescript@8.0.1': + resolution: {integrity: sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} @@ -8681,10 +8824,18 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.4': + resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -8693,6 +8844,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -12765,27 +12920,27 @@ packages: storybook: ^10.1.11 vue: ^3.0.0 - '@stryker-mutator/api@9.6.1': - resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/api@10.0.0': + resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/core@9.6.1': - resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/core@10.0.0': + resolution: {integrity: sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==} + engines: {node: '>=22.0.0'} hasBin: true - '@stryker-mutator/instrumenter@9.6.1': - resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} - engines: {node: '>=20.0.0'} + '@stryker-mutator/instrumenter@10.0.0': + resolution: {integrity: sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==} + engines: {node: '>=22.0.0'} - '@stryker-mutator/util@9.6.1': - resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + '@stryker-mutator/util@10.0.0': + resolution: {integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==} - '@stryker-mutator/vitest-runner@9.6.1': - resolution: {integrity: sha512-eyUHTCf3Ui+SUn/tpFJwzw6MV391kyBLZk/cDHFUfKFELqKMLbvd7e81axArlApKqO6cOnLfrxlwED+2SRN0ow==} - engines: {node: '>=14.18.0'} + '@stryker-mutator/vitest-runner@10.0.0': + resolution: {integrity: sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw==} + engines: {node: '>=22.0.0'} peerDependencies: - '@stryker-mutator/core': 9.6.1 + '@stryker-mutator/core': 10.0.0 vitest: '>=2.0.0' '@stylistic/eslint-plugin@5.0.0': @@ -13419,6 +13574,9 @@ packages: '@types/ftp@0.3.33': resolution: {integrity: sha512-L7wFlX3t9GsGgNS0oxLt6zbAZZGgsdptMmciL4cdxHmbL3Hz4Lysh8YqAR34eHsJ1uacJITcZBBDl5XpQlxPpQ==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/glob@8.0.0': resolution: {integrity: sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==} @@ -13458,6 +13616,9 @@ packages: '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-diff@1.0.0': resolution: {integrity: sha512-dCXC1F73Sqriz2d8Wt/sP/DztE+rlfIRPxW9WSYheHp/l3gvkeSvM6l4vhm7t4Dgn8AJAxNKajx/eobbPdP6Wg==} @@ -14670,8 +14831,8 @@ packages: resolution: {integrity: sha512-TGZJ/Q6PO0ns/a72zw/d3FI0ywqY7oMqTbRzji2/AsoA/1frIhIOuVoqZMapDt6XFppbbdT0NEzd9dYwmKI0rQ==} engines: {node: '>=10'} - angular-html-parser@10.4.0: - resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + angular-html-parser@10.11.0: + resolution: {integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==} engines: {node: '>= 14'} ansi-colors@4.1.3: @@ -16666,9 +16827,6 @@ packages: es-get-iterator@1.1.3: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -17924,6 +18082,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -19744,14 +19905,14 @@ packages: resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} engines: {node: '>=18'} - mutation-testing-elements@3.7.3: - resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + mutation-testing-elements@3.8.4: + resolution: {integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==} - mutation-testing-metrics@3.7.3: - resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + mutation-testing-metrics@3.8.4: + resolution: {integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==} - mutation-testing-report-schema@3.7.3: - resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + mutation-testing-report-schema@3.8.4: + resolution: {integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==} mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -23502,8 +23663,8 @@ packages: weak-map@1.0.8: resolution: {integrity: sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==} - weapon-regex@1.3.6: - resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + weapon-regex@2.0.4: + resolution: {integrity: sha512-ubuhY5Lo4phWcMsJqe8j62m9uhsuo/VpfK5XsSgYGRGDSt10hwVwOBTriPRd0dac+KYbGNXOrfXjM6xCp2NUKg==} weaviate-client@3.9.0: resolution: {integrity: sha512-7qwg7YONAaT4zWnohLrFdzky+rZegVe76J+Tky/+7tuyvjFpdKgSrdqI/wPDh8aji0ZGZrL4DdGwGfFnZ+uV4w==} @@ -25842,8 +26003,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.0': + dependencies: + '@babel/helper-validator-identifier': 8.0.4 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.0': {} + '@babel/compat-data@8.0.0': {} + '@babel/core@7.29.0(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -25884,6 +26052,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.1': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-compilation-targets': 8.0.0 + '@babel/helpers': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.0.1 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 2.1.3 + semver: 7.7.3 + '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.8 @@ -25892,10 +26079,23 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 + '@babel/helper-annotate-as-pure@8.0.0': + dependencies: + '@babel/types': 8.0.4 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -25904,6 +26104,14 @@ snapshots: lru-cache: 5.1.1 semver: 7.7.3 + '@babel/helper-compilation-targets@8.0.0': + dependencies: + '@babel/compat-data': 8.0.0 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.28.1 + lru-cache: 11.5.1 + semver: 7.7.3 + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -25917,19 +26125,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@8.1.1) - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) - '@babel/traverse': 7.29.0(supports-color@8.1.1) - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -25943,6 +26138,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.1) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/traverse': 8.0.4 + semver: 7.7.3 + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -25992,20 +26198,27 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@8.0.0': {} + '@babel/helper-member-expression-to-functions@7.28.5(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.0(supports-color@5.5.0) - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-member-expression-to-functions@7.28.5(supports-color@8.1.1)': dependencies: '@babel/traverse': 7.29.0(supports-color@8.1.1) - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color + '@babel/helper-member-expression-to-functions@8.0.0': + dependencies: + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@babel/helper-module-imports@7.28.6(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.0(supports-color@5.5.0) @@ -26020,6 +26233,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26029,15 +26247,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-module-imports': 7.28.6(supports-color@8.1.1) - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -26047,12 +26256,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@babel/traverse': 8.0.4 + '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 + + '@babel/helper-optimise-call-expression@8.0.0': + dependencies: + '@babel/types': 8.0.4 '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26080,15 +26304,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@8.1.1) - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -26098,6 +26313,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-member-expression-to-functions': 8.0.0 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/traverse': 8.0.4 + '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@5.5.0)': dependencies: '@babel/traverse': 7.29.0(supports-color@5.5.0) @@ -26112,19 +26334,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + dependencies: + '@babel/traverse': 8.0.4 + '@babel/types': 8.0.4 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.4': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helper-wrap-function@7.28.3(supports-color@5.5.0)': dependencies: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0(supports-color@5.5.0) - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -26132,7 +26365,7 @@ snapshots: dependencies: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0(supports-color@8.1.1) - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -26141,6 +26374,11 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.8 + '@babel/helpers@8.0.0': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 @@ -26149,6 +26387,10 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26219,23 +26461,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0)) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0)) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-create-class-features-plugin': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.1) '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: @@ -26245,10 +26476,10 @@ snapshots: dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))': + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: @@ -26270,15 +26501,15 @@ snapshots: '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@5.5.0))': + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))': + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: @@ -26434,14 +26665,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -26450,6 +26673,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-destructuring@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26502,14 +26730,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -26518,6 +26738,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-explicit-resource-management@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-destructuring': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26636,14 +26862,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.0(supports-color@8.1.1) @@ -26652,6 +26870,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-module-transforms': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26848,6 +27072,31 @@ snapshots: '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + + '@babel/plugin-transform-react-jsx-development@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.1) + + '@babel/plugin-transform-react-jsx@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-syntax-jsx': 8.0.1(@babel/core@8.0.1) + '@babel/types': 8.0.4 + + '@babel/plugin-transform-react-pure-annotations@8.0.1(@babel/core@8.0.1)': + dependencies: + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: '@babel/core': 7.29.0(supports-color@5.5.0) @@ -26936,27 +27185,14 @@ snapshots: '@babel/core': 7.29.0(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': + '@babel/plugin-transform-typescript@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@5.5.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0)) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@8.1.1) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0)) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-create-class-features-plugin': 8.0.1(@babel/core@8.0.1) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.1) '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0(supports-color@5.5.0))': dependencies: @@ -27170,27 +27406,23 @@ snapshots: '@babel/types': 7.29.0 esutils: 2.0.3 - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0)': + '@babel/preset-react@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0(supports-color@5.5.0)) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-react-display-name': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-jsx-development': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-react-pure-annotations': 8.0.1(@babel/core@8.0.1) - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1)': + '@babel/preset-typescript@8.0.1(@babel/core@8.0.1)': dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0(supports-color@5.5.0)) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color + '@babel/core': 8.0.1 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.1) + '@babel/plugin-transform-typescript': 8.0.1(@babel/core@8.0.1) '@babel/runtime@7.29.7': {} @@ -27200,6 +27432,12 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@babel/traverse@7.29.0(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -27224,6 +27462,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.4': + dependencies: + '@babel/code-frame': 8.0.0 + '@babel/generator': 8.0.0 + '@babel/helper-globals': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/template': 8.0.0 + '@babel/types': 8.0.4 + obug: 2.1.3 + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -27234,6 +27482,11 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + '@balena/dockerignore@1.0.2': {} '@bazel/runfiles@6.5.0': {} @@ -31819,19 +32072,19 @@ snapshots: vue: 3.5.26(typescript@6.0.2) vue-component-type-helpers: 3.3.11 - '@stryker-mutator/api@9.6.1': + '@stryker-mutator/api@10.0.0': dependencies: - mutation-testing-metrics: 3.7.3 - mutation-testing-report-schema: 3.7.3 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 tslib: 2.8.1 typed-inject: 5.0.0 - '@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@5.5.0)': + '@stryker-mutator/core@10.0.0(@types/node@20.19.41)': dependencies: '@inquirer/prompts': 8.3.2(@types/node@20.19.41) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/instrumenter': 9.6.1(supports-color@5.5.0) - '@stryker-mutator/util': 9.6.1 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/instrumenter': 10.0.0 + '@stryker-mutator/util': 10.0.0 ajv: 8.20.0 chalk: 5.6.2 commander: 14.0.3 @@ -31842,9 +32095,9 @@ snapshots: lodash.groupby: 4.6.0 minimatch: 10.2.3 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 + mutation-testing-elements: 3.8.4 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 npm-run-path: 6.0.0 progress: 2.0.3 rxjs: 7.8.1 @@ -31856,94 +32109,35 @@ snapshots: typed-rest-client: 2.3.1 transitivePeerDependencies: - '@types/node' - - supports-color - '@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@8.1.1)': + '@stryker-mutator/instrumenter@10.0.0': dependencies: - '@inquirer/prompts': 8.3.2(@types/node@20.19.41) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/instrumenter': 9.6.1(supports-color@8.1.1) - '@stryker-mutator/util': 9.6.1 - ajv: 8.20.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.3 - 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.1 - semver: 7.7.3 - 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(supports-color@5.5.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@5.5.0) - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@5.5.0) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/util': 9.6.1 - angular-html-parser: 10.4.0 + '@babel/core': 8.0.1 + '@babel/generator': 8.0.0 + '@babel/parser': 8.0.4 + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.1) + '@babel/plugin-transform-explicit-resource-management': 8.0.1(@babel/core@8.0.1) + '@babel/preset-react': 8.0.1(@babel/core@8.0.1) + '@babel/preset-typescript': 8.0.1(@babel/core@8.0.1) + '@babel/traverse': 8.0.4 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/util': 10.0.0 + angular-html-parser: 10.11.0 semver: 7.7.3 tslib: 2.8.1 - weapon-regex: 1.3.6 - transitivePeerDependencies: - - supports-color + weapon-regex: 2.0.4 - '@stryker-mutator/instrumenter@9.6.1(supports-color@8.1.1)': + '@stryker-mutator/util@10.0.0': {} + + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@20.19.41))(vitest@4.1.9)': dependencies: - '@babel/core': 7.29.0(supports-color@8.1.1) - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0(supports-color@5.5.0))(supports-color@8.1.1) - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/util': 9.6.1 - angular-html-parser: 10.4.0 - semver: 7.7.3 - tslib: 2.8.1 - weapon-regex: 1.3.6 - transitivePeerDependencies: - - supports-color - - '@stryker-mutator/util@9.6.1': {} - - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@5.5.0))(vitest@4.1.9)': - dependencies: - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@20.19.41)(supports-color@5.5.0) - '@stryker-mutator/util': 9.6.1 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/core': 10.0.0(@types/node@20.19.41) + '@stryker-mutator/util': 10.0.0 semver: 7.7.3 tslib: 2.8.1 vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@5.5.0)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) - '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@20.19.41)(supports-color@8.1.1))(vitest@4.1.9)': - dependencies: - '@stryker-mutator/api': 9.6.1 - '@stryker-mutator/core': 9.6.1(@types/node@20.19.41)(supports-color@8.1.1) - '@stryker-mutator/util': 9.6.1 - semver: 7.7.3 - tslib: 2.8.1 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) - '@stylistic/eslint-plugin@5.0.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1))': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.6.1)(supports-color@8.1.1)) @@ -32617,6 +32811,8 @@ snapshots: dependencies: '@types/node': 20.19.41 + '@types/gensync@1.0.5': {} + '@types/glob@8.0.0': dependencies: '@types/minimatch': 5.1.2 @@ -32663,6 +32859,8 @@ snapshots: '@types/tough-cookie': 4.0.5 parse5: 7.3.0 + '@types/jsesc@2.5.1': {} + '@types/json-diff@1.0.0': {} '@types/json-schema@7.0.15': {} @@ -33454,7 +33652,7 @@ snapshots: '@vitest/mocker': 4.1.9(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) playwright: 1.62.1 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@5.5.0)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) transitivePeerDependencies: - bufferutil - msw @@ -33470,7 +33668,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@5.5.0)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) ws: 8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -33490,7 +33688,7 @@ snapshots: obug: 2.1.3 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@5.5.0)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) optionalDependencies: '@vitest/browser': 4.1.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vitest@4.1.9) @@ -34171,7 +34369,7 @@ snapshots: transitivePeerDependencies: - supports-color - angular-html-parser@10.4.0: {} + angular-html-parser@10.11.0: {} ansi-colors@4.1.3: {} @@ -34633,7 +34831,7 @@ snapshots: babel-walk@3.0.0-canary-5: dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 bail@2.0.2: {} @@ -35429,8 +35627,8 @@ snapshots: constantinople@4.0.1: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 constants-browserify@1.0.0: {} @@ -36583,8 +36781,6 @@ snapshots: isarray: 2.0.5 stop-iteration-iterator: 1.1.0 - es-module-lexer@2.0.0: {} - es-module-lexer@2.3.2: {} es-object-atoms@1.1.1: @@ -38302,6 +38498,8 @@ snapshots: import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} @@ -39404,8 +39602,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 mailparser@3.9.3: @@ -40486,13 +40684,13 @@ snapshots: dependencies: zod: 3.25.76 - mutation-testing-elements@3.7.3: {} + mutation-testing-elements@3.8.4: {} - mutation-testing-metrics@3.7.3: + mutation-testing-metrics@3.8.4: dependencies: - mutation-testing-report-schema: 3.7.3 + mutation-testing-report-schema: 3.8.4 - mutation-testing-report-schema@3.7.3: {} + mutation-testing-report-schema@3.8.4: {} mute-stream@0.0.8: {} @@ -40728,7 +40926,7 @@ snapshots: node-source-walk@7.0.1: dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.8 node-ssh@13.2.0: dependencies: @@ -44820,7 +45018,7 @@ snapshots: '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - es-module-lexer: 2.0.0 + es-module-lexer: 2.3.2 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.3 @@ -44851,7 +45049,7 @@ snapshots: '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - es-module-lexer: 2.0.0 + es-module-lexer: 2.3.2 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.3 @@ -45072,7 +45270,7 @@ snapshots: weak-map@1.0.8: {} - weapon-regex@1.3.6: {} + weapon-regex@2.0.4: {} weaviate-client@3.9.0(encoding@0.1.13): dependencies: @@ -45259,8 +45457,8 @@ snapshots: with@7.0.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 assert-never: 1.2.1 babel-walk: 3.0.0-canary-5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 82836ef177e..20b87bae6bb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -123,8 +123,8 @@ catalog: '@pinecone-database/pinecone': ^5.0.2 '@qdrant/js-client-rest': ^1.16.2 '@rudderstack/rudder-sdk-node': 3.0.5 - '@stryker-mutator/core': 9.6.1 - '@stryker-mutator/vitest-runner': 9.6.1 + '@stryker-mutator/core': 10.0.0 + '@stryker-mutator/vitest-runner': 10.0.0 '@supabase/supabase-js': 2.112.3 '@testcontainers/k3s': ^11.13.0 '@testcontainers/kafka': ^11.13.0 @@ -452,7 +452,7 @@ overrides: undici@6: catalog:undici-v6 undici@7: catalog:undici-v7 node-gyp>undici: catalog:undici-v7 - '@babel/traverse': ^7.23.2 + '@babel/traverse@<7.23.2': ^7.23.2 '@vitest/browser@<4.1.10': 4.1.10 immutable: 5.1.8 nanoid@<3.3.18: 'catalog:' diff --git a/scripts/mutation-health/DEMO-HANDOVER.md b/scripts/mutation-health/DEMO-HANDOVER.md deleted file mode 100644 index 68c7e56542b..00000000000 --- a/scripts/mutation-health/DEMO-HANDOVER.md +++ /dev/null @@ -1,92 +0,0 @@ -# Demo handover: stacked PR on #30956 - -Use this prompt to drive the mutant-fix loop end-to-end and open a stacked PR that demonstrates the trial. - ---- - -## Prompt - -> I want to demo the mutation-health mutant-fix loop from PR #30956. Drive the whole flow from a fresh branch and open a stacked PR. -> -> **Base branch**: `devp-stryker-mvp-spike` (the PR's branch — not master yet). -> -> **Steps**: -> -> 1. `git fetch origin && git checkout devp-stryker-mvp-spike && git pull && git checkout -b demo/strengthen-` -> -> 2. Query the live ledger to find the lowest-score red file: -> ```bash -> curl -sS 'https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=n8n-workflow' \ -> | jq '.ledger | map(select(.status == "red")) | sort_by(.last_score | tonumber) | .[0]' -> ``` -> Use whatever it returns. As of 2026-05-22, that's `src/workflow-checksum.ts` at 38.64% — but check live state first. -> -> 3. Run the local mutation-testing skill on that file: -> `/mutant-score packages/workflow/src/` -> -> Confirm the output JSON shows the score and a list of survivors with mutator + location + covering tests. -> -> 4. Run the strengthen skill: -> `/mutant-fix` -> -> It'll triage survivors (HIGH/MODERATE/LOW), edit the covering test file with targeted assertions, and re-run `mutant-score` to verify the score climbed. Max 5 survivors per pass. -> -> 5. Review the diff yourself: `git diff packages/workflow/test/` -> -> Sanity-check each new assertion. Reject anything that's mocking-the-mock, asserting trivia, or pinning behaviour the source doesn't actually have. The skill is supposed to refuse to fabricate but humans verify. -> -> 6. If you want to push further, re-invoke `/mutant-fix` for the next 5 survivors. Or move on. -> -> 7. Final verification: -> `/mutant-score packages/workflow/src/` -> -> Capture the before/after score for the PR body. -> -> 8. Push and open a stacked PR **against `devp-stryker-mvp-spike`** (not master): -> ```bash -> git push -u origin demo/strengthen- -> gh pr create --draft --base devp-stryker-mvp-spike \ -> --title "test(core): strengthen assertions (demo) (no-changelog)" \ -> --body "" -> ``` -> -> **PR body template:** -> -> ```markdown -> ## Summary -> -> Demo PR for #30956. Drives the `mutant-fix` loop against `packages/workflow/src/` to show the trial loop end-to-end. -> -> **Before**: % mutation score, survivors -> **After**: % mutation score, survivors -> -> Survivors addressed (with rationale): -> 1. — added to -> 2. ... -> -> ## Test plan -> - [ ] `pnpm mutate packages/workflow/src/` reproduces the post-score locally -> - [ ] `pnpm --filter=n8n-workflow test test/.test.ts` passes -> - [ ] Each new assertion has a clear "this would have caught X bug" justification -> ``` -> -> **Goals of the demo:** -> -> - Reviewer sees a real diff with surgical assertion edits, not big-bang test rewrites -> - The before/after numbers are reproducible (`pnpm mutate` gives the same answer to anyone) -> - The skill refused to fabricate or split low-leverage survivors out — what landed is what mattered -> -> **What NOT to do:** -> -> - Don't rewrite whole test files. The skill should only add/extend covering tests. -> - Don't bypass the verify step. Every change must be backed by a re-run that shows the score moved. -> - Don't auto-merge. This is a draft demo; the reviewer takes it forward. - ---- - -## Why this is a good first PR for a reviewer - -- Small (a handful of assertion lines) -- Numerically verifiable (run `pnpm mutate` yourself, see the same number) -- Demonstrates the full loop without committing to the AI auto-PR pipeline yet -- Stacked on `devp-stryker-mvp-spike` so the loop's machinery is already on the branch diff --git a/scripts/mutation-health/README.md b/scripts/mutation-health/README.md index a717e06cca7..bf95b75ee62 100644 --- a/scripts/mutation-health/README.md +++ b/scripts/mutation-health/README.md @@ -1,6 +1,6 @@ # `scripts/mutation-health/` -Phase 1 substrate for the Mutation Health Observability initiative. +Patch-scoped mutation testing for n8n: prove the tests covering your change actually assert its behaviour. ## What is mutation testing? @@ -49,201 +49,83 @@ That divergence is exactly why this project exists. --- + ## What's in this directory -| File | Purpose | +| File | Role | | --- | --- | -| `pick-next.mjs` | Walk `/src/` (per-package mode) or every vitest-eligible package (global mode, `--global`), merge with the live ledger, return the next source file(s) to mutate | -| `mutate.mjs` | Run Stryker on one source file of any vitest package, write `summary.json` | -| `stryker.default.mjs` | Default Stryker config for onboarded packages (points at the package's own `vitest.config.*`) | -| `emit-payload.mjs` | Turn a Stryker `summary.json` into a BQ-ready writer payload | -| `ledger.mjs` | Read-all ledger access: `readLedger({ path, pkg? })` returns every row across every package in one pass, optionally narrowed to one package without re-reading | -| `signals.mjs` | Per-file git-derived risk signals (churn + fix-density) used by the global picker's value formula | -| `build-matrix.mjs` | Convert the global picker's top-N output (or an on-demand `SOURCE_FILE`) into the GHA `mutate` matrix; runs the die-loud `ELIGIBLE_PACKAGES` ↔ ledger guard | +| `mutate.mjs` | The whole engine. Runs Stryker over a package and emits an actionable summary. Exposed as `pnpm mutate`. | +| `mutate.test.mjs` | Unit tests for its pure helpers (`node --test 'scripts/mutation-health/*.test.mjs'`). | +| `stryker.default.mjs` | Shared Stryker config for any vitest package. A package that needs special handling ships its own `stryker.config.mjs`, which `mutate.mjs` prefers. | -`mutate.mjs` is package-agnostic — run `pnpm mutate ` from the repo root and the package is inferred from the path (or pass `--package-dir ` for a package-relative target, as the nightly does). It uses the package's own `stryker.config.mjs` if one exists (e.g. `packages/workflow` carves out the isolated-vm engine), otherwise `stryker.default.mjs`. +Outputs land in `/reports/mutation/` (gitignored): -The reader and writer webhooks are plain HTTP — the GHA hits them with `curl`. There is no fetch/post wrapper script; if you want to call them locally, see [Local usage](#local-usage). +- `raw.json` — the full Stryker Mutation Testing Elements report (600 KB+; don't read it directly). +- `summary.json` — the compact actionable summary: every survivor's location, mutator, replacement, and covering tests. **This is the file to read.** -The BQ table schema lives with the writer workflow (in n8n's internal Quality project), not in this repo — the writer owns the MERGE statement and is the single source of truth. +## Usage -## End-to-end pipeline +The primary mode is `--diff`: mutate only the lines this branch changed. -``` -[GHA nightly cron, .github/workflows/mutation-health-nightly.yml] - │ - ├─► setup job (fetch-depth: 0, one process per night) - │ │ - │ ├─► curl GET reader webhook → live-ledger.json (read-all BQ state) - │ │ │ - │ │ └─► [n8n: QA Mutation Health Reader] ──► SELECT from BQ ledger - │ │ - │ ├─► signals.mjs → signals.json (churn + fix-density) - │ │ - │ ├─► build-matrix.mjs → matrix={ include: [top-N picks] } - │ │ │ - │ │ ├─► die-loud guard: empty ledger throws (strict mode); each - │ │ │ ELIGIBLE_PACKAGES.name must have ≥1 non-`new` row. - │ │ │ Escape hatch: `bootstrap_packages` workflow_dispatch input. - │ │ │ - │ │ └─► pick-next.mjs --global --top-n N - │ │ walks every eligible package's src/, merges with ledger, - │ │ ranks: w_churn·churn + w_fix·fixDensity + w_cov·(1−cov) - │ │ priority: new → red → stale → skip green - │ │ - │ └─► outputs.matrix = { include: [{name, dir, slug, mode, source_file, file_slug}, ...] } - │ - └─► mutate job (one per matrix include) - │ - ├─► mutate.mjs --package-dir → summary.json - │ - ├─► emit-payload.mjs → bq-payload.json - │ - └─► curl POST writer webhook → INSERTs event + MERGEs ledger row - ↓ - [n8n writer workflow: QA: Mutation Health Writer] - ↓ - ┌───────────────────────────────────┐ - │ qa_mutation_health_ledger (MERGE) │ - │ qa_performance_metrics (INSERT) │ - └───────────────────────────────────┘ +```bash +# Everything you changed vs origin/master — committed and uncommitted — +# batched into one Stryker run per package. +pnpm mutate --diff +pnpm mutate --diff --base upstream/master + +# One file, whole. +pnpm mutate packages/@n8n/crdt/src/utils.ts + +# One file, only lines 40-75. +pnpm mutate packages/@n8n/crdt/src/utils.ts:40-75 + +# Package-relative target. +pnpm mutate src/cron.ts --package-dir packages/workflow ``` -The writer workflow lives in n8n's internal Quality project. It's created and maintained outside this repo. This README documents the contract it implements. +Exit codes: `0` gate passed · `1` gate failed (summary.json still written — this is the +iterate signal) · `2` usage error · `3` Stryker could not run. A toolchain failure is +**never** `1`, so a broken checkout can't be mistaken for a score of zero. -## Passes, packages & onboarding +### Why `--diff` is fast -The nightly runs a **dynamic matrix of top-N picks** (built once in the `setup` job of `mutation-health-nightly.yml`). The setup job fetches the read-all live ledger, gathers git-derived signals, and calls `pick-next.mjs --global --top-n N` once across every eligible package. Each picked row becomes one `mutate` job; jobs run independently, the ledger is keyed by package + file, so they don't collide. Two passes, selectable via the `mode` dispatch input (`both` on schedule): +Two things do the work: -- **baseline** — files with no result yet (the `new` bucket). Builds out coverage. Maps from `effective_status: new` on a picked row. -- **coverage** — revisits the weakest scored files (`red`/`stale`, lowest first). Strengthens existing tests. Maps from `effective_status: red | stale` on a picked row. +1. **Patch scoping.** Stryker's mutation-range syntax (`file.ts:13-16`) means only the mutants + inside your changed lines are generated. You're scored on the lines you touched, not on + inherited debt. +2. **One dry run per package.** Targets are comma-joined into a single `--mutate` argument. + Repeated `--mutate` flags silently *overwrite* each other in Stryker's CLI, so comma-joining + is the only way to batch — and it means a package pays for its dry run once, not once per file. -To onboard a **vitest** package: add one `{ name, dir }` entry to `ELIGIBLE_PACKAGES` in `pick-next.mjs`. The nightly setup job derives its matrix from that single source of truth, so the picker and the workflow can't drift. No per-package config needed — `stryker.default.mjs` auto-resolves the package's own `vitest.config.*` (verified on plain and DI-decorator packages). Add a local `stryker.config.mjs` only if the package needs special handling. +On top of that, Stryker's vitest runner only loads the tests *related* to the mutated files, so +cost tracks the related suite rather than package size. Measured end-to-end, whole-file: +`@n8n/decorators` 1s · `@n8n/scheduler` 3s · `packages/workflow` 13s · `nodes-base` 26s · +`packages/cli` 88s. Line-scoping cuts these further. -Then, **once**, trigger the nightly with `workflow_dispatch` and set `bootstrap_packages` to the new package name (e.g. `@n8n/decorators`). The divergence guard otherwise refuses to schedule for a package that has zero non-`new` ledger rows — that's how it catches an `ELIGIBLE_PACKAGES.name` ↔ `ledger.package` rename mismatch, but it can't tell that from a legitimate new onboarding. The one-off `bootstrap_packages` value skips the guard for that package on that run only; subsequent scheduled nightlies revert to strict mode once the new package's rows have been populated. +### In-place mutation -For a **genuine cold-start** (first run after the ledger is provisioned, the read-all returns `[]`), trigger with `bootstrap_packages: '*'` instead — it acknowledges the empty ledger and skips the per-package divergence check for every entry. +Runs use Stryker's `--inPlace`. Its default sandbox copy breaks on any package whose vitest +config resolves a workspace dependency through a path alias — the alias doesn't survive the +copy, and `packages/cli` dies on `ERR_LOAD_URL … .stryker-tmp/@n8n/backend-test-utils`. -Not yet covered: `@n8n/expression-runtime` (it _is_ the isolated-vm engine; blocked on the patch in DEVP-257). +Stryker restores your files on a clean exit and on `Ctrl-C`. Because a hard crash would not, +and because in `--diff` mode those files hold *uncommitted work* (so `git checkout --` is not a +safe undo), `mutate.mjs` snapshots the exact bytes of every target before the run and writes +them back if they differ afterwards. -## State transitions +## Which packages can be scored -| Trigger | Stored `status` | -| --- | --- | -| Source file in `src/` but no row yet | synthesised as `new` at pick time; not stored | -| Last run passed the gate (score ≥ `threshold_at_run` AND no unjustified survivors) | `green` | -| Last run failed the gate (score < `threshold_at_run` OR ≥1 `Survived`/`NoCoverage` mutant) | `red` | +Any package whose `test` script runs **vitest** — which, since the Jest migration, is nearly all +of them, including `nodes-base`, `nodes-langchain`, `cli` and `db`. `--diff` derives eligibility +per file and prints a one-line reason for anything it skips; there is no curated list to +maintain. -Stored statuses are just two: `red` and `green`. `new` is computed in-memory by the picker for any file in the source tree that has no ledger row yet — the row is only persisted after that file's first scored run. The picker also computes a transient `stale` state — any `green` row whose `last_checked_at` is older than 4 weeks is treated as `stale` for that pick. No `last_checked_sha` is needed; no git history is consulted. +Not scored: -Picker priority: `new` → `red` → `stale` → skip fresh `green`. - -- Within `new`: alphabetical (rows exit the bucket as they're scored) -- Within `red`: lowest score first (weakest tests revisited first) -- Within `stale`: oldest `last_checked_at` first (natural cycling of long-stable files) - -If every row is green and fresh, the picker exits 0 with `{"picked": null, "reason": "all-green"}` — a healthy "nothing to do" state, not a failure. - -## Webhook contracts - -Two n8n workflows back the pipeline. Both live in the internal Quality project (`L8csxtEbFpFOWlf8`) and are created/maintained outside this repo. Both run unauthenticated (URL-as-secret pattern, matching existing `qa_*` workers). - -| Endpoint | Method | Workflow | Purpose | -| --- | --- | --- | --- | -| `https://internal.users.n8n.cloud/webhook/mutation-health-writer` | POST | `QA: Mutation Health Writer` (`iYEBmBat8OscRTVq`) | INSERT events + MERGE ledger | -| `https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=` | GET | `QA: Mutation Health Reader` (`ZmRsNUwvgfCSq0JI`) | Read current ledger state | - -### Writer webhook - -`POST https://internal.users.n8n.cloud/webhook/mutation-health-writer` with `Content-Type: application/json`: - -```json -{ - "ledger": [ - { - "source_file_path": "packages/workflow/src/cron.ts", - "package": "n8n-workflow", - "last_score": 95.12, - "coverage": 0.93, - "churn": 7, - "fix_density": 2.41, - "threshold_at_run": 80, - "last_checked_at": "2026-05-22T10:03:55.660Z", - "status": "green", - "mutants_killed": 39, - "mutants_survived": 2, - "mutants_no_coverage": 0, - "mutants_timeout": 0 - } - ], - "events": [ - { - "benchmark_name": "mutation_health", - "value": 95.12, - "timestamp": "2026-05-22T10:03:55.660Z", - "dimensions": { - "package": "n8n-workflow", - "source_file": "packages/workflow/src/cron.ts", - "sha": "095239e175", - "status_after": "green", - "threshold": 80, - "coverage": 0.93, - "churn": 7, - "fix_density": 2.41, - "mutants_killed": 39, - "mutants_survived": 2, - "mutants_no_coverage": 0, - "mutants_timeout": 0 - } - } - ] -} -``` - -Either array may be empty (manual smoke tests sometimes send only `events`). - -Each ledger row also carries `coverage` — the scored file's line-coverage proxy in `[0,1]`, the share of mutants a test actually exercised — written back by `mutate.mjs` after each run. The global picker reads it next cycle as the `(1 − coverage)` term of its value formula, so a file no test touches (`coverage` 0) gets the strongest urge to be scored. - -Rows also carry `churn` — the count of commits touching the source file within a recent window (default `90 days`, override with `--churn-window`), derived from git in `emit-payload.mjs`. It feeds the picker's `churn` term so hot files outrank cold ones. On a shallow clone history is truncated and the count would undercount, so `churn` is emitted as `null` rather than a misleading low number. - -The third value-formula term, `fix_density`, is emitted the same way — the file's time-decayed, delta-weighted fix-density (the same git-derived signal `signals.mjs` computes: lines changed by `fix:`-shaped commits, decayed by a half-life on commit age). `emit-payload.mjs` reads one per-package `git log --numstat` pass (bounded by `--fix-density-window`, default `1 year`; half-life via `--fix-density-half-life`, default `90` days) and scores each file. A file with no fix commits scores `0` (known: no fixes); a shallow clone or git failure emits `null` (unknown), the same guard as `churn`. A richer variant joining against the bug taxonomy is a possible future enhancement on the writer side, but is not required to feed the picker. - -The writer: - -1. For each `events[]` row → `INSERT` into `qa_performance_metrics`. -2. For each `ledger[]` row → `MERGE` into `qa_mutation_health_ledger` on `source_file_path`. Status is always `red` or `green` — the picker synthesises `new` in-memory and never posts it. - -The webhook URL is delivered to GHA via the `MUTATION_HEALTH_WEBHOOK` repo secret. The secret URL itself is the only auth (matches existing `qa_*` writer pattern); rotate the secret if leaked. - -### Reader webhook - -`GET https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=`: - -```json -{ - "ledger": [ - { - "source_file_path": "packages/workflow/src/cron.ts", - "package": "n8n-workflow", - "last_score": 95.12, - "coverage": 0.93, - "churn": 7, - "fix_density": 2.41, - "threshold_at_run": 80, - "last_checked_at": "2026-05-22T10:03:55.660Z", - "status": "green", - "mutants_killed": 39, - "mutants_survived": 2, - "mutants_no_coverage": 0, - "mutants_timeout": 0 - } - ] -} -``` - -The `package` query param is validated server-side against the same pnpm-workspace allowlist regex used elsewhere in the pipeline; invalid input returns 500. No SQL is constructed or accepted on the client side — the SELECT is hardcoded in the workflow. - -Unauthenticated — the URL is not a secret. The data isn't sensitive (file paths + integer scores), but treat the URL as low-trust: anyone with it can read all current ledger state for the queried package. +- `@n8n/expression-runtime` — Stryker's dry run SIGABRTs on the isolated-vm engine ([DEVP-257](https://linear.app/n8n/issue/DEVP-257)). +- `.vue` single-file components — every SFC package crashed Stryker's mutate step in the 2026-06 sweep, and the component layer is low-value to mutate. +- Tests, declarations, stories, configs, migrations and build output. ## Gate semantics @@ -256,49 +138,12 @@ Stryker excludes `Ignored` mutants from both numerator and denominator of the sc `summary.json` surfaces every `Ignored` mutant alongside its disable-comment reason so reviewers can spot-check the justifications — those become the high-signal review artifact rather than N padding tests. +A **partial** run never passes: if Stryker exits non-zero but left a salvageable `raw.json`, +the summary is kept (survivors found so far are still useful) and flagged `partial`, because +mutants it never got to could be survivors. + ### Threshold (provisional) -Runs use `STRYKER_THRESHOLD=80` as a placeholder. The threshold moves to evidence-based after ~4 weeks of accumulated data. Until then, treat `red`/`green` verdicts as preliminary. - -## Local usage - -```bash -# Run Stryker on one file (the inner loop — also invokable via /mutant-score skill). -# Package is inferred from the repo-relative path; works for any vitest package. -pnpm mutate packages/workflow/src/cron.ts -pnpm mutate packages/@n8n/crdt/src/utils.ts - -# Pull current ledger from BQ -curl --fail -sS \ - 'https://internal.users.n8n.cloud/webhook/mutation-health-ledger?package=n8n-workflow' \ - -o /tmp/ledger.json - -# Pick the next file to score (per-package mode) -node scripts/mutation-health/pick-next.mjs \ - --package-dir packages/workflow \ - --ledger-file /tmp/ledger.json - -# Pick top-N files across every vitest-eligible package, ranked by the -# global value formula w_churn·churn + w_fix·fixDensity + w_cov·(1 − coverage). -# Signals and coverage files are optional — missing terms contribute 0 -# except `(1 − coverage)`, which degrades to 1 (worst-case) so untracked -# files float to the top. -node scripts/mutation-health/pick-next.mjs \ - --global \ - --ledger-file /tmp/ledger.json \ - --signals-file /tmp/signals.json \ - --coverage-file /tmp/coverage.json \ - --top-n 5 \ - --block '@n8n/expression-runtime' - -# Build a BQ payload from a Stryker run -node scripts/mutation-health/emit-payload.mjs \ - --summary packages/workflow/reports/mutation/summary.json \ - --package n8n-workflow - -# POST the result (requires MUTATION_HEALTH_WEBHOOK to be set) -curl --fail -sS -X POST \ - -H 'Content-Type: application/json' \ - --data @packages/workflow/reports/mutation/bq-payload.json \ - "$MUTATION_HEALTH_WEBHOOK" -``` +Runs use `STRYKER_THRESHOLD=80` as a placeholder. Scoped to a patch the number is coarse — a +handful of mutants makes for a jumpy percentage — so the load-bearing half of the gate is +"no unjustified survivors", not the score. diff --git a/scripts/mutation-health/build-matrix.mjs b/scripts/mutation-health/build-matrix.mjs deleted file mode 100644 index faeaa995146..00000000000 --- a/scripts/mutation-health/build-matrix.mjs +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env node -/** - * Build the nightly mutation-health (package × pass) matrix from one global - * picker invocation, instead of redeclaring the package list in the workflow. - * - * ELIGIBLE_PACKAGES is sourced from `pick-next.mjs` (the picker's single - * source of truth — DEVP-497), so the nightly setup job never drifts from - * what the picker walks. - * - * Inputs (env): - * LEDGER_FILE Required (unless SOURCE_FILE is set). Read-all live ledger JSON. - * REQUESTED_MODE Optional. 'both' | 'baseline' | 'coverage'. Default 'both'. - * TOP_N Optional. Positive integer. Default 6. - * SOURCE_FILE Optional. Skip picker, emit a single matrix entry for a - * repo-relative file (used by the on-demand re-score path). - * SIGNALS_FILE Optional. Passed to picker as --signals-file. - * COVERAGE_FILE Optional. Explicit coverage map passed to picker as - * --coverage-file. When unset, a sparse map is derived - * from the read-all ledger rows (those carrying a - * `coverage` value) and written to a temp file. - * BOOTSTRAP_PACKAGES Optional. Comma-separated ELIGIBLE_PACKAGES.name list to - * exempt from the divergence guard (use after onboarding - * a new package — its ledger rows don't exist yet). Use - * '*' to acknowledge a genuine cold-start (also allows an - * empty ledger). Empty (default) = strict mode: empty - * ledger AND any per-package divergence both throw. - * - * Output (stdout): exactly one line, `matrix=`, suitable for appending - * to $GITHUB_OUTPUT. `` matches GHA matrix shape: { include: [...] }. - * The picker's global mode returns an array; this script branches on that - * shape so a future per-package fallback in the picker stays compatible. - * - * Each matrix row exposes: - * name workspace package name (e.g. n8n-workflow) - * dir repo-relative package dir (e.g. packages/workflow) - * slug short package slug (e.g. workflow) - * mode baseline | coverage | file (preserves the original - * concurrency-group key shape) - * source_file repo-relative .ts to mutate - * file_slug artefact-safe slug derived from `source_file` — keeps the - * upload-artifact name unique when multiple top-N picks share - * the same (mode, slug) pair - * - * Exit codes: - * 0 — wrote a matrix line (include[] may be empty when picker has no work) - * 2 — usage / config error, ledger divergence, or picker spawn failure - */ - -import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { parseLedgerBody } from './ledger.mjs'; -import { ELIGIBLE_PACKAGES } from './pick-next.mjs'; - -function die(code, msg) { - process.stderr.write(`${msg}\n`); - process.exit(code); -} - -export function slugForPackage(pkg) { - return pkg.dir.split('/').pop(); -} - -export function fileSlug(repoRelPath) { - return repoRelPath - .replace(/[^a-zA-Z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -export function modeForEffectiveStatus(effectiveStatus) { - if (effectiveStatus === 'new') return 'baseline'; - if (effectiveStatus === 'red' || effectiveStatus === 'stale') return 'coverage'; - throw new Error(`Cannot map effective_status="${effectiveStatus}" to a matrix mode`); -} - -export function findPackageForSourceFile(sourceFile, eligible = ELIGIBLE_PACKAGES) { - return eligible.find((p) => sourceFile.startsWith(`${p.dir}/`)); -} - -/** - * Die-loud guard against `ELIGIBLE_PACKAGES.name` ↔ ledger `.package` - * divergence and against silently re-baselining the whole tree on a - * transient/degraded read. - * - * Strict mode (`allowEmptyLedger: false`, empty `skipPackages`): - * - Empty ledger throws — a `200` from the reader with `[]` (BQ hiccup, - * degraded fetch) would otherwise look identical to "every file is new" - * and re-baseline the whole eligible tree in one night. - * - For each eligible package, fewer than one non-`new` row throws — the - * stored ledger only ever holds `red`/`green` rows (`new` is synthesised - * by the picker at pick time and never written), so "zero prior-status - * rows for an eligible package in a non-empty ledger" is the signature - * of an ELIGIBLE_PACKAGES.name ↔ ledger.package mismatch. - * - * Escape hatch (operator-driven via the workflow_dispatch - * `bootstrap_packages` input): - * - `allowEmptyLedger: true` is the genuine cold-start acknowledgement. - * - Packages in `skipPackages` are exempt from the divergence check — - * used after onboarding a new entry to ELIGIBLE_PACKAGES (its rows - * don't exist yet) so the first nightly can populate them. Subsequent - * scheduled nightlies revert to strict mode. - */ -export function assertNoLedgerDivergence( - ledgerRows, - eligible = ELIGIBLE_PACKAGES, - { allowEmptyLedger = false, skipPackages = [] } = {}, -) { - if (!Array.isArray(ledgerRows) || ledgerRows.length === 0) { - if (allowEmptyLedger) return; - throw new Error( - 'Ledger divergence: read-all ledger is empty. Either the reader webhook ' + - 'returned [] for an unexpected reason (BQ hiccup / degraded fetch) and the ' + - 'whole eligible tree would be silently re-baselined, or this is a genuine ' + - "cold start. Re-run with workflow_dispatch input `bootstrap_packages: '*'` " + - 'to acknowledge the cold start; otherwise investigate the reader webhook.', - ); - } - const skip = new Set(skipPackages); - for (const pkg of eligible) { - if (skip.has(pkg.name)) continue; - const priorHits = ledgerRows.filter( - (r) => r && r.package === pkg.name && r.status && r.status !== 'new', - ); - if (priorHits.length === 0) { - throw new Error( - `Ledger divergence: read-all ledger has ${ledgerRows.length} row(s) but zero ` + - `prior-status (non-"new") rows for eligible package "${pkg.name}". ` + - 'If this is a newly-onboarded package, re-run with workflow_dispatch input ' + - `\`bootstrap_packages: "${pkg.name}"\` to skip the guard once. Otherwise, ` + - 'suspected ELIGIBLE_PACKAGES.name ↔ ledger.package mismatch — refusing to ' + - 'silently re-baseline the package. Verify the package name and ledger contents.', - ); - } - } -} - -export function buildMatrixRowForFile(sourceFile, eligible = ELIGIBLE_PACKAGES) { - const pkg = findPackageForSourceFile(sourceFile, eligible); - if (!pkg) throw new Error(`No mutation-tracked package owns ${sourceFile}`); - return { - name: pkg.name, - dir: pkg.dir, - slug: slugForPackage(pkg), - mode: 'file', - source_file: sourceFile, - file_slug: fileSlug(sourceFile), - }; -} - -export function buildMatrixFromPicked(pickedRows, eligible = ELIGIBLE_PACKAGES) { - if (!Array.isArray(pickedRows)) { - throw new Error('Picker output is not an array — expected global-mode shape `{ picked: [...] }`'); - } - return { - include: pickedRows.map((row) => { - const pkg = eligible.find((p) => p.name === row.package); - if (!pkg) { - throw new Error( - `Picker returned package "${row.package}" which is not in ELIGIBLE_PACKAGES — refusing to schedule a mutate job for an unknown package`, - ); - } - return { - name: pkg.name, - dir: pkg.dir, - slug: slugForPackage(pkg), - mode: modeForEffectiveStatus(row.effective_status), - source_file: row.source_file_path, - file_slug: fileSlug(row.source_file_path), - }; - }), - }; -} - -/** - * Derive the picker's coverage map `{ : 0..1 }` from - * the read-all ledger rows. Only rows that carry a finite numeric `coverage` - * contribute — rows the writer left without a coverage value (never re-scored - * since the column landed) are omitted on purpose. - * - * A sparse map is correct and safe: `pick-next.mjs` treats an absent path as - * coverage 0, which makes the `(1 − coverage)` value term 1 (worst case = - * highest urge). The map fills in over nightly cycles as files are re-scored. - */ -export function buildCoverageMap(ledgerRows) { - const map = {}; - if (!Array.isArray(ledgerRows)) return map; - for (const row of ledgerRows) { - if (!row || typeof row.source_file_path !== 'string') continue; - if (typeof row.coverage === 'number' && Number.isFinite(row.coverage)) { - map[row.source_file_path] = row.coverage; - } - } - return map; -} - -function writeCoverageMapToTemp(coverageMap) { - const dir = mkdtempSync(path.join(os.tmpdir(), 'mutation-coverage-')); - const file = path.join(dir, 'coverage.json'); - writeFileSync(file, JSON.stringify(coverageMap)); - return file; -} - -function readLedgerRows(ledgerFile) { - if (!ledgerFile) die(2, 'Missing required LEDGER_FILE env var'); - if (!existsSync(ledgerFile)) die(2, `Ledger file not found: ${ledgerFile}`); - try { - return parseLedgerBody(readFileSync(ledgerFile, 'utf8')).rows; - } catch (err) { - die(2, `Failed to read ledger at ${ledgerFile}: ${err.message}`); - return []; - } -} - -function runPicker({ ledgerFile, requestedMode, topN, signalsFile, coverageFile }) { - const here = path.dirname(fileURLToPath(import.meta.url)); - const pickerPath = path.join(here, 'pick-next.mjs'); - const args = [pickerPath, '--global', '--top-n', String(topN), '--ledger-file', ledgerFile]; - if (requestedMode === 'baseline' || requestedMode === 'coverage') { - args.push('--mode', requestedMode); - } - if (signalsFile) args.push('--signals-file', signalsFile); - if (coverageFile) args.push('--coverage-file', coverageFile); - - const res = spawnSync(process.execPath, args, { encoding: 'utf8' }); - if (res.error) die(2, `Failed to spawn picker: ${res.error.message}`); - if (res.stderr) process.stderr.write(res.stderr); - if (res.status !== 0) die(res.status ?? 2, `Picker exited with status ${res.status}`); - try { - return JSON.parse(res.stdout); - } catch (err) { - die(2, `Failed to parse picker output as JSON: ${err.message}\nstdout: ${res.stdout}`); - return null; - } -} - -const isCli = import.meta.url === `file://${process.argv[1]}`; -if (isCli) { - const sourceFile = (process.env.SOURCE_FILE ?? '').trim(); - const requestedMode = (process.env.REQUESTED_MODE ?? 'both').trim() || 'both'; - const topNRaw = (process.env.TOP_N ?? '6').trim() || '6'; - const ledgerFile = (process.env.LEDGER_FILE ?? '').trim(); - const signalsFile = (process.env.SIGNALS_FILE ?? '').trim(); - const coverageFile = (process.env.COVERAGE_FILE ?? '').trim(); - - if (sourceFile) { - try { - const row = buildMatrixRowForFile(sourceFile); - process.stdout.write(`matrix=${JSON.stringify({ include: [row] })}\n`); - process.exit(0); - } catch (err) { - die(2, err.message); - } - } - - if (!['both', 'baseline', 'coverage'].includes(requestedMode)) { - die(2, `Invalid REQUESTED_MODE="${requestedMode}". Use 'both', 'baseline', or 'coverage'.`); - } - - const topN = Number(topNRaw); - if (!Number.isInteger(topN) || topN <= 0) { - die(2, `Invalid TOP_N="${topNRaw}" (expected positive integer).`); - } - - const bootstrapRaw = (process.env.BOOTSTRAP_PACKAGES ?? '').trim(); - const bootstrapWildcard = bootstrapRaw === '*'; - const skipPackages = bootstrapWildcard - ? ELIGIBLE_PACKAGES.map((p) => p.name) - : bootstrapRaw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const allowEmptyLedger = bootstrapWildcard; - - const ledgerRows = readLedgerRows(ledgerFile); - try { - assertNoLedgerDivergence(ledgerRows, ELIGIBLE_PACKAGES, { - allowEmptyLedger, - skipPackages, - }); - } catch (err) { - die(2, err.message); - } - - // Feed ledger coverage into the global picker so the `(1 − coverage)` value - // term goes live. An explicit COVERAGE_FILE env wins (operator override); - // otherwise derive a sparse map from the read-all ledger rows we already - // hold and hand it to the picker via a temp file. - let effectiveCoverageFile = coverageFile; - if (!effectiveCoverageFile) { - const coverageMap = buildCoverageMap(ledgerRows); - if (Object.keys(coverageMap).length > 0) { - effectiveCoverageFile = writeCoverageMapToTemp(coverageMap); - } - } - - const pickerOutput = runPicker({ - ledgerFile, - requestedMode, - topN, - signalsFile, - coverageFile: effectiveCoverageFile, - }); - - const pickedRows = Array.isArray(pickerOutput?.picked) ? pickerOutput.picked : []; - try { - const matrix = buildMatrixFromPicked(pickedRows); - process.stdout.write(`matrix=${JSON.stringify(matrix)}\n`); - } catch (err) { - die(2, err.message); - } -} diff --git a/scripts/mutation-health/build-matrix.test.mjs b/scripts/mutation-health/build-matrix.test.mjs deleted file mode 100644 index 24fcd0915ed..00000000000 --- a/scripts/mutation-health/build-matrix.test.mjs +++ /dev/null @@ -1,343 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - assertNoLedgerDivergence, - buildCoverageMap, - buildMatrixFromPicked, - buildMatrixRowForFile, - fileSlug, - findPackageForSourceFile, - modeForEffectiveStatus, - slugForPackage, -} from './build-matrix.mjs'; -import { ELIGIBLE_PACKAGES } from './pick-next.mjs'; - -const ELIGIBLE = ELIGIBLE_PACKAGES; - -describe('slugForPackage', () => { - it('returns the trailing path segment of `dir`', () => { - assert.equal(slugForPackage({ name: 'n8n-workflow', dir: 'packages/workflow' }), 'workflow'); - assert.equal(slugForPackage({ name: '@n8n/crdt', dir: 'packages/@n8n/crdt' }), 'crdt'); - assert.equal( - slugForPackage({ name: '@n8n/decorators', dir: 'packages/@n8n/decorators' }), - 'decorators', - ); - }); -}); - -describe('fileSlug', () => { - it('sanitises a repo-relative path into an artefact-safe slug', () => { - assert.equal( - fileSlug('packages/workflow/src/cron.ts'), - 'packages-workflow-src-cron-ts', - ); - assert.equal( - fileSlug('packages/@n8n/crdt/src/utils.ts'), - 'packages-n8n-crdt-src-utils-ts', - ); - }); - - it('collapses runs of separators and trims edges', () => { - assert.equal(fileSlug('a//b///c.ts'), 'a-b-c-ts'); - assert.equal(fileSlug('.hidden/file.ts'), 'hidden-file-ts'); - }); -}); - -describe('modeForEffectiveStatus', () => { - it('maps new → baseline and red/stale → coverage', () => { - assert.equal(modeForEffectiveStatus('new'), 'baseline'); - assert.equal(modeForEffectiveStatus('red'), 'coverage'); - assert.equal(modeForEffectiveStatus('stale'), 'coverage'); - }); - - it('throws on green or unknown — green never enters the candidate set', () => { - assert.throws(() => modeForEffectiveStatus('green'), /Cannot map/); - assert.throws(() => modeForEffectiveStatus(undefined), /Cannot map/); - }); -}); - -describe('findPackageForSourceFile', () => { - it('matches by exact dir prefix with a trailing separator', () => { - const pkg = findPackageForSourceFile('packages/workflow/src/cron.ts', ELIGIBLE); - assert.ok(pkg); - assert.equal(pkg.name, 'n8n-workflow'); - }); - - it('returns undefined for unowned paths', () => { - assert.equal(findPackageForSourceFile('README.md', ELIGIBLE), undefined); - }); -}); - -describe('buildMatrixRowForFile (on-demand re-score path)', () => { - it('emits a single matrix row with mode=file for an owned file', () => { - const row = buildMatrixRowForFile('packages/@n8n/crdt/src/utils.ts', ELIGIBLE); - assert.deepEqual(row, { - name: '@n8n/crdt', - dir: 'packages/@n8n/crdt', - slug: 'crdt', - mode: 'file', - source_file: 'packages/@n8n/crdt/src/utils.ts', - file_slug: 'packages-n8n-crdt-src-utils-ts', - }); - }); - - it('throws for files outside ELIGIBLE_PACKAGES', () => { - assert.throws( - () => buildMatrixRowForFile('packages/nodes-base/utils/binary.ts', ELIGIBLE), - /No mutation-tracked package owns/, - ); - }); -}); - -describe('assertNoLedgerDivergence (die-loud guard)', () => { - it('throws loudly on empty ledger in strict mode (transient/degraded read masquerades as cold-start)', () => { - // Default mode: an empty ledger could be a genuine cold-start OR a BQ - // hiccup that re-baselines the whole eligible tree silently. We can't - // tell which, so we fail loud and require an explicit operator gesture. - assert.throws( - () => assertNoLedgerDivergence([], ELIGIBLE), - /read-all ledger is empty/, - ); - }); - - it('allows empty ledger when allowEmptyLedger is set (genuine cold-start acknowledgement)', () => { - // bootstrap_packages: '*' on workflow_dispatch sets allowEmptyLedger:true. - assert.doesNotThrow(() => - assertNoLedgerDivergence([], ELIGIBLE, { allowEmptyLedger: true }), - ); - }); - - it('passes when every eligible package has at least one non-`new` row', () => { - const rows = ELIGIBLE.map((pkg, i) => ({ - source_file_path: `${pkg.dir}/src/seed-${i}.ts`, - package: pkg.name, - status: 'green', - })); - assert.doesNotThrow(() => assertNoLedgerDivergence(rows, ELIGIBLE)); - }); - - it('fails loudly when an eligible package has zero non-`new` rows in a non-empty ledger', () => { - // First package has a real row; second has zero. Mimics ELIGIBLE_PACKAGES - // vs ledger.package divergence (e.g. a rename like n8n-workflow vs workflow). - const rows = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - status: 'green', - }, - ]; - assert.throws( - () => assertNoLedgerDivergence(rows, ELIGIBLE), - /zero prior-status \(non-"new"\) rows for eligible package/, - ); - }); - - it('error message hints at the bootstrap_packages escape hatch and names the missing package', () => { - // The error must be actionable: an operator needs to know which input - // to set and which package to put in it. - const fakeEligible = [ - { name: 'n8n-workflow', dir: 'packages/workflow' }, - { name: '@n8n/newly-onboarded', dir: 'packages/@n8n/newly-onboarded' }, - ]; - const rows = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - status: 'green', - }, - ]; - assert.throws(() => assertNoLedgerDivergence(rows, fakeEligible), (err) => { - return ( - /bootstrap_packages/.test(err.message) && - /@n8n\/newly-onboarded/.test(err.message) - ); - }); - }); - - it('skips packages listed in skipPackages — newly-onboarded entry boots without a red nightly', () => { - // Operator adds `@n8n/newly-onboarded` to ELIGIBLE_PACKAGES (one-line - // onboarding per the README), and runs the next nightly with - // `bootstrap_packages: "@n8n/newly-onboarded"`. The new package has zero - // ledger rows; the guard must skip it for that one run so the nightly - // can populate its rows. - const fakeEligible = [ - { name: 'n8n-workflow', dir: 'packages/workflow' }, - { name: '@n8n/newly-onboarded', dir: 'packages/@n8n/newly-onboarded' }, - ]; - const rows = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - status: 'green', - }, - ]; - assert.doesNotThrow(() => - assertNoLedgerDivergence(rows, fakeEligible, { - skipPackages: ['@n8n/newly-onboarded'], - }), - ); - // Defence in depth: the other eligible packages are still guarded. - const onlyNewRows = [ - { - source_file_path: 'packages/@n8n/newly-onboarded/src/foo.ts', - package: '@n8n/newly-onboarded', - status: 'green', - }, - ]; - assert.throws( - () => - assertNoLedgerDivergence(onlyNewRows, fakeEligible, { - skipPackages: ['@n8n/newly-onboarded'], - }), - /zero prior-status .* "n8n-workflow"/, - ); - }); - - it('ignores `new` rows when counting prior-status hits', () => { - // Even with rows present, if they're all `new` it counts as zero prior-status. - const rows = ELIGIBLE.map((pkg, i) => ({ - source_file_path: `${pkg.dir}/src/seed-${i}.ts`, - package: pkg.name, - status: 'new', - })); - assert.throws(() => assertNoLedgerDivergence(rows, ELIGIBLE), /zero prior-status/); - }); -}); - -describe('buildCoverageMap (ledger coverage → picker --coverage-file map)', () => { - it('produces a { path: 0..1 } map from rows that carry a numeric coverage', () => { - const rows = [ - { source_file_path: 'packages/workflow/src/a.ts', coverage: 0.4 }, - { source_file_path: 'packages/@n8n/crdt/src/z.ts', coverage: 0.1 }, - ]; - assert.deepEqual(buildCoverageMap(rows), { - 'packages/workflow/src/a.ts': 0.4, - 'packages/@n8n/crdt/src/z.ts': 0.1, - }); - }); - - it('omits rows without a usable coverage value (sparse map = correct)', () => { - // Unscored files have no coverage; the picker treats an absent path as - // 0 (worst case). So omitting them is safe and expected. - const rows = [ - { source_file_path: 'packages/workflow/src/a.ts', coverage: 0.4 }, - { source_file_path: 'packages/workflow/src/b.ts', coverage: null }, - { source_file_path: 'packages/workflow/src/c.ts' }, - { source_file_path: 'packages/workflow/src/d.ts', coverage: 'nope' }, - { source_file_path: 'packages/workflow/src/e.ts', coverage: Number.NaN }, - ]; - assert.deepEqual(buildCoverageMap(rows), { - 'packages/workflow/src/a.ts': 0.4, - }); - }); - - it('keeps boundary coverage values 0 and 1', () => { - const rows = [ - { source_file_path: 'a.ts', coverage: 0 }, - { source_file_path: 'b.ts', coverage: 1 }, - ]; - assert.deepEqual(buildCoverageMap(rows), { 'a.ts': 0, 'b.ts': 1 }); - }); - - it('skips malformed rows and tolerates a non-array input', () => { - const rows = [null, {}, { coverage: 0.5 }, { source_file_path: 'ok.ts', coverage: 0.7 }]; - assert.deepEqual(buildCoverageMap(rows), { 'ok.ts': 0.7 }); - assert.deepEqual(buildCoverageMap(undefined), {}); - assert.deepEqual(buildCoverageMap(null), {}); - }); -}); - -describe('buildMatrixFromPicked (global picker → matrix shape)', () => { - it('consumes the global-mode array shape and emits one row per pick', () => { - const picked = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - prior_status: 'new', - effective_status: 'new', - value: 3.5, - }, - { - source_file_path: 'packages/@n8n/crdt/src/utils.ts', - package: '@n8n/crdt', - prior_status: 'red', - effective_status: 'red', - value: 2.1, - }, - ]; - const matrix = buildMatrixFromPicked(picked, ELIGIBLE); - assert.equal(matrix.include.length, 2); - assert.deepEqual(matrix.include[0], { - name: 'n8n-workflow', - dir: 'packages/workflow', - slug: 'workflow', - mode: 'baseline', - source_file: 'packages/workflow/src/cron.ts', - file_slug: 'packages-workflow-src-cron-ts', - }); - assert.equal(matrix.include[1].mode, 'coverage'); - assert.equal(matrix.include[1].slug, 'crdt'); - }); - - it('returns an empty include[] when the picker has no work (picked: [])', () => { - assert.deepEqual(buildMatrixFromPicked([], ELIGIBLE), { include: [] }); - }); - - it('throws when the picker output is not an array (per-package shape would leak through)', () => { - assert.throws( - () => buildMatrixFromPicked({ source_file_path: 'x.ts' }, ELIGIBLE), - /not an array/, - ); - assert.throws(() => buildMatrixFromPicked(null, ELIGIBLE), /not an array/); - }); - - it('throws when a picked row carries `green` effective_status (green should never enter the candidate set)', () => { - // Defence in depth: the picker's contract is that `green` rows are - // skipped before ranking. If a future bug leaks a green row through, - // the matrix builder dies via modeForEffectiveStatus rather than - // scheduling a mutate job for a file that doesn't need re-scoring. - const picked = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - prior_status: 'green', - effective_status: 'green', - value: 0, - }, - ]; - assert.throws(() => buildMatrixFromPicked(picked, ELIGIBLE), /Cannot map effective_status/); - }); - - it('throws when a picked row carries an unknown effective_status', () => { - const picked = [ - { - source_file_path: 'packages/workflow/src/cron.ts', - package: 'n8n-workflow', - prior_status: 'red', - effective_status: 'totally-bogus', - value: 1, - }, - ]; - assert.throws(() => buildMatrixFromPicked(picked, ELIGIBLE), /Cannot map effective_status/); - }); - - it('refuses to schedule a job for a package outside ELIGIBLE_PACKAGES', () => { - // Defence in depth: if the picker ever leaked a row for a non-eligible - // package (e.g. via a future bug or a stale ledger row), the matrix - // builder dies rather than spawning a mutate job we can't service. - const picked = [ - { - source_file_path: 'packages/nodes-base/utils/binary.ts', - package: 'n8n-nodes-base', - prior_status: 'new', - effective_status: 'new', - value: 1, - }, - ]; - assert.throws( - () => buildMatrixFromPicked(picked, ELIGIBLE), - /not in ELIGIBLE_PACKAGES/, - ); - }); -}); diff --git a/scripts/mutation-health/emit-payload.mjs b/scripts/mutation-health/emit-payload.mjs deleted file mode 100644 index 15d6acda07d..00000000000 --- a/scripts/mutation-health/emit-payload.mjs +++ /dev/null @@ -1,372 +0,0 @@ -#!/usr/bin/env node -/** - * Emit a BQ payload from a mutate.mjs summary.json. - * - * Input: packages//reports/mutation/summary.json (from `pnpm mutate `) - * Output: JSON document with two keys, `ledger` (rows for qa_mutation_health_ledger) - * and `events` (rows for qa_performance_metrics, benchmark_name="mutation_health"). - * - * Each ledger row carries all three of the global picker's value-formula terms: - * - * coverage — the scored file's coverage fraction (clamped to [0,1]) - * written back by mutate.mjs, used as the `(1 − coverage)` - * term (DEVP-496). - * churn — commit count touching the source file within a recent - * window, derived here from git, used as the `churn` term - * (DEVP-546). - * fix_density — the file's time-decayed, delta-weighted fix-density (the - * same git-derived signal `signals.mjs` computes), used as the - * `fix_density` term (DEVP-546). - * - * (A richer fix-density variant joined against the bug taxonomy is a possible - * future enhancement on the writer side, but the git-derived signal here is - * what feeds the picker's value formula.) - * - * The output is what the n8n writer workflow consumes via webhook. This script - * intentionally does NOT call BigQuery directly — the writer workflow owns - * BQ credentials and the MERGE statement for the ledger upsert. - * - * Usage: - * node scripts/mutation-health/emit-payload.mjs \ - * --summary packages/workflow/reports/mutation/summary.json \ - * --package n8n-workflow \ - * [--signals ] # signals.json from `signals.mjs gatherSignals` - * # (the setup job's full-history signals). When - * # present, churn/fix_density are read from it per - * # file instead of computed from git — the only - * # path that works under the mutate job's shallow - * # clone, and keeps stored values consistent with - * # what the picker ranked on. - * [--churn-window "90 days"] # git approxidate for the churn count (git fallback only) - * [--fix-density-window "1 year"] # git approxidate bounding the fix-density log read (git fallback only) - * [--fix-density-half-life 90] # half-life in days for the fix-density decay (git fallback only) - * [--out ] # default: /reports/mutation/bq-payload.json - */ - -import { execFileSync } from 'node:child_process'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; - -import { - GIT_LOG_FORMAT, - parseGitLog, - computeFixDensity, - DEFAULT_HALF_LIFE_DAYS, -} from './signals.mjs'; - -function die(code, msg) { - process.stderr.write(`${msg}\n`); - process.exit(code); -} - -function parseArgs(argv) { - const out = {}; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (!a.startsWith('--')) continue; - const key = a.slice(2); - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - out[key] = true; - } else { - out[key] = next; - i++; - } - } - return out; -} - -/** - * Coverage fraction for a summary file row, clamped to [0,1]. Prefers the - * `coverage` mutate.mjs wrote back; falls back to deriving it from the mutant - * counts for summaries produced before the writeback existed. Returns null only - * when neither source is usable, so the ledger column degrades gracefully. - */ -export function coverageForLedger(f) { - if (typeof f.coverage === 'number' && Number.isFinite(f.coverage)) { - return +Math.min(1, Math.max(0, f.coverage)).toFixed(4); - } - const c = f.counts; - if (!c) return null; - const covered = (c.killed ?? 0) + (c.survived ?? 0) + (c.timeout ?? 0) + (c.runtimeError ?? 0); - const total = covered + (c.noCoverage ?? 0); - if (total === 0) return 0; - return +Math.min(1, Math.max(0, covered / total)).toFixed(4); -} - -/** Default git approxidate window for the per-file churn count. */ -export const DEFAULT_CHURN_WINDOW = '90 days'; - -/** - * Default git approxidate window bounding the fix-density log read. A 90-day - * half-life means commits older than ~1 year contribute negligibly, so this - * bounds the per-package log read without affecting the signal in practice. - */ -export const DEFAULT_FIX_DENSITY_WINDOW = '1 year'; - -// 256MB: a per-package `git log --numstat` over year-scale history can blow the -// default 1MB stdout cap; `rev-list --count` output is tiny so this is harmless -// for churn, letting both factories share one runner. -const GIT_MAX_BUFFER = 256 * 1024 * 1024; - -function defaultRunGit(args, cwd) { - return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }); -} - -/** - * Build a `churnFor(sourceRel)` that returns the number of commits touching a - * file within `since` (a git approxidate, e.g. "90 days") via - * `git rev-list --count`. - * - * Shallow clones are detected up-front: their history is truncated, so the - * count would be misleadingly low — we return null for every file rather than - * emit a wrong signal. A git failure on an individual file (e.g. a path that - * never existed) also degrades to null, leaving the ledger column empty rather - * than guessing. - * - * `runGit` is injectable so the shallow-guard and count parsing are unit - * testable without a real repo; in the pipeline it shells out to git. - */ -export function makeChurnFor({ - cwd = process.cwd(), - since = DEFAULT_CHURN_WINDOW, - runGit = defaultRunGit, -} = {}) { - let shallow = false; - try { - shallow = runGit(['rev-parse', '--is-shallow-repository'], cwd).trim() === 'true'; - } catch { - // Can't ask (e.g. not a git checkout) — treat every file as unknown. - shallow = true; - } - return (sourceRel) => { - if (shallow) return null; - try { - const out = runGit(['rev-list', '--count', `--since=${since}`, 'HEAD', '--', sourceRel], cwd); - const n = Number(out.trim()); - return Number.isFinite(n) ? n : null; - } catch { - return null; - } - }; -} - -/** - * Build a `fixDensityFor(sourceRel)` returning a file's time-decayed, - * delta-weighted fix-density — the same git-derived signal `signals.mjs` - * computes — scoped to a single package's history. - * - * One `git log --numstat` pass (scoped to `pathspec`, bounded by `since`) is - * parsed with `signals.mjs`'s pure helpers, so churn and fix-density share the - * same fix-detection + decay logic rather than reimplementing it here. Files - * with no fix commits score 0 (known: no fixes), distinct from the `null` a - * shallow clone or git failure emits (unknown) — mirroring `makeChurnFor`'s - * shallow guard so a truncated history never emits a misleadingly low signal. - * - * `now`/`halfLifeDays` are injectable for deterministic tests; `runGit` is - * injectable so the parse + guard are unit testable without a real repo. - */ -export function makeFixDensityFor({ - cwd = process.cwd(), - since = DEFAULT_FIX_DENSITY_WINDOW, - halfLifeDays = DEFAULT_HALF_LIFE_DAYS, - now = Math.floor(Date.now() / 1000), - pathspec, - runGit = defaultRunGit, -} = {}) { - let density = null; - try { - if (runGit(['rev-parse', '--is-shallow-repository'], cwd).trim() !== 'true') { - const args = ['log', '--no-merges', `--pretty=format:${GIT_LOG_FORMAT}`, '--numstat']; - if (since) args.push(`--since=${since}`); - if (pathspec) args.push('--', pathspec); - density = computeFixDensity(parseGitLog(runGit(args, cwd)), { halfLifeDays, now }); - } - } catch { - // Shallow clone, not a git checkout, or a git/parse failure — every file - // unknown, so the column degrades to null rather than guessing. - density = null; - } - return (sourceRel) => { - if (density === null) return null; - return +(density.get(sourceRel) ?? 0).toFixed(4); - }; -} - -/** - * Build `{ churnFor, fixDensityFor }` that read from the setup job's - * `signals.json` (the full-history signals `signals.mjs gatherSignals` wrote and - * the picker ranked on) instead of from git. - * - * This is the path the nightly `mutate` job uses: its checkout is shallow, so - * the git-derived factories above would emit `null` for every file. Reusing the - * already-computed signals keeps the stored ledger columns consistent with the - * exact churn/fix-density the global picker scored — and matches how the picker - * reads them (`extractSignals` in `pick-next.mjs`): churn is the per-file commit - * count, fix-density the decayed score. - * - * A file absent from the signals map had no commits (churn) / no fix commits - * (fix-density) in the gather window, so both lookups return 0 — known-zero, not - * the `null` (unknown) the shallow-clone git path emits. The whole map is keyed - * by repo-relative path, the same key `buildPayload` joins on. - */ -export function makeSignalLookups(signals) { - const churn = signals?.churn ?? {}; - const fixDensity = signals?.fixDensity ?? {}; - return { - churnFor: (sourceRel) => { - const entry = churn[sourceRel]; - if (typeof entry === 'number') return Number.isFinite(entry) ? entry : 0; - if (typeof entry?.commits === 'number' && Number.isFinite(entry.commits)) { - return entry.commits; - } - return 0; - }, - fixDensityFor: (sourceRel) => { - const v = fixDensity[sourceRel]; - return typeof v === 'number' && Number.isFinite(v) ? +v.toFixed(4) : 0; - }, - }; -} - -/** - * Build the `{ ledger, events }` payload from a parsed summary. Pure given its - * signal dependencies: takes the summary plus run metadata, returns the rows - * the writer webhook consumes. `churnFor(sourceRel)` / `fixDensityFor(sourceRel)` - * yield the per-file churn count and fix-density (or null); both default to a - * no-op so callers without git stay pure. - */ -export function buildPayload( - summary, - { pkg, sha, pkgRelToRepo, churnFor = () => null, fixDensityFor = () => null }, -) { - const threshold = Number(summary.threshold); - const timestamp = summary.generatedAt; - - const ledger = []; - const events = []; - - for (const f of summary.files) { - const sourceRel = path.posix.join(pkgRelToRepo, f.file); - const status = f.thresholdMet ? 'green' : 'red'; - const coverage = coverageForLedger(f); - const churn = churnFor(sourceRel); - const fixDensity = fixDensityFor(sourceRel); - - ledger.push({ - source_file_path: sourceRel, - package: pkg, - last_score: f.score, - coverage, - churn, - fix_density: fixDensity, - threshold_at_run: threshold, - last_checked_at: timestamp, - status, - mutants_killed: f.counts.killed, - mutants_survived: f.counts.survived, - mutants_no_coverage: f.counts.noCoverage, - mutants_timeout: f.counts.timeout, - }); - - events.push({ - benchmark_name: 'mutation_health', - value: f.score, - timestamp, - dimensions: { - package: pkg, - source_file: sourceRel, - sha, - status_after: status, - threshold, - coverage, - churn, - fix_density: fixDensity, - mutants_killed: f.counts.killed, - mutants_survived: f.counts.survived, - mutants_no_coverage: f.counts.noCoverage, - mutants_timeout: f.counts.timeout, - }, - }); - } - - return { ledger, events }; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - - const summaryPath = args.summary; - const pkg = args.package; - - if (!summaryPath) die(2, 'Missing required --summary '); - if (!pkg) die(2, 'Missing required --package '); - if (!existsSync(summaryPath)) die(2, `Summary not found: ${summaryPath}`); - - const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); - - const summary = JSON.parse(await readFile(summaryPath, 'utf8')); - - if (!Array.isArray(summary.files)) { - die(2, 'Summary missing `files` array.'); - } - - // pkg-root = two dirs up from the summary (reports/mutation/summary.json) - const pkgRoot = path.resolve(path.dirname(summaryPath), '../..'); - const repoRoot = path.resolve( - execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(), - ); - const pkgRelToRepo = path.relative(repoRoot, pkgRoot); - - // Prefer the setup job's full-history signals.json when provided: the nightly - // mutate job's checkout is shallow, so the git-derived factories below would - // emit null for every file. Falls back to git for local/standalone runs that - // have full history and no signals file. - let churnFor; - let fixDensityFor; - const signalsPath = typeof args.signals === 'string' ? args.signals : undefined; - if (signalsPath) { - if (!existsSync(signalsPath)) die(2, `Signals file not found: ${signalsPath}`); - const signals = JSON.parse(await readFile(signalsPath, 'utf8')); - ({ churnFor, fixDensityFor } = makeSignalLookups(signals)); - } else { - churnFor = makeChurnFor({ - cwd: repoRoot, - since: typeof args['churn-window'] === 'string' ? args['churn-window'] : DEFAULT_CHURN_WINDOW, - }); - - const halfLifeArg = Number(args['fix-density-half-life']); - fixDensityFor = makeFixDensityFor({ - cwd: repoRoot, - since: - typeof args['fix-density-window'] === 'string' - ? args['fix-density-window'] - : DEFAULT_FIX_DENSITY_WINDOW, - halfLifeDays: - Number.isFinite(halfLifeArg) && halfLifeArg > 0 ? halfLifeArg : DEFAULT_HALF_LIFE_DAYS, - pathspec: pkgRelToRepo, - }); - } - - const { ledger, events } = buildPayload(summary, { - pkg, - sha, - pkgRelToRepo, - churnFor, - fixDensityFor, - }); - - const outPath = args.out ?? path.join(pkgRoot, 'reports/mutation/bq-payload.json'); - await mkdir(path.dirname(outPath), { recursive: true }); - await writeFile(outPath, JSON.stringify({ ledger, events }, null, 2)); - - process.stderr.write( - `Emitted ${ledger.length} ledger row(s) + ${events.length} event row(s) → ${outPath}\n`, - ); -} - -const isCli = import.meta.url === `file://${process.argv[1]}`; -if (isCli) { - await main(); -} diff --git a/scripts/mutation-health/emit-payload.test.mjs b/scripts/mutation-health/emit-payload.test.mjs deleted file mode 100644 index bfa16807b19..00000000000 --- a/scripts/mutation-health/emit-payload.test.mjs +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - buildPayload, - coverageForLedger, - makeChurnFor, - makeFixDensityFor, - makeSignalLookups, -} from './emit-payload.mjs'; - -const summaryFixture = () => ({ - threshold: 60, - generatedAt: '2026-06-24T03:30:00.000Z', - files: [ - { - file: 'src/hot.ts', - score: 42, - thresholdMet: false, - coverage: 0.5, - counts: { killed: 3, survived: 2, noCoverage: 1, timeout: 0, runtimeError: 0 }, - }, - { - file: 'src/cold.ts', - score: 90, - thresholdMet: true, - coverage: 0.95, - counts: { killed: 9, survived: 0, noCoverage: 0, timeout: 1, runtimeError: 0 }, - }, - ], -}); - -describe('coverageForLedger', () => { - it('prefers the written-back coverage, clamped to [0,1]', () => { - assert.equal(coverageForLedger({ coverage: 0.5 }), 0.5); - assert.equal(coverageForLedger({ coverage: 1.7 }), 1); - assert.equal(coverageForLedger({ coverage: -0.3 }), 0); - }); - - it('derives from mutant counts when coverage is absent', () => { - assert.equal( - coverageForLedger({ counts: { killed: 3, survived: 1, timeout: 0, noCoverage: 0 } }), - 1, - ); - assert.equal( - coverageForLedger({ counts: { killed: 1, survived: 0, timeout: 0, noCoverage: 1 } }), - 0.5, - ); - }); - - it('returns null when neither source is usable', () => { - assert.equal(coverageForLedger({}), null); - }); -}); - -describe('makeSignalLookups', () => { - const signals = { - churn: { - 'packages/workflow/src/hot.ts': { commits: 7, linesChanged: 120 }, - 'packages/workflow/src/bare.ts': 4, - }, - fixDensity: { - 'packages/workflow/src/hot.ts': 3.14159, - }, - }; - - it('reads the per-file commit count for churn', () => { - const { churnFor } = makeSignalLookups(signals); - assert.equal(churnFor('packages/workflow/src/hot.ts'), 7); - }); - - it('accepts a bare numeric churn entry', () => { - const { churnFor } = makeSignalLookups(signals); - assert.equal(churnFor('packages/workflow/src/bare.ts'), 4); - }); - - it('reads and rounds fix-density', () => { - const { fixDensityFor } = makeSignalLookups(signals); - assert.equal(fixDensityFor('packages/workflow/src/hot.ts'), 3.1416); - }); - - it('returns known-zero (not null) for a file absent from the signals map', () => { - const { churnFor, fixDensityFor } = makeSignalLookups(signals); - assert.equal(churnFor('packages/workflow/src/cold.ts'), 0); - assert.equal(fixDensityFor('packages/workflow/src/cold.ts'), 0); - }); - - it('tolerates an empty or partial signals object', () => { - const { churnFor, fixDensityFor } = makeSignalLookups({}); - assert.equal(churnFor('any.ts'), 0); - assert.equal(fixDensityFor('any.ts'), 0); - }); -}); - -describe('buildPayload with signal lookups', () => { - it('lands non-null churn/fix_density when signals carry the file (DEVP-552)', () => { - const signals = { - churn: { 'packages/workflow/src/hot.ts': { commits: 7, linesChanged: 120 } }, - fixDensity: { 'packages/workflow/src/hot.ts': 2.5 }, - }; - const { churnFor, fixDensityFor } = makeSignalLookups(signals); - - const { ledger, events } = buildPayload(summaryFixture(), { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - churnFor, - fixDensityFor, - }); - - const hot = ledger.find((r) => r.source_file_path === 'packages/workflow/src/hot.ts'); - assert.equal(hot.churn, 7); - assert.equal(hot.fix_density, 2.5); - assert.notEqual(hot.churn, null); - assert.notEqual(hot.fix_density, null); - - // A scored file with no signal entry is known-zero, never null. - const cold = ledger.find((r) => r.source_file_path === 'packages/workflow/src/cold.ts'); - assert.equal(cold.churn, 0); - assert.equal(cold.fix_density, 0); - - // Same values flow into the perf-metric event dimensions. - const hotEvent = events.find( - (e) => e.dimensions.source_file === 'packages/workflow/src/hot.ts', - ); - assert.equal(hotEvent.dimensions.churn, 7); - assert.equal(hotEvent.dimensions.fix_density, 2.5); - }); - - it('defaults to null churn/fix_density without signal lookups', () => { - const { ledger } = buildPayload(summaryFixture(), { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - }); - assert.equal(ledger[0].churn, null); - assert.equal(ledger[0].fix_density, null); - }); -}); - -describe('git fallback factories degrade to null on a shallow clone', () => { - // The shallow-clone signature emit-payload originally hit in the mutate job: - // both factories must emit null rather than a misleadingly low signal. - const shallowRunGit = (args) => { - if (args[0] === 'rev-parse' && args.includes('--is-shallow-repository')) return 'true\n'; - return ''; - }; - - it('makeChurnFor returns null for every file under a shallow clone', () => { - const churnFor = makeChurnFor({ runGit: shallowRunGit }); - assert.equal(churnFor('packages/workflow/src/hot.ts'), null); - }); - - it('makeFixDensityFor returns null for every file under a shallow clone', () => { - const fixDensityFor = makeFixDensityFor({ runGit: shallowRunGit }); - assert.equal(fixDensityFor('packages/workflow/src/hot.ts'), null); - }); -}); diff --git a/scripts/mutation-health/ledger.mjs b/scripts/mutation-health/ledger.mjs deleted file mode 100644 index 61f0926260f..00000000000 --- a/scripts/mutation-health/ledger.mjs +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -/** - * Read-all ledger access. - * - * One call returns every row in the live BigQuery ledger across every - * package — call sites no longer need to invoke per package. Per-package - * behaviour is preserved by passing `pkg`, which narrows the same - * single-pass read to one package without re-fetching. - * - * Accepted body shapes (matching the reader-webhook contract): - * - * '' → { rows: [] } - * '{"ledger":[]}' → { rows: [] } - * '{"ledger":[ {row}, ... ]}' → { rows: [ {row}, ... ] } - * - * The empty-body case mirrors the reader webhook's response for packages - * it has never scored — the picker must still synthesise `new` rows from - * the source tree in that situation. - * - * Malformed JSON or a missing `ledger` array throw, leaving the caller - * to decide whether to die or fall back. - */ - -import { readFile } from 'node:fs/promises'; - -/** - * Pure parser. Used directly by tests and via the `readLedger` wrapper. - */ -export function parseLedgerBody(raw, { pkg } = {}) { - const trimmed = typeof raw === 'string' ? raw.trim() : ''; - const payload = trimmed === '' ? { ledger: [] } : JSON.parse(trimmed); - if (!payload || !Array.isArray(payload.ledger)) { - throw new Error('Ledger payload missing `ledger` array.'); - } - const all = payload.ledger; - if (pkg === undefined) return { rows: all }; - return { rows: all.filter((r) => r && r.package === pkg) }; -} - -/** - * Read the ledger file and return all rows in one pass. With `pkg`, the - * same single read is narrowed to one package — existing per-package call - * sites stay structurally identical, the pipeline can later switch to a - * single fetch shared across packages without touching the call site. - */ -export async function readLedger({ path, pkg } = {}) { - if (!path) throw new TypeError('readLedger requires a `path`'); - const raw = await readFile(path, 'utf8'); - return parseLedgerBody(raw, { pkg }); -} diff --git a/scripts/mutation-health/ledger.test.mjs b/scripts/mutation-health/ledger.test.mjs deleted file mode 100644 index 390e1c528b7..00000000000 --- a/scripts/mutation-health/ledger.test.mjs +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { parseLedgerBody, readLedger } from './ledger.mjs'; - -const body = (rows) => JSON.stringify({ ledger: rows }); - -// A multi-package fixture store: rows for three onboarded vitest packages -// sit side-by-side. Shape mirrors what a read-all reader webhook would -// return when no `?package=` filter is applied. -const MULTI_PKG_FIXTURE = [ - { - source_file_path: 'packages/workflow/src/a.ts', - package: 'n8n-workflow', - status: 'red', - last_score: 30, - last_checked_at: '2026-05-01T00:00:00.000Z', - }, - { - source_file_path: 'packages/workflow/src/b.ts', - package: 'n8n-workflow', - status: 'green', - last_score: 95, - last_checked_at: '2026-06-01T00:00:00.000Z', - }, - { - source_file_path: 'packages/@n8n/crdt/src/x.ts', - package: '@n8n/crdt', - status: 'red', - last_score: 40, - last_checked_at: '2026-05-15T00:00:00.000Z', - }, - { - source_file_path: 'packages/@n8n/decorators/src/y.ts', - package: '@n8n/decorators', - status: 'green', - last_score: 85, - last_checked_at: '2026-06-10T00:00:00.000Z', - }, -]; - -describe('parseLedgerBody', () => { - it('treats an empty body as a zero-row ledger', () => { - assert.deepEqual(parseLedgerBody(''), { rows: [] }); - assert.deepEqual(parseLedgerBody(' '), { rows: [] }); - }); - - it('treats `{"ledger":[]}` as a zero-row ledger', () => { - assert.deepEqual(parseLedgerBody('{"ledger":[]}'), { rows: [] }); - }); - - it('throws on a payload missing the `ledger` array', () => { - assert.throws(() => parseLedgerBody('{}'), /missing `ledger` array/); - assert.throws(() => parseLedgerBody('{"ledger":"oops"}'), /missing `ledger` array/); - assert.throws(() => parseLedgerBody('null'), /missing `ledger` array/); - }); - - it('lets malformed JSON throw via JSON.parse', () => { - assert.throws(() => parseLedgerBody('not json'), SyntaxError); - }); -}); - -// PR-gate contract from DEVP-495: -// "multi-package fixture store returns all rows in one call" -// -// The fixture holds rows for three packages. A single call must return -// every row, with no per-package invocation at the call site. -describe('read-all contract (DEVP-495 PR gate)', () => { - it('returns every row across every package in one call (no filter)', () => { - const { rows } = parseLedgerBody(body(MULTI_PKG_FIXTURE)); - assert.equal(rows.length, MULTI_PKG_FIXTURE.length); - assert.deepEqual([...new Set(rows.map((r) => r.package))].sort(), [ - '@n8n/crdt', - '@n8n/decorators', - 'n8n-workflow', - ]); - }); - - it('preserves row order from the underlying store', () => { - const { rows } = parseLedgerBody(body(MULTI_PKG_FIXTURE)); - assert.deepEqual( - rows.map((r) => r.source_file_path), - MULTI_PKG_FIXTURE.map((r) => r.source_file_path), - ); - }); - - it('narrows to one package without re-reading when `pkg` is supplied', () => { - const { rows } = parseLedgerBody(body(MULTI_PKG_FIXTURE), { pkg: 'n8n-workflow' }); - assert.equal(rows.length, 2); - assert.ok(rows.every((r) => r.package === 'n8n-workflow')); - }); - - it('returns an empty set when narrowing to a package with no rows', () => { - const { rows } = parseLedgerBody(body(MULTI_PKG_FIXTURE), { pkg: 'n8n-nonesuch' }); - assert.deepEqual(rows, []); - }); - - it('drops rows missing a `package` field when narrowing', () => { - const { rows } = parseLedgerBody( - body([...MULTI_PKG_FIXTURE, { source_file_path: 'stray.ts' }]), - { pkg: 'n8n-workflow' }, - ); - assert.equal(rows.length, 2); - assert.ok(rows.every((r) => r.package === 'n8n-workflow')); - }); -}); - -describe('readLedger (file I/O)', () => { - const tmp = mkdtempSync(path.join(tmpdir(), 'mh-ledger-')); - - it('reads a multi-package fixture file and returns every row in one pass', async () => { - const file = path.join(tmp, 'multi.json'); - writeFileSync(file, body(MULTI_PKG_FIXTURE)); - const { rows } = await readLedger({ path: file }); - assert.equal(rows.length, MULTI_PKG_FIXTURE.length); - }); - - it('reads an empty-body file as a zero-row ledger (reader-webhook contract for unscored packages)', async () => { - const file = path.join(tmp, 'empty.json'); - writeFileSync(file, ''); - const { rows } = await readLedger({ path: file }); - assert.deepEqual(rows, []); - }); - - it('filters to a single package on top of the single-pass read', async () => { - const file = path.join(tmp, 'filtered.json'); - writeFileSync(file, body(MULTI_PKG_FIXTURE)); - const { rows } = await readLedger({ path: file, pkg: '@n8n/crdt' }); - assert.equal(rows.length, 1); - assert.equal(rows[0].package, '@n8n/crdt'); - }); - - it('requires a `path`', async () => { - await assert.rejects(readLedger(), /requires a `path`/); - await assert.rejects(readLedger({}), /requires a `path`/); - }); -}); diff --git a/scripts/mutation-health/mutate.mjs b/scripts/mutation-health/mutate.mjs index af9377b7a82..a4b9aefe273 100644 --- a/scripts/mutation-health/mutate.mjs +++ b/scripts/mutation-health/mutate.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node /** - * Run Stryker on a single source file of a workspace package and emit an - * actionable summary. Package-agnostic: the nightly matrix and the per-package - * `mutate` npm scripts both call this one script. + * Run Stryker over a workspace package and write an actionable summary. This + * script works for any package. Run it as `pnpm mutate` from the repo root. * - * Usage (also exposed as `pnpm mutate ` from the repo root): - * node scripts/mutation-health/mutate.mjs [--package-dir ] [--config ] + * pnpm mutate [:-] [--package-dir ] [--config ] + * pnpm mutate --diff [--base ] [--config ] * - * The package is inferred from a repo-relative file path; pass --package-dir when - * the target is package-relative (the nightly does this). - * node scripts/mutation-health/mutate.mjs packages/@n8n/crdt/src/utils.ts # inferred - * node scripts/mutation-health/mutate.mjs src/cron.ts --package-dir packages/workflow + * Use `--diff` before you merge. It reads the changed line ranges from + * `git diff -U0 $(git merge-base HEAD)`, which covers committed branch + * work and uncommitted edits. It mutates only those lines, in one Stryker run + * per package. This makes the gate apply to the patch: it scores the lines you + * changed, not the debt you inherited. * * Stryker config resolution (first match wins): * 1. --config explicit override @@ -27,40 +27,31 @@ * the survivors it found rather than dying with nothing. * * Each summary file row also carries a `coverage` fraction in [0,1] — the share - * of mutants a test actually exercised — so the global picker can read - * `(1 − coverage)` from the ledger next cycle (DEVP-496). `emit-payload.mjs` - * forwards it onto the ledger row. + * of mutants a test actually exercised. * * Gate semantics: - * A run passes only when the mutation score meets `STRYKER_THRESHOLD` AND - * every remaining mutant is either killed or explicitly justified via a - * `// Stryker disable …` comment (status `Ignored`). Any `Survived` or - * `NoCoverage` mutant is an unjustified survivor and fails the gate even - * above the threshold — raw score alone counts low-value and equivalent - * mutants in the denominator and lets agents pad the suite to 80%. See - * DEVP-442. + * A run passes only when the score meets `STRYKER_THRESHOLD` and no mutant + * survives without a reason. To give a reason, add a `// Stryker disable …` + * comment. Stryker then reports the mutant as `Ignored` and keeps it out of + * the score. Each `Survived` or `NoCoverage` mutant fails the gate, also + * above the threshold. The score alone lets an author add weak tests to reach + * 80% and leave real gaps. See DEVP-442. * * Exit codes: - * 0 — gate passed (score ≥ threshold AND no unjustified survivors) - * 1 — gate failed: score < threshold OR at least one Survived/NoCoverage - * mutant remains (AI loop should iterate), OR the run was partial (a - * non-zero Stryker exit with a salvaged raw.json — untested mutants - * could be survivors, so a partial run never passes). Also used when - * Stryker reports "No tests were executed" — the file has no covering - * tests, so we synthesise a score-0 red summary rather than hard-failing - * the job. The ledger then records the gap and the picker advances. - * 2 — usage / config error - * 3 — Stryker run failed for any other reason (instrumentation crash etc.) + * 0 — the gate passed. + * 1 — the gate failed. Iterate: read summary.json and strengthen the tests. + * 2 — usage or config error. + * 3 — Stryker did not resolve or did not run. Never 1: a broken toolchain + * must stay distinct from a score of zero. */ -import { spawn } from 'node:child_process'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '../..'); @@ -110,9 +101,7 @@ export function scoreFromCounts(c) { * could be covered (ran + no-coverage). Ignored and compile-error mutants * never ran for reasons unrelated to coverage, so they sit outside the ratio. * - * Returns a fraction in [0,1] — clamped defensively. The global picker reads it - * back from the ledger as the `(1 − coverage)` term of its value formula, so a - * file no test touches (coverage 0) gets the strongest urge to be scored. + * Returns a fraction in [0,1]. The result is clamped. */ export function coverageFromCounts(c) { const covered = (c.killed ?? 0) + (c.survived ?? 0) + (c.timeout ?? 0) + (c.runtimeError ?? 0); @@ -121,6 +110,25 @@ export function coverageFromCounts(c) { return +Math.min(1, Math.max(0, covered / total)).toFixed(4); } +/** + * What a finished Stryker run produced. `hasReport` must describe THIS run: + * the caller deletes the previous reports first, because a file left by an + * earlier run makes a crashed run look complete and report the earlier target. + * + * complete — the run finished and wrote a report. + * partial — the run wrote a report, then exited non-zero. Untested mutants + * can still be survivors, so this never passes the gate. + * no-tests — no test covers the target. A result, not an error: the score is + * zero and every mutant has no coverage. See DEVP-414. + * failed — no report. The caller reports a toolchain failure. + */ +export function classifyRun({ exitCode, output, hasReport }) { + if (!hasReport) { + return /no tests were executed/i.test(output) ? 'no-tests' : 'failed'; + } + return exitCode === 0 ? 'complete' : 'partial'; +} + // A run is only "passing" when the score meets the floor AND every unkilled // mutant has been explicitly justified (Ignored via a Stryker disable // comment). Any Survived/NoCoverage mutant is unjustified by definition. @@ -131,7 +139,7 @@ export function gatePassed(score, counts, threshold) { /** * Build the compact summary from a raw Stryker Mutation Testing Elements * report. Pure: takes the parsed report plus run metadata, returns the summary - * object written to summary.json (and consumed by emit-payload.mjs). + * object written to summary.json. */ export function buildSummary(raw, { threshold, target, generatedAt }) { // test-id → test-name lookup so survivors can name the tests that covered @@ -266,142 +274,308 @@ function findPackageRoot(fromAbs) { return null; } -async function main() { - // --- args: one positional target + --package-dir (required) + --config (optional) - const argv = process.argv.slice(2); - let packageDirArg; - let configArg; - let targetArg; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--package-dir') packageDirArg = argv[++i]; - else if (a === '--config') configArg = argv[++i]; - else if (!a.startsWith('--') && targetArg === undefined) targetArg = a; +// --- diff-mode planning (pure helpers exported for the unit tests) --- + +// Stryker's dry run stops with SIGABRT on the isolated-vm engine. See DEVP-257. +const BLOCKED_PACKAGES = new Set(['@n8n/expression-runtime']); + +const NON_SOURCE = [ + /\.d\.ts$/, + /\.(test|spec)\.[cm]?tsx?$/, + /(^|\/)__(tests|mocks)__\//, + /\.stories\.[cm]?tsx?$/, + /\.config\.[cm]?[jt]s$/, + /(^|\/)(dist|node_modules|coverage)\//, + /(^|\/)tests?\//, + /(^|\/)migrations\//, +]; + +// This is an exclusion list, not a `src/`-only allowlist. nodes-base and +// nodes-langchain keep their code in `nodes/` and `credentials/`. An allowlist +// drops the largest mutable surface in the repo. +export function isMutableSource(repoRelPath) { + if (!/\.[cm]?tsx?$/.test(repoRelPath)) return false; + return !NON_SOURCE.some((re) => re.test(repoRelPath)); +} + +// Merge overlapping and adjacent ranges. Stryker then gets one span per region. +export function mergeRanges(ranges) { + const out = []; + for (const r of [...ranges].sort((a, b) => a.start - b.start)) { + const last = out.at(-1); + if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end); + else out.push({ ...r }); + } + return out; +} + +// Read the new-side line ranges from `git diff -U0` hunk headers. +// `@@ -12,0 +13,4 @@` gives `{ start: 13, end: 16 }`. A new-side count of zero +// is a deletion. No code stays there to mutate, so this drops it. +export function parseHunkRanges(diffText) { + const ranges = []; + for (const line of diffText.split('\n')) { + const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (!m) continue; + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + if (count === 0) continue; + ranges.push({ start, end: start + count - 1 }); + } + return mergeRanges(ranges); +} + +// Join the targets with commas. Stryker keeps only the last `--mutate` flag, +// thus repeated flags mutate one target and the package pays for a dry run +// again for each file. +export function formatMutateArg(targets) { + return targets.join(','); +} + +export function splitRange(target) { + const m = /^(.*):(\d+)-(\d+)$/.exec(target); + return m ? { file: m[1], range: `${m[2]}-${m[3]}` } : { file: target, range: null }; +} + +function git(args) { + const res = spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (res.error) die(2, `git ${args[0]} failed to start: ${res.error.message}`); + return res; +} + +function packageNameOf(pkgRoot) { + try { + return JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')).name ?? ''; + } catch { + return ''; + } +} + +// Stryker runs the package's own vitest. If the `test` script runs something +// else, the package is skipped, not failed. +function packageUsesVitest(pkgRoot) { + try { + const pkg = JSON.parse(readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); + return /\bvitest\b/.test(pkg.scripts?.test ?? ''); + } catch { + return false; + } +} + +// Why a package cannot be scored, or null when it can. Both planners call this, +// so a named target and a --diff target always get the same answer. +function ineligibleReason(pkgRoot) { + const pkgName = packageNameOf(pkgRoot) || path.relative(repoRoot, pkgRoot); + if (BLOCKED_PACKAGES.has(packageNameOf(pkgRoot))) { + return `${pkgName} is blocked: the isolated-vm engine crashes Stryker's dry run (DEVP-257)`; + } + if (!packageUsesVitest(pkgRoot)) return `${pkgName} is not a vitest package`; + return null; +} + +function planFromDiff(base) { + // Diff the merge base against the working tree, not against HEAD. This also + // scores uncommitted edits. On a PR checkout the tree is clean, thus the + // result is the same as the branch diff. + const mergeBase = git(['merge-base', base, 'HEAD']); + if (mergeBase.status !== 0) { + die(2, `No merge base with '${base}' — is the ref fetched?\n${mergeBase.stderr.trim()}`); + } + const from = mergeBase.stdout.trim(); + + const names = git(['diff', '--name-only', from]); + if (names.status !== 0) { + die(2, `git diff against '${base}' failed.\n${names.stderr.trim()}`); } - const usage = - 'Usage: node scripts/mutation-health/mutate.mjs [--package-dir ] [--config ]\n' + - ' - repo-relative file → package is inferred: node scripts/mutation-health/mutate.mjs packages/@n8n/crdt/src/utils.ts\n' + - ' - package-relative file → pass --package-dir: node scripts/mutation-health/mutate.mjs src/cron.ts --package-dir packages/workflow'; + const byPackage = new Map(); + const skipped = []; + for (const file of names.stdout + .split('\n') + .map((s) => s.trim()) + .filter(Boolean)) { + if (!isMutableSource(file)) continue; + const abs = path.resolve(repoRoot, file); + if (!existsSync(abs)) continue; // the branch deleted the file - if (!targetArg) die(2, `Missing mutate target.\n${usage}`); + const pkgRoot = findPackageRoot(abs); + if (!pkgRoot) { + skipped.push([file, 'no enclosing package']); + continue; + } + const reason = ineligibleReason(pkgRoot); + if (reason) { + skipped.push([file, reason]); + continue; + } - // Resolve pkgRoot + the src-relative target, supporting two call styles: - // 1. --package-dir given → target is package-relative (or absolute). (the nightly's style) - // 2. no --package-dir → target is a repo-relative file; infer the package from it. - let pkgRoot; - let target; - if (packageDirArg) { - pkgRoot = path.resolve(repoRoot, packageDirArg); - if (!existsSync(pkgRoot)) die(2, `Package dir not found: ${pkgRoot}`); - target = path.isAbsolute(targetArg) ? path.relative(pkgRoot, targetArg) : targetArg; - } else { - const abs = path.resolve(repoRoot, targetArg); - if (!existsSync(abs)) die(2, `Target not found: ${abs}\n${usage}`); - const found = findPackageRoot(abs); - if (!found) - die(2, `Could not infer the package for ${targetArg} — pass --package-dir.\n${usage}`); - pkgRoot = found; - target = path.relative(pkgRoot, abs); + const ranges = parseHunkRanges(git(['diff', '-U0', from, '--', file]).stdout); + if (ranges.length === 0) continue; + + const rel = path.relative(pkgRoot, abs); + const packageDir = path.relative(repoRoot, pkgRoot); + const job = byPackage.get(pkgRoot) ?? { pkgRoot, packageDir, targets: [] }; + for (const r of ranges) job.targets.push(`${rel}:${r.start}-${r.end}`); + byPackage.set(pkgRoot, job); } + return { jobs: [...byPackage.values()], skipped }; +} - if (!target.startsWith('src/') || target.includes('..')) { - die(2, `Target must be under the package's src/. Got: ${target}`); +// --- running --- + +function resolveConfig(pkgRoot, configArg) { + if (configArg) return path.resolve(repoRoot, configArg); + const local = path.join(pkgRoot, 'stryker.config.mjs'); + return existsSync(local) ? local : path.join(__dirname, 'stryker.default.mjs'); +} + +// Try the package's own copy first, then the root devDep. A package that pins +// Stryker thus gets the version it pinned. A miss is a broken checkout, not a +// red gate: exit 3 keeps it distinct from a score of zero. +function resolveStrykerBin(pkgRoot, packageDir) { + for (const from of [path.join(pkgRoot, 'package.json'), import.meta.url]) { + try { + const resolved = createRequire(from).resolve('@stryker-mutator/core/package.json'); + return path.join(path.dirname(resolved), 'bin/stryker.js'); + } catch { + continue; + } } - if (!existsSync(path.join(pkgRoot, target))) { - die(2, `Target not found: ${path.join(pkgRoot, target)}`); - } - const packageDir = path.relative(repoRoot, pkgRoot); - - // --- resolve the Stryker config: override → package-local → shared default - const localConfig = path.join(pkgRoot, 'stryker.config.mjs'); - const defaultConfig = path.join(__dirname, 'stryker.default.mjs'); - const configPath = configArg - ? path.resolve(repoRoot, configArg) - : existsSync(localConfig) - ? localConfig - : defaultConfig; - - // --- resolve the Stryker binary from the hoisted store (works for any package) - const strykerBin = path.join( - path.dirname(require.resolve('@stryker-mutator/core/package.json')), - 'bin/stryker.js', + return die( + 3, + `Could not resolve @stryker-mutator/core from ${packageDir} or the repo root. ` + + 'Run `pnpm install` — it is a root devDep.', ); +} + +// Runs use `--inPlace`, because the sandbox copy breaks each package whose +// vitest config finds a workspace dependency through a path alias. The alias +// does not stay correct in the copy. See the README for the failure. +// +// Stryker restores the files after a usual exit and after SIGINT, but not after +// a crash. In diff mode the files hold uncommitted work, thus `git checkout --` +// is not a safe undo. Keep a copy of the bytes and write them back instead. +function snapshotFiles(absPaths) { + const snap = new Map(); + for (const p of absPaths) { + try { + snap.set(p, readFileSync(p)); + } catch { + // The file is unreadable. There is nothing to restore. + } + } + return snap; +} + +function restoreFiles(snap) { + const restored = []; + for (const [p, original] of snap) { + try { + if (!readFileSync(p).equals(original)) { + writeFileSync(p, original); + restored.push(path.relative(repoRoot, p)); + } + } catch { + // The file is gone. There is nothing to restore. + } + } + return restored; +} + +async function runJob({ pkgRoot, packageDir, targets }, { configArg }) { + const configPath = resolveConfig(pkgRoot, configArg); + const strykerBin = resolveStrykerBin(pkgRoot, packageDir); + const mutateArg = formatMutateArg(targets); const reportDir = path.join(pkgRoot, 'reports/mutation'); const rawJsonPath = path.join(reportDir, 'raw.json'); const summaryJsonPath = path.join(reportDir, 'summary.json'); await mkdir(reportDir, { recursive: true }); + // Delete the previous reports first. The code below reads "raw.json exists" + // as "this run wrote a report". A file from an earlier run makes a crashed + // run report the earlier target and its score, and it also hides the + // no-covering-tests result. After this, a report on disk is always this run's. + await Promise.all([rm(rawJsonPath, { force: true }), rm(summaryJsonPath, { force: true })]); + process.stderr.write( - `Running Stryker on ${packageDir}/${target} (config: ${path.relative(repoRoot, configPath)}, threshold: ${THRESHOLD}%)\n`, + `\nRunning Stryker on ${packageDir} — ${targets.length} target(s) ` + + `(config: ${path.relative(repoRoot, configPath)}, threshold: ${THRESHOLD}%)\n`, ); - // Capture Stryker's combined output while still forwarding it to the parent - // stdio (so CI logs look unchanged). We need the buffer to detect the - // "No tests were executed" dry-run case below. + const snap = snapshotFiles([ + ...new Set(targets.map((t) => path.join(pkgRoot, splitRange(t).file))), + ]); + const strykerOutputChunks = []; - const strykerExitCode = await new Promise((resolve) => { - const child = spawn(process.execPath, [strykerBin, 'run', configPath, '--mutate', target], { - cwd: pkgRoot, - stdio: ['inherit', 'pipe', 'pipe'], + let child; + const onSignal = () => { + child?.kill('SIGINT'); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + let strykerExitCode; + try { + strykerExitCode = await new Promise((resolve) => { + child = spawn( + process.execPath, + [strykerBin, 'run', configPath, '--inPlace', '--mutate', mutateArg], + { cwd: pkgRoot, stdio: ['inherit', 'pipe', 'pipe'] }, + ); + child.stdout.on('data', (chunk) => { + strykerOutputChunks.push(chunk); + process.stdout.write(chunk); + }); + child.stderr.on('data', (chunk) => { + strykerOutputChunks.push(chunk); + process.stderr.write(chunk); + }); + child.on('exit', (code) => resolve(code ?? 1)); + child.on('error', (err) => die(3, `Stryker failed to start: ${err.message}`)); }); - child.stdout.on('data', (chunk) => { - strykerOutputChunks.push(chunk); - process.stdout.write(chunk); - }); - child.stderr.on('data', (chunk) => { - strykerOutputChunks.push(chunk); - process.stderr.write(chunk); - }); - child.on('exit', (code) => resolve(code ?? 1)); - child.on('error', (err) => die(3, `Stryker failed to start: ${err.message}`)); - }); + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + const restored = restoreFiles(snap); + if (restored.length > 0) { + process.stderr.write( + `⚠ Stryker left mutants behind; restored ${restored.length} file(s): ${restored.join(', ')}\n`, + ); + } + } const strykerOutput = Buffer.concat(strykerOutputChunks).toString('utf8'); + const target = mutateArg; - // "No tests were executed" is Stryker's dry-run verdict when nothing in the - // test suite covers the picked source file. That's the most informative - // mutation result there is — effectively 0%, every mutant no-coverage — so we - // record it as a score-0 red ledger row instead of hard-failing the job. The - // picker can then advance to the next file the following night. See DEVP-414. - const noTestsExecuted = /no tests were executed/i.test(strykerOutput) && !existsSync(rawJsonPath); + const outcome = classifyRun({ + exitCode: strykerExitCode, + output: strykerOutput, + hasReport: existsSync(rawJsonPath), + }); - // A non-zero exit shouldn't discard a report Stryker already wrote — salvage a - // partial summary from it below; only die when there's genuinely nothing to keep. - const strykerFailedWithoutReport = - strykerExitCode !== 0 && !noTestsExecuted && !existsSync(rawJsonPath); - if (strykerFailedWithoutReport) { - die( - 3, - `Stryker exited with code ${strykerExitCode} without producing ${path.relative(repoRoot, rawJsonPath)}`, + if (outcome === 'failed') { + process.stderr.write( + `✗ ${packageDir}: Stryker exited ${strykerExitCode} without producing ` + + `${path.relative(repoRoot, rawJsonPath)}\n`, ); + return { packageDir, summaryJsonPath, failed: true }; } - if (noTestsExecuted) { - // Best-effort mutant count from the instrument phase log line, e.g. - // INFO Instrumenter Instrumented 1 source file(s) with 47 mutant(s) - // Falls back to 0 if Stryker never got that far. + if (outcome === 'no-tests') { const mutantMatch = strykerOutput.match( /Instrumented\s+\d+\s+source file\(s\)\s+with\s+(\d+)\s+mutant/i, ); - const noCoverage = mutantMatch ? Number(mutantMatch[1]) : 0; const summary = buildNoTestsSummary({ threshold: THRESHOLD, target, - noCoverage, + noCoverage: mutantMatch ? Number(mutantMatch[1]) : 0, generatedAt: new Date().toISOString(), }); await writeFile(summaryJsonPath, JSON.stringify(summary, null, 2)); - process.stderr.write( - `\n=== Mutation summary ===\n` + - `✗ ${target} 0.00% (no covering tests — recorded as score-0 red)\n` + - `Summary written: ${summaryJsonPath}\n`, - ); - process.exit(1); - } - - if (!existsSync(rawJsonPath)) { - die(3, `Stryker did not produce ${rawJsonPath}`); + return { packageDir, summaryJsonPath, summary, noTests: true }; } const raw = JSON.parse(await readFile(rawJsonPath, 'utf8')); @@ -410,20 +584,27 @@ async function main() { target, generatedAt: new Date().toISOString(), }); - - // Report present but non-zero exit → run was cut short; flag it so consumers - // (and the gate below) know the survivor list may be incomplete. - const partial = strykerExitCode !== 0; - if (partial) summary.partial = true; + if (outcome === 'partial') summary.partial = true; await writeFile(summaryJsonPath, JSON.stringify(summary, null, 2)); + return { packageDir, summaryJsonPath, summary }; +} - if (partial) { +function reportJob({ packageDir, summaryJsonPath, summary, noTests, failed }) { + if (failed) return; + if (noTests) { process.stderr.write( - `\n⚠ Stryker exited with code ${strykerExitCode}; summary built from a partial raw.json — results may be incomplete.\n`, + `✗ ${packageDir} ${summary.target} 0.00% (no covering tests — recorded as score-0 red)\n`, + ); + process.stderr.write(` summary: ${path.relative(repoRoot, summaryJsonPath)}\n`); + return; + } + if (summary.partial) { + process.stderr.write( + `⚠ ${packageDir}: Stryker exited non-zero; summary built from a partial raw.json — ` + + 'results may be incomplete.\n', ); } - process.stderr.write('\n=== Mutation summary ===\n'); for (const f of summary.files) { const mark = f.thresholdMet ? '✓' : '✗'; process.stderr.write( @@ -436,24 +617,136 @@ async function main() { ); } for (const ig of f.ignored) { - const reason = ig.reason ? ` — ${ig.reason}` : ' — (no reason given)'; process.stderr.write( - ` · ${'ignored'.padEnd(10)} ${ig.mutator.padEnd(22)} ${ig.location}${reason}\n`, + ` · ${'ignored'.padEnd(10)} ${ig.mutator.padEnd(22)} ${ig.location}` + + `${ig.reason ? ` — ${ig.reason}` : ' — (no reason given)'}\n`, ); } } - // A partial run never passes: mutants it never tested could be survivors, so - // reporting PASS would mark the file done with work left undone. - const passed = summary.overall.thresholdMet && !summary.partial; - const gateState = passed ? 'PASS' : summary.partial ? 'FAIL (partial)' : 'FAIL'; - const unjustified = summary.overall.counts.survived + summary.overall.counts.noCoverage; - process.stderr.write( - `\nGate: ${gateState} • threshold: ${THRESHOLD}% • score: ${summary.overall.score.toFixed(2)}% • ` + - `unjustified survivors: ${unjustified} • ignored (justified): ${summary.overall.counts.ignored}\n`, - ); - process.stderr.write(`Summary written: ${summaryJsonPath}\n`); + process.stderr.write(` summary: ${path.relative(repoRoot, summaryJsonPath)}\n`); +} - process.exit(passed ? 0 : 1); +// --- entry point --- + +const USAGE = `Usage: + node scripts/mutation-health/mutate.mjs [:-] [--package-dir ] [--config ] + node scripts/mutation-health/mutate.mjs --diff [--base ] [--config ] + + # one file, whole + node scripts/mutation-health/mutate.mjs packages/@n8n/crdt/src/utils.ts + # one file, only lines 40-75 + node scripts/mutation-health/mutate.mjs packages/@n8n/crdt/src/utils.ts:40-75 + # package-relative target + node scripts/mutation-health/mutate.mjs src/cron.ts --package-dir packages/workflow + # every line this branch changed, batched one Stryker run per package + node scripts/mutation-health/mutate.mjs --diff --base origin/master`; + +function planFromTarget(targetArg, packageDirArg) { + const { file, range } = splitRange(targetArg); + + let pkgRoot; + let rel; + if (packageDirArg) { + pkgRoot = path.resolve(repoRoot, packageDirArg); + if (!existsSync(pkgRoot)) die(2, `Package dir not found: ${pkgRoot}`); + rel = path.isAbsolute(file) ? path.relative(pkgRoot, file) : file; + } else { + const abs = path.resolve(repoRoot, file); + if (!existsSync(abs)) die(2, `Target not found: ${abs}\n${USAGE}`); + pkgRoot = findPackageRoot(abs); + if (!pkgRoot) die(2, `Could not infer the package for ${file} — pass --package-dir.\n${USAGE}`); + rel = path.relative(pkgRoot, abs); + } + + if (rel.startsWith('..') || path.isAbsolute(rel)) { + die(2, `Target must live inside the package. Got: ${rel}`); + } + if (!existsSync(path.join(pkgRoot, rel))) { + die(2, `Target not found: ${path.join(pkgRoot, rel)}`); + } + if (!isMutableSource(rel)) { + die(2, `Not a mutable source file (test/declaration/config/build output): ${rel}`); + } + // --diff skips an ineligible package. A named target must refuse for the same + // reason, or it starts a run that is known to crash. + const reason = ineligibleReason(pkgRoot); + if (reason) die(2, `Cannot mutate ${rel}: ${reason}`); + + return { + pkgRoot, + packageDir: path.relative(repoRoot, pkgRoot), + targets: [range ? `${rel}:${range}` : rel], + }; +} + +async function main() { + const argv = process.argv.slice(2); + let packageDirArg; + let configArg; + let targetArg; + let baseArg = 'origin/master'; + let diffMode = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--package-dir') packageDirArg = argv[++i]; + else if (a === '--config') configArg = argv[++i]; + else if (a === '--base') baseArg = argv[++i]; + else if (a === '--diff') diffMode = true; + else if (!a.startsWith('--') && targetArg === undefined) targetArg = a; + } + + if (diffMode && targetArg) die(2, `--diff takes no positional target.\n${USAGE}`); + if (!diffMode && !targetArg) die(2, `Missing mutate target.\n${USAGE}`); + + let jobs; + let skipped = []; + if (diffMode) { + ({ jobs, skipped } = planFromDiff(baseArg)); + for (const [file, why] of skipped) process.stderr.write(` skipped ${file} — ${why}\n`); + if (jobs.length === 0) { + process.stderr.write(`\nNothing mutable changed vs ${baseArg}.\n`); + process.exit(0); + } + const files = jobs.reduce((n, j) => n + j.targets.length, 0); + process.stderr.write( + `\nMutating ${files} changed range(s) across ${jobs.length} package(s) vs ${baseArg}.\n`, + ); + } else { + jobs = [planFromTarget(targetArg, packageDirArg)]; + } + + const results = []; + for (const job of jobs) { + results.push(await runJob(job, { configArg })); + } + + process.stderr.write('\n=== Mutation summary ===\n'); + for (const r of results) reportJob(r); + + if (results.some((r) => r.failed)) { + process.stderr.write('\nGate: ERROR — at least one Stryker run produced no report.\n'); + process.exit(3); + } + + // A partial run never passes. The mutants it did not test can be survivors. + const overall = results.reduce( + (acc, r) => { + const c = r.summary.overall.counts; + acc.survived += c.survived + c.noCoverage; + acc.ignored += c.ignored; + acc.passed &&= r.summary.overall.thresholdMet && !r.summary.partial; + acc.partial ||= Boolean(r.summary.partial); + return acc; + }, + { survived: 0, ignored: 0, passed: true, partial: false }, + ); + + const gateState = overall.passed ? 'PASS' : overall.partial ? 'FAIL (partial)' : 'FAIL'; + process.stderr.write( + `\nGate: ${gateState} • threshold: ${THRESHOLD}% • ` + + `unjustified survivors: ${overall.survived} • ignored (justified): ${overall.ignored}\n`, + ); + process.exit(overall.passed ? 0 : 1); } const isCli = import.meta.url === `file://${process.argv[1]}`; diff --git a/scripts/mutation-health/mutate.test.mjs b/scripts/mutation-health/mutate.test.mjs index 0011dcc1d97..2020dffb16e 100644 --- a/scripts/mutation-health/mutate.test.mjs +++ b/scripts/mutation-health/mutate.test.mjs @@ -2,17 +2,17 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { - coverageFromCounts, - buildSummary, buildNoTestsSummary, + buildSummary, + classifyRun, + coverageFromCounts, + formatMutateArg, + isMutableSource, + mergeRanges, + parseHunkRanges, scoreFromCounts, + splitRange, } from './mutate.mjs'; -import { - buildPayload, - coverageForLedger, - makeChurnFor, - makeFixDensityFor, -} from './emit-payload.mjs'; // A minimal Stryker Mutation Testing Elements report for one source file. Mix // of statuses so coverage (anything that ran / ran + no-coverage) is a genuine @@ -162,218 +162,189 @@ describe('buildNoTestsSummary (no covering tests)', () => { }); }); -// PR-gate contract (DEVP-496): -// "mutate.mjs fixture run produces ledger row with coverage field in [0,1]" -describe('coverage writeback to the ledger row (DEVP-496 PR gate)', () => { - it('a fixture run produces a ledger row whose coverage is in [0,1]', () => { - const summary = buildSummary(RAW_FIXTURE, RUN_META); - const { ledger } = buildPayload(summary, { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - }); +describe('classifyRun', () => { + const DONE = 'Instrumented 1 source file(s) with 8 mutant(s)'; + const NO_TESTS = 'ERROR Stryker No tests were executed. Stryker will exit prematurely.'; - assert.equal(ledger.length, 1); - const row = ledger[0]; - assert.ok(Object.hasOwn(row, 'coverage'), 'ledger row carries a coverage field'); - assert.ok(isFractionInUnitInterval(row.coverage), `coverage ${row.coverage} must be in [0,1]`); - assert.equal(row.coverage, 0.75); - assert.equal(row.source_file_path, 'packages/workflow/src/cron.ts'); + it('is complete when the run wrote a report and exited zero', () => { + assert.equal(classifyRun({ exitCode: 0, output: DONE, hasReport: true }), 'complete'); }); - it('forwards coverage onto the event row dimensions too', () => { - const summary = buildSummary(RAW_FIXTURE, RUN_META); - const { events } = buildPayload(summary, { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - }); - assert.ok(isFractionInUnitInterval(events[0].dimensions.coverage)); + it('is partial when the run wrote a report and then exited non-zero', () => { + assert.equal(classifyRun({ exitCode: 1, output: DONE, hasReport: true }), 'partial'); }); - it('clamps an out-of-range summary coverage into [0,1]', () => { - assert.equal(coverageForLedger({ coverage: 1.4, counts: {} }), 1); - assert.equal(coverageForLedger({ coverage: -0.2, counts: {} }), 0); + it('is no-tests when nothing covers the target', () => { + assert.equal(classifyRun({ exitCode: 1, output: NO_TESTS, hasReport: false }), 'no-tests'); }); - it('falls back to deriving coverage from counts for pre-writeback summaries', () => { - // No `coverage` field (an older summary) → derived from the mutant census. - const row = coverageForLedger({ - counts: { killed: 3, survived: 0, timeout: 0, noCoverage: 1, runtimeError: 0 }, - }); - assert.equal(row, 0.75); - assert.ok(isFractionInUnitInterval(row)); + it('is failed when the run produced no report', () => { + assert.equal(classifyRun({ exitCode: 1, output: 'SIGABRT', hasReport: false }), 'failed'); + }); + + // The caller deletes the previous reports before each run. Without that, a + // crashed run finds the earlier report and is classified `partial`, so it + // reports the earlier target and its score instead of failing. + it('trusts hasReport as this run only — a report plus a crash is partial, never failed', () => { + assert.equal(classifyRun({ exitCode: 3, output: 'SIGABRT', hasReport: true }), 'partial'); + }); + + // Same trap for the no-tests path: a leftover report used to suppress it, and + // a genuine score-0 red was reported as the earlier run's passing score. + it('still detects no-tests when the run crashed without a report', () => { + assert.equal(classifyRun({ exitCode: 3, output: NO_TESTS, hasReport: false }), 'no-tests'); }); }); -// DEVP-546: the ledger row also carries a git-derived `churn` count so the -// global picker can rank by the value formula's churn term. -describe('churn writeback to the ledger row (DEVP-546)', () => { - it('forwards the per-file churn count and fix-density onto the ledger and event rows', () => { - const summary = buildSummary(RAW_FIXTURE, RUN_META); - const churnFor = (p) => (p === 'packages/workflow/src/cron.ts' ? 7 : null); - const fixDensityFor = (p) => (p === 'packages/workflow/src/cron.ts' ? 2.5 : null); - const { ledger, events } = buildPayload(summary, { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - churnFor, - fixDensityFor, - }); - - assert.ok(Object.hasOwn(ledger[0], 'churn'), 'ledger row carries a churn field'); - assert.equal(ledger[0].churn, 7); - assert.equal(events[0].dimensions.churn, 7); - assert.ok(Object.hasOwn(ledger[0], 'fix_density'), 'ledger row carries a fix_density field'); - assert.equal(ledger[0].fix_density, 2.5); - assert.equal(events[0].dimensions.fix_density, 2.5); +describe('isMutableSource', () => { + it('accepts product source wherever a package keeps it', () => { + assert.ok(isMutableSource('packages/workflow/src/cron.ts')); + // nodes-base has no src/. An allowlist drops the largest surface in the repo. + assert.ok(isMutableSource('packages/nodes-base/nodes/Slack/Slack.node.ts')); + assert.ok(isMutableSource('packages/nodes-base/credentials/SlackApi.credentials.ts')); + assert.ok(isMutableSource('packages/frontend/editor-ui/src/stores/ui.store.ts')); + // `[cm]?` in the extension test is there for the ESM/CJS variants. + assert.ok(isMutableSource('packages/@n8n/db/src/index.mts')); + assert.ok(isMutableSource('packages/@n8n/db/src/index.cts')); }); - it('defaults churn and fix_density to null when no signal source is wired in', () => { - const summary = buildSummary(RAW_FIXTURE, RUN_META); - const { ledger } = buildPayload(summary, { - pkg: 'n8n-workflow', - sha: 'deadbeef', - pkgRelToRepo: 'packages/workflow', - }); + it('rejects tests, declarations, configs and build output', () => { + assert.equal(isMutableSource('packages/workflow/src/cron.test.ts'), false); + assert.equal(isMutableSource('packages/workflow/src/cron.spec.ts'), false); + // The ESM/CJS variants are accepted as source, so they have to be + // excluded as tests too. + assert.equal(isMutableSource('packages/workflow/src/cron.test.mts'), false); + assert.equal(isMutableSource('packages/workflow/src/__tests__/cron.ts'), false); + assert.equal(isMutableSource('packages/workflow/src/__mocks__/cron.ts'), false); + assert.equal(isMutableSource('packages/workflow/src/types.d.ts'), false); + assert.equal(isMutableSource('packages/cli/vitest.config.ts'), false); + assert.equal(isMutableSource('packages/workflow/dist/cron.js'), false); + assert.equal(isMutableSource('packages/workflow/test/helper.ts'), false); + assert.equal(isMutableSource('packages/@n8n/db/src/migrations/sqlite/x.ts'), false); + assert.equal(isMutableSource('packages/design-system/src/Button.stories.ts'), false); + // The extension test is anchored: `.ts` has to end the path, not merely + // appear in it. Committed snapshots sit next to their source and would + // otherwise be handed to Stryker as mutable TypeScript. + assert.equal(isMutableSource('packages/cli/src/__snapshots__/foo.test.ts.snap'), false); + }); - assert.ok(Object.hasOwn(ledger[0], 'churn'), 'ledger row carries a churn field'); - assert.equal(ledger[0].churn, null); - assert.ok(Object.hasOwn(ledger[0], 'fix_density'), 'ledger row carries a fix_density field'); - assert.equal(ledger[0].fix_density, null); + // .vue stays out. Each SFC package crashed Stryker's mutate step in the + // 2026-06 sweep, and the component layer gives little value. + it('rejects everything that is not TypeScript', () => { + assert.equal(isMutableSource('packages/frontend/editor-ui/src/App.vue'), false); + assert.equal(isMutableSource('packages/workflow/src/cron.js'), false); + assert.equal(isMutableSource('README.md'), false); + assert.equal(isMutableSource('packages/workflow/package.json'), false); }); }); -describe('makeChurnFor (git-derived churn)', () => { - // Stub git: shallow probe answers `false`, every rev-list answers `count`. - function stubGit({ shallow = 'false', count } = {}) { - return (args) => { - if (args.includes('--is-shallow-repository')) return `${shallow}\n`; - if (typeof count === 'function') return count(args); - return `${count}\n`; - }; - } - - it('counts commits touching a file within the window', () => { - const churnFor = makeChurnFor({ runGit: stubGit({ count: 7 }) }); - assert.equal(churnFor('packages/workflow/src/cron.ts'), 7); - }); - - it('passes the configured window through to git rev-list', () => { - const seen = []; - const runGit = (args) => { - seen.push(args); - return args.includes('--is-shallow-repository') ? 'false\n' : '3\n'; - }; - const churnFor = makeChurnFor({ since: '30 days', runGit }); - churnFor('a/b.ts'); - const revList = seen.find((a) => a[0] === 'rev-list'); - assert.ok(revList.includes('--since=30 days')); - assert.ok(revList.includes('a/b.ts')); - }); - - it('returns null on a shallow clone (truncated history would undercount)', () => { - const churnFor = makeChurnFor({ runGit: stubGit({ shallow: 'true', count: 999 }) }); - assert.equal(churnFor('any/file.ts'), null); - }); - - it('returns null when git fails for a file', () => { - const runGit = (args) => { - if (args.includes('--is-shallow-repository')) return 'false\n'; - throw new Error('git boom'); - }; - assert.equal(makeChurnFor({ runGit })('x.ts'), null); - }); - - it('returns null when the count is not a finite number', () => { - const churnFor = makeChurnFor({ runGit: stubGit({ count: 'not-a-number' }) }); - assert.equal(churnFor('x.ts'), null); - }); - - it('treats a non-git directory (probe throws) as unknown churn', () => { - const runGit = () => { - throw new Error('not a git repository'); - }; - assert.equal(makeChurnFor({ runGit })('x.ts'), null); - }); -}); - -describe('makeFixDensityFor (git-derived fix-density)', () => { - const NOW = 1_700_000_000; // fixed reference time (unix seconds) for determinism - - // Stub git: shallow probe answers `shallow`, the log read answers `log` - // (synthetic `git log --numstat` output in signals.mjs's GIT_LOG_FORMAT). - function stubGit({ shallow = 'false', log = '' } = {}) { - return (args) => (args.includes('--is-shallow-repository') ? `${shallow}\n` : log); - } - - // A fix commit and a non-fix commit, both touching the same file at `NOW`. - const FIX_AT_NOW = [ - `COMMIT abc123 ${NOW} fix(core): patch a bug`, - '10\t0\tpackages/core/src/hot.ts', - '', - `COMMIT def456 ${NOW} feat(core): add a feature`, - '5\t0\tpackages/core/src/hot.ts', - ].join('\n'); - - it('sums delta-weighted contributions of fix commits touching the file', () => { - const fixDensityFor = makeFixDensityFor({ now: NOW, runGit: stubGit({ log: FIX_AT_NOW }) }); - // Only the fix commit counts; age 0 → weight 1 → 10 lines changed. - assert.equal(fixDensityFor('packages/core/src/hot.ts'), 10); - }); - - it('scores a file with no fix commits as 0 (known: no fixes), not null', () => { - const fixDensityFor = makeFixDensityFor({ now: NOW, runGit: stubGit({ log: FIX_AT_NOW }) }); - assert.equal(fixDensityFor('packages/core/src/cold.ts'), 0); - }); - - it('decays older fixes by the configured half-life', () => { - const oneHalfLifeAgo = NOW - 90 * 86_400; - const log = [ - `COMMIT old111 ${oneHalfLifeAgo} fix: older patch`, - '8\t0\tpackages/core/src/hot.ts', +describe('parseHunkRanges', () => { + it('reads new-side ranges out of `git diff -U0` headers', () => { + const diff = [ + 'diff --git a/src/cron.ts b/src/cron.ts', + '--- a/src/cron.ts', + '+++ b/src/cron.ts', + '@@ -12,0 +13,4 @@ export function tick() {', + '+const a = 1;', + '@@ -40,2 +44,2 @@', + '+const b = 2;', + // Counts run past one digit on both sides for any hunk of ten lines + // or more, which is most of them. + '@@ -80,12 +90,14 @@', + '+const c = 3;', ].join('\n'); - const fixDensityFor = makeFixDensityFor({ - now: NOW, - halfLifeDays: 90, - runGit: stubGit({ log }), - }); - // One half-life old → weight 0.5 → 8 * 0.5 = 4. - assert.equal(fixDensityFor('packages/core/src/hot.ts'), 4); + assert.deepEqual(parseHunkRanges(diff), [ + { start: 13, end: 16 }, + { start: 44, end: 45 }, + { start: 90, end: 103 }, + ]); }); - it('passes the configured window + pathspec through to git log', () => { - const seen = []; - const runGit = (args) => { - seen.push(args); - return args.includes('--is-shallow-repository') ? 'false\n' : ''; - }; - makeFixDensityFor({ since: '6 months', pathspec: 'packages/core', now: NOW, runGit })('x.ts'); - const logArgs = seen.find((a) => a[0] === 'log'); - assert.ok(logArgs.includes('--since=6 months')); - assert.ok(logArgs.includes('packages/core')); + // `git diff` of a file that itself talks about diffs (a patch fixture, this + // very test file) carries hunk-header text inside `+`/`-` content lines. + // Only a header at the start of a line is a header. + it('ignores hunk-header text that appears inside a content line', () => { + const diff = ['@@ -1,0 +5,1 @@', "+const H = '@@ -1,2 +300,4 @@';"].join('\n'); + assert.deepEqual(parseHunkRanges(diff), [{ start: 5, end: 5 }]); }); - it('returns null on a shallow clone (truncated history would undercount)', () => { - const fixDensityFor = makeFixDensityFor({ - now: NOW, - runGit: stubGit({ shallow: 'true', log: FIX_AT_NOW }), - }); - assert.equal(fixDensityFor('packages/core/src/hot.ts'), null); + it('treats a header with no new-side count as a single line', () => { + assert.deepEqual(parseHunkRanges('@@ -5 +7 @@'), [{ start: 7, end: 7 }]); }); - it('returns null when git fails', () => { - const runGit = (args) => { - if (args.includes('--is-shallow-repository')) return 'false\n'; - throw new Error('git boom'); - }; - assert.equal(makeFixDensityFor({ now: NOW, runGit })('x.ts'), null); + it('drops pure deletions — nothing survives there to mutate', () => { + assert.deepEqual(parseHunkRanges('@@ -10,4 +9,0 @@'), []); }); - it('treats a non-git directory (probe throws) as unknown fix-density', () => { - const runGit = () => { - throw new Error('not a git repository'); - }; - assert.equal(makeFixDensityFor({ now: NOW, runGit })('x.ts'), null); + it('returns nothing for a diff with no hunks', () => { + assert.deepEqual(parseHunkRanges(''), []); + }); +}); + +describe('mergeRanges', () => { + it('merges overlapping ranges', () => { + assert.deepEqual( + mergeRanges([ + { start: 1, end: 5 }, + { start: 3, end: 9 }, + ]), + [{ start: 1, end: 9 }], + ); + }); + + it('merges adjacent ranges so Stryker gets one span per region', () => { + assert.deepEqual( + mergeRanges([ + { start: 1, end: 4 }, + { start: 5, end: 8 }, + ]), + [{ start: 1, end: 8 }], + ); + }); + + it('keeps ranges with a real gap apart, and sorts them', () => { + assert.deepEqual( + mergeRanges([ + { start: 20, end: 22 }, + { start: 1, end: 4 }, + ]), + [ + { start: 1, end: 4 }, + { start: 20, end: 22 }, + ], + ); + }); + + it('leaves a fully-contained range absorbed', () => { + assert.deepEqual( + mergeRanges([ + { start: 1, end: 20 }, + { start: 5, end: 9 }, + ]), + [{ start: 1, end: 20 }], + ); + }); +}); + +describe('formatMutateArg', () => { + it('comma-joins every target into one flag value', () => { + assert.equal( + formatMutateArg(['src/a.ts:1-4', 'src/a.ts:20-22', 'src/b.ts']), + 'src/a.ts:1-4,src/a.ts:20-22,src/b.ts', + ); + }); +}); + +describe('splitRange', () => { + it('splits a trailing line range off the path', () => { + assert.deepEqual(splitRange('src/cron.ts:13-16'), { file: 'src/cron.ts', range: '13-16' }); + }); + + it('leaves a bare path alone', () => { + assert.deepEqual(splitRange('src/cron.ts'), { file: 'src/cron.ts', range: null }); + }); + + it('does not mistake a Windows drive letter or a colon in a dirname for a range', () => { + assert.deepEqual(splitRange('src/a:b/cron.ts'), { file: 'src/a:b/cron.ts', range: null }); }); }); diff --git a/scripts/mutation-health/pick-next.mjs b/scripts/mutation-health/pick-next.mjs deleted file mode 100644 index 6360bdcae94..00000000000 --- a/scripts/mutation-health/pick-next.mjs +++ /dev/null @@ -1,625 +0,0 @@ -#!/usr/bin/env node -/** - * Walk a package's source tree (per-package mode) or every vitest-eligible - * package's source tree (global mode), merge with the live BQ ledger snapshot, - * return the next file(s) to mutate. - * - * Files present in src/ but absent from the live ledger are synthesised as - * status='new'. No separate seed step needed — the ledger fills in - * organically as files get scored. - * - * Stored statuses (from BQ): new | red | green - * Effective statuses (computed at pick time): new | red | stale | green - * - * Picker priority: new → red → stale → skip green - * - * Per-package mode tiebreaks within each bucket: - * - new: alphabetical by source_file_path - * - red: lowest score first (focus on weakest tests) - * - stale: oldest last_checked_at first (natural cycling) - * - * Global mode tiebreaks within each bucket: highest value first, where - * value = w_churn·churn + w_fix·fixDensity + w_cov·(1 − coverage) - * — churn and fix-density come from `signals.mjs`, coverage from an optional - * input file. Path used as final lexical tiebreak for determinism. - * - * "Stale" is an in-memory promotion of green rows older than - * STALE_AFTER_WEEKS (default 4). Not stored. - * - * Inputs (per-package mode): - * --package-dir Required. Repo-relative path to the package, e.g. packages/workflow - * --ledger-file Required. Live ledger JSON: { "ledger": [ ... ] } - * --mode Optional. Restrict the picker to one bucket. - * --stale-after-weeks Optional. Default 4. - * - * Inputs (global mode): - * --global Required to enter global mode. - * --ledger-file Required. Live read-all ledger JSON (rows for every package). - * --signals-file Optional. JSON from `signals.mjs gatherSignals`. - * --coverage-file Optional. JSON map { "": 0..1 } of line-coverage. - * --top-n Optional. Default 1. How many top-ranked rows to emit. - * --block Optional. Comma-separated package names to exclude from the walk. - * --w-churn / --w-fix-density / --w-coverage Optional. Per-signal weights for value formula. - * --mode Optional. Restrict picker to one bucket (same as per-package). - * --stale-after-weeks Optional. Default 4. - * - * Output (stdout): - * per-package mode → - * { picked: { source_file_path, package, prior_status, effective_status } } - * OR { picked: null, reason: "all-green" | "empty-source-tree" - * | "no-new-files" | "nothing-below-threshold" } - * global mode → - * { picked: [ { source_file_path, package, prior_status, effective_status, value }, ... ] } - * OR { picked: [], reason: "all-green" | "empty-source-tree" - * | "no-new-files" | "nothing-below-threshold" } - * - * Exit codes: - * 0 — picked a row OR nothing to do (with picked: null / [] sentinel) - * 2 — usage / config error - */ - -import { readdir, readFile } from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; - -import { readLedger } from './ledger.mjs'; - -function die(code, msg) { - process.stderr.write(`${msg}\n`); - process.exit(code); -} - -function parseArgs(argv) { - const out = {}; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (!a.startsWith('--')) continue; - const key = a.slice(2); - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - out[key] = true; - } else { - out[key] = next; - i++; - } - } - return out; -} - -// Files with no useful mutation surface: barrels, declarations, type-only modules. -// Matched against either the full basename (`types.ts`) or the trailing -// dot-segment (`foo.types.ts` → `types`), so dotted-suffix declaration files -// are caught the same as their plain-named counterparts. `methods` covers -// NativeDoc `*.methods.ts` descriptor files; `schemas` covers pure Zod -// declaration files; `message-event-bus` is an exact-basename entry for a -// bulk enum + interfaces + Zod module with no function logic. -const LOW_VALUE_BASENAMES = new Set([ - 'interfaces', - 'index', - 'constants', - 'types', - 'methods', - 'schemas', - 'message-event-bus', -]); - -// Files with fewer than this many non-blank, non-import lines have so little -// surface area they're not worth mutating — e.g. trivial error subclasses with -// empty bodies or one hardcoded super() call. This catches what an exact-name -// list can't (`error` can't be skipped wholesale because files like -// `workflow-activation.error.ts` have real branching logic). -const MIN_MEANINGFUL_LINES = 15; - -// Directories whose contents are tests/fixtures/mocks, not production code. -// Pruned in `walkSources` so we don't descend; `isMutationWorthy` re-checks as a -// safety net for packages that co-locate tests as siblings (e.g. `foo.test.ts`). -const NON_SOURCE_DIRS = new Set(['__tests__', '__mocks__', 'fixtures']); - -// The vitest-eligible mutation-tracked packages. Single source of truth for -// global mode: the picker walks every entry here unless overridden via -// --block. The nightly workflow's `setup` job builds its matrix from this -// export (DEVP-497), so adding a package = one-line append here, nothing else. -export const ELIGIBLE_PACKAGES = [ - { name: 'n8n-workflow', dir: 'packages/workflow' }, - { name: '@n8n/crdt', dir: 'packages/@n8n/crdt' }, - { name: '@n8n/decorators', dir: 'packages/@n8n/decorators' }, - { name: '@n8n/expression-runtime', dir: 'packages/@n8n/expression-runtime' }, - { name: 'n8n', dir: 'packages/cli' }, -]; - -export function isEligible(pkgName) { - return ELIGIBLE_PACKAGES.some((p) => p.name === pkgName); -} - -export const DEFAULT_WEIGHTS = Object.freeze({ churn: 1, fixDensity: 1, coverage: 1 }); - -/** - * Value formula: w_churn·churn + w_fix·fixDensity + w_cov·(1 − coverage). - * - * Missing signals contribute 0 (no penalty, no boost). Missing coverage is - * treated as 0 so the (1 − coverage) term becomes 1 — unknown coverage = - * worst case = highest urge to score, matching the picker's bias toward - * surfacing untracked files. - */ -export function computeValue( - { churn = 0, fixDensity = 0, coverage = 0 } = {}, - weights = DEFAULT_WEIGHTS, -) { - const churnVal = Number.isFinite(Number(churn)) ? Number(churn) : 0; - const fdVal = Number.isFinite(Number(fixDensity)) ? Number(fixDensity) : 0; - const covRaw = Number(coverage); - const covVal = Number.isFinite(covRaw) ? Math.max(0, Math.min(1, covRaw)) : 0; - return ( - (weights.churn ?? 0) * churnVal + - (weights.fixDensity ?? 0) * fdVal + - (weights.coverage ?? 0) * (1 - covVal) - ); -} - -/** - * `signals` shape matches `gatherSignals`'s JSON output: churn[path] = { commits, linesChanged } - * (or a bare number from caller transforms), fixDensity[path] = number. - * Returns the per-row `{ churn, fixDensity, coverage }` triple used by `computeValue`. - */ -export function extractSignals(row, { signals = {}, coverage = {} } = {}) { - const churnEntry = signals.churn?.[row.source_file_path]; - const churnVal = - typeof churnEntry === 'number' - ? churnEntry - : typeof churnEntry?.commits === 'number' - ? churnEntry.commits - : 0; - const fixDensity = signals.fixDensity?.[row.source_file_path] ?? 0; - const cov = coverage[row.source_file_path]; - return { - churn: churnVal, - fixDensity, - coverage: typeof cov === 'number' ? cov : 0, - }; -} - -function countMeaningfulLines(content) { - let count = 0; - for (const raw of content.split('\n')) { - const line = raw.trim(); - if (!line) continue; - if (line.startsWith('import ')) continue; - count++; - } - return count; -} - -export function isMutationWorthy(absPath, { read = readFileSync } = {}) { - if (absPath.endsWith('.d.ts')) return false; - if (/\.(test|spec)\.ts$/.test(absPath)) return false; - if (absPath.includes(`${path.sep}__tests__${path.sep}`)) return false; - if (absPath.includes(`${path.sep}__mocks__${path.sep}`)) return false; - const base = path.basename(absPath, '.ts'); - const lastSegment = base.split('.').at(-1); - if (LOW_VALUE_BASENAMES.has(base) || LOW_VALUE_BASENAMES.has(lastSegment)) return false; - if (countMeaningfulLines(read(absPath, 'utf8')) < MIN_MEANINGFUL_LINES) return false; - return true; -} - -export async function walkSources(dir) { - const entries = await readdir(dir, { withFileTypes: true }); - const out = []; - for (const e of entries) { - const full = path.join(dir, e.name); - if (e.isDirectory()) { - if (NON_SOURCE_DIRS.has(e.name)) continue; - out.push(...(await walkSources(full))); - } else if (e.isFile() && e.name.endsWith('.ts')) { - out.push(full); - } - } - return out; -} - -const PRIORITY = { new: 0, red: 1, stale: 2, green: 3 }; - -const MODE_BUCKETS = { - baseline: new Set(['new']), - coverage: new Set(['red', 'stale']), -}; - -export function computeEffectiveStatus(row, { now, staleAfterMs }) { - if (row.status === 'new') return 'new'; - if (row.status === 'red') return 'red'; - if (row.last_checked_at) { - const age = now - Date.parse(row.last_checked_at); - if (age > staleAfterMs) return 'stale'; - } - return 'green'; -} - -/** - * Rank a flat list of (already-merged) candidate rows by bucket priority, - * then by value formula (descending) within each bucket, with the source - * path as the final lexical tiebreak. - * - * Pure function — used by both the global CLI path and the test suite. - * Excludes any row whose package is in the `blocked` set, exits the - * `green` bucket entirely (those are "nothing to do"), and applies the - * optional `--mode` bucket filter. - * - * Returns the rows in rank order, each annotated with `effective_status` - * and `value`. Caller decides how many to keep. - */ -export function rankCandidates( - rows, - { - now, - staleAfterMs, - mode, - blocked = new Set(), - signals = {}, - coverage = {}, - weights = DEFAULT_WEIGHTS, - } = {}, -) { - const annotated = rows - .filter((r) => !blocked.has(r.package)) - .map((row) => { - const effective_status = computeEffectiveStatus(row, { now, staleAfterMs }); - const signalTriple = extractSignals(row, { signals, coverage }); - return { - ...row, - effective_status, - value: computeValue(signalTriple, weights), - }; - }); - - const filtered = annotated.filter((r) => { - if (r.effective_status === 'green') return false; - if (mode && !MODE_BUCKETS[mode].has(r.effective_status)) return false; - return true; - }); - - filtered.sort((a, b) => { - const pa = PRIORITY[a.effective_status] ?? 99; - const pb = PRIORITY[b.effective_status] ?? 99; - if (pa !== pb) return pa - pb; - if (b.value !== a.value) return b.value - a.value; - return a.source_file_path.localeCompare(b.source_file_path); - }); - - return filtered; -} - -/** - * Synthesise a "new" row for every mutation-worthy source file that has no - * ledger row yet, then layer the live ledger rows on top (ledger wins). Used - * by both per-package and global walks. - */ -export function mergeWithLedger({ worthyPaths, pkgName, ledgerRows }) { - const byPath = new Map(); - for (const row of ledgerRows) { - if (row.package === pkgName) byPath.set(row.source_file_path, row); - } - return worthyPaths.map( - (p) => - byPath.get(p) ?? { - source_file_path: p, - package: pkgName, - last_score: null, - threshold_at_run: null, - last_checked_at: null, - status: 'new', - }, - ); -} - -function parseStaleAfterWeeks(staleArg) { - const DEFAULT = 4; - if (staleArg === undefined) return DEFAULT; - const parsed = Number(staleArg); - if (Number.isFinite(parsed) && parsed > 0) return parsed; - process.stderr.write(`Invalid --stale-after-weeks=${staleArg}, falling back to ${DEFAULT}.\n`); - return DEFAULT; -} - -function parseWeights(args) { - const w = { ...DEFAULT_WEIGHTS }; - for (const [flag, key] of [ - ['w-churn', 'churn'], - ['w-fix-density', 'fixDensity'], - ['w-coverage', 'coverage'], - ]) { - if (args[flag] === undefined) continue; - const parsed = Number(args[flag]); - if (!Number.isFinite(parsed) || parsed < 0) { - die(2, `Invalid --${flag}=${args[flag]} (expected non-negative number).`); - } - w[key] = parsed; - } - return w; -} - -function parseTopN(args) { - if (args['top-n'] === undefined) return 1; - const parsed = Number(args['top-n']); - if (!Number.isInteger(parsed) || parsed <= 0) { - die(2, `Invalid --top-n=${args['top-n']} (expected positive integer).`); - } - return parsed; -} - -function parseBlocked(args) { - const raw = args.block; - if (raw === undefined || raw === true) return new Set(); - return new Set( - String(raw) - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ); -} - -function resolveLedgerPath(ledgerFile) { - return path.isAbsolute(ledgerFile) ? ledgerFile : path.join(process.cwd(), ledgerFile); -} - -async function readJsonIfPresent(file) { - if (!file) return null; - const resolved = path.isAbsolute(file) ? file : path.join(process.cwd(), file); - if (!existsSync(resolved)) die(2, `File not found: ${resolved}`); - try { - return JSON.parse(await readFile(resolved, 'utf8')); - } catch (err) { - die(2, `Failed to parse JSON at ${resolved}: ${err.message}`); - } - return null; -} - -async function collectPackageCandidates({ repoRoot, pkg }) { - const srcDir = path.join(repoRoot, pkg.dir, 'src'); - if (!existsSync(srcDir)) return null; - const allSources = (await walkSources(srcDir)).sort(); - const worthy = allSources - .filter((p) => isMutationWorthy(p)) - .map((abs) => path.relative(repoRoot, abs)); - return { pkg, worthy }; -} - -async function runGlobal({ args, repoRoot, now }) { - const ledgerFile = args['ledger-file']; - if (!ledgerFile) die(2, 'Missing required --ledger-file '); - const ledgerPath = resolveLedgerPath(ledgerFile); - if (!existsSync(ledgerPath)) die(2, `Ledger file not found: ${ledgerPath}`); - - const mode = args.mode; - if (mode !== undefined && mode !== true && !Object.hasOwn(MODE_BUCKETS, mode)) { - die(2, `Invalid --mode=${mode}. Use 'baseline' or 'coverage' (omit for combined).`); - } - const modeArg = mode === true ? undefined : mode; - - const staleAfterWeeks = parseStaleAfterWeeks(args['stale-after-weeks']); - const staleAfterMs = staleAfterWeeks * 7 * 24 * 60 * 60 * 1000; - const topN = parseTopN(args); - const blocked = parseBlocked(args); - const weights = parseWeights(args); - - let liveLedger; - try { - ({ rows: liveLedger } = await readLedger({ path: ledgerPath })); - } catch (err) { - die(2, err.message); - } - - const signals = (await readJsonIfPresent(args['signals-file'])) ?? { churn: {}, fixDensity: {} }; - const coverage = (await readJsonIfPresent(args['coverage-file'])) ?? {}; - - const eligible = ELIGIBLE_PACKAGES.filter((p) => !blocked.has(p.name)); - const merged = []; - for (const pkg of eligible) { - const collected = await collectPackageCandidates({ repoRoot, pkg }); - if (!collected) { - process.stderr.write(`No src/ for ${pkg.name} at ${pkg.dir}; skipping.\n`); - continue; - } - merged.push( - ...mergeWithLedger({ - worthyPaths: collected.worthy, - pkgName: pkg.name, - ledgerRows: liveLedger, - }), - ); - } - - if (merged.length === 0) { - process.stderr.write('No mutation-worthy source files found across eligible packages.\n'); - process.stdout.write(JSON.stringify({ picked: [], reason: 'empty-source-tree' }) + '\n'); - process.exit(0); - } - - const ranked = rankCandidates(merged, { - now, - staleAfterMs, - mode: modeArg, - blocked, - signals, - coverage, - weights, - }); - - const counts = ranked.reduce((acc, r) => { - acc[r.effective_status] = (acc[r.effective_status] ?? 0) + 1; - return acc; - }, {}); - process.stderr.write( - `Global walk: candidates=${merged.length} ranked=${ranked.length} • ` + - `new=${counts.new ?? 0} red=${counts.red ?? 0} stale=${counts.stale ?? 0}\n`, - ); - - if (ranked.length === 0) { - const reason = - modeArg === 'baseline' - ? 'no-new-files' - : modeArg === 'coverage' - ? 'nothing-below-threshold' - : 'all-green'; - process.stderr.write(`Nothing to do for mode=${modeArg ?? 'combined'} (${reason}).\n`); - process.stdout.write(JSON.stringify({ picked: [], reason }) + '\n'); - process.exit(0); - } - - const top = ranked.slice(0, topN).map((r) => ({ - source_file_path: r.source_file_path, - package: r.package, - prior_status: r.status, - effective_status: r.effective_status, - value: r.value, - })); - - for (const t of top) { - process.stderr.write( - `Picked: ${t.source_file_path} ` + - `[${t.package}] priority=${t.effective_status} value=${t.value.toFixed(4)}\n`, - ); - } - - process.stdout.write(JSON.stringify({ picked: top }) + '\n'); -} - -async function runPerPackage({ args, repoRoot, now }) { - const pkgDirArg = args['package-dir']; - const ledgerFile = args['ledger-file']; - if (!pkgDirArg) die(2, 'Missing required --package-dir '); - if (!ledgerFile) die(2, 'Missing required --ledger-file '); - - const pkgDir = path.isAbsolute(pkgDirArg) ? pkgDirArg : path.join(repoRoot, pkgDirArg); - if (!existsSync(pkgDir)) die(2, `Package dir not found: ${pkgDir}`); - - const pkgJsonPath = path.join(pkgDir, 'package.json'); - if (!existsSync(pkgJsonPath)) die(2, `No package.json at ${pkgJsonPath}`); - const pkgName = JSON.parse(await readFile(pkgJsonPath, 'utf8')).name; - - const srcDir = path.join(pkgDir, 'src'); - if (!existsSync(srcDir)) die(2, `No src/ in ${pkgDir}`); - - const ledgerPath = resolveLedgerPath(ledgerFile); - if (!existsSync(ledgerPath)) die(2, `Ledger file not found: ${ledgerPath}`); - - const staleAfterWeeks = parseStaleAfterWeeks(args['stale-after-weeks']); - const staleAfterMs = staleAfterWeeks * 7 * 24 * 60 * 60 * 1000; - - // One read returns every row across every package; we narrow to this - // package's rows internally so the picker's per-package behaviour is - // preserved whether the file holds one package or many. - let liveLedger; - try { - ({ rows: liveLedger } = await readLedger({ path: ledgerPath, pkg: pkgName })); - } catch (err) { - die(2, err.message); - } - - const allSources = (await walkSources(srcDir)).sort(); - const worthy = allSources - .filter((p) => isMutationWorthy(p)) - .map((abs) => path.relative(repoRoot, abs)); - - if (worthy.length === 0) { - process.stderr.write('No mutation-worthy source files found under src/.\n'); - process.stdout.write(JSON.stringify({ picked: null, reason: 'empty-source-tree' }) + '\n'); - process.exit(0); - } - - const merged = mergeWithLedger({ worthyPaths: worthy, pkgName, ledgerRows: liveLedger }); - - const annotated = merged.map((row) => ({ - ...row, - effective_status: computeEffectiveStatus(row, { now, staleAfterMs }), - })); - - annotated.sort((a, b) => { - const pa = PRIORITY[a.effective_status] ?? 99; - const pb = PRIORITY[b.effective_status] ?? 99; - if (pa !== pb) return pa - pb; - - if (a.effective_status === 'new') { - return a.source_file_path.localeCompare(b.source_file_path); - } - - if (a.effective_status === 'red') { - const sa = a.last_score == null ? Infinity : Number(a.last_score); - const sb = b.last_score == null ? Infinity : Number(b.last_score); - if (sa !== sb) return sa - sb; - return a.source_file_path.localeCompare(b.source_file_path); - } - - // stale: oldest last_checked_at first - const ta = a.last_checked_at ? Date.parse(a.last_checked_at) : 0; - const tb = b.last_checked_at ? Date.parse(b.last_checked_at) : 0; - if (ta !== tb) return ta - tb; - return a.source_file_path.localeCompare(b.source_file_path); - }); - - const counts = annotated.reduce((acc, r) => { - acc[r.effective_status] = (acc[r.effective_status] ?? 0) + 1; - return acc; - }, {}); - - process.stderr.write( - `Source files: ${worthy.length} • ` + - `new=${counts.new ?? 0} red=${counts.red ?? 0} stale=${counts.stale ?? 0} green=${counts.green ?? 0}\n`, - ); - - const mode = args.mode; - if (mode !== undefined && mode !== true && !Object.hasOwn(MODE_BUCKETS, mode)) { - die( - 2, - `Invalid --mode=${mode}. Use 'baseline' or 'coverage' (omit for combined new→red→stale).`, - ); - } - const modeArg = mode === true ? undefined : mode; - const candidates = modeArg - ? annotated.filter((r) => MODE_BUCKETS[modeArg].has(r.effective_status)) - : annotated; - - const top = candidates[0]; - - if (!top || (!modeArg && top.effective_status === 'green')) { - const reason = - modeArg === 'baseline' - ? 'no-new-files' - : modeArg === 'coverage' - ? 'nothing-below-threshold' - : 'all-green'; - process.stderr.write(`Nothing to do for mode=${modeArg ?? 'combined'} (${reason}).\n`); - process.stdout.write(JSON.stringify({ picked: null, reason }) + '\n'); - process.exit(0); - } - - process.stderr.write( - `Picked: ${top.source_file_path}\n` + - ` priority=${top.effective_status} ` + - `(was ${top.status}, last_checked_at=${top.last_checked_at ?? 'never'})\n`, - ); - - process.stdout.write( - JSON.stringify({ - picked: { - source_file_path: top.source_file_path, - package: top.package, - prior_status: top.status, - effective_status: top.effective_status, - }, - }) + '\n', - ); -} - -const isCli = import.meta.url === `file://${process.argv[1]}`; -if (isCli) { - const args = parseArgs(process.argv.slice(2)); - const repoRoot = path.resolve( - execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(), - ); - const now = Date.now(); - if (args.global) { - await runGlobal({ args, repoRoot, now }); - } else { - await runPerPackage({ args, repoRoot, now }); - } -} diff --git a/scripts/mutation-health/pick-next.test.mjs b/scripts/mutation-health/pick-next.test.mjs deleted file mode 100644 index 81a4f55ac6e..00000000000 --- a/scripts/mutation-health/pick-next.test.mjs +++ /dev/null @@ -1,412 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - DEFAULT_WEIGHTS, - ELIGIBLE_PACKAGES, - computeEffectiveStatus, - computeValue, - extractSignals, - isEligible, - mergeWithLedger, - rankCandidates, -} from './pick-next.mjs'; - -// Fixed reference epoch (2026-06-20 00:00:00 UTC). All age-based assertions -// derive from this so they don't drift with wall-clock. -const NOW = Date.parse('2026-06-20T00:00:00.000Z'); -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; -const STALE_AFTER_MS = 4 * WEEK_MS; - -// Three-package fixture ledger. Each row pins a known status (new is -// implicit — files not in the ledger become `new` when merged) so the -// bucketed-ordering assertions don't depend on staleness math. -const MULTI_PKG_LEDGER = [ - // n8n-workflow — one red row (will be ordered by value) - { - source_file_path: 'packages/workflow/src/a.ts', - package: 'n8n-workflow', - status: 'red', - last_score: 30, - last_checked_at: '2026-05-01T00:00:00.000Z', - }, - // @n8n/crdt — one red row - { - source_file_path: 'packages/@n8n/crdt/src/x.ts', - package: '@n8n/crdt', - status: 'red', - last_score: 40, - last_checked_at: '2026-05-15T00:00:00.000Z', - }, - // @n8n/decorators — one green row that has aged past STALE_AFTER_MS - { - source_file_path: 'packages/@n8n/decorators/src/y.ts', - package: '@n8n/decorators', - status: 'green', - last_score: 85, - last_checked_at: '2026-04-01T00:00:00.000Z', - }, - // excluded package — should never appear in any candidate set - { - source_file_path: 'packages/@n8n/expression-runtime/src/forbidden.ts', - package: '@n8n/expression-runtime', - status: 'red', - last_score: 10, - last_checked_at: '2026-06-10T00:00:00.000Z', - }, -]; - -// Synthetic worthy-file inputs per package — these come from walking each -// package's src/ in the real CLI. Here we feed them straight to -// mergeWithLedger so the test has no filesystem dependency. -const WALK_INPUTS = { - 'n8n-workflow': ['packages/workflow/src/a.ts', 'packages/workflow/src/b.ts'], - '@n8n/crdt': ['packages/@n8n/crdt/src/x.ts', 'packages/@n8n/crdt/src/z.ts'], - '@n8n/decorators': ['packages/@n8n/decorators/src/y.ts'], -}; - -// Signals are tuned so the value ordering within each bucket is -// deterministic and obvious: -// - within `new`: crdt/z.ts (churn 100) > workflow/b.ts (churn 1) -// - within `red`: workflow/a.ts (churn 50, fix 5) > crdt/x.ts (churn 2, fix 0) -const SIGNALS = { - churn: { - 'packages/workflow/src/a.ts': { commits: 50, linesChanged: 999 }, - 'packages/workflow/src/b.ts': { commits: 1, linesChanged: 1 }, - 'packages/@n8n/crdt/src/x.ts': { commits: 2, linesChanged: 2 }, - 'packages/@n8n/crdt/src/z.ts': { commits: 100, linesChanged: 200 }, - 'packages/@n8n/decorators/src/y.ts': { commits: 0, linesChanged: 0 }, - }, - fixDensity: { - 'packages/workflow/src/a.ts': 5, - 'packages/@n8n/crdt/src/x.ts': 0, - 'packages/@n8n/crdt/src/z.ts': 1, - }, -}; - -const COVERAGE = { - 'packages/workflow/src/a.ts': 0.4, - 'packages/workflow/src/b.ts': 0.9, - 'packages/@n8n/crdt/src/x.ts': 0.95, - 'packages/@n8n/crdt/src/z.ts': 0.1, -}; - -function buildMerged() { - const merged = []; - for (const [pkgName, worthy] of Object.entries(WALK_INPUTS)) { - merged.push(...mergeWithLedger({ worthyPaths: worthy, pkgName, ledgerRows: MULTI_PKG_LEDGER })); - } - return merged; -} - -describe('isEligible (vitest allowlist unit test)', () => { - it('admits every name in ELIGIBLE_PACKAGES', () => { - assert.ok(ELIGIBLE_PACKAGES.length >= 2, 'allowlist must span at least two packages'); - for (const pkg of ELIGIBLE_PACKAGES) { - assert.equal(isEligible(pkg.name), true, `expected eligible: ${pkg.name}`); - } - }); - - it('rejects packages outside the allowlist', () => { - assert.equal(isEligible('@n8n/foo'), false); - assert.equal(isEligible(''), false); - assert.equal(isEligible(undefined), false); - }); -}); - -describe('computeValue', () => { - it('applies w_churn·churn + w_fix·fixDensity + w_cov·(1 − coverage)', () => { - const v = computeValue( - { churn: 10, fixDensity: 2, coverage: 0.25 }, - { churn: 1, fixDensity: 3, coverage: 4 }, - ); - // 1*10 + 3*2 + 4*(1 - 0.25) = 10 + 6 + 3 = 19 - assert.equal(v, 19); - }); - - it('treats missing signals as zero (no penalty, no boost)', () => { - // Default weights {1,1,1}. With everything missing → 1*0 + 1*0 + 1*(1-0) = 1. - assert.equal(computeValue({}), 1); - assert.equal(computeValue({ churn: undefined, fixDensity: null }), 1); - }); - - it('clamps coverage to [0,1]', () => { - assert.equal(computeValue({ coverage: 2 }, { churn: 0, fixDensity: 0, coverage: 1 }), 0); - assert.equal(computeValue({ coverage: -1 }, { churn: 0, fixDensity: 0, coverage: 1 }), 1); - }); -}); - -describe('extractSignals', () => { - it('handles `{ commits, linesChanged }` shape from gatherSignals', () => { - const triple = extractSignals( - { source_file_path: 'packages/workflow/src/a.ts' }, - { signals: SIGNALS, coverage: COVERAGE }, - ); - assert.deepEqual(triple, { churn: 50, fixDensity: 5, coverage: 0.4 }); - }); - - it('also accepts a bare-number churn value', () => { - const triple = extractSignals( - { source_file_path: 'foo.ts' }, - { signals: { churn: { 'foo.ts': 7 }, fixDensity: { 'foo.ts': 3 } }, coverage: {} }, - ); - assert.deepEqual(triple, { churn: 7, fixDensity: 3, coverage: 0 }); - }); - - it('zero-fills when the file is absent from signals/coverage', () => { - assert.deepEqual(extractSignals({ source_file_path: 'missing.ts' }), { - churn: 0, - fixDensity: 0, - coverage: 0, - }); - }); -}); - -describe('computeEffectiveStatus', () => { - it('promotes a green row older than the stale window to stale', () => { - const row = { status: 'green', last_checked_at: '2026-04-01T00:00:00.000Z' }; - assert.equal(computeEffectiveStatus(row, { now: NOW, staleAfterMs: STALE_AFTER_MS }), 'stale'); - }); - - it('keeps a fresh green row green', () => { - const row = { status: 'green', last_checked_at: '2026-06-19T00:00:00.000Z' }; - assert.equal(computeEffectiveStatus(row, { now: NOW, staleAfterMs: STALE_AFTER_MS }), 'green'); - }); - - it('passes new/red through unchanged', () => { - assert.equal( - computeEffectiveStatus({ status: 'new' }, { now: NOW, staleAfterMs: STALE_AFTER_MS }), - 'new', - ); - assert.equal( - computeEffectiveStatus({ status: 'red' }, { now: NOW, staleAfterMs: STALE_AFTER_MS }), - 'red', - ); - }); -}); - -describe('mergeWithLedger', () => { - it('synthesises new rows for worthy paths not in the ledger', () => { - const merged = mergeWithLedger({ - worthyPaths: ['packages/workflow/src/a.ts', 'packages/workflow/src/b.ts'], - pkgName: 'n8n-workflow', - ledgerRows: MULTI_PKG_LEDGER, - }); - const a = merged.find((r) => r.source_file_path === 'packages/workflow/src/a.ts'); - const b = merged.find((r) => r.source_file_path === 'packages/workflow/src/b.ts'); - assert.equal(a.status, 'red'); // from the live ledger - assert.equal(b.status, 'new'); // synthesised - assert.equal(b.last_score, null); - }); - - it("never lets another package's rows leak into the merge", () => { - // Ledger has a row for @n8n/expression-runtime; merging for n8n-workflow - // must ignore it entirely. - const merged = mergeWithLedger({ - worthyPaths: ['packages/workflow/src/a.ts'], - pkgName: 'n8n-workflow', - ledgerRows: MULTI_PKG_LEDGER, - }); - assert.equal(merged.length, 1); - assert.equal(merged[0].package, 'n8n-workflow'); - }); -}); - -// PR-gate contract from DEVP-494: -// "integration tests: fixture ledger spanning ≥2 packages returns N rows -// ordered by value within buckets, ≥2 packages present, zero -// excluded-package rows" -describe('rankCandidates (DEVP-494 PR gate)', () => { - it('orders results bucket-first (new → red → stale), then by value desc within each bucket', () => { - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - signals: SIGNALS, - coverage: COVERAGE, - weights: DEFAULT_WEIGHTS, - }); - - // Effective bucket sequence must be monotonic in priority. - const priority = { new: 0, red: 1, stale: 2, green: 3 }; - for (let i = 1; i < ranked.length; i++) { - assert.ok( - priority[ranked[i - 1].effective_status] <= priority[ranked[i].effective_status], - `bucket order broken at index ${i}: ${ranked[i - 1].effective_status} → ${ranked[i].effective_status}`, - ); - } - - // Within each bucket, value must be non-increasing. - let prev = null; - for (const row of ranked) { - if (prev && prev.effective_status === row.effective_status) { - assert.ok( - prev.value >= row.value, - `value descending broken in bucket=${row.effective_status}: ${prev.value} → ${row.value}`, - ); - } - prev = row; - } - - // Spot-check the new bucket: crdt/z.ts (churn 100) must outrank - // workflow/b.ts (churn 1). - const newBucket = ranked.filter((r) => r.effective_status === 'new'); - assert.equal(newBucket[0].source_file_path, 'packages/@n8n/crdt/src/z.ts'); - - // Spot-check the red bucket: workflow/a.ts (churn 50, fix 5) must - // outrank crdt/x.ts (churn 2, fix 0). - const redBucket = ranked.filter((r) => r.effective_status === 'red'); - assert.equal(redBucket[0].source_file_path, 'packages/workflow/src/a.ts'); - - // Stale bucket has just decorators/y.ts (green → stale by age). - const staleBucket = ranked.filter((r) => r.effective_status === 'stale'); - assert.equal(staleBucket.length, 1); - assert.equal(staleBucket[0].package, '@n8n/decorators'); - }); - - it('top-N output spans ≥2 packages on the multi-package fixture', () => { - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - signals: SIGNALS, - coverage: COVERAGE, - }); - const topN = ranked.slice(0, 4); - const distinctPackages = new Set(topN.map((r) => r.package)); - assert.ok( - distinctPackages.size >= 2, - `top-${topN.length} must span ≥2 packages; got ${[...distinctPackages].join(', ')}`, - ); - }); - - it('excludes blocked-package rows entirely (zero excluded rows)', () => { - // Add a worthy-file row for the blocked package as if its src/ tree - // were walked. The picker must still drop every row from that pkg. - const blockedRows = mergeWithLedger({ - worthyPaths: ['packages/@n8n/expression-runtime/src/forbidden.ts'], - pkgName: '@n8n/expression-runtime', - ledgerRows: MULTI_PKG_LEDGER, - }); - const merged = [...buildMerged(), ...blockedRows]; - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - signals: SIGNALS, - coverage: COVERAGE, - blocked: new Set(['@n8n/expression-runtime']), - }); - const blockedHits = ranked.filter((r) => r.package === '@n8n/expression-runtime'); - assert.equal(blockedHits.length, 0, 'blocked package rows must not appear'); - }); - - it('drops every green row from the candidate set (combined mode)', () => { - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - signals: SIGNALS, - coverage: COVERAGE, - }); - assert.equal( - ranked.filter((r) => r.effective_status === 'green').length, - 0, - 'green rows must be filtered out of the candidate set', - ); - }); - - it('--mode baseline restricts the candidate set to `new` only', () => { - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - mode: 'baseline', - signals: SIGNALS, - coverage: COVERAGE, - }); - assert.ok(ranked.length > 0); - assert.ok( - ranked.every((r) => r.effective_status === 'new'), - `baseline mode must yield only "new" rows; got ${ranked.map((r) => r.effective_status).join(', ')}`, - ); - }); - - it('--mode coverage restricts the candidate set to `red`/`stale` only', () => { - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - mode: 'coverage', - signals: SIGNALS, - coverage: COVERAGE, - }); - assert.ok(ranked.length > 0); - assert.ok( - ranked.every((r) => r.effective_status === 'red' || r.effective_status === 'stale'), - `coverage mode must yield only red/stale rows; got ${ranked.map((r) => r.effective_status).join(', ')}`, - ); - }); - - it('at equal churn/fix-density, the lower-coverage file outranks the higher-coverage one within a bucket', () => { - // The (1 − coverage) term is load-bearing now that build-matrix feeds - // ledger coverage into the picker. With identical churn and fix-density, - // coverage alone decides order: the less-covered file is the more urgent - // re-score target and must rank first. - const merged = mergeWithLedger({ - worthyPaths: ['packages/workflow/src/low.ts', 'packages/workflow/src/high.ts'], - pkgName: 'n8n-workflow', - ledgerRows: [], - }); - const signals = { - churn: { - 'packages/workflow/src/low.ts': { commits: 10, linesChanged: 20 }, - 'packages/workflow/src/high.ts': { commits: 10, linesChanged: 20 }, - }, - fixDensity: { - 'packages/workflow/src/low.ts': 3, - 'packages/workflow/src/high.ts': 3, - }, - }; - const coverage = { - 'packages/workflow/src/low.ts': 0.1, - 'packages/workflow/src/high.ts': 0.9, - }; - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - signals, - coverage, - weights: DEFAULT_WEIGHTS, - }); - assert.equal(ranked[0].source_file_path, 'packages/workflow/src/low.ts'); - assert.equal(ranked[1].source_file_path, 'packages/workflow/src/high.ts'); - assert.ok( - ranked[0].value > ranked[1].value, - `low-coverage value (${ranked[0].value}) must exceed high-coverage value (${ranked[1].value})`, - ); - }); - - it('weights tune the ordering — boosting churn flips priority within a bucket', () => { - // Two new files in the same bucket: crdt/z.ts (churn=100, fix=1) and - // workflow/b.ts (churn=1). With default weights crdt wins. If we zero - // out churn and only weight fix-density, the row WITHOUT a fix - // (workflow/b.ts) falls behind crdt/z.ts (fix=1), but with churn=0 - // and fixDensity=0 the tiebreak becomes the (1−coverage) term: - // workflow/b.ts has coverage 0.9 → value 0.1, crdt/z.ts has 0.1 → - // value 0.9. crdt/z.ts still wins. Now zero out everything except - // w_coverage and watch the lower-coverage file climb. - const merged = buildMerged(); - const ranked = rankCandidates(merged, { - now: NOW, - staleAfterMs: STALE_AFTER_MS, - mode: 'baseline', - signals: SIGNALS, - coverage: COVERAGE, - weights: { churn: 0, fixDensity: 0, coverage: 1 }, - }); - const top = ranked[0]; - // crdt/z.ts has the lowest coverage (0.1) among new rows → highest - // (1 − coverage) value. - assert.equal(top.source_file_path, 'packages/@n8n/crdt/src/z.ts'); - }); -}); diff --git a/scripts/mutation-health/signals.mjs b/scripts/mutation-health/signals.mjs deleted file mode 100644 index ae1d2c19646..00000000000 --- a/scripts/mutation-health/signals.mjs +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env node -/** - * Per-file risk signals derived from git history. - * - * Two signals, both keyed by repo-relative file path: - * - * churn — how often the file changes (commits + lines touched) - * fixDensity — how "buggy" the file looks: a sum over fix commits - * touching the file, where each contribution is the lines - * changed in that commit, weighted by a half-life decay on - * the commit's age. Recent fixes dominate; ancient fixes - * fade out smoothly. - * - * Fix detection is conventional-commit shaped: the commit subject must match - * `fix:` or `fix(scope): ` (case-insensitive; `!` breaking marker allowed). - * - * The module is split into pure helpers so unit tests can feed synthetic - * `git log` output without touching the filesystem or invoking git: - * - * parseGitLog(text) → Commit[] - * computeChurn(commits) → Map - * computeFixDensity(commits, { halfLifeDays, now }) → Map - * - * A high-level `gatherSignals` shells out to git in the current repo for - * pipeline use. The unit-test contract (cold-file < hot-file at identical - * mutation status) is proved against `parseGitLog` + the two `compute*` - * helpers; the git wrapper is the same pure pipeline with a real log on the - * front. - */ - -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -const execFileP = promisify(execFile); - -const COMMIT_MARKER = 'COMMIT'; -// `--pretty=format:'COMMIT %H %ct %s'` — fields are space-separated; subjects -// may contain spaces so we only split on the first two. -const FIX_SUBJECT_RE = /^fix(\([^)]+\))?!?:\s/i; -const SECONDS_PER_DAY = 86_400; - -export const DEFAULT_HALF_LIFE_DAYS = 90; -export const GIT_LOG_FORMAT = `${COMMIT_MARKER} %H %ct %s`; - -/** - * Detect whether a commit subject is a conventional fix. - * Exported so callers can override fix detection if they need to. - */ -export function isFixSubject(subject) { - return typeof subject === 'string' && FIX_SUBJECT_RE.test(subject); -} - -/** - * Parse the output of - * git log --no-merges --pretty=format:'COMMIT %H %ct %s' --numstat - * into an array of commit records. - * - * Numstat emits added/removed counts per file, with `-` for binary files — - * those are normalised to 0. Blank lines between commits and trailing - * whitespace are tolerated; unknown lines are skipped silently so callers can - * pre-process or post-process without breaking the parser. - */ -export function parseGitLog(input) { - if (typeof input !== 'string') return []; - const commits = []; - let current = null; - for (const raw of input.split('\n')) { - const line = raw.replace(/\r$/, ''); - if (line.startsWith(`${COMMIT_MARKER} `)) { - if (current) commits.push(current); - const rest = line.slice(COMMIT_MARKER.length + 1); - const firstSpace = rest.indexOf(' '); - if (firstSpace === -1) { - current = null; - continue; - } - const sha = rest.slice(0, firstSpace); - const afterSha = rest.slice(firstSpace + 1); - const secondSpace = afterSha.indexOf(' '); - if (secondSpace === -1) { - current = null; - continue; - } - const timestamp = Number(afterSha.slice(0, secondSpace)); - const subject = afterSha.slice(secondSpace + 1); - if (!Number.isFinite(timestamp)) { - current = null; - continue; - } - current = { - sha, - timestamp, - subject, - isFix: isFixSubject(subject), - files: [], - }; - continue; - } - if (!current || !line.trim()) continue; - // numstat row: "\t\t"; `-` means binary. - const parts = line.split('\t'); - if (parts.length < 3) continue; - const added = parts[0] === '-' ? 0 : Number(parts[0]); - const removed = parts[1] === '-' ? 0 : Number(parts[1]); - if (!Number.isFinite(added) || !Number.isFinite(removed)) continue; - // Renames surface as `old => new` or `{old => new}/file`; keep the - // path verbatim for now — the picker side will normalise once renames - // are in scope (deferred to C5+). - current.files.push({ path: parts.slice(2).join('\t'), added, removed }); - } - if (current) commits.push(current); - return commits; -} - -/** - * Per-file churn: commit count + total lines added+removed within the window. - * `since`/`until` are unix-seconds, both inclusive; omit to include all. - */ -export function computeChurn(commits, { since, until } = {}) { - const out = new Map(); - for (const c of commits) { - if (since !== undefined && c.timestamp < since) continue; - if (until !== undefined && c.timestamp > until) continue; - for (const f of c.files) { - let entry = out.get(f.path); - if (!entry) { - entry = { commits: 0, linesChanged: 0 }; - out.set(f.path, entry); - } - entry.commits += 1; - entry.linesChanged += f.added + f.removed; - } - } - return out; -} - -/** - * Per-file time-decayed, delta-weighted fix-density. - * - * density(file) = Σ over fix commits c touching file: - * delta(c, file) * 0.5 ^ (age_days(c) / halfLifeDays) - * - * `now` is required (unix-seconds) so the result is fully deterministic — the - * caller decides the reference time so tests aren't wall-clock dependent. - */ -export function computeFixDensity( - commits, - { halfLifeDays = DEFAULT_HALF_LIFE_DAYS, now } = {}, -) { - if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) { - throw new RangeError( - `halfLifeDays must be a positive finite number; got ${halfLifeDays}`, - ); - } - if (typeof now !== 'number' || !Number.isFinite(now)) { - throw new TypeError( - 'computeFixDensity requires explicit `now` (unix-seconds number) for determinism', - ); - } - const halfLifeSeconds = halfLifeDays * SECONDS_PER_DAY; - const out = new Map(); - for (const c of commits) { - if (!c.isFix) continue; - const ageSeconds = Math.max(0, now - c.timestamp); - const weight = Math.pow(0.5, ageSeconds / halfLifeSeconds); - for (const f of c.files) { - const delta = f.added + f.removed; - if (delta === 0) continue; - out.set(f.path, (out.get(f.path) ?? 0) + weight * delta); - } - } - return out; -} - -/** - * Read git log from `cwd` and return both signals. - * - * Returned shape is plain objects (not Maps) so it round-trips through JSON - * for the eventual writer payload; callers that want a Map can rebuild via - * `new Map(Object.entries(...))`. - */ -export async function gatherSignals({ - cwd = process.cwd(), - since, - halfLifeDays = DEFAULT_HALF_LIFE_DAYS, - now = Math.floor(Date.now() / 1000), -} = {}) { - const args = ['log', '--no-merges', `--pretty=format:${GIT_LOG_FORMAT}`, '--numstat']; - if (since) args.push(`--since=${since}`); - // maxBuffer: huge histories blow the default 1MB cap; 256MB is plenty for - // year-scale windows on packages/ subdirectories. - const { stdout } = await execFileP('git', args, { cwd, maxBuffer: 256 * 1024 * 1024 }); - const commits = parseGitLog(stdout); - const churn = computeChurn(commits); - const density = computeFixDensity(commits, { halfLifeDays, now }); - return { - halfLifeDays, - now, - churn: Object.fromEntries(churn), - fixDensity: Object.fromEntries(density), - }; -} - -const isCli = import.meta.url === `file://${process.argv[1]}`; -if (isCli) { - const argv = process.argv.slice(2); - const opts = {}; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (!a.startsWith('--')) continue; - const key = a.slice(2); - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - opts[key] = true; - } else { - opts[key] = next; - i++; - } - } - const halfLifeDays = opts['half-life-days'] ? Number(opts['half-life-days']) : undefined; - gatherSignals({ - cwd: opts.cwd ?? process.cwd(), - since: opts.since, - halfLifeDays, - }) - .then((result) => { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - }) - .catch((err) => { - process.stderr.write(`${err.message}\n`); - process.exit(1); - }); -} diff --git a/scripts/mutation-health/signals.test.mjs b/scripts/mutation-health/signals.test.mjs deleted file mode 100644 index 052854b476b..00000000000 --- a/scripts/mutation-health/signals.test.mjs +++ /dev/null @@ -1,211 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - DEFAULT_HALF_LIFE_DAYS, - computeChurn, - computeFixDensity, - isFixSubject, - parseGitLog, -} from './signals.mjs'; - -const DAY = 86_400; -// Fixed reference epoch (2026-06-20 00:00:00 UTC) — tests must not depend on -// wall-clock; the signal API requires explicit `now` for exactly this reason. -const NOW = 1_750_377_600; - -const commit = (sha, ageDays, subject, files) => { - const lines = [`COMMIT ${sha} ${NOW - ageDays * DAY} ${subject}`]; - for (const [path, added, removed] of files) { - lines.push(`${added}\t${removed}\t${path}`); - } - return lines.join('\n'); -}; - -const fixture = (...blocks) => blocks.join('\n\n'); - -describe('isFixSubject', () => { - it('matches conventional fix prefixes', () => { - assert.equal(isFixSubject('fix: oops'), true); - assert.equal(isFixSubject('fix(workflow): oops'), true); - assert.equal(isFixSubject('fix(core)!: breaking fix'), true); - assert.equal(isFixSubject('FIX: case-insensitive'), true); - }); - - it('rejects non-fix subjects', () => { - assert.equal(isFixSubject('feat: new thing'), false); - assert.equal(isFixSubject('chore: rename'), false); - // "prefix" detection — we intentionally don't accept loose "fixes a bug" - // in commit bodies; the convention here is `fix:` at the start. - assert.equal(isFixSubject('refactor: this fixes a bug indirectly'), false); - }); -}); - -describe('parseGitLog', () => { - it('parses sha, timestamp, subject, and files', () => { - const log = commit('aaa1', 1, 'fix(core): null pointer', [['src/a.ts', 40, 10]]); - const commits = parseGitLog(log); - assert.equal(commits.length, 1); - assert.equal(commits[0].sha, 'aaa1'); - assert.equal(commits[0].timestamp, NOW - DAY); - assert.equal(commits[0].subject, 'fix(core): null pointer'); - assert.equal(commits[0].isFix, true); - assert.deepEqual(commits[0].files, [{ path: 'src/a.ts', added: 40, removed: 10 }]); - }); - - it('treats binary numstat (- -) as zero-change', () => { - const log = commit('bin1', 1, 'fix: refresh sprite', [ - ['assets/foo.png', '-', '-'], - ]).replace('-\t-', '-\t-'); // sanity: still a real tab - const commits = parseGitLog(log); - assert.equal(commits[0].files[0].added, 0); - assert.equal(commits[0].files[0].removed, 0); - }); - - it('returns [] on non-string input and tolerates noise lines', () => { - assert.deepEqual(parseGitLog(undefined), []); - const noisy = ['', 'totally not a commit line', '5\t5\torphan.ts', ''].join('\n'); - assert.deepEqual(parseGitLog(noisy), []); - }); - - it('marks non-fix commits as isFix=false', () => { - const log = commit('c1', 1, 'chore: rename var', [['src/a.ts', 2, 2]]); - assert.equal(parseGitLog(log)[0].isFix, false); - }); -}); - -describe('computeChurn', () => { - it('aggregates commit count and total lines per file', () => { - const log = fixture( - commit('a', 1, 'fix: x', [['src/hot.ts', 40, 10]]), - commit('b', 10, 'chore: y', [['src/hot.ts', 5, 5]]), - commit('c', 30, 'fix: z', [ - ['src/hot.ts', 20, 8], - ['src/other.ts', 1, 0], - ]), - ); - const churn = computeChurn(parseGitLog(log)); - assert.deepEqual(churn.get('src/hot.ts'), { commits: 3, linesChanged: 88 }); - assert.deepEqual(churn.get('src/other.ts'), { commits: 1, linesChanged: 1 }); - }); - - it('honours since/until windows', () => { - const log = fixture( - commit('recent', 1, 'fix: x', [['src/a.ts', 10, 0]]), - commit('old', 365, 'fix: y', [['src/a.ts', 10, 0]]), - ); - const commits = parseGitLog(log); - const recent = computeChurn(commits, { since: NOW - 30 * DAY }); - assert.equal(recent.get('src/a.ts').commits, 1); - }); -}); - -describe('computeFixDensity', () => { - it('refuses to run without an explicit `now`', () => { - assert.throws(() => computeFixDensity([], {}), /requires explicit `now`/); - assert.throws( - () => computeFixDensity([], { now: NOW, halfLifeDays: 0 }), - /halfLifeDays/, - ); - }); - - it('weighs recent fixes more than ancient fixes (same delta, same file)', () => { - const log = fixture( - commit('recent', 1, 'fix: x', [['src/a.ts', 10, 0]]), - commit('ancient', 365, 'fix: y', [['src/b.ts', 10, 0]]), - ); - const density = computeFixDensity(parseGitLog(log), { - halfLifeDays: 90, - now: NOW, - }); - assert.ok( - density.get('src/a.ts') > density.get('src/b.ts'), - `recent ${density.get('src/a.ts')} should exceed ancient ${density.get('src/b.ts')}`, - ); - }); - - it('decays by exactly the half-life ratio', () => { - const log = fixture( - commit('t0', 0, 'fix: x', [['src/a.ts', 100, 0]]), - commit('t1', DEFAULT_HALF_LIFE_DAYS, 'fix: y', [['src/b.ts', 100, 0]]), - ); - const density = computeFixDensity(parseGitLog(log), { now: NOW }); - const ratio = density.get('src/b.ts') / density.get('src/a.ts'); - assert.ok(Math.abs(ratio - 0.5) < 1e-9, `expected half-life ratio, got ${ratio}`); - }); - - it('weighs bigger deltas more (same age, same file)', () => { - const log = fixture( - commit('big', 1, 'fix: x', [['src/a.ts', 100, 0]]), - commit('small', 1, 'fix: y', [['src/b.ts', 5, 0]]), - ); - const density = computeFixDensity(parseGitLog(log), { - halfLifeDays: 90, - now: NOW, - }); - assert.ok(density.get('src/a.ts') > density.get('src/b.ts')); - }); - - it('ignores non-fix commits entirely', () => { - const log = commit('chore', 1, 'chore: refactor', [['src/a.ts', 100, 0]]); - const density = computeFixDensity(parseGitLog(log), { - halfLifeDays: 90, - now: NOW, - }); - assert.equal(density.get('src/a.ts'), undefined); - }); - - it('treats future-dated commits (skew/clock-drift) as age 0, not negative', () => { - // Floor at 0 guards against decay > 1 if a clock-skewed commit lands - // "in the future" relative to `now`. Behaviour: weight === 1, not >1. - const log = commit('future', -7, 'fix: x', [['src/a.ts', 10, 0]]); - const density = computeFixDensity(parseGitLog(log), { - halfLifeDays: 90, - now: NOW, - }); - assert.equal(density.get('src/a.ts'), 10); - }); -}); - -// PR-gate contract from DEVP-492: -// "cold-file value < hot-file value at identical mutation status -// using fixture git logs" -// -// Build two files that share the same hypothetical mutation status — what -// differs is purely their history. `hot.ts` has three recent fix commits with -// meaningful deltas; `cold.ts` has one ancient fix and one recent chore. The -// risk signal MUST rank hot above cold on both churn and fix-density, -// regardless of what their mutation score happens to be. -describe('cold-vs-hot contract (DEVP-492 PR gate)', () => { - const log = fixture( - commit('h1', 1, 'fix(core): null pointer in hot path', [['src/hot.ts', 40, 10]]), - commit('h2', 10, 'fix: edge case', [['src/hot.ts', 30, 5]]), - commit('h3', 30, 'fix(workflow): off-by-one', [['src/hot.ts', 20, 8]]), - commit('c1', 600, 'fix: ancient bug', [['src/cold.ts', 40, 10]]), - commit('c2', 5, 'chore: rename variable', [['src/cold.ts', 2, 2]]), - ); - const commits = parseGitLog(log); - - it('hot file has higher churn (commit count and lines changed)', () => { - const churn = computeChurn(commits); - const hot = churn.get('src/hot.ts'); - const cold = churn.get('src/cold.ts'); - assert.ok(hot.commits > cold.commits, `hot.commits=${hot.commits} cold=${cold.commits}`); - assert.ok( - hot.linesChanged > cold.linesChanged, - `hot.linesChanged=${hot.linesChanged} cold=${cold.linesChanged}`, - ); - }); - - it('hot file has higher fix-density across realistic half-lives', () => { - for (const halfLifeDays of [30, 90, 180]) { - const density = computeFixDensity(commits, { halfLifeDays, now: NOW }); - const hot = density.get('src/hot.ts'); - const cold = density.get('src/cold.ts') ?? 0; - assert.ok( - hot > cold, - `half-life=${halfLifeDays}d: hot=${hot} must exceed cold=${cold}`, - ); - } - }); -});