mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 09:12:12 +08:00
test: Pin the diff parsing and source filter in mutate.mjs (#36746)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Generated
+491
-293
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -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:'
|
||||
|
||||
@@ -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-<file-basename>`
|
||||
>
|
||||
> 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/<picked-file>`
|
||||
>
|
||||
> 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/<picked-file>`
|
||||
>
|
||||
> 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-<file-basename>
|
||||
> gh pr create --draft --base devp-stryker-mvp-spike \
|
||||
> --title "test(core): strengthen <file-basename> assertions (demo) (no-changelog)" \
|
||||
> --body "<see template below>"
|
||||
> ```
|
||||
>
|
||||
> **PR body template:**
|
||||
>
|
||||
> ```markdown
|
||||
> ## Summary
|
||||
>
|
||||
> Demo PR for #30956. Drives the `mutant-fix` loop against `packages/workflow/src/<file>` to show the trial loop end-to-end.
|
||||
>
|
||||
> **Before**: <X>% mutation score, <N> survivors
|
||||
> **After**: <Y>% mutation score, <M> survivors
|
||||
>
|
||||
> Survivors addressed (with rationale):
|
||||
> 1. <mutator at location> — added <which assertion> to <which existing test>
|
||||
> 2. ...
|
||||
>
|
||||
> ## Test plan
|
||||
> - [ ] `pnpm mutate packages/workflow/src/<file>` reproduces the post-score locally
|
||||
> - [ ] `pnpm --filter=n8n-workflow test test/<file>.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
|
||||
@@ -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 `<pkg>/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 <repo-relative-file>` from the repo root and the package is inferred from the path (or pass `--package-dir <pkg>` 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 `<package>/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 <pkg> <source_file> → 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=<name>` | 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=<pkg>`:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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=<json>`, suitable for appending
|
||||
* to $GITHUB_OUTPUT. `<json>` 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 `{ <repo-rel source_file_path>: 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);
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Emit a BQ payload from a mutate.mjs summary.json.
|
||||
*
|
||||
* Input: packages/<pkg>/reports/mutation/summary.json (from `pnpm mutate <file>`)
|
||||
* 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 <path>] # 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 <path>] # default: <pkg>/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 <path>');
|
||||
if (!pkg) die(2, 'Missing required --package <name>');
|
||||
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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`/);
|
||||
});
|
||||
});
|
||||
+453
-160
@@ -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 <file>` from the repo root):
|
||||
* node scripts/mutation-health/mutate.mjs <file> [--package-dir <repo-rel-path>] [--config <path>]
|
||||
* pnpm mutate <file>[:<start>-<end>] [--package-dir <dir>] [--config <path>]
|
||||
* pnpm mutate --diff [--base <ref>] [--config <path>]
|
||||
*
|
||||
* 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 <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 <path> 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 <file> [--package-dir <repo-rel-path>] [--config <path>]\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 <file>[:<start>-<end>] [--package-dir <dir>] [--config <path>]
|
||||
node scripts/mutation-health/mutate.mjs --diff [--base <ref>] [--config <path>]
|
||||
|
||||
# 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]}`;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <path> Required. Repo-relative path to the package, e.g. packages/workflow
|
||||
* --ledger-file <path> Required. Live ledger JSON: { "ledger": [ ... ] }
|
||||
* --mode <baseline|coverage> Optional. Restrict the picker to one bucket.
|
||||
* --stale-after-weeks <n> Optional. Default 4.
|
||||
*
|
||||
* Inputs (global mode):
|
||||
* --global Required to enter global mode.
|
||||
* --ledger-file <path> Required. Live read-all ledger JSON (rows for every package).
|
||||
* --signals-file <path> Optional. JSON from `signals.mjs gatherSignals`.
|
||||
* --coverage-file <path> Optional. JSON map { "<repo-rel-path>": 0..1 } of line-coverage.
|
||||
* --top-n <n> Optional. Default 1. How many top-ranked rows to emit.
|
||||
* --block <list> Optional. Comma-separated package names to exclude from the walk.
|
||||
* --w-churn / --w-fix-density / --w-coverage <n> Optional. Per-signal weights for value formula.
|
||||
* --mode <baseline|coverage> Optional. Restrict picker to one bucket (same as per-package).
|
||||
* --stale-after-weeks <n> 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 <path>');
|
||||
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 <relative-path-to-package>');
|
||||
if (!ledgerFile) die(2, 'Missing required --ledger-file <path>');
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<file, { commits, linesChanged }>
|
||||
* computeFixDensity(commits, { halfLifeDays, now }) → Map<file, number>
|
||||
*
|
||||
* 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: "<added>\t<removed>\t<path>"; `-` 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);
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user