diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 83a05c35e..16672ad2e 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -69,6 +69,7 @@ jobs: const temp = process.env.RUNNER_TEMP; await downloadArtifact('pr-meta', path.join(temp, 'dl')); await downloadArtifact('test-reports', path.join(temp, 'dl')); + await downloadArtifact('test-dupes', path.join(temp, 'dl')); - name: Extract artifacts shell: bash @@ -456,6 +457,34 @@ jobs: echo "" fi + # ── Possible duplicate tests (advisory, ci.yml test-dupes job) ── + # The JSON is produced by PR code, so only whitelisted kinds and + # strict path/line shapes reach the comment; free text is dropped. + DUPES_FILE=$(find_first "$DIR/test-dupes" "test-dupes.json") + if [ -n "$DUPES_FILE" ]; then + jq -r ' + def path_ok: type == "string" and test("\\A[A-Za-z0-9._/@+-]{1,300}\\z"); + def line_ok: type == "number" and . >= 1 and . <= 1000000 and floor == .; + def loc: "`\(.file):\(.line)`"; + [ .findings[]? + | select(.kind | IN("same-test", "assertions-pinned", "same-fixture-setup", "copied-helper", "orphan-bench-gate")) + | select((.file | path_ok) and (.line | line_ok)) + | .matches = ([(.matches // [])[]? | select((.file | path_ok) and (.line | line_ok))][:3]) + ] + | if length == 0 then empty else + "### Possible duplicate tests", + "", + "Advisory only, never blocks merge: \(length) finding(s) on lines this PR changes. Run `npm run test:dupes` in `gitnexus/` for details; mark an intentional repeat with `// dupe-ok: `.", + "", + "| Kind | Changed | Already at |", + "|------|---------|------------|", + (.[:20][] | "| `\(.kind)` | \(loc) | \(if (.matches | length) == 0 then "no workflow runs it" else (.matches | map(loc) | join("
")) end) |"), + (if length > 20 then "", "…and \(length - 20) more." else empty end), + "" + end + ' "$DUPES_FILE" 2>/dev/null || true + fi + echo "---" echo "📋 [View full run](${RUN_URL}) · Generated by CI" echo "GITNEXUS_CI_REPORT_EOF_7f3a" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5526191b..096eb0a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,37 @@ jobs: permissions: contents: read + # ── Advisory duplicate-test scan ───────────────────────────────── + # Comment-only: ci-report.yml renders this JSON into the sticky PR + # comment. Kept out of `ci-status.needs` and marked continue-on-error, + # so neither a finding nor a scanner crash can block a merge. + # fetch-depth 2: HEAD is the PR merge commit, HEAD^1 the base tip. + test-dupes: + name: Duplicate test scan + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 2 + - uses: ./.github/actions/setup-gitnexus + with: + lifecycle-scripts: 'false' + - name: Scan lines this PR changes + run: npm run test:dupes -- --base HEAD^1 --json ../test-dupes/test-dupes.json + working-directory: gitnexus + - name: Upload scan result + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-dupes + path: test-dupes/ + retention-days: 1 + if-no-files-found: ignore + # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an diff --git a/.github/workflows/mutation-runner-validation.yml b/.github/workflows/mutation-runner-validation.yml new file mode 100644 index 000000000..2c48ed76f --- /dev/null +++ b/.github/workflows/mutation-runner-validation.yml @@ -0,0 +1,394 @@ +name: Mutation Runner Validation + +# Validates the repo-local Stryker test runner for Vitest 5 and its validation gates +# (gitnexus/tools/mutation/, TESTING.md "Mutation testing tooling"), and the test +# scenario map built on it (gitnexus/tools/scenario-map/). +# +# pull_request gate 2: the conformance suite, one job per profile; and the scenario-map +# controls run. Runs only when the tooling (tools/mutation, tools/scenario-map, +# tools/test-dupes), its fixtures (mutation-scenario-map among them), the +# gitnexus package manifest or lockfile (Vitest and Stryker versions), the base +# Vitest config, the gitnexus-shared package manifest (its exports drive the +# mutation config's aliases) or the setup-gitnexus action (the jobs' install +# path) change. The paths are workflow-level, so a scenario-map change also runs +# gate 2, an accepted extra cost. +# workflow_dispatch gate 2 plus gate 1 (fidelity), gate 3 (oracle), gate 4 (controls) and +# the oracle cost benchmark, each selectable by an input. +# +# Advisory: every job is continue-on-error and none is in ci.yml's ci-status.needs. Nothing here +# blocks a merge. Gate 3's full sample takes hours, so it never runs on pull requests. +# +# Gate 3 (`oracle` input): +# compare one job: a fresh vitest5 runner run per frozen manifest in +# gitnexus/test/fixtures/mutation-oracle/, then one compare over all three, so the +# strata are counted across the scopes combined, as the freeze counted them. Cheap: +# one Stryker run per scope. +# refreeze re-freezes manifests from scratch, one job per scope (`refreeze_scope`). The +# full three-scope sample does not fit one 6-hour job on 4 vCPU (benchmark: 6.18 h +# at best), so it is sharded by scope: shared about 4.5 h, schema-constants about +# 45 min, middleware about 1 h. The new manifest is uploaded as an artifact for a +# maintainer to review and commit; CI never commits it. Local equivalent, from +# gitnexus/ (the work dir must lie outside the checkout; a freeze is resumable): +# npm run test:mutation:oracle:freeze -- \ +# --from-manifest test/fixtures/mutation-oracle/.json \ +# --seed --work +# +# Gate 4 runs as one job: both profiles (A: three concurrency-1 runs in parallel, B: three +# concurrency-3 runs in turn) took about 81 min locally under load. Split jobs would have to +# ship every run directory, prepared copies included, to a `--compare-only` job at the same +# absolute paths, because the staleness check hashes the prepared inputs. +# +# Every tool writes outside the checkout (`--out`/`--work` under $RUNNER_TEMP): a prepared copy +# must not lie inside it. setup-gitnexus installs Node 22, the Node major recorded in the frozen +# manifests; another major makes gate 3 report "re-freeze required". Timings were measured on a +# loaded WSL2 machine with the checkout on a 9p mount; ubuntu-latest (4 vCPU, local disk) will +# differ, so the timeouts leave margin. + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/mutation-runner-validation.yml' + - 'gitnexus/tools/mutation/**' + - 'gitnexus/tools/scenario-map/**' + - 'gitnexus/tools/test-dupes/**' + - 'gitnexus/test/fixtures/mutation-*/**' + - 'gitnexus/package.json' + - 'gitnexus/package-lock.json' + - 'gitnexus/vitest.config.ts' + - 'gitnexus-shared/package.json' + - '.github/actions/setup-gitnexus/**' + workflow_dispatch: + inputs: + fidelity: + description: 'Gate 1: execution-path fidelity on the pure-JS category' + type: boolean + default: true + oracle: + description: 'Gate 3: compare the frozen manifests, re-freeze them, or skip' + type: choice + options: [compare, refreeze, skip] + default: compare + refreeze_scope: + description: 'Oracle scope to re-freeze (oracle = refreeze)' + type: choice + options: [all, shared, schema-constants, middleware] + default: all + seed: + description: 'New sample seed for a re-freeze (required when oracle = refreeze)' + type: string + required: false + default: '' + controls: + description: 'Gate 4: planted controls and determinism' + type: boolean + default: true + bench: + description: 'Oracle cost benchmark' + type: boolean + default: true + +permissions: + contents: read + +# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention". +# A new push supersedes a PR run; manual dispatches (hours long) are never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + working-directory: gitnexus + +jobs: + # ── Gate 2: runner conformance suite ─────────────────────────────── + conformance: + name: Gate 2 conformance (${{ matrix.profile }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + permissions: + contents: read + strategy: + fail-fast: false + matrix: + # The profile names of gitnexus/test/fixtures/mutation-conformance/expected.json. + profile: [concurrency-2-reuse-1, concurrency-1-reuse-unlimited] + steps: + # persist-credentials: false — the job uploads artifacts and never pushes. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full install, as the test jobs do: tsx, Stryker and Vitest are devDependencies. + - uses: ./.github/actions/setup-gitnexus + - name: Run the conformance suite + env: + PROFILE: ${{ matrix.profile }} + run: npm run test:mutation:conformance -- --profile "$PROFILE" --out "$RUNNER_TEMP/mutation/conformance" + - name: Upload conformance results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-conformance-${{ matrix.profile }} + path: | + ${{ runner.temp }}/mutation/conformance/observed.json + ${{ runner.temp }}/mutation/conformance/*/*/results.json + ${{ runner.temp }}/mutation/conformance/*/*/stryker.log + retention-days: 14 + + # ── Gate 1: execution-path fidelity and envelope ─────────────────── + fidelity: + name: Gate 1 fidelity + if: github.event_name == 'workflow_dispatch' && inputs.fidelity + runs-on: ubuntu-latest + timeout-minutes: 60 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + # The envelope goes to the artifact, not over the committed + # test/fixtures/mutation-oracle/envelope.json. + - name: Run gate 1 + run: | + npm run test:mutation:fidelity -- \ + --out "$RUNNER_TEMP/mutation/fidelity" \ + --envelope "$RUNNER_TEMP/mutation/envelope/envelope.json" + - name: Upload gate 1 report and envelope + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-gate1 + path: | + ${{ runner.temp }}/mutation/fidelity/*.json + ${{ runner.temp }}/mutation/envelope/envelope.json + retention-days: 14 + + # ── Gate 3: compare a fresh runner run with the frozen manifests ─── + oracle-compare: + name: Gate 3 oracle compare + if: github.event_name == 'workflow_dispatch' && inputs.oracle == 'compare' + runs-on: ubuntu-latest + timeout-minutes: 120 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Run the runner on every oracle scope and compare + run: | + work="$RUNNER_TEMP/mutation/gate3" + mkdir -p "$work" + pairs=() + for name in shared schema-constants middleware; do + manifest="test/fixtures/mutation-oracle/$name.json" + if [ ! -f "$manifest" ]; then + echo "::error::$manifest is missing; freeze it first" + exit 2 + fi + jq '.scope' "$manifest" > "$work/$name.scope.json" + # Exit 1 means results with integrity flags; gate 3 reports those as differences. + status=0 + npm run test:mutation:run -- --scope "$work/$name.scope.json" \ + --out "$work/$name" --concurrency 4 || status=$? + if [ "$status" -gt 1 ]; then exit "$status"; fi + pairs+=(--scope "$manifest,$work/$name/results.json") + done + npm run test:mutation:oracle -- "${pairs[@]}" --out "$work/gate3-report.json" + - name: Upload gate 3 report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-gate3 + path: | + ${{ runner.temp }}/mutation/gate3/gate3-report.json + ${{ runner.temp }}/mutation/gate3/*.scope.json + ${{ runner.temp }}/mutation/gate3/*/results.json + ${{ runner.temp }}/mutation/gate3/*/stryker.log + retention-days: 14 + + # ── Gate 3: re-freeze manifests, sharded by scope ────────────────── + oracle-refreeze: + name: Gate 3 re-freeze (${{ matrix.scope }}) + if: github.event_name == 'workflow_dispatch' && inputs.oracle == 'refreeze' + runs-on: ubuntu-latest + # Projected: shared ~4.5 h with Stryker runs and setup; the others ~1 h. 6 h job cap. + timeout-minutes: ${{ matrix.scope == 'shared' && 355 || 150 }} + continue-on-error: true + permissions: + contents: read + strategy: + fail-fast: false + matrix: + scope: ${{ fromJSON(inputs.refreeze_scope == 'all' && '["shared","schema-constants","middleware"]' || format('["{0}"]', inputs.refreeze_scope)) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Check the seed + env: + SCOPE: ${{ matrix.scope }} + SEED: ${{ inputs.seed }} + run: | + manifest="test/fixtures/mutation-oracle/$SCOPE.json" + if [ -z "$SEED" ]; then + echo "::error::a re-freeze needs a new sample seed (the seed input)" + exit 2 + fi + frozen="$(jq -r '.sample.seed' "$manifest")" + if [ "$SEED" = "$frozen" ]; then + echo "::error::$manifest was frozen with seed $frozen; give a new seed" + exit 2 + fi + # --manifest lies outside the checkout, so the manifest records the default + # test/fixtures/mutation-oracle/.json path, and only a freeze without problems + # writes it. /manifest.json is written either way and lists any problems. + - name: Re-freeze the scope + env: + SCOPE: ${{ matrix.scope }} + SEED: ${{ inputs.seed }} + run: | + out="$RUNNER_TEMP/mutation/refreeze" + mkdir -p "$out" + npm run test:mutation:oracle:freeze -- \ + --from-manifest "test/fixtures/mutation-oracle/$SCOPE.json" \ + --seed "$SEED" --work "$out/work" --manifest "$out/$SCOPE.json" + - name: Upload the re-frozen manifest + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-oracle-refreeze-${{ matrix.scope }} + path: | + ${{ runner.temp }}/mutation/refreeze/${{ matrix.scope }}.json + ${{ runner.temp }}/mutation/refreeze/work/manifest.json + ${{ runner.temp }}/mutation/refreeze/work/freeze-work.json + ${{ runner.temp }}/mutation/refreeze/work/command/oracle-command-run.json + ${{ runner.temp }}/mutation/refreeze/work/*/stryker.log + retention-days: 30 + + # ── Gate 4: planted controls and determinism ─────────────────────── + controls: + name: Gate 4 controls + if: github.event_name == 'workflow_dispatch' && inputs.controls + runs-on: ubuntu-latest + timeout-minutes: 240 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Run gate 4 + run: npm run test:mutation:controls -- --work "$RUNNER_TEMP/mutation/controls" + - name: Upload gate 4 report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-gate4 + path: | + ${{ runner.temp }}/mutation/controls/controls-report.json + ${{ runner.temp }}/mutation/controls/runs/*/*/results.json + ${{ runner.temp }}/mutation/controls/runs/*/*/stryker.log + retention-days: 14 + + # ── Oracle cost benchmark ────────────────────────────────────────── + bench: + name: Oracle cost benchmark + if: github.event_name == 'workflow_dispatch' && inputs.bench + runs-on: ubuntu-latest + timeout-minutes: 150 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + # Per oracle scope: the oracle's command-runner run and a vitest5 runner run, then the + # benchmark over all three. Concurrency 4 matches the recorded numbers. + - name: Run the oracle cost benchmark + run: | + work="$RUNNER_TEMP/mutation/bench" + mkdir -p "$work" + scopes=() + for name in shared schema-constants middleware; do + manifest="test/fixtures/mutation-oracle/$name.json" + if [ ! -f "$manifest" ]; then + echo "::error::$manifest is missing; freeze it first" + exit 2 + fi + jq '.scope' "$manifest" > "$work/$name.scope.json" + npm run test:mutation:oracle:command -- --scope "$work/$name.scope.json" \ + --out "$work/$name-command" --concurrency 4 + # Exit 1 means results with integrity flags; the benchmark only reads which + # mutants are static and which tests cover the timed-out ones. + status=0 + npm run test:mutation:run -- --scope "$work/$name.scope.json" \ + --out "$work/$name-vitest5" --concurrency 4 || status=$? + if [ "$status" -gt 1 ]; then exit "$status"; fi + scopes+=(--scope "$work/$name-command/oracle-command-run.json,$work/$name-vitest5/results.json") + done + npm run test:mutation:bench -- "${scopes[@]}" --out "$work/report" --concurrency 4 + - name: Upload benchmark report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-oracle-bench + path: | + ${{ runner.temp }}/mutation/bench/report/oracle-cost.json + ${{ runner.temp }}/mutation/bench/report/oracle-cost.txt + ${{ runner.temp }}/mutation/bench/*-command/oracle-command-run.json + ${{ runner.temp }}/mutation/bench/*/stryker.log + retention-days: 14 + + # ── Scenario map controls ────────────────────────────────────────── + # The PR check of test-scenario-map.yml's pipeline (plan, value flow, one shard, merge) over the + # controls fixture (gitnexus/test/fixtures/mutation-scenario-map/), mutation scope `all`, then + # every expected.json mutation and fingerprint verdict and rule compared; exit 1 means a pair + # differs. build: 'true' because the value-flow stage's PDG index + # spawns analyze workers from dist/. About 30 min locally. + scenario-map-controls: + name: Scenario map controls + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 60 + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Run the scenario-map controls + env: + GITNEXUS_WORKER_READY_TIMEOUT_MS: '120000' + run: npm run test:scenario-map -- controls --work "$RUNNER_TEMP/scenario-map-controls" + - name: Upload the controls report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-controls-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/scenario-map-controls/scenario-map-report.json + ${{ runner.temp }}/scenario-map-controls/scenario-map-report.md + ${{ runner.temp }}/scenario-map-controls/issue-body.md + ${{ runner.temp }}/scenario-map-controls/plan.json + ${{ runner.temp }}/scenario-map-controls/value-flow.json + ${{ runner.temp }}/scenario-map-controls/shard-0.json + ${{ runner.temp }}/scenario-map-controls/value-flow/pdg/analyze.log + retention-days: 14 diff --git a/.github/workflows/test-scenario-map.yml b/.github/workflows/test-scenario-map.yml new file mode 100644 index 000000000..d3fa58bc9 --- /dev/null +++ b/.github/workflows/test-scenario-map.yml @@ -0,0 +1,755 @@ +name: Test Scenario Map + +# Advisory report on the validated pure-JS unit tests of gitnexus/ (R13, R14, KTD13, KTD14, +# KTD17; docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md): duplicate tests (the same +# execution path, input data and expectation: the scenario fingerprint), and, with mutation +# evidence, tests that kill no evaluated mutant and per-file gaps. The CLI is +# gitnexus/tools/scenario-map/scenario-map.ts; its header documents the stages, flags, +# work-directory layout and exit codes. Local equivalent, from gitnexus/ (the work dir must lie +# outside the checkout; `npm run build` first: the fingerprint's integration tests load dist/): +# npm run test:scenario-map -- all --work --mutation none +# +# Triggers and mutation scope (`--mutation`, types.ts `MutationScope`): +# schedule weekly, `none`: the fingerprint only, minutes, no shard; +# workflow_dispatch input `mutation`: `none` (default), `possible-duplicates` or `all` (the +# full Phase 1 run, hours over up to 20 shards); +# pull_request TEMPORARY, remove before merge: the `scenario-map-test` label, added by +# magyargergo on PR #3686 only, runs the whole suite (no diff scope) with +# mutation `possible-duplicates`, to test every stage on CI before merge. +# When a maintainer adds the `scenario-map` label to a pull request: +# `possible-duplicates`, the deep run for a suspicious change, diff-scoped +# (`--changed-since` on the coverage slices: only pairs with a test of a changed +# test file; Layer 1 over those files, the files sharing their +# checks-and-context keys and every file with a test of no static key). A PR +# run writes no tracking issue and no baseline; its report is an artifact and +# the job summary. +# +# Jobs, each one CLI stage over one work directory ($RUNNER_TEMP/scenario-map) exchanged as +# artifacts: +# coverage one matrix job per Layer 1 slice (4, fail-fast off): the fingerprint's unit and +# integration test files, split like the CI test shards. +# plan eligibility, Layer 0, the prepared copy and run manifest, Layer 1 merged from the +# slices (a slice that did not report leaves its files unrecorded), Layer 2, +# candidates and the provisional shard plan. +# value-flow for scope `all` the PDG index (`analyze --pdg` spawns workers from dist/) and the +# static value-flow check; the trace run and the final shard plan +# (a narrower scope re-plans its target after the trace). It runs +# before the shards, so a shard isolates only the pairs value flow leaves able to be +# strong (the CLI's deviation from KTD14's parallel value-flow job). +# shards prints the final shard count (the re-plan's, or the provisional plan's when value +# flow left no usable result); it sizes the shard matrix, and 0 skips the shards. +# shard one matrix job per shard (fail-fast off): Layer 3 group runs, row deletion and +# isolation until a 300-minute deadline (KTD17). Results upload even on failure. +# merge always runs once a plan exists: merges whatever the shards and value flow left +# (a missing shard leaves the run partial and its pairs Inconclusive, R11), compares +# against the newest `scenario-map-baseline` artifact of an earlier completed run of +# this workflow on the default branch, and writes the report. Only a complete +# default-branch run uploads a new baseline (KTD13). +# upsert-issue default branch only: a complete run replaces the tracking issue's body unless +# the issue already records a newer run; a partial run comments and leaves the body +# alone. The issue is found by label in any state and never closed. +# +# Advisory: every job is continue-on-error, so nothing here fails the run or blocks a merge. Exit +# code 1 of a stage means "finished with incomplete results"; it is a warning, the job carries on +# and merge reports the run as partial. Exit code 2 (usage or setup error) fails the step. +# The full matrix can hold up to 20 runners for about 5.5 hours. +# +# Artifact names carry the run attempt. A downstream job takes the plan and value-flow artifact +# names from those jobs' outputs, so re-running one job reuses the earlier attempt's inputs; merge +# copies shard results oldest attempt first, so a later attempt of a shard wins. +# +# The controls run (the U17 fixture, every expected mutation and fingerprint verdict) is a PR job +# in mutation-runner-validation.yml. + +on: + schedule: + # Mondays 04:17 UTC, outside the busy CI hours. + - cron: '17 4 * * 1' + workflow_dispatch: + inputs: + mutation: + description: 'Mutation evidence: none (fingerprint only), possible-duplicates, or all' + type: choice + options: [none, possible-duplicates, all] + default: none + seed: + description: 'Seed of every sampled choice, the spot audit included (default: the run id)' + type: string + required: false + default: '' + shards: + description: 'Number of mutation shards (default: the fewest that fit, at most 20)' + type: string + required: false + default: '' + # The deep run of one pull request: only the `scenario-map` label starts it (the plan job's + # `if`), and the TEMPORARY `scenario-map-test` label on PR #3686 by magyargergo; any other + # label event leaves every job skipped. + pull_request: + types: [labeled] + +permissions: + contents: read + +# Concurrency convention: see CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention". +# Weekly and manual runs share the tracking issue and the baseline, so they share one slot and run +# one at a time; each pull request has its own slot. A run that holds hours of shard work is never +# cancelled. (GitHub keeps one pending run: a third run queued behind a pending one replaces it.) +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'default' }} + cancel-in-progress: false + +defaults: + run: + shell: bash + working-directory: gitnexus + +env: + # Stryker concurrency: the plan's cost estimate and the shards' runs (ubuntu-latest: 4 vCPUs). + SCENARIO_MAP_CONCURRENCY: '4' + # One shard's budget in the plan and its deadline in the shard job (KTD17). The shard job's + # timeout-minutes (330) leaves room for setup and the last uploads. + SCENARIO_MAP_DEADLINE_MINUTES: '300' + +jobs: + # ── Coverage: Layer 1 in slices ──────────────────────────────────── + coverage: + name: Coverage ${{ matrix.index }} + # TEMPORARY: the `scenario-map-test` clause (PR #3686 testing); remove before merge. + if: github.event_name != 'pull_request' || github.event.label.name == 'scenario-map' || (github.event.label.name == 'scenario-map-test' && github.event.sender.login == 'magyargergo' && github.event.pull_request.number == 3686) + runs-on: ubuntu-latest + # Four slices balanced like the CI test shards (test/helpers/shard-balance.ts): the whole unit + # and integration suite runs with coverage in three 7-8 minute shards of the test workflow + # (ci.yml run 30014110469). Layer 1 repeats only test bodies and snapshots per test (about 5% + # of an integration file whose work sits in beforeAll), and the prefilter drops a quarter. + timeout-minutes: 75 + continue-on-error: true + permissions: + contents: read + strategy: + fail-fast: false + matrix: + index: [0, 1, 2, 3] + steps: + # A pull request's merge commit needs its first parent (the base) for the diff scope. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 1 }} + # Integration tests load dist/ (the built CLI and its worker scripts). + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Run the slice + timeout-minutes: 60 + env: + INDEX: ${{ matrix.index }} + OF: ${{ strategy.job-total }} + # The plan's scope (its "Choose the mutation scope" step): unless `all`, Layer 1 skips the + # files whose tests all have a checks-and-context key no other test shares. + MUTATION: ${{ github.event_name == 'pull_request' && 'possible-duplicates' || inputs.mutation || 'none' }} + EVENT: ${{ github.event_name }} + LABEL: ${{ github.event.label.name }} + run: | + args=(--index "$INDEX" --of "$OF" --mutation "$MUTATION" + --work "$RUNNER_TEMP/scenario-map" --concurrency "$SCENARIO_MAP_CONCURRENCY") + # The merge commit's first parent is the base: the pull request's own changes. The + # TEMPORARY `scenario-map-test` run covers the whole suite. + if [ "$EVENT" = "pull_request" ] && [ "$LABEL" = "scenario-map" ]; then + args+=(--changed-since 'HEAD^1') + fi + status=0 + npm run test:scenario-map -- coverage "${args[@]}" || status=$? + if [ "$status" -eq 1 ]; then + echo "::warning::a test file of slice $INDEX failed or wrote no record; the report counts it" + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + - name: Upload the slice + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-coverage-${{ github.run_attempt }}-${{ matrix.index }} + path: ${{ runner.temp }}/scenario-map/coverage-slice-${{ matrix.index }}.json + if-no-files-found: error + retention-days: 14 + + # ── Plan: Layers 0-2, candidates, shard plan, run manifest ───────── + plan: + name: Plan + needs: coverage + # TEMPORARY: the `scenario-map-test` clause (PR #3686 testing); remove before merge. + if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.label.name == 'scenario-map' || (github.event.label.name == 'scenario-map-test' && github.event.sender.login == 'magyargergo' && github.event.pull_request.number == 3686)) }} + runs-on: ubuntu-latest + timeout-minutes: 60 + continue-on-error: true + permissions: + contents: read + outputs: + artifact: ${{ steps.artifact.outputs.name }} + mutation: ${{ steps.scope.outputs.mutation }} + # Scheduled runs always run on the default branch; their event payload has no repository. + default_branch: ${{ github.event_name == 'schedule' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + steps: + # persist-credentials: false — the jobs upload artifacts and never push. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Layer 1 comes from the slices: the plan runs no test and needs no dist/. + - uses: ./.github/actions/setup-gitnexus + - name: Choose the mutation scope + id: scope + env: + MUTATION: ${{ github.event_name == 'pull_request' && 'possible-duplicates' || inputs.mutation || 'none' }} + run: echo "mutation=$MUTATION" >> "$GITHUB_OUTPUT" + # No slice at all fails the plan (nothing to merge); a missing slice leaves its files + # unrecorded in the report. + - name: Download the coverage slices + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: scenario-map-coverage-* + path: ${{ runner.temp }}/scenario-map-coverage + - name: Place the coverage slices + run: | + shopt -s nullglob + root="$RUNNER_TEMP/scenario-map-coverage" + dest="$RUNNER_TEMP/scenario-map-slices" + mkdir -p "$dest" + found=("$root"/scenario-map-coverage-*) + # download-artifact unpacks a pattern that matches ONE artifact into the path itself. + if [ "${#found[@]}" -eq 0 ] && [ -d "$root" ]; then found=("$root"); fi + # scenario-map-coverage--: oldest attempt first, so a later attempt wins. + while IFS= read -r dir; do + [ -n "$dir" ] || continue + for file in "$dir"/coverage-slice-*.json; do cp "$file" "$dest/"; done + done < <(printf '%s\n' "${found[@]}" | sort -V) + ls "$dest" + - name: Plan the run + env: + SEED: ${{ inputs.seed || github.run_id }} + SHARDS: ${{ inputs.shards }} + MUTATION: ${{ steps.scope.outputs.mutation }} + run: | + # The slices carry Layer 1, and a pull request's diff scope with it. + args=(--work "$RUNNER_TEMP/scenario-map" --seed "$SEED" --mutation "$MUTATION" + --concurrency "$SCENARIO_MAP_CONCURRENCY" + --deadline-minutes "$SCENARIO_MAP_DEADLINE_MINUTES" + --coverage "$RUNNER_TEMP/scenario-map-slices") + if [ -n "$SHARDS" ]; then args+=(--shards "$SHARDS"); fi + npm run test:scenario-map -- plan "${args[@]}" + - name: Upload the plan + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-plan-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/scenario-map/plan.json + ${{ runner.temp }}/scenario-map/scenario-run-manifest.json + ${{ runner.temp }}/scenario-map/layer0.json + ${{ runner.temp }}/scenario-map/layer2.json + ${{ runner.temp }}/scenario-map/coverage/coverage.json + if-no-files-found: error + retention-days: 14 + - name: Name the plan artifact + id: artifact + env: + NAME: scenario-map-plan-${{ github.run_attempt }} + run: echo "name=$NAME" >> "$GITHUB_OUTPUT" + + # ── Value flow: PDG static check, trace run, final shard plan ────── + value-flow: + name: Value flow + needs: plan + if: ${{ !cancelled() && needs.plan.outputs.artifact != '' }} + runs-on: ubuntu-latest + # Locally: PDG index about 3 min (peak RSS about 8.4 GB of the runner's 16 GB), the whole + # stage about 5 min. + timeout-minutes: 60 + continue-on-error: true + permissions: + contents: read + outputs: + artifact: ${{ steps.artifact.outputs.name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The trace runs integration tests, and the PDG index of scope `all` spawns workers: dist/. + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + - name: Download the plan + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.plan.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + - name: Run value flow + timeout-minutes: 50 + env: + GITNEXUS_WORKER_READY_TIMEOUT_MS: '120000' + run: | + status=0 + npm run test:scenario-map -- value-flow --work "$RUNNER_TEMP/scenario-map" || status=$? + if [ "$status" -eq 1 ]; then + echo "::warning::value flow is degraded, or its trace did not run or was invalid; the report says so" + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + - name: Upload the value-flow result + id: upload + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-value-flow-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/scenario-map/value-flow.json + ${{ runner.temp }}/scenario-map/value-flow/pdg/analyze.log + retention-days: 14 + # Named only when value-flow.json was uploaded: without it, shards and merge fall back to + # the provisional plan and merge reports value flow missing. + - name: Name the value-flow artifact + id: artifact + if: always() && steps.upload.outcome == 'success' + env: + NAME: scenario-map-value-flow-${{ github.run_attempt }} + run: | + if [ -f "$RUNNER_TEMP/scenario-map/value-flow.json" ]; then + echo "name=$NAME" >> "$GITHUB_OUTPUT" + fi + + # ── Shard count: sizes the matrix ────────────────────────────────── + shards: + name: Shard count + needs: [plan, value-flow] + # Scope `none` plans no shard. + if: ${{ !cancelled() && needs.plan.outputs.artifact != '' && needs.plan.outputs.mutation != 'none' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + continue-on-error: true + permissions: + contents: read + outputs: + count: ${{ steps.count.outputs.count }} + matrix: ${{ steps.count.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Download the plan + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.plan.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + - name: Download the value-flow result + if: needs.value-flow.outputs.artifact != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.value-flow.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + # --silent: the stage prints the count and nothing else; npm's banner would precede it. + - name: Read the final shard count + id: count + run: | + count="$(npm run --silent test:scenario-map -- shards --work "$RUNNER_TEMP/scenario-map")" + if ! [[ "$count" =~ ^[0-9]+$ ]]; then + echo "::error::the shards stage printed '$count', not a shard count" + exit 1 + fi + echo "count=$count" >> "$GITHUB_OUTPUT" + echo "matrix=$(jq -cn --argjson n "$count" '[range($n)]')" >> "$GITHUB_OUTPUT" + echo "Final shard plan: $count shard(s)" + + # ── Layer 3 shards ───────────────────────────────────────────────── + shard: + name: Shard ${{ matrix.index }} + needs: [plan, value-flow, shards] + if: ${{ !cancelled() && needs.shards.outputs.matrix != '' && needs.shards.outputs.count != '0' }} + runs-on: ubuntu-latest + timeout-minutes: 330 + continue-on-error: true + permissions: + contents: read + strategy: + fail-fast: false + # Leave runner slots for PR CI on the default plan (about 9 shards at concurrency 4). + max-parallel: 10 + matrix: + index: ${{ fromJSON(needs.shards.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Download the plan + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.plan.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + - name: Download the value-flow result + if: needs.value-flow.outputs.artifact != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.value-flow.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + # The shard verifies its own prepared copy against the run manifest; a mismatch runs + # nothing and leaves its groups' pairs Inconclusive. The step timeout sits inside the job's, + # so a shard that overruns its deadline still uploads what it finished. + - name: Run the shard + timeout-minutes: 315 + env: + INDEX: ${{ matrix.index }} + COUNT: ${{ needs.shards.outputs.count }} + run: | + status=0 + npm run test:scenario-map -- shard --work "$RUNNER_TEMP/scenario-map" \ + --index "$INDEX" --of "$COUNT" \ + --deadline-minutes "$SCENARIO_MAP_DEADLINE_MINUTES" \ + --concurrency "$SCENARIO_MAP_CONCURRENCY" || status=$? + if [ "$status" -eq 1 ]; then + echo "::warning::shard $INDEX finished with incomplete results; the report says which" + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + # Every group result is written as soon as it exists: upload whatever the shard finished, + # even after a failure. + - name: Upload the shard results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-shard-${{ github.run_attempt }}-${{ matrix.index }} + path: | + ${{ runner.temp }}/scenario-map/shard-${{ matrix.index }}.json + ${{ runner.temp }}/scenario-map/row-deletion-shard-${{ matrix.index }}.json + ${{ runner.temp }}/scenario-map/observations/ + ${{ runner.temp }}/scenario-map/batches/ + ${{ runner.temp }}/scenario-map/isolation/ + ${{ runner.temp }}/scenario-map/groups/*/stryker/stryker.log + ${{ runner.temp }}/scenario-map/groups/*/*/stryker/stryker.log + retention-days: 14 + + # ── Merge: verdicts and the report ───────────────────────────────── + merge: + name: Merge and report + needs: [plan, value-flow, shard] + if: ${{ always() && needs.plan.outputs.artifact != '' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + continue-on-error: true + permissions: + contents: read + actions: read # list and download an earlier run's scenario-map-baseline artifact + outputs: + artifact: ${{ steps.artifact.outputs.name }} + complete: ${{ steps.merge.outputs.complete }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + - name: Download the plan + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.plan.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + - name: Download the value-flow result + if: needs.value-flow.outputs.artifact != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.value-flow.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map + # No shard result at all is a valid, partial outcome: merge reports every shard missing. + - name: Download the shard results + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: scenario-map-shard-* + path: ${{ runner.temp }}/scenario-map-shards + - name: Place the shard results + run: | + shopt -s nullglob + work="$RUNNER_TEMP/scenario-map" + root="$RUNNER_TEMP/scenario-map-shards" + found=("$root"/scenario-map-shard-*) + # download-artifact unpacks a pattern that matches ONE artifact into the path itself, not + # into a directory named after it (a one-shard plan): the path is then that artifact. + if [ "${#found[@]}" -eq 0 ] && [ -d "$root" ]; then found=("$root"); fi + # scenario-map-shard--: oldest attempt first, so a later attempt wins. + while IFS= read -r dir; do + [ -n "$dir" ] || continue + echo "Using $(basename "$dir")" + cp -R "$dir/." "$work/" + done < <(printf '%s\n' "${found[@]}" | sort -V) + # KTD13: the newest non-expired `scenario-map-baseline` of an earlier completed run of THIS + # workflow on the default branch; only complete default-branch runs upload one. Never a + # repository-wide artifact listing: it holds fork pull_request runs too, and a fork branch + # may carry the default branch's name. Only schedule and workflow_dispatch runs count (a + # labelled pull request run never does), and each run's head repository is checked as well. None found: "first run" banner. A + # failure here must not cost the run its report: merge then runs without a baseline, and a + # baseline merge cannot read gets the incompatible-baseline banner. + - name: Fetch the baseline + id: baseline + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch || github.ref_name }} + run: | + dir="$RUNNER_TEMP/scenario-map-baseline" + mkdir -p "$dir" + if ! gh api --method GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/test-scenario-map.yml/runs" \ + -f branch="$DEFAULT_BRANCH" -f status=completed -f per_page=20 > "$dir/runs.json"; then + echo "::warning::could not list the earlier runs; the report shows no baseline" + exit 0 + fi + id="" + # Newest first: this workflow's scheduled or manual runs other than this one, whose + # repository and head repository are this repository. + while IFS= read -r run_id; do + [ -n "$run_id" ] || continue + if ! gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?name=scenario-map-baseline" \ + < /dev/null > "$dir/artifacts.json"; then + continue + fi + id="$(jq -r '[.artifacts[] | select(.expired | not)] + | sort_by(.created_at) | last | .id // empty' "$dir/artifacts.json")" + if [ -n "$id" ]; then + echo "Baseline run: $run_id" + break + fi + done < <(jq -r --argjson run "$GITHUB_RUN_ID" --argjson repo "$GITHUB_REPOSITORY_ID" ' + [.workflow_runs[] + | select(.id != $run + and (.event == "schedule" or .event == "workflow_dispatch") + and .repository.id == $repo + and .head_repository.id == $repo)] + | sort_by(.created_at) | reverse | .[].id' "$dir/runs.json") + if [ -z "$id" ]; then + echo "No earlier scenario-map-baseline artifact: first run, no baseline." + exit 0 + fi + if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" > "$dir/baseline.zip" \ + || ! unzip -q -o "$dir/baseline.zip" -d "$dir/report" \ + || [ ! -f "$dir/report/scenario-map-report.json" ]; then + echo "::warning::could not fetch baseline artifact $id; the report shows no baseline" + exit 0 + fi + echo "Baseline: artifact $id" + echo "report=$dir/report/scenario-map-report.json" >> "$GITHUB_OUTPUT" + - name: Merge + id: merge + env: + BASELINE: ${{ steps.baseline.outputs.report }} + ON_DEFAULT_BRANCH: ${{ needs.plan.outputs.default_branch }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REF: ${{ github.ref }} + run: | + work="$RUNNER_TEMP/scenario-map" + args=(--work "$work" --run-id "$GITHUB_RUN_ID" --run-attempt "$RUN_ATTEMPT" + --run-url "$RUN_URL" --ref "$REF") + if [ -n "$BASELINE" ]; then args+=(--baseline "$BASELINE"); fi + if [ "$ON_DEFAULT_BRANCH" = 'true' ]; then args+=(--default-branch); fi + status=0 + npm run test:scenario-map -- merge "${args[@]}" || status=$? + if [ "$status" -eq 1 ]; then + echo "::warning::partial report: some shard or value-flow result is missing" + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + complete="$(jq -r '.completeness.complete == true' "$work/scenario-map-report.json")" + echo "complete=$complete" >> "$GITHUB_OUTPUT" + - name: Upload the report + id: upload + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-report-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/scenario-map/scenario-map-report.json + ${{ runner.temp }}/scenario-map/scenario-map-report.md + ${{ runner.temp }}/scenario-map/issue-body.md + retention-days: 90 + # The tracking-issue view on the run page: a pull request run's only rendered report. + - name: Summarise the report + if: always() + run: | + body="$RUNNER_TEMP/scenario-map/issue-body.md" + if [ -f "$body" ]; then tail -n +2 "$body" >> "$GITHUB_STEP_SUMMARY"; fi + - name: Name the report artifact + id: artifact + if: always() && steps.upload.outcome == 'success' + env: + NAME: scenario-map-report-${{ github.run_attempt }} + run: | + if [ -f "$RUNNER_TEMP/scenario-map/scenario-map-report.json" ]; then + echo "name=$NAME" >> "$GITHUB_OUTPUT" + fi + # overwrite: a re-run attempt replaces this run's own baseline instead of failing on it. + - name: Upload the baseline + if: steps.merge.outputs.complete == 'true' && needs.plan.outputs.default_branch == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scenario-map-baseline + path: ${{ runner.temp }}/scenario-map/scenario-map-report.json + if-no-files-found: error + retention-days: 90 + overwrite: true + + # ── Tracking issue (R13) ─────────────────────────────────────────── + # The only job with a write scope, and only on the default branch. No install scripts run here + # (lifecycle-scripts: 'false'): tsx alone runs the decision. + upsert-issue: + name: Upsert tracking issue + needs: [plan, merge] + if: ${{ !cancelled() && needs.plan.outputs.default_branch == 'true' && needs.merge.outputs.artifact != '' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + continue-on-error: true + permissions: + contents: read + issues: write # find, open, update or comment on the tracking issue (and its label) + env: + LABEL: test-scenario-map + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + with: + lifecycle-scripts: 'false' + - name: Download the report + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.merge.outputs.artifact }} + path: ${{ runner.temp }}/scenario-map-report + - name: Find the tracking issue + id: find + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const path = require('path'); + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + labels: process.env.LABEL, + state: 'all', + per_page: 100, + }); + // Any state; the oldest one when several carry the label. + const tracking = issues + .filter((issue) => !issue.pull_request) + .sort((a, b) => a.number - b.number)[0]; + if (tracking === undefined) { + core.info('No tracking issue yet.'); + return; + } + const issueDir = path.join(process.env.RUNNER_TEMP, 'scenario-map-issue'); + fs.mkdirSync(issueDir, { recursive: true }); + fs.writeFileSync(path.join(issueDir, 'existing-body.md'), tracking.body ?? ''); + core.setOutput('number', String(tracking.number)); + core.info(`Tracking issue #${tracking.number} (${tracking.state}).`); + # The decision is issue-body.ts's own R13/KTD13 rule (shouldUpdateIssue): a complete + # default-branch run updates unless the body's first-line run marker records a newer run. + - name: Decide the update + id: decide + env: + ISSUE_NUMBER: ${{ steps.find.outputs.number }} + run: | + node --import tsx --input-type=module <<'EOF' + import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + import path from 'node:path'; + import { shouldUpdateIssue } from './tools/scenario-map/report/issue-body.ts'; + import { completenessLine, degradedLine, runLink } from './tools/scenario-map/report/markdown.ts'; + + const { ISSUE_NUMBER, GITHUB_OUTPUT, RUNNER_TEMP } = process.env; + const reportDir = path.join(RUNNER_TEMP, 'scenario-map-report'); + const issueDir = path.join(RUNNER_TEMP, 'scenario-map-issue'); + const report = JSON.parse( + readFileSync(path.join(reportDir, 'scenario-map-report.json'), 'utf8'), + ); + const existing = ISSUE_NUMBER + ? readFileSync(path.join(issueDir, 'existing-body.md'), 'utf8') + : null; + let action = 'skip'; + if (report.completeness.complete) { + if (shouldUpdateIssue(report, existing)) action = existing === null ? 'create' : 'update'; + else console.log('The issue already records a newer run: body left alone.'); + } else if (existing !== null) { + action = 'comment'; + const lines = [ + `Partial ${runLink(report.run)}: the issue body is left unchanged; only a complete run updates it.`, + '', + `- ${completenessLine(report)}`, + `- ${degradedLine(report)}`, + '', + "The run's report artifact holds every result it produced.", + ]; + mkdirSync(issueDir, { recursive: true }); + writeFileSync(path.join(issueDir, 'comment.md'), `${lines.join('\n')}\n`); + } else { + console.log('Partial run and no tracking issue yet: nothing to comment on.'); + } + appendFileSync(GITHUB_OUTPUT, `action=${action}\n`); + console.log(`Tracking issue action: ${action}`); + EOF + # Bodies are read from files, never passed through env or outputs. Never closes the issue. + - name: Apply the update + if: steps.decide.outputs.action != 'skip' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION: ${{ steps.decide.outputs.action }} + ISSUE_NUMBER: ${{ steps.find.outputs.number }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { owner, repo } = context.repo; + const label = process.env.LABEL; + const read = (dir, file) => + fs.readFileSync(path.join(process.env.RUNNER_TEMP, dir, file), 'utf8'); + switch (process.env.ACTION) { + case 'create': { + try { + await github.rest.issues.getLabel({ owner, repo, name: label }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ + owner, + repo, + name: label, + color: 'c5def5', + description: 'Tracking issue of the weekly test scenario map', + }); + } + const { data } = await github.rest.issues.create({ + owner, + repo, + title: 'Test scenario map: repeat candidates, weak tests and gaps', + body: read('scenario-map-report', 'issue-body.md'), + labels: [label], + }); + core.info(`Opened tracking issue #${data.number}.`); + break; + } + case 'update': + await github.rest.issues.update({ + owner, + repo, + issue_number: Number(process.env.ISSUE_NUMBER), + body: read('scenario-map-report', 'issue-body.md'), + }); + core.info(`Updated the body of #${process.env.ISSUE_NUMBER}.`); + break; + case 'comment': + await github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(process.env.ISSUE_NUMBER), + body: read('scenario-map-issue', 'comment.md'), + }); + core.info(`Commented on #${process.env.ISSUE_NUMBER}.`); + break; + default: + throw new Error(`unknown action ${process.env.ACTION}`); + } diff --git a/.gitignore b/.gitignore index 2256b7110..902b806c9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ coverage/ .vitest/ .tmp-test/ gitnexus/.tmp-test/ +# Mutation testing (gitnexus/tools/mutation). Prepared copies and run directories live +# outside the checkout; these catch a Stryker run started inside it. +.stryker-tmp/ +gitnexus/reports/mutation/ # Misc *.local diff --git a/.gitleaksignore b/.gitleaksignore index 243cd4410..aeca3a91f 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -2,3 +2,7 @@ c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348 # Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present. 3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8 +# Vitest provide/inject key name in the mutation runner protocol from PR #3686; no credential was present. +bf4fe7666212c1e2a82aea8309095f63e4b23dcc:gitnexus/tools/mutation/runner/protocol.ts:generic-api-key:11 +# The previous wording of the comment above quoted that key name and was itself flagged; no credential was present. +02989dd688f863248993122879818ffc4cc95e11:.gitleaksignore:generic-api-key:5 diff --git a/TESTING.md b/TESTING.md index 450a0c792..cbad267a9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,6 +26,7 @@ From `gitnexus/`: | `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code | | `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue | | `npm run test:watch` | Vitest in watch mode | Active development | +| `npm run test:dupes` | Duplicate-test scan of lines changed vs `main` | Before adding tests or benchmarks | Vitest transpiles TypeScript without checking types. Run `npm run typecheck:tests` in `gitnexus/` to check test code and helpers against the production types. This @@ -159,12 +160,167 @@ The PR report shows the reconciled result as **Unverified**. Zero means every te has execution evidence; individual OS logs still show tests that require another OS as skipped. Failed executions remain failures even if another job passes. +### Duplicate-test scan (advisory) + +The `Duplicate test scan` job in `ci.yml` runs `npm run test:dupes` on the lines a PR +changes, and the PR report lists what it finds under **Possible duplicate tests**. It +never fails a build. It reports: + +| Kind | Meaning | +| -------------------- | --------------------------------------------------------------------------- | +| `same-test` | Same body and same setup as an existing test | +| `assertions-pinned` | Every assertion is a literal `fn(...)` → matcher another test already makes | +| `same-fixture-setup` | A `before*` hook builds a fixture another describe already builds | +| `copied-helper` | A top-level helper identical to one in another file or `test/helpers` | +| `orphan-bench-gate` | A bench script with `--check` that no workflow or npm script runs | + +"Same setup" includes enclosing hooks, describe-level statements, wrapper arguments, +loop heads, `each` tables, referenced file-local declarations and resolved imports, so +identical bodies that run against different fixtures are not reported. Mark an +intentional repeat with `// dupe-ok: ` on the reported line or the line above. +Locally, `npm run test:dupes` compares against `origin/main`; pass `--base ` to +change that, or `--all` to scan every test. Python tests are not scanned. + `npm run test:benchmarks` discovers all tests gated by `GITNEXUS_BENCH` and runs them serially. Keep timing measurements out of parallel coverage workers. The `eval-tests` job installs the locked Python dependencies and runs the workflow preflight Vitest tests as well as pytest; those tests must exercise real Python validation, never a stubbed success. +## Mutation testing tooling + +`gitnexus/tools/mutation/` holds a Stryker test runner for Vitest 5 (`vitest5`) and the tools +that validate it. The runner supports Vitest `5.0.x` only and refuses any other version. Its +validated scope is the pure-JS category: 251 unit test files (4,667 tests) that load no native +code, workers or child processes and read no fixtures. The gates stay out of the main suite +(only the tools' unit tests in `test/unit/mutation/` run there), and nothing ships in the npm +package. + +Every run works in a prepared copy outside the checkout (`--out`/`--work` must lie outside it). +The copy blanks each `as` type assertion in the mutated files with spaces of equal length, +because Stryker never mutates code under one, and relocates `gitnexus-shared/src` into the +package. From `gitnexus/` (each tool documents its options in its file header): + +| Command | What it does | +| -------------------------------------- | --------------------------------------------------------------------- | +| `npm run test:mutation:run` | One Stryker run over a scope file with the `vitest5` runner | +| `npm run test:mutation:classify` | Lists the pure-JS unit test files | +| `npm run test:mutation:fidelity` | Gate 1: the unmutated run matches plain Vitest; records the envelope | +| `npm run test:mutation:conformance` | Gate 2: pinned outcomes of the conformance fixture project | +| `npm run test:mutation:oracle` | Gate 3: compares runner runs with the frozen oracle manifests | +| `npm run test:mutation:oracle:freeze` | Freezes (or re-freezes) one oracle manifest | +| `npm run test:mutation:oracle:command` | The oracle's own Stryker run, with Stryker's `command` runner | +| `npm run test:mutation:controls` | Gate 4: planted positive controls and determinism across two profiles | +| `npm run test:mutation:bench` | Oracle cost benchmark | +| `npm run test:mutation:prepare` | Builds a prepared copy on its own | + +Frozen artifacts live in `gitnexus/test/fixtures/`: `mutation-conformance/` (the gate 2 +fixture project and `expected.json`), `mutation-oracle/` (the gate 1 envelope and one gate 3 +manifest per oracle scope: `shared`, `schema-constants`, `middleware`) and +`mutation-controls/planted.json` (gate 4). + +Re-freeze a manifest when gate 3 reports "re-freeze required", that is after a change to a +scope's tests or their imports, the Vitest configs, the prepare tool, or the Stryker, Vitest or +Node major version. Use the `refreeze` command recorded in the manifest, with a new seed. +Re-select the controls when gate 4 reports "re-select required". After a Vitest, Stryker or Node +upgrade, re-run all four gates. Refresh `expected.json` from the conformance `observed.json` +only for an intended change. + +`.github/workflows/mutation-runner-validation.yml` runs gate 2 on PRs that touch the tooling, +its fixtures, `gitnexus/package.json`, the lockfile or `gitnexus/vitest.config.ts`. A manual +dispatch also runs gates 1, 3 and 4 and the benchmark, or re-freezes oracle manifests sharded +by scope and uploads them for a maintainer to commit. The workflow is advisory. + +### Test Scenario Map + +`gitnexus/tools/scenario-map/` builds a report on the eligible pure-JS tests: tests that repeat +a scenario, tests that kill no evaluated mutant, and per-file gaps. It runs Layers 0–3 of the +design (`docs/plans/2026-10-23-test-scenario-map-design.md`) on the `vitest5` runner. It only +reports. It never edits, deletes or folds a test. + +From `gitnexus/`, run `npm run test:scenario-map -- --work `. The work directory +must lie outside the checkout. Every stage reads and writes JSON there, so a local `all` run and +CI produce the same report. `value-flow`, `all` and `controls` need a built `dist/` +(`npm run build`). The CLI header (`tools/scenario-map/scenario-map.ts`) documents every flag +and file. + +| Stage | What it does | Writes (under `--work`) | +| ------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `plan` | Eligibility, Layers 0–2, the run manifest, candidates, shard plan | `plan.json`, `scenario-run-manifest.json`, `layer0.json`, `layer2.json`, `coverage/` | +| `value-flow` | PDG index, static value-flow check, trace run, final shard plan | `value-flow.json` | +| `shards` | Prints the final shard count (the CI matrix size) | Nothing | +| `shard` | `--index --of `: Layer 3 runs, row deletion, isolation pass | `observations/`, `batches/`, `isolation/`, `row-deletion-shard-.json`, `shard-.json` | +| `merge` | Merges every result, computes verdicts, compares with `--baseline` | `scenario-map-report.json`, `scenario-map-report.md`, `issue-body.md` | +| `all` | `plan` (one shard), `value-flow`, `shard` 0 of 1 and `merge` | All of the above | +| `controls` | `all` over the controls fixture, then compares every expected result | All of the above | + +Exit code 0 means complete results. Exit code 1 means the stage finished with incomplete results, +or a controls verdict differs. Exit code 2 is a usage or setup error. + +The controls fixture is `gitnexus/test/fixtures/mutation-scenario-map/`: one pair per +`*.case.ts` file (16 pairs). It holds the pure-JS adversarial pairs of design §8, an +order-dependent pair, an object-key row and positive controls. `expected.json` was written from +the design before any pipeline run. Never regenerate it from observed output: a mismatch is a +finding. The fixture has its own Vitest config and tsconfig, and no repo project collects it. +The `scenario-map-controls` job in `.github/workflows/mutation-runner-validation.yml` runs +`controls` on the same PRs as gate 2. + +**Supported matchers** (`SUPPORTED_MATCHERS` in `tools/scenario-map/facet/checks.ts`), after +`expect(…)` or `expect.soft(…)` and an optional `.not`, `.resolves` or `.rejects`: +`toBe`, `toEqual`, `toStrictEqual`, `toMatchObject`, `toBeTruthy`, `toBeFalsy`, `toBeNull`, +`toBeUndefined`, `toBeDefined`, `toBeNaN`, `toBeNullable`, `toBeTypeOf`, `toBeInstanceOf`, +`toBeGreaterThan`, `toBeGreaterThanOrEqual`, `toBeLessThan`, `toBeLessThanOrEqual`, +`toBeCloseTo`, `toBeOneOf`, `toContain`, `toContainEqual`, `toHaveLength`, `toHaveProperty`, +`toMatch`, `toThrow`, `toThrowError`, `toHaveBeenCalled`, `toHaveBeenCalledOnce`, +`toHaveBeenCalledTimes`, `toHaveBeenCalledWith`, `toHaveBeenCalledExactlyOnceWith`, +`toHaveBeenLastCalledWith`, `toHaveBeenNthCalledWith`, `toHaveBeenCalledBefore`, +`toHaveBeenCalledAfter`, `toBeCalled`, `toBeCalledTimes`, `toBeCalledWith`, `toHaveReturned`, +`toHaveReturnedTimes`, `toHaveReturnedWith`, `toHaveLastReturnedWith`, `toHaveNthReturnedWith`, +`toReturn`, `toReturnTimes`, `toReturnWith`, `toHaveResolved`, `toHaveResolvedTimes`, +`toHaveResolvedWith`, `toHaveLastResolvedWith`, `toHaveNthResolvedWith`, and the snapshot +matchers `toMatchSnapshot`, `toMatchInlineSnapshot`, `toMatchFileSnapshot`, +`toThrowErrorMatchingSnapshot`, `toThrowErrorMatchingInlineSnapshot`. `expect.assertions(n)` and +`expect.hasAssertions()` count as checks. Any other form is unsupported and blocks strong +evidence: `assert.*`, chai-style chains, custom matchers, `toSatisfy`, and an `expect` inside a +loop, a conditional, a catch clause, a nested function or a helper. + +**Wrapped built-ins** of the trace run (`BASE_WRAP_LIST` in +`tools/scenario-map/value-flow/builtins.ts`), by group: + +- `math`: every `Math` function except `random`. +- `string`: `String.prototype` `toUpperCase`, `toLowerCase`, `toLocaleUpperCase`, + `toLocaleLowerCase`, `normalize`, `localeCompare`, `trim`, `trimStart`, `trimEnd`, `padStart`, + `padEnd`, `slice`, `substring`, `substr`, `replace`, `replaceAll`, `split`, `at`, `charAt`, + `charCodeAt`, `codePointAt`, `startsWith`, `endsWith`, `includes`, `indexOf`, `lastIndexOf`, + `repeat`. +- `regexp`: `RegExp.prototype.test` and `exec`; `String.prototype.match`, `matchAll` and + `search`. +- `number`: `parseInt`, `parseFloat`, the `Number` constructor, and `Number.prototype.toFixed`, + `toPrecision`, `toExponential`, `toString`, `toLocaleString`. +- `json`: `JSON.parse`, `JSON.stringify`. +- `intl`: the `Intl` constructors `Collator`, `DateTimeFormat`, `NumberFormat`, `PluralRules`, + `RelativeTimeFormat`, `ListFormat`, `Segmenter`, `DisplayNames`; `Collator.prototype.compare`, + `DateTimeFormat.prototype.format` and `NumberFormat.prototype.format`; and the methods + `formatToParts`, `formatRange`, `formatRangeToParts`, `select`, `selectRange`, `segment` and + `of`. +- `date`: the `Date` constructor, `Date.UTC`, `Date.parse`, every `Date.prototype` getter and + setter, and its formatters. +- `sort`: `Array.prototype.sort` and `toSorted`, recorded with the receiver and the comparator. + +The trace also wraps the opaque callees that the static value-flow check finds. Storage and +table reads stay unwrapped: array `push` and `unshift`, and every method of `Map`, `Set`, +`WeakMap` and `WeakSet`. + +`.github/workflows/test-scenario-map.yml` runs the pipeline every Monday and on manual dispatch +(inputs `seed` and `shards`). Its jobs are `plan`, `value-flow`, `shards`, a `shard` matrix, +`merge` and `upsert-issue`. Every job is advisory (`continue-on-error`). Only a complete run from +the default branch uploads the `scenario-map-baseline` artifact and replaces the body of the +tracking issue (label `test-scenario-map`), unless the issue already records a newer run. A +partial run adds a comment and leaves the body alone. The workflow never closes the issue. + +Mark a human keep with `// dupe-ok: ` on either test of a pair, on the test call's line +or the line above. The report then lists the pair as kept, with its evidence. + ## Regression testing Re-run the full relevant suite when: diff --git a/docs/plans/2026-10-23-1823-feat-stryker-vitest5-runner-phase0-plan.md b/docs/plans/2026-10-23-1823-feat-stryker-vitest5-runner-phase0-plan.md new file mode 100644 index 000000000..8541b90be --- /dev/null +++ b/docs/plans/2026-10-23-1823-feat-stryker-vitest5-runner-phase0-plan.md @@ -0,0 +1,749 @@ +--- +title: Stryker Vitest 5 Runner, Phase 0 Pure-JS Slice - Plan +type: feat +date: 2026-10-23 +origin: docs/plans/2026-10-23-test-scenario-map-design.md +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +deepened: 2026-10-23 +--- + +# Stryker Vitest 5 Runner, Phase 0 Pure-JS Slice - Plan + +## Goal Capsule + +- **Objective:** maintainers can run Stryker mutation testing on `gitnexus/` pure-JS unit tests under the repo's own Vitest 5, and trust each per-test kill inside the validated envelope because four validation gates passed. Gate 3's evidence is a frozen, stratified sample, so its claim is a bounded difference rate per scope, not a check of every mutant. This is the precondition for every strong verdict in the Test Scenario Map (see origin: `docs/plans/2026-10-23-test-scenario-map-design.md` §5.4a). +- **Means:** a repo-local Stryker test-runner plugin for Vitest 5 (KTD1), a prepared-copy step that makes type-asserted code mutable (KTD7, KTD8), and an independent Vitest 5 cross-check oracle with its own Stryker run (KTD11, KTD18). +- **Authority:** this plan's R-IDs and KTDs, then the origin design (§5.4, §5.4a, §9 Phase 0), then repo rules in `AGENTS.md`, `GUARDRAILS.md`, `TESTING.md`. +- **Stop conditions:** stop and report if the Vitest 5 runner cannot reach per-test kill attribution on the pure-JS slice, or if gate 3 leaves an unresolved difference. An exclusion must name the runner mechanism behind the difference and remove every pure-JS test that shows that trait, not only the sampled tests; after any fix or exclusion, gate 3 re-freezes with a new seed. Never weaken a gate to pass it. +- **Execution profile:** tooling work in `gitnexus/tools/mutation/`, fixtures under `gitnexus/test/fixtures/mutation-*`, unit tests, one CI workflow, and supporting edits to `gitnexus/package.json`, `gitnexus/package-lock.json`, `gitnexus/tsconfig.test.json`, ignore and lint configs, `TESTING.md` and the plan and design docs. No production code under `gitnexus/src` changes. No test under `gitnexus/test/unit` or `gitnexus/test/integration` is edited or deleted. +- **Tail ownership:** the runner, oracle manifests and CI workflow are owned by this PR (#3686). Phase 1 (Layers 1-3 reporting) consumes them. + +--- + +## Product Contract + +### Summary + +Build a Stryker 10 test-runner plugin that works with Vitest 5.0.x. Validate it on the pure-JS test category through the four gates in the origin design: execution-path fidelity, a conformance suite, a Vitest 5 cross-check oracle, and positive controls with determinism. Include the prepared-copy step that makes `as`-asserted code mutable and resolves `gitnexus-shared` to source. Benchmark the oracle's cost. + +### Problem Frame + +Stryker's official `@stryker-mutator/vitest-runner` 10.0.0 runs 0 tests per mutant under Vitest 5, so no mutant is ever killed. Root cause, confirmed in source: Vitest 5 matches `testNamePattern` against `task.fullTestName`, joined with `' > '`, while the runner builds space-joined names. Every test inside a `describe` is filtered out. Upstream merged a fix (StrykerJS PR #6214, 2026-10-23) but has not released it; `latest` is still 10.0.0. Without a working, validated runner there is no mutation evidence, and the Test Scenario Map cannot issue any strong verdict. + +### Key Decisions + +- **Vitest 5 only, for the runner and its oracle** (session-settled: user-directed — chosen over a Vitest 4 copy harness and Vitest 4 spike results as oracle: the repo uses Vitest 5). Governs R1, R8, R13. +- **Build the runner in this effort and run mutation on the repo itself** (session-settled: user-directed — chosen over extending the scratch Vitest 4 harness project: the user asked for a Vitest 5 runner). Governs R1, R2, R3. +- **Scope is `gitnexus/` plus `gitnexus-shared/src` as a CLI test dependency** (session-settled: user-directed — chosen over including `gitnexus-web/`: web is deferred). Governs R5, R10. +- **Reports only; CI stays advisory** (session-settled: user-directed — chosen over automatic deletion or blocking CI: the user chose comment-only). Governs R12. +- **Phase 0 validates the pure-JS category only** (session-settled: user-approved — chosen over also validating native-backed and fixture-reading categories in Phase 0: Codex round 7 showed Phase 0 could not meet its gates at that scope). Governs R6, R9, R10. +- **Strictness over recall: anything unvalidated is out of scope or weak, never strong** (session-settled: user-approved — chosen over facet or path agreement alone as strong evidence: the top anti-goal is calling distinct scenarios the same). Governs R3, R8. + +### Requirements + +**Runner** + +- R1. Stryker runs mutation on `gitnexus/` tests under Vitest 5.0.x with per-test coverage, `disableBail`, static mutants, hit limits and timeouts. +- R2. Every mutant run records an outcome class per test from the KTD17 table: assertion, runtime, hook, collection, timeout, infrastructure, inconclusive or invalid. +- R3. Test identities are stable between the dry run and every mutant run, including `it.each`/`test.for` rows. A run that executes zero tests, or collects a different test set, is invalid. + +**Prepared sources** + +- R4. Code inside TypeScript `as` assertions, including `as const` tables, is mutated, and every mutant's identity sits on the original source span. +- R5. `gitnexus-shared` resolves to source in the mutation configuration, and its mutants map back to `gitnexus-shared/src` paths. + +**Validation gates (origin §5.4a)** + +- R6. Gate 1: a normal Vitest 5 run and the runner's unmutated run agree on the pure-JS category, and the validated envelope is recorded. +- R7. Gate 2: a committed conformance suite pins runner behaviour for the cases in U4. +- R8. Gate 3: a frozen Vitest 5 cross-check oracle, independent of the runner, agrees with the runner on every supported observation. +- R9. Gate 4: planted positive controls behave as expected, and two runs with different scheduling give identical kill sets. +- R10. A static classifier decides which test files belong to the pure-JS category. + +**Operations** + +- R11. The oracle's fresh-process cost is measured and recorded. +- R12. A path-gated CI workflow runs the conformance suite when the runner, Vitest, Stryker or the lockfile change. The oracle runs on demand. +- R13. The runner refuses to run on a Vitest version outside the supported minor. +- R14. Residual improvements over upstream StrykerJS are written up in `gitnexus/tools/mutation/UPSTREAM.md`, not opened upstream, in this run. + +### Success Criteria + +- The pure-JS category passes gates 1-4 with zero unresolved differences. +- On the three KTD11 scopes, the runner's per-test outcomes match the oracle on every sampled mutant, and every required stratum (KTD11) is represented in the frozen sample. +- The oracle benchmark, run by the `workflow_dispatch` job on `ubuntu-latest` (4 vCPU), reports per-launch cost and a projected full-sample wall clock that fits one 6-hour job. + +### Scope Boundaries + +- Out: Layers 1-3 reporting, the value-flow check, the kill-validity protocol and the scheduled job (origin §9 Phase 1). +- Out: native-backed, fixture-reading, LadybugDB and worker-backed categories (origin §9 Phases 2-3). +- Out: `gitnexus-web/` and Python tests. +- Out: any edit to existing tests or production code. + +#### Deferred to Follow-Up Work + +- TypeScript `enum` members are never mutated: the instrumenter skips `TSEnumDeclaration`, and enums emit runtime code, so they cannot be blanked. Record as a known blind spot; revisit in Phase 1. +- Opening the upstream StrykerJS PR or issues (R14 prepares notes only). +- A Dependabot ignore rule for the Vitest minor. The runtime guard (KTD2) and the lockfile CI trigger cover Phase 0. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **Own runner plugin, written in TypeScript, not a vendored copy of the upstream runner.** The upstream fix (#6214) covers only the separator. This plan also needs failure classes, a shared run manifest, task-id selection, no related mode and the repo's own pool, which change most of the upstream file. A fresh implementation avoids vendoring Apache-2.0 code into a PolyForm-licensed repo. Drop it once an upstream release passes the same four gates (origin §5.4a Maintenance). No upstream release can pass gates 2 and 3 until upstream exposes per-test failure classes, so until then the plugin is a standing commitment. (session-settled: user-directed — chosen over extending the scratch Vitest 4 harness project: the user asked for a Vitest 5 runner.) Conflict call-out: origin §9 Phase 0 says "open the upstream PR"; upstream already merged the root-cause fix, so R14 narrows this to residual-delta notes. +- KTD2. **Version guard.** At init the runner reads the Vitest version resolved from the project root and refuses anything outside `5.0.x`. A second Vitest copy resolved from the plugin's own location is an error, because `inject` then returns undefined. Results produced on a Vitest or Stryker version not recorded in the gate 1 envelope are unvalidated until gates 2 and 3 re-pass on that version. (session-settled: user-directed — chosen over a Vitest 4 harness: the repo uses Vitest 5.) +- KTD3. **Test identity.** The Stryker test id is `#`. Parse ids on the first `#`, because titles contain `#` (for example `(#2415)`). Tests that share a full name (duplicate titles, or `it.each` rows whose titles truncate at 40 characters) get a `::` suffix. Rows of one `it.each`/`test.for` call share the call-site location, so a remaining collision adds the row's ordinal within that call (`:::`). Rows that needed the ordinal are not oracle-validated (origin §5.4a gate 3). Each id maps to its Vitest task id in the run manifest (KTD16). Per-test coverage keys use exactly these ids. +- KTD4. **Mutant runs select tests by Vitest task id.** For each file, build one test specification carrying the task ids of the filtered tests, and run the specifications directly. Never use related mode or name patterns. Task ids are positional and stay stable for runtime mutants, because any hit during collection makes a mutant static. The setup file marks the end of collection in its file-level `beforeAll`. A hit with no current test after that point is a hook hit and is recorded as per-test coverage for every test of the suite whose hook ran. Only load-time and collection-time hits stay static. Static mutants run exactly the manifest's full test set, never the config's whole include. With forks and default isolation, each file gets a fresh module graph and the setup file runs before the test file is imported, so `reloadEnvironment: true` needs no extra reload logic. The Stryker `testFiles` option is forbidden: with it, static mutants receive file paths as their filter and runtime activation, and become false survivors. The runner rejects any filter entry that is not a manifest test id. +- KTD5. **Isolation.** Keep the configured pool (`forks`) instead of forcing threads. Each run starts fresh workers. Each run, including every oracle process (U12, U13), uses its own Vitest `cacheDir` inside the sandbox, never the shared `node_modules/.vite`. The runner deletes `__STRYKER_ACTIVE_MUTANT__` from its own environment and its workers' environment. It never serialises the Vitest config. +- KTD6. **Failure classes travel in sidecar files.** Stryker's result type has no field for them. The runner takes an absolute sidecar directory as a plugin option, declared in an exported validation schema. Each mutant run writes one file per local mutant id, attempt and worker (`STRYKER_MUTATOR_WORKER`), written to a temporary name and then renamed. The dry run writes a record too. A missing record means timeout or unknown, because Stryker kills a timed-out process and retries crashed runs. Records use the classes in KTD17. +- KTD7. **Prepared copy blanks `as` assertions with equal-length spaces.** The instrumenter skips every `TSAsExpression` subtree (`tsTypeAnnotationNodeTypes`), not only `as const`; there are 317 `as const` nodes in `gitnexus/src` alone. Replace the ` as ` text with spaces of the same length, keeping newlines, so every line and column is unchanged and the span map is an identity on columns. The check is AST equality: the blanked file's AST must equal the original AST with every `TSAsExpression` unwrapped. "Still parses" is not enough, because ASI before `(`, `[` or a template literal, precedence (`a + b as T * c`) and a parenthesised `(x) as T` can parse to a different tree. After instrumentation, no `TSAsExpression` may remain in mutate-scope files. `satisfies` and the non-null `!` are not on the skip list and stay. Set Stryker `disableTypeChecks: false`, because its default prepends `// @ts-nocheck` and shifts every test line by one. This extends origin §5.4, which named only table-level `as const`; the mechanism and the span-map check are the same. +- KTD8. **`gitnexus-shared/src` is relocated inside the prepared `gitnexus/` tree.** Stryker cannot mutate files outside its working directory. The relocation path never contains `node_modules`. The mutation config derives its regex aliases from the `gitnexus-shared` package `exports` (today `.` and `./test-helpers`, which cover all 811 imports) and computes them relative to the config file, so they resolve inside whichever sandbox runs. `gitnexus-shared/src` uses only package-relative imports. A path map translates relocated paths back to `gitnexus-shared/src/...`. +- KTD9. **Mutation Vitest config.** A committed `vitest.mutation.config.ts` reuses the root test settings of `gitnexus/vitest.config.ts`, replaces its projects with one `mutation` project whose include list comes from the run's scope, adds the KTD8 aliases, and sets `dangerouslyIgnoreUnhandledErrors: false`. Every difference from the base config is on gate 1's allowed list (U6). +- KTD10. **Tooling lives in `gitnexus/tools/mutation/`.** `gitnexus/scripts/` ships in the npm package (`files` includes `scripts`), so it is the wrong home. Entry points run with `node --import tsx`, exposed as `test:mutation:*` npm scripts. Stryker's test-runner child processes receive only `testRunnerNodeArgs`, so the generated Stryker config sets `testRunnerNodeArgs: ["--import", "tsx"]`. Never use `NODE_OPTIONS`, which leaks into Vitest fork workers. Tests for pure functions live in `gitnexus/test/unit/mutation/`. +- KTD11. **Oracle scopes.** Three pure-JS scopes: `src/core/ingestion/type-extractors/shared.ts` (143 tests, static mutants, no hooks), `gitnexus-shared/src/lbug/schema-constants.ts` via `test/unit/schema.test.ts` (`as const` tables, the alias, one `it.each`), and one hook-bearing pure-JS scope chosen with the classifier (U5). Freezing counts every stratum across the three scopes combined. Within one scope, a stratum is required only when that scope's tests contain the construct (`beforeAll` hooks, `it.each`/`test.for`). An empty required stratum fails freezing unless the affected tests are marked not oracle-validated. This augments origin §5.4a gate 3, which named only `shared.ts`, because that scope exercises neither the pre-step, the alias, `beforeAll` nor `it.each`. +- KTD12. **Frozen manifests use content hashes.** Each manifest records content hashes of the full static import closure of the scope's test files (from the U5 walk), `gitnexus/vitest.config.ts`, the mutation config and the prepare tool, plus the operator set, the Stryker and Vitest versions, the Node major version, test ids and global mutant ids. A hash or version mismatch fails with "re-freeze required", and a documented re-freeze command exists. Content hashes survive squash-merges and shallow CI checkouts, which git SHAs do not. +- KTD13. **Normalisations allowed by gate 3.** Exactly four: the timeout rows of KTD17 are one class, NoCoverage and survived are one class (the raw Stryker status is kept for the report), title escaping, and path separators. Everything else must agree (origin §5.4a gate 3 acceptance). +- KTD14. **Conformance and oracle are tools plus a path-gated workflow, not tests in the main suite.** They run Stryker and many Vitest processes, which would slow every CI shard. The conformance fixture lives under `gitnexus/test/fixtures/` with `*.case.ts` files, so neither the default project, the test-completeness gate nor the PR-time dupe scanner collects it. +- KTD15. **The oracle installs its own probes and classifier.** An oracle setup file confirms the installed mutant id before any test-file import, records hits separately from activation, and sorts hits into the same three buckets as the runner: load and collection, hook, and per-test. An oracle reporter records the executed test identity and outcome class with the oracle's own failure classifier. Each launch has a wall-clock limit and a process-group kill. Code under `oracle/` never imports code under `runner/`; a unit test enforces it. +- KTD16. **Run directory and run manifest.** Each Stryker run gets an absolute run directory. Only one pool process runs the dry run; the others, and replacements started after crashes, timeouts or `maxTestRunnerReuse`, only call `init` and `mutantRun`. So the dry-run process writes a run manifest atomically: test files, Stryker id to Vitest task id and location, and the expected test set per file. Every runner process loads it before its first mutant run and refuses to run without it. +- KTD17. **One outcome-class table for runner and oracle.** Stryker's status is Killed for every killed row below, so Stryker's own report stays correct. The kill sets used by gates 3 and 4, the success criteria and Phase 1 count only `killed-by-test` rows with class assertion, as origin §2 requires. + + | Observation | Class | Runner reports to Stryker | + | ---------------------------------------------------------------------- | --------------------------------------- | --------------------------------------- | + | Test fails with `AssertionError` | killed-by-test, assertion | Killed | + | Test or hook fails with Vitest's own `testTimeout`/`hookTimeout` error | timeout | Timeout | + | Test fails with another error | killed-by-test, runtime | Killed | + | `beforeEach`/`afterEach` fails for a test | killed-by-test, hook | Killed | + | `beforeAll` fails; suite tests skipped with a suite error | killed-outside-test, hook | Killed, attributed to the suite's tests | + | `afterAll` fails; tests stay passing | killed-outside-test, hook | Killed, no test attribution | + | Test file fails to import or collect | killed-outside-test, collection | Killed, no test attribution | + | Unhandled error during the run | infrastructure | Error (inconclusive) | + | A selected test has `retry` above zero | inconclusive | Error, sidecar records the reason | + | Hit limit, Stryker timer or oracle wall clock | timeout | Timeout | + | Zero tests, or a test set different from the manifest | invalid | Error, sidecar records the reason | + | No selected test hits the mutant | survived (NoCoverage is the same class) | Survived or NoCoverage | + + The command runner sees only an exit code, so U13 wraps its command to write Vitest JSON per active mutant. The oracle then classifies its own runs into the same table. + +- KTD18. **The oracle runs its own Stryker run.** One Stryker run cannot use both `vitest5` and `command`. The oracle's run uses `testRunner: command` with `cleanTempDir: false`. Its single-test launches run in that kept, instrumented sandbox. Local mutant ids of both runs map to global ids through U2. The runner and the oracle share Stryker core (planning and instrumentation), the prepare step and the mutation config. They share no runner code. The oracle derives its own test inventory with a Vitest list run in the kept sandbox and compares it with the runner's run manifest. The mutant inventories of runs A and B must be identical sets of global ids. Any inventory difference blocks gate 3 before outcomes are compared. +- KTD19. **Independence is enforced, not assumed.** In both runs, every production module must resolve inside the active sandbox; U6 checks it. Gate 4 plants controls in relocated `gitnexus-shared` code and in one `as const` table, so a mis-pointed alias shows up as a failed control instead of two runs agreeing that every shared mutant survived. Gate 1 also records the modules each pure-JS test loads at runtime; a native binding, worker or `child_process` load fails gate 1, so a classifier mistake cannot shrink the category silently. + +### High-Level Technical Design + +Components and data flow. Two Stryker runs share the prepared copy, Stryker core and the mutation config; they share no runner code (KTD18). + +```mermaid +flowchart TB + SRC[gitnexus/ and gitnexus-shared/src] --> PREP[Prepare: copy, relocate shared src, blank as assertions] + PREP --> PMAP[Path map] + PREP --> PCOPY[Prepared copy] + PCOPY --> RUNA[Stryker run A: vitest5 runner plugin] + RUNA --> RESA[mutation.json A + sidecar files + run manifest] + PCOPY --> RUNB[Stryker run B: command runner + Vitest JSON wrapper, cleanTempDir false] + RUNB --> RESB[mutation.json B + per-mutant Vitest JSON] + RUNB --> KEPT[Kept instrumented sandbox] + KEPT --> SINGLE[Single-test launches: oracle setup file + oracle reporter] + SINGLE --> OKILL[Oracle per-test outcomes] + RESA --> IDS[Global mutant ids] + RESB --> IDS + PMAP --> IDS + FROZEN[Frozen oracle manifest] --> CMP[Gate 3 comparison and acceptance] + IDS --> CMP + OKILL --> CMP +``` + +Validation gates. Gate 2 validates the runner itself; gates 1, 3 and 4 validate one category. A category is out of scope until all pass. + +```mermaid +flowchart TB + G2{Gate 2: runner conformance suite} -->|any pinned case fails| STOP[Runner not trusted] + G2 -->|pass| C[Classifier: pure-JS test files] + C --> G1{Gate 1 fidelity: identities, outcomes, config diffs, resolved paths} + G1 -->|difference outside allowed list| OUT[Category stays out of scope] + G1 -->|agree| G3{Gate 3 oracle: every supported observation agrees} + G3 -->|unresolved difference| STOP2[Stop and report] + G3 -->|pass| G4{Gate 4 positive controls + determinism} + G4 -->|fail| OUT + G4 -->|pass| IN[Category validated; envelope recorded] +``` + +Mutant run inside the runner, directional only: + +```text +mutantRun(activeMutant, testFilter?, hitLimit, mutantActivation): + manifest = load run manifest from the run directory; refuse if absent + reject filter entries that are not manifest test ids + specs = per-file specs with the Vitest task ids of testFilter, + or the manifest's full test set for a static mutant + provide(mode=mutant, activeMutant, mutantActivation, hitLimit) # fresh forks + run specs; collect tasks for these modules only + classify every observation with the KTD17 table + write the sidecar file for (mutant, attempt, worker), then rename + return Killed, Survived, Timeout or Error per KTD17 +``` + +### Output Structure + +```text +gitnexus/tools/mutation/ + runner/ Stryker plugin: plugin entry, setup file, ids, run manifest, classification, sidecar + prepare/ prepared copy, as-blanking, shared relocation, path map, mutant identity, span-map check + driver/ scope file to Stryker config, Stryker run, normalised results + classify/ pure-JS category classifier + fidelity/ gate 1 comparison and envelope record + conformance/ gate 2 driver + oracle/ command-runner wrapper, single-test launcher, freeze, compare; oracle setup file and reporter + controls/ gate 4 planted controls and determinism run + bench/ oracle cost benchmark + UPSTREAM.md probe findings and residual deltas against upstream + vitest.mutation.config.ts +gitnexus/test/fixtures/mutation-conformance/ fixture project (*.case.ts) and expected.json +gitnexus/test/fixtures/mutation-oracle/ frozen oracle manifests and the gate 1 envelope record +gitnexus/test/fixtures/mutation-controls/ planted.json (gate 4 controls) +gitnexus/test/unit/mutation/ unit tests for pure functions +.github/workflows/mutation-runner-validation.yml +``` + +### Assumptions + +- Vitest 5.0.x keeps `fullTestName`, per-specification test ids, task `meta` propagation and `afterAll((ctx, suite))` as read in 5.0.5. These are marked experimental; KTD2's guard limits exposure, and U11 confirms them before anything is built. +- `test/unit/schema.test.ts` is pure JS by the classifier's rules. The spike ran it in a pure-JS scratch project; U5's acceptance test confirms it. +- A hook-bearing pure-JS scope exists among unit tests. If U5 finds none, the `beforeAll` stratum is covered only by the conformance fixture, and real tests with `beforeAll` stay not oracle-validated. +- The oracle's full sample fits one 6-hour `workflow_dispatch` job on `ubuntu-latest`. It is about 14,300 single-test launches for `shared.ts` alone (100 mutants times 143 tests, origin §5.4a), plus 100 sampled mutants times the test count of each other KTD11 scope, plus strata top-ups, plus one command-runner pass per mutant. If U12's projection exceeds the job limit, sampling changes go back to the maintainer as a design change. + +### Risks and Dependencies + +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Vitest marks `state.getFiles`, task `meta` and specification APIs experimental; a minor release can change them. | KTD2 version guard; the conformance workflow runs on every lockfile change (U9). | +| Dependabot floats `vitest ^5.0.0`; a lockfile-only bump to 5.1 disables the runner. | The guard fails loudly with the supported range. Re-validate through the four gates, then widen the range. | +| Stryker 10.0.1+ ships the upstream fix and changes replacement printing, so global mutant ids shift. | Exact pins (U3); manifests record the Stryker version and fail with "re-freeze required" (KTD12). | +| Blanking `as` assertions exposes code the instrumenter never mutated before; some mutants may produce invalid code, and many tables become static mutants. | The prepare step checks AST equality (KTD7), and U12 counts static mutants. Compile-error mutants are recorded by Stryker as `CompileError` and never count as kills (origin §2). | +| The oracle's full sample may be too slow for CI. | U12 benchmarks before U7's full freeze. The oracle stays `workflow_dispatch`-only until a budget is set (origin §5.4a). | +| Fork pool plus parallel Stryker workers oversubscribes 4 vCPU. | `maxWorkers` per Vitest run is on the allowed-difference list (U6). Stryker concurrency is set per run and recorded in the envelope. | +| PR #3686 grows from an advisory scanner into tooling. | All Phase 0 code sits in `gitnexus/tools/mutation/`, `gitnexus/test/unit/mutation/`, fixtures and one workflow. Nothing in `gitnexus/src` changes. | + +### System-Wide Impact + +- **npm package:** `gitnexus/tools/` is not in `files`, so nothing ships. `@stryker-mutator/core` and `@stryker-mutator/api` are devDependencies only. +- **CI:** one new advisory workflow. It is outside `ci-status.needs` and does not run on PRs that touch none of its paths. +- **Main test suite:** the conformance fixtures use `*.case.ts` under `test/fixtures/`, so the `default` project, the test-completeness gate and the PR-time dupe scanner ignore them (KTD14). New unit tests in `test/unit/mutation/` join the normal shards. +- **Repo hygiene:** `.gitignore`, `.prettierignore` and ESLint ignores gain the prepared-copy directory, `.stryker-tmp/` and mutation reports (U1). + +### Sources and Research + +- Root cause and runner internals: `@stryker-mutator/vitest-runner` 10.0.0 `dist/src/vitest-test-runner.js` (`run` builds space-joined names); Vitest 5.0.5 `dist/task-utils.js` (`fullTestName` match), `chunks/cli-api.*.js` (`parseFilter`, JSON reporter). Vitest 5 migration guide: "`testNamePattern` Matches the `>`-Joined Full Name". +- Upstream: StrykerJS PR #6214 (separator fix, merged, unreleased), #6246 (end anchoring), #6236 (forced threads pool), #6256 (NoCoverage reported as Survived). +- Stryker internals: `core/dist/src/mutants/mutant-test-planner.js` (static vs runtime activation, `reloadEnvironment`), `core/dist/src/test-runner/command-test-runner.js` (exit-code status, `__STRYKER_ACTIVE_MUTANT__`), `instrumenter/dist/src/util/syntax-helpers.js` (`tsTypeAnnotationNodeTypes` skip list), `instrumenter/dist/src/disable-type-checks.js` (`// @ts-nocheck` prepend), `core/dist/src/utils/object-utils.js` (`toSchemaLocation`, 1-based, end exclusive). +- Repo: `gitnexus/vitest.config.ts` (projects, `pool: 'forks'`, `includeTaskLocation`, `dangerouslyIgnoreUnhandledErrors`), `gitnexus/scripts/test-completeness.ts` (test key and every-file gate), `gitnexus/test/unit/test-completeness.test.ts` (subprocess Vitest pattern), `.github/workflows/impact-pdg-mutation-report.yml` (scheduled tool-workflow pattern), `gitnexus-shared/package.json` (dist exports). +- Spike prior art (scratch, not committed): `gitnexus/vitest.stryker-bake.config.ts`, `/tmp/test-audit/v4proj/vitest.confirm.config.ts` (regex aliases), `/tmp/test-audit/spike5/row-delete.mjs`. + +--- + +## Implementation Units + +| U-ID | Title | Key files | Depends on | +| ---- | ----------------------------------------- | ---------------------------------------------------------------- | -------------- | +| U11 | Go/no-go probe | `gitnexus/tools/mutation/UPSTREAM.md` | none | +| U1 | Prepared copy, path map, mutation config | `gitnexus/tools/mutation/prepare/`, `vitest.mutation.config.ts` | U11 | +| U3 | Vitest 5 runner plugin | `gitnexus/tools/mutation/runner/` | U11, U1 | +| U2 | Global mutant identity, span-map check | `gitnexus/tools/mutation/prepare/mutant-identity.ts` | U1, U3 | +| U10 | Scope driver | `gitnexus/tools/mutation/driver/` | U1, U2, U3 | +| U4 | Conformance suite (gate 2) | `gitnexus/test/fixtures/mutation-conformance/`, `conformance/` | U10 | +| U5 | Pure-JS classifier | `gitnexus/tools/mutation/classify/` | none | +| U6 | Gate 1 fidelity and envelope | `gitnexus/tools/mutation/fidelity/` | U5, U10 | +| U13 | Oracle command-runner run and wrapper | `gitnexus/tools/mutation/oracle/command-*` | U10 | +| U12 | Oracle single-test launcher and benchmark | `gitnexus/tools/mutation/oracle/launch-*`, `bench/` | U13 | +| U7 | Oracle freeze and compare (gate 3) | `gitnexus/tools/mutation/oracle/freeze.ts`, `compare.ts` | U5, U12, U13 | +| U8 | Gate 4 controls and determinism | `gitnexus/tools/mutation/controls/` | U7 | +| U9 | CI workflow and docs | `.github/workflows/mutation-runner-validation.yml`, `TESTING.md` | U4, U6, U7, U8 | + +### U11. Go/no-go probe + +**Goal:** confirm the root cause and the Vitest 5 and Stryker behaviours the plan depends on, before anything is built. This is the plan's first stop condition. + +**Requirements:** R1, R14; KTD1, KTD4, KTD10. + +**Dependencies:** none. + +**Files:** + +- `gitnexus/tools/mutation/UPSTREAM.md` (create: probe findings, one line each, with the Vitest and Stryker versions) + +**Approach:** probe in a scratch location outside the repo, on the `shared.ts` scope (no `as const`, so no prepare step is needed). Record each answer: + +1. In Vitest 5.0.5, `-t 'Describe test'` runs 0 tests and `-t 'Describe > test'` runs them. +2. A test specification with per-spec test ids runs exactly those tests. +3. An unfiltered run with a mutant active at module load executes tests and can fail them. +4. A TypeScript plugin loads in Stryker's child processes with `testRunnerNodeArgs: ["--import", "tsx"]`. +5. Task `meta` written in a setup file reaches the main process, and `afterAll((ctx, suite))` receives the suite. +6. During a suite's `beforeAll`, no current test is set, and a file-level setup `beforeAll` runs before it. + +**Test expectation:** none -- throwaway probe; its findings are recorded in `UPSTREAM.md` and no probe code is committed. + +**Verification:** all six answers are recorded. If any is negative, stop and report before U1. + +### U1. Prepared copy, path map and mutation config + +**Goal:** produce a prepared copy of `gitnexus/` in which `as` assertions are blank and `gitnexus-shared/src` is relocated, plus the mutation Vitest config that runs in it. + +**Requirements:** R4, R5; KTD7, KTD8, KTD9, KTD10. + +**Dependencies:** U11. + +**Files:** + +- `gitnexus/tools/mutation/prepare/prepare.ts` (create) +- `gitnexus/tools/mutation/prepare/blank-assertions.ts` (create) +- `gitnexus/tools/mutation/prepare/path-map.ts` (create) +- `gitnexus/tools/mutation/vitest.mutation.config.ts` (create) +- `gitnexus/package.json` (modify: `test:mutation:prepare` script) +- `.gitignore`, `.prettierignore`, `eslint.config.*` (modify: ignore the prepared-copy directory, `.stryker-tmp/`, mutation run directories and reports) +- `gitnexus/tsconfig.test.json` (modify: include `tools/**/*` so entry points no test imports are typechecked) +- `gitnexus/test/unit/mutation/blank-assertions.test.ts` (create) +- `gitnexus/test/unit/mutation/path-map.test.ts` (create) + +**Approach:** + +1. Copy the `gitnexus/` sources into a prepared directory outside the checkout, symlinking `node_modules`. Leave out `vendor/` (native prebuilds, about 224 MB), `dist/` and other build output; the pure-JS slice does not load them. +2. Copy `gitnexus-shared/src` into the prepared tree at a fixed relative location without `node_modules` in its path, and record the path map (KTD8). +3. For each `.ts` file in the mutate scope, blank every `TSAsExpression` per KTD7 and run the AST-equality check. +4. The mutation config derives root test settings from `vitest.config.ts`, derives aliases from the `gitnexus-shared` package `exports`, and takes its include list from a scope file written by the caller. + +**Patterns to follow:** `gitnexus/tools/test-dupes/test-dupes.ts` (exported pure functions plus a main guard); `/tmp/test-audit/v4proj/vitest.confirm.config.ts` (regex aliases). + +**Test scenarios:** + +- `const T = ['a', 'b'] as const;` blanks to the same length with `]` and `;` at unchanged columns. +- `] as const satisfies Record` keeps `satisfies Record` and blanks only `as const`. +- Nested `x as unknown as T` blanks both assertions. +- A multi-line type in `as { a: string;\n b: number }` keeps its newline, and later lines keep their line numbers. +- `(a as T).b` and `() => x as T` pass the AST-equality check. +- A synthetic `a + b as T * c` fails the AST-equality check, and the file is reported instead of written. +- A synthetic case where blanking would leave a line starting with `(` after an expression without a semicolon fails the AST-equality check. +- A file with no assertions is byte-identical after the step. +- The path map translates a relocated path back to `gitnexus-shared/src/lbug/schema-constants.ts` and leaves `gitnexus/src/...` paths unchanged. +- A path outside both roots is rejected, not passed through. +- Aliases are generated for every key in the `gitnexus-shared` `exports` map and nothing else. + +**Verification:** the prepared copy runs `test/unit/schema.test.ts` and the two `shared.ts` test files green under `vitest.mutation.config.ts`, and every module loaded from `gitnexus-shared` resolves inside the relocated copy. + +### U3. Vitest 5 runner plugin + +**Goal:** a Stryker `vitest5` test runner that gives correct per-test coverage, kills and failure classes under Vitest 5.0.x. + +**Requirements:** R1, R2, R3, R13; KTD1-KTD6, KTD16, KTD17. + +**Dependencies:** U11, U1. + +**Files:** + +- `gitnexus/tools/mutation/runner/plugin.ts` (create: plugin declaration, options schema, runner class) +- `gitnexus/tools/mutation/runner/setup.ts` (create: worker setup file for `__stryker__` globals, coverage, hit counts, static vs runtime activation) +- `gitnexus/tools/mutation/runner/test-ids.ts` (create) +- `gitnexus/tools/mutation/runner/run-manifest.ts` (create) +- `gitnexus/tools/mutation/runner/classify-outcome.ts` (create) +- `gitnexus/tools/mutation/runner/sidecar.ts` (create) +- `gitnexus/tools/mutation/runner/version-guard.ts` (create) +- `gitnexus/package.json`, `gitnexus/package-lock.json` (modify: exact pins of `@stryker-mutator/core` and `@stryker-mutator/api` 10.0.0) +- `gitnexus/test/unit/mutation/test-ids.test.ts` (create) +- `gitnexus/test/unit/mutation/run-manifest.test.ts` (create) +- `gitnexus/test/unit/mutation/classify-outcome.test.ts` (create) +- `gitnexus/test/unit/mutation/sidecar.test.ts` (create) +- `gitnexus/test/unit/mutation/version-guard.test.ts` (create) + +**Approach:** + +1. Dry run: run the scope's files, record per-test coverage keyed by KTD3 ids in `afterAll` metadata, write the run manifest (KTD16) and a dry-run sidecar record, and report one result per test. +2. Mutant run: follow the KTD4 selection, KTD5 isolation, KTD6 sidecar and KTD17 classes. +3. Report Timeout when the summed hit count reaches the hit limit, including when production code swallows the hit-limit error. + +**Patterns to follow:** the upstream runner's structure for provide/inject and setup-file injection, re-implemented per KTD1; `gitnexus/scripts/test-completeness.ts` for test keys. + +**Test scenarios:** + +- An id for `describe A > it b (#2415)` round-trips, and parsing splits on the first `#` only. +- A title containing `' > '` produces an id that still round-trips. +- Two tests with the same full name get location-suffixed ids. Two rows with identical name and location are flagged not oracle-validated. +- A run manifest written by one process loads in another and maps every Stryker id to one Vitest task id. +- A mutant run without a run manifest refuses to run with a clear error. +- A filter containing a file path instead of a test id is rejected. +- Each KTD17 row classifies as stated, from synthetic task results: `AssertionError`, `TypeError`, a failing `beforeEach`, a `beforeAll` suite error with skipped tests, a failing `afterAll` with passing tests, a file with zero tasks and an import error, an unhandled error, a hit-limit stop. +- Sidecar files from two workers for the same mutant and attempt do not overwrite each other, and a half-written file is never visible under the final name. +- A hit recorded after the file-level setup `beforeAll` with no current test is attributed to every test of the suite whose hook ran. A hit during collection stays static. +- Rows of one `it.each` call with identical truncated names get distinct ids by row ordinal and are flagged not oracle-validated. +- The guard accepts 5.0.5, rejects 5.1.0 and 4.1.12, and rejects a second Vitest copy resolved from the plugin location. + +**Verification:** the unit tests pass, and the plugin loads in a Stryker child process. The end-to-end kill check runs in U10, which owns the generated Stryker config. + +### U2. Global mutant identity and span-map check + +**Goal:** give every mutant a global identity on original source spans, and prove the span map is correct for every mutant. + +**Requirements:** R4, R5; KTD7, KTD12. + +**Dependencies:** U1, U3 (a real `mutation.json` for verification). + +**Files:** + +- `gitnexus/tools/mutation/prepare/mutant-identity.ts` (create) +- `gitnexus/tools/mutation/prepare/span-map-check.ts` (create) +- `gitnexus/test/unit/mutation/mutant-identity.test.ts` (create) +- `gitnexus/test/unit/mutation/span-map-check.test.ts` (create) + +**Approach:** + +1. Read Stryker's `mutation.json` (`files[path].mutants[].location`, 1-based, end exclusive). +2. Identity is original path (via the path map), content hash of the original file, span, mutator name and replacement (origin §2 mutant identity, with a content hash per KTD12). It also returns the local id map for one run. +3. The check applies each replacement at the same span in the original file and compares with the prepared file's mutated text, masking blanked ranges. + +**Test scenarios:** + +- Two mutants on the same span with different replacements get different identities. +- The same mutant read from two runs, with different local ids, gets the same global identity. +- A mutant inside a blanked-assertion line maps to the same line and column in the original. +- A replacement whose original span text differs from the prepared span text, outside blanked ranges, fails the check with the mutant's identity in the message. +- 1-based end-exclusive locations convert correctly at line start and line end. + +**Verification:** the check passes for every mutant of the three oracle scopes (KTD11). + +### U10. Scope driver + +**Goal:** one entry point that turns a scope file into a prepared copy, a generated Stryker config, a Stryker run and normalised results, for U4, U6, U7, U8 and U13. + +**Requirements:** R1, R4, R5; KTD4, KTD6, KTD10, KTD16, KTD18. + +**Dependencies:** U1, U2, U3. + +**Files:** + +- `gitnexus/tools/mutation/driver/run-scope.ts` (create) +- `gitnexus/tools/mutation/driver/stryker-config.ts` (create) +- `gitnexus/package.json` (modify: `test:mutation:run` script) +- `gitnexus/test/unit/mutation/stryker-config.test.ts` (create) + +**Approach:** + +1. A scope file names test files and mutate globs, which may include relocated `gitnexus-shared` paths. +2. The generated Stryker config sets: `appendPlugins`, the test runner (`vitest5` or `command`), `coverageAnalysis: "perTest"`, `disableBail: true`, `disableTypeChecks: false`, a non-existent `tsconfigFile`, `testRunnerNodeArgs`, the run directory, the sidecar directory, `cleanTempDir` and `ignorePatterns` that keep `vendor/` and build output out of the sandbox. It never sets `testFiles`. +3. Results are normalised through U2: global mutant ids, statuses, killers, and sidecar classes joined per mutant. + +**Test scenarios:** + +- A scope with a relocated shared path produces mutate globs inside the prepared tree. +- The `command` variant sets `cleanTempDir: false`. The `vitest5` variant sets the run and sidecar directories as absolute paths. +- `testFiles` never appears in a generated config. +- Normalisation joins a sidecar record to its mutant through the local id map, and flags a `killedBy` entry with no sidecar record. + +**Verification:** `test:mutation:run` on the `shared.ts` scope produces non-empty per-test kill sets, no mutant run executes zero tests, and the normalised results carry global ids and sidecar classes for every mutant. + +### U4. Conformance fixture project and conformance check (gate 2) + +**Goal:** pin runner behaviour on a small fixture project, so regressions in the runner, Vitest or Stryker show up as concrete failures. + +**Requirements:** R7, R2, R3; KTD6, KTD14, KTD16, KTD17. + +**Dependencies:** U10. + +**Files:** + +- `gitnexus/test/fixtures/mutation-conformance/` (create: tiny production modules, `*.case.ts` test files, own Vitest config) +- `gitnexus/tools/mutation/conformance/run-conformance.ts` (create) +- `gitnexus/test/fixtures/mutation-conformance/expected.json` (create: pinned per-test coverage, kills and outcome classes; under `test/fixtures/` so tool-written JSON is outside `format:check`) +- `gitnexus/package.json` (modify: `test:mutation:conformance` script) +- `gitnexus/test/unit/mutation/conformance-compare.test.ts` (create: comparison logic only) + +**Approach:** the driver runs the fixture project through U10, reads normalised results and compares them with `expected.json`. Every case below is a planted mutant or test with a pinned expectation. The suite runs twice: at Stryker concurrency 2 with `maxTestRunnerReuse: 1`, and at concurrency 1 with Stryker's default reuse, so one runner process handles many mutants. + +**Test scenarios (pinned cases):** + +- A static mutant in a module-level table is killed with the module re-imported fresh. +- A mutant hit while tests are collected (in a `describe` body, or in a production-computed `it.each` table) is recorded as static. A changed test set under that mutant is invalid, not a non-kill. +- A mutant executed only in a suite's `beforeAll` is attributed to every test of that suite. +- Under `disableBail`, two tests kill the same mutant and both are reported. +- An `it.each` table whose row titles truncate to the same name keeps distinct ids by row ordinal, and those rows are flagged not oracle-validated. +- A mutant that throws a `TypeError` is Killed in Stryker's report and runtime in the sidecar. +- A failing `beforeAll` is killed-outside-test, hook, attributed to the suite's tests. +- A failing `afterAll` leaves its tests passing and is killed-outside-test, hook. +- A static mutant that makes a test file fail to import is killed-outside-test, collection, never invalid and never Survived. +- An infinite loop inside a production `try/catch` that swallows the hit-limit error is a timeout through the hit count. +- An unhandled rejection during a mutant run is infrastructure. +- A test with `retry: 1` is inconclusive. +- Exporting `__STRYKER_ACTIVE_MUTANT__` in the parent environment changes no result. +- A mutant covered by no test has the raw Stryker status NoCoverage and the normalised class survived. +- A test failing with Vitest's own `testTimeout` error is class timeout. +- One runner process handles two consecutive mutants, and the second run shows no state from the first. +- A mutant run that selects zero tests is invalid. +- Mutants handled by processes that never ran the dry run still select the right tests through the run manifest. + +**Verification:** `test:mutation:conformance` passes, and changing any pinned expectation makes it fail with the case named. + +### U5. Pure-JS category classifier + +**Goal:** decide which `gitnexus/test/unit` files belong to the pure-JS category. + +**Requirements:** R10; KTD11. + +**Dependencies:** none. + +**Files:** + +- `gitnexus/tools/mutation/classify/classify.ts` (create) +- `gitnexus/test/unit/mutation/classify.test.ts` (create) + +**Approach:** + +1. Walk each test file's import closure: static imports, plus `import()`, `require()` and `createRequire` calls whose specifier is a string literal or a `const` bound to one. Resolve relative imports and `gitnexus-shared` specifiers. +2. Reject a file whose closure reaches a native package (tree-sitter grammars, `@ladybugdb/core`, onnxruntime, any `.node` binding), a worker-spawning module, or `child_process`. +3. Reject a test that reads files as text from `src/`, from `test/fixtures/`, or from a path outside `gitnexus/` (for example `../../../gitnexus-shared/src`). Production modules that import `fs` do not cause rejection by themselves. +4. Reject a file whose closure contains a dynamic import, `require` or `createRequire` call with a specifier that cannot be resolved this way (reason: "unresolvable dynamic import"). +5. Emit the accepted list and a reason for every rejection. +6. Report which accepted files have `beforeAll` hooks, for KTD11's third scope. + +**Test scenarios:** + +- `test/unit/shared-type-extractors.test.ts`, `test/unit/extract-element-type-from-string.test.ts` and `test/unit/schema.test.ts` are accepted. +- `test/unit/receiver-twin-list-drift.test.ts` is rejected with reason "reads source outside gitnexus/". +- `test/unit/type-env.test.ts` is rejected with reason "native package: tree-sitter". +- A synthetic test that reads `src/x.ts` as text is rejected. +- A synthetic test whose production closure imports `fs` but reads no files in the test is accepted. +- `test/unit/node-module-compat.test.ts` is rejected: it reaches `node:child_process` through `await import(...)` of `src/core/embeddings/runtime-install.ts`. +- A module loaded through `createRequire` that resolves to a native package causes rejection. +- A dynamic import with a computed, non-constant specifier causes rejection with reason "unresolvable dynamic import". + +**Verification:** the accepted list is identical across two runs, and every rejection carries a reason. + +### U6. Gate 1 execution-path fidelity and envelope + +**Goal:** prove that the runner's unmutated run behaves like a normal Vitest 5 run on the pure-JS category, and record the validated envelope. + +**Requirements:** R6; KTD9, KTD19. + +**Dependencies:** U5, U10. + +**Files:** + +- `gitnexus/tools/mutation/fidelity/fidelity.ts` (create) +- `gitnexus/tools/mutation/fidelity/allowed-config-diffs.ts` (create) +- `gitnexus/test/unit/mutation/fidelity-compare.test.ts` (create) + +**Approach:** + +1. Rebuild `gitnexus-shared/dist`, then run the pure-JS list with the base config. +2. Run the same list through the runner's unmutated dry run via U10. +3. Compare test identities as `[file, titles, line, column]`, outcomes, effective config and resolved production module paths. +4. Allowed config differences: `setupFiles`, `maxWorkers`, `maxConcurrency`, `bail`, coverage, the KTD8 aliases, `dangerouslyIgnoreUnhandledErrors`, `cacheDir`, `NODE_ENV`/`VITEST`, `disableTypeChecks`, the project name, the include and exclude lists, the projects array, `sequence.groupOrder`, and `root` compared after mapping sandbox paths to checkout paths. +5. Path rule (KTD19): every production module of the Stryker run resolves inside the active sandbox. `gitnexus-shared/dist` paths in the base run map to relocated `src` paths. +6. Runtime load check (KTD19): in the base run, record the modules each pure-JS test loads, and fail on any native binding, worker or `child_process` load. +7. Write the envelope record to `gitnexus/test/fixtures/mutation-oracle/envelope.json`: Vitest and Stryker versions, Stryker concurrency and runner reuse, config, Node version, OS, platform-dependent skips, environment variables read at load (for example `GITNEXUS_EMBEDDING_DIMS`). + +**Test scenarios:** + +- Identical identity lists with one line shifted by one fail with the shifted test named. +- A config difference outside the allowed list fails. An allowed difference passes and is listed in the envelope. +- A Stryker run that loads a module from the prepared copy or the original checkout, rather than the sandbox, fails the path rule. +- A test passing in one run and skipped in the other fails. +- A pure-JS test that loads `node:child_process` at runtime fails the runtime load check. +- The envelope record lists the Vitest and Stryker versions of the run. + +**Verification:** gate 1 passes on the pure-JS list, and the envelope record is written. + +### U13. Oracle command-runner run and wrapper + +**Goal:** the oracle's own Stryker run, which gives a per-mutant outcome class for every mutant and keeps the instrumented sandbox for single-test launches. + +**Requirements:** R8; KTD17, KTD18. + +**Dependencies:** U10. + +**Files:** + +- `gitnexus/tools/mutation/oracle/command-wrapper.ts` (create: runs Vitest with a JSON reporter and writes the report under the active mutant id) +- `gitnexus/tools/mutation/oracle/command-run.ts` (create) +- `gitnexus/tools/mutation/oracle/oracle-classify.ts` (create: the oracle's own KTD17 classifier) +- `gitnexus/test/unit/mutation/oracle-classify.test.ts` (create) + +**Approach:** + +1. Run the scope through U10 with the `command` runner and `cleanTempDir: false`. The command is the wrapper. +2. The wrapper writes one Vitest JSON report per active mutant, so zero-test runs, infrastructure failures and kills outside tests are distinguishable from test kills. +3. Classify every mutant with the oracle's own classifier, and record the kept sandbox path. + +**Test scenarios:** + +- A JSON report with one failing test and an `AssertionError` stack classifies as killed-by-test, assertion. +- A JSON report with a file-level error and no tests classifies as killed-outside-test, collection. +- A mutant with Stryker status Timeout and no report classifies as timeout, read from `mutation.json`. +- A missing report for a mutant with a non-zero exit and no Timeout status classifies as infrastructure. +- A report with zero executed tests classifies as invalid. + +**Verification:** every mutant in the `shared.ts` scope has an oracle class, and the kept sandbox exists after the run. + +### U12. Oracle single-test launcher and benchmark + +**Goal:** run one test with one mutant in a fresh process inside the kept sandbox, and measure what the oracle costs. + +**Requirements:** R8, R11; KTD13, KTD15. + +**Dependencies:** U13. + +**Files:** + +- `gitnexus/tools/mutation/oracle/launch-single.ts` (create) +- `gitnexus/tools/mutation/oracle/oracle-setup.ts` (create) +- `gitnexus/tools/mutation/oracle/oracle-reporter.ts` (create) +- `gitnexus/tools/mutation/bench/oracle-cost.ts` (create) +- `gitnexus/package.json` (modify: `test:mutation:bench` script) +- `gitnexus/test/unit/mutation/launch-single.test.ts` (create: observation validity rules) +- `gitnexus/test/unit/mutation/oracle-boundary.test.ts` (create: no import from `runner/` under `oracle/`) + +**Approach:** + +1. Select the test by `:` plus an escaped, anchored `-t`, with the mutant installed through the environment before any import. +2. A launch is valid only when exactly one test executed with the intended identity and the installed mutant id matches. +3. Enforce a wall-clock limit per launch with a process-group kill, set above the base config's `testTimeout` plus `hookTimeout` so a hang surfaces as Vitest's own timeout first. Both are class timeout (KTD17). +4. The benchmark times launches on a small sample, counts static mutants after blanking, times the command-runner pass, and projects the three-scope full-sample wall clock on 4 vCPU against the 6-hour job limit. + +**Test scenarios:** + +- A launch that executes zero tests, or two tests, is an invalid observation. +- A passing launch that never hits the mutant is a valid non-covering observation. +- A launch whose installed mutant id differs from the intended one is invalid. +- A launch killed by the wall-clock limit is a timeout observation. +- The cost projection from a fixed list of timings is deterministic. +- A module under `oracle/` importing from `runner/` fails the boundary test. + +**Verification:** the benchmark report lists per-launch cost, static-mutant count, command-runner cost and the projected full-sample wall clock. + +### U7. Oracle freeze and compare (gate 3) + +**Goal:** freeze expected outcomes for the three oracle scopes and compare the runner with them. + +**Requirements:** R8; KTD11, KTD12, KTD13, KTD17. + +**Dependencies:** U5, U12, U13. + +**Files:** + +- `gitnexus/tools/mutation/oracle/freeze.ts` (create) +- `gitnexus/tools/mutation/oracle/compare.ts` (create) +- `gitnexus/test/fixtures/mutation-oracle/*.json` (create: one frozen manifest per scope) +- `gitnexus/package.json` (modify: `test:mutation:oracle` and `test:mutation:oracle:freeze` scripts) +- `gitnexus/test/unit/mutation/oracle-compare.test.ts` (create) +- `gitnexus/test/unit/mutation/oracle-sample.test.ts` (create) + +**Approach:** + +1. Freeze: select the three scopes, sample 100 mutants per scope with a recorded seed, and top up the strata: static/module-load, `beforeAll`, several killers, survivors, NoCoverage, `it.each`. Count every stratum, and write the manifest per KTD12. +2. Baseline: every selected test passes alone with no mutant, and the oracle's own test inventory matches the runner's run manifest (KTD18). +3. Per-mutant classes come from U13. Per-test outcomes for sampled mutants come from U12 launches. +4. Compare with the runner's normalised results from U10. Every supported observation must agree, under KTD13's normalisations only. + +**Execution note:** run U12's benchmark before the full freeze, so the cost is known first. + +**Test scenarios:** + +- The same seed gives the same sample, and a different seed changes it. +- Strata are counted across the three scopes. The `shared.ts` scope alone, with no hooks and no `it.each`, freezes without a `beforeAll` or `it.each` stratum. +- A scope whose tests contain `beforeAll` hooks but whose sample has none fails freezing unless those tests are marked not oracle-validated. +- A test present in the oracle's own inventory but missing from the runner's run manifest blocks gate 3 before outcomes are compared. +- A global mutant id present in run A but not run B blocks gate 3. +- Runner Timeout against an oracle wall-clock kill is accepted as one class. +- Runner NoCoverage against oracle survived is accepted as one class, and the raw status is kept in the report. +- Runner killed-by-test assertion against oracle runtime blocks, even with a recorded cause. +- A manifest whose content hashes or versions no longer match fails with "re-freeze required". + +**Verification:** the oracle passes on all three scopes with zero unresolved differences, and the manifests are committed. + +### U8. Gate 4 positive controls and determinism + +**Goal:** finish the pure-JS validation with planted positive controls and a determinism check. + +**Requirements:** R9; KTD19. + +**Dependencies:** U7. + +**Files:** + +- `gitnexus/tools/mutation/controls/run-controls.ts` (create) +- `gitnexus/test/fixtures/mutation-controls/planted.json` (create: frozen global mutant ids with expected killers and expected non-killers) +- `gitnexus/package.json` (modify: `test:mutation:controls` script) +- `gitnexus/test/unit/mutation/controls.test.ts` (create) + +**Approach:** + +1. Controls are selected from the run's own Stryker mutants by global id; no source is edited. Freezing picks them, records their expected killers and non-killers, and regenerates them on re-freeze. At least one control sits in relocated `gitnexus-shared` code and one in an `as const` table (KTD19). +2. Each control is killed by the tests that depend on it and survives under tests that do not touch it. +3. Determinism: profile A runs at Stryker concurrency 1 with default runner reuse; profile B runs at concurrency 3 with `maxTestRunnerReuse: 1`. Kill sets, keyed by global mutant id and test id, must be identical. + +**Test scenarios:** + +- A planted mutant killed by a test outside its expected killers fails the controls check. +- A planted mutant that survives its expected killer fails the controls check. +- Two kill-set maps that differ in one test fail the determinism comparison with the mutant and test named. + +**Verification:** gate 4 passes on the pure-JS category, including the shared-code and `as const` controls. + +### U9. CI workflow and documentation + +**Goal:** run the conformance suite automatically on relevant changes, and document the tooling and the Phase 0 findings. + +**Requirements:** R12, R14; KTD14. + +**Dependencies:** U4, U6, U7, U8. + +**Files:** + +- `.github/workflows/mutation-runner-validation.yml` (create) +- `TESTING.md` (modify: mutation tooling section) +- `docs/plans/2026-10-23-test-scenario-map-design.md` (add to git with force, since `docs/` is gitignored; update §5.4, §5.4a, §7 with Phase 0 findings: `as` assertions, `disableTypeChecks`, upstream #6214, oracle scopes, the second Stryker run, benchmark numbers) +- `docs/plans/2026-10-23-1823-feat-stryker-vitest5-runner-phase0-plan.md` (add to git with force) +- `gitnexus/tools/mutation/UPSTREAM.md` (modify: residual deltas against upstream, for a later upstream PR) + +**Approach:** + +1. Workflow triggers: `pull_request` path-gated on `gitnexus/tools/mutation/**`, `gitnexus/test/fixtures/mutation-*/**`, `gitnexus/package.json`, `gitnexus/package-lock.json` and `gitnexus/vitest.config.ts`, running the conformance suite. `workflow_dispatch` also runs gates 1, 3 and 4 and the oracle benchmark. +2. Workflow conventions: top-level `concurrency` block, SHA-pinned actions, `persist-credentials: false`, `permissions: contents: read`, advisory (`continue-on-error: true`), outside `ci-status.needs`. + +**Patterns to follow:** `.github/workflows/impact-pdg-mutation-report.yml` (tool workflow with dispatch, setup action, artifact upload); the `test-dupes` job in `.github/workflows/ci.yml` (advisory job conventions). + +**Test expectation:** none -- CI configuration and documentation; verified by the workflow linter and a dispatch run. + +**Verification:** the workflow passes `check-workflow-concurrency.py`, a PR touching `gitnexus/tools/mutation/**` triggers it, and the design doc reflects Phase 0 findings. + +--- + +## Verification Contract + +| Check | Command (from `gitnexus/` unless noted) | Applies to | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------- | +| Unit tests for tooling | `npx vitest run test/unit/mutation` | U1-U8, U10, U12, U13 | +| Existing dupe-scanner tests still pass | `npx vitest run test/unit/test-dupes.test.ts` | all | +| Typecheck tests and the tools they import | `npm run typecheck:tests` | all | +| Lint and format (repo root) | `npm run lint` and `npm run format:check` | all | +| Prepared copy runs the oracle scopes green | `npm run test:mutation:prepare`, then the scopes under `tools/mutation/vitest.mutation.config.ts` | U1 | +| Runner kills on `shared.ts` | `npm run test:mutation:run` on the `shared.ts` scope | U3, U10 | +| Gate 2 | `npm run test:mutation:conformance` | U4 | +| Oracle benchmark | `npm run test:mutation:bench` | U12 | +| Gate 3 | `npm run test:mutation:oracle` | U7 | +| Gate 4 | `npm run test:mutation:controls` | U8 | +| Workflow concurrency rule (repo root) | `python3 .github/scripts/check-workflow-concurrency.py` | U9 | +| GitNexus change analysis before each commit (repo root) | `node .gitnexus/run.cjs detect-changes --scope all --repo .` | all | + +--- + +## Definition of Done + +- Gates 1-4 pass on the pure-JS category, and the envelope record and frozen oracle manifests are committed. +- The runner produces non-empty per-test kill sets on `shared.ts` that match the oracle. +- The oracle benchmark numbers are recorded in the design doc. +- The conformance workflow runs on a PR touching `gitnexus/tools/mutation/**`. +- No existing test or production file under `gitnexus/src`, `gitnexus/test/unit` or `gitnexus/test/integration` changed. +- The untracked spike files (`gitnexus/spike-input-facet.mts`, `gitnexus/test/spike-coverage.setup.ts`, `gitnexus/vitest.spike-cov.config.ts`, `gitnexus/vitest.stryker-bake.config.ts`) are not committed. +- Abandoned-attempt code and debug probes are removed from the diff. +- `CHANGELOG.md` is unchanged. diff --git a/docs/plans/2026-10-23-test-scenario-map-design.md b/docs/plans/2026-10-23-test-scenario-map-design.md new file mode 100644 index 000000000..c4cc841f0 --- /dev/null +++ b/docs/plans/2026-10-23-test-scenario-map-design.md @@ -0,0 +1,715 @@ +# Test Scenario Map — design + +Date: 2026-10-23 · Status: design — Codex round 7 (fresh full review, `gpt-6-astra` xhigh): rework, 9 findings, all applied; blocker resolved with a semantic value-flow check (§5.3a). Approved 2026-10-23 ("we can proceed"). Phase 0 (§5.4a, pure-JS runner slice) built 2026-10-24: gates 1-4 pass on the pure-JS category. Phase 1 (§9, pure-JS report) built 2026-10-25 in `gitnexus/tools/scenario-map/`; its deviations from this design are applied in place and listed in §12 · Related: PR #3686 (advisory duplicate-test scan; Phase 0 tooling in `gitnexus/tools/mutation/`) + +## 1. Intent + +GitNexus has about 22,700 test cases (1,350 files) plus 42 benchmark directories. **Scope of this effort: the CLI package `gitnexus/`** (about 1,280 test files: `test/unit`, `test/integration`, `bench/`). `gitnexus-web/` is deferred. We want three things: + +1. **No repeated scenarios.** Many tests may exercise the same code path, but each must exercise it in a _different scenario_. Two tests that run the same path, check the same thing, and differ only in data the code treats identically are one scenario written twice. +2. **Less clutter.** Repeated scenarios should be removed, and same-logic table rows folded into `it.each`, without losing any protection. +3. **High quality.** The tool should also surface tests that catch none of the evaluated faults and code that the evaluated tests do not protect. + +The tool reports. It never deletes or edits tests. Nothing it emits is a removal decision: every removal needs strong evidence (§2), a human scenario review, and the removal check in a cleanup PR (§6). + +## 2. Definitions + +| Term | Meaning | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Test identity** | Test file, describe path, title, declaration location, and parameter-row index for `it.each`/`test.for` rows. Duplicate titles are disambiguated by declaration order. Stable across runs and shards. | +| **Mutant identity** | Source revision + file + span + operator + replacement. Globally comparable across runs and shards (Stryker's per-run mutant IDs are not). | +| **Execution provenance** | Per test (from the V8 hook, §5.2): the set of files it executed, including files excluded from mutation, `node_modules` entry points of native packages (tree-sitter, `@ladybugdb/core`, onnxruntime), and worker-spawn call sites. It covers what the canonical path cannot see. **Strong-eligible provenance** (the one predicate used everywhere): provenance is known, and contains no mutation-excluded file and no native-package entry point, and no worker spawn unless the worker code is inside a validated instrumented-worker envelope (§5.4). | +| **Canonical path** | For verdicts: the set of mutant identities a test covers (Stryker `coveredBy`), including mutants executed by its suite's `beforeAll`. V8 block coverage (§5.2) is used **only to retrieve candidates**, never for a verdict. | +| **Checks** | The shape of each `expect(subject).matcher(…)` with literals abstracted. Selector literals inside the subject (`getNodesByLabel(r, 'Function')`) stay exact. Expected values are not lost: they are part of the input facet (§5.3 rule 3). Snapshot assertions are compared by snapshot content hash. Supported assertion forms are listed in the implementation; anything else makes the test _unsupported_ for strong evidence. | +| **Context** | Enclosing hooks, `vi.mock`/`vi.spyOn`/`vi.stubEnv`, env writes and wrapper-call arguments (reusing #3686's context closure). Must be exactly equal for any repeat or row verdict. | +| **Input facet** | A signature of what the test feeds the code (§5.3). It also keeps the concrete values it discarded, per position, for the value-flow check (§5.3a). | +| **Kill** | A mutant for which the test fails **by assertion failure**. Timeouts, runtime errors, crashes, hook/fixture failures and infrastructure failures are **not** kills; they make the comparison inconclusive. | +| **Kill set** | The test's kills over the **evaluated mutant universe** of that run (`disableBail`). | + +### Scenario fingerprint (primary verdict since 2026-10-25) + +Two tests are **duplicates** when they run the **same execution path** (identical V8 block sets) on the **same input data** (equal input facets and discarded values, every input resolved, the runtime trace clean) and check the **same expectation** (equal checks-and-context keys). This needs no mutation run. A pair with an unresolved input is at most a **possible duplicate**; other input values make a **table-row** candidate; the same path and input with other checks is **same scenario, different checks**. Rules: `gitnexus/tools/scenario-map/verdict/fingerprint.ts`; plan: `docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md`. The mutation verdicts below remain as optional evidence (§6: dispatch, or the `scenario-map` label on a pull request). + +### Verdict categories + +Precedence, highest first: **Inconclusive → Out of scope → Weak → Repeat (strong) → Repeat (weak) → Row → Review → Distinct.** Subsumed is a per-test property that is reported alongside these verdicts. A test with an empty kill set can never be part of a repeat. + +| Verdict | Rule | Action | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| **Inconclusive** | Any of: a timeout, error or flaky result on a mutant relevant to the pair, or missing results, unless a kill witness already rules out a repeat (a mutant both tests ran cleanly, killed by assertion by exactly one; the pair then goes on to Row or Distinct); differing outcomes on rerun; an isolation check that disagrees (§5.4); unattributable path (concurrent tests) | Rerun or investigate; no other verdict | +| **Out of scope** | Covers no evaluated mutant, or its test category has not passed runner validation (§5.4a) | Not judged | +| **Weak** | Covers evaluated mutants but **kills no evaluated mutants** | Strengthen or delete (human) | +| **Repeat candidate (strong evidence)** | All of: exact canonical path; same checks; same context; same input facet; identical non-empty kill sets over one complete common mutant universe; kills are assertion failures and stable on rerun; the full isolation pass agrees (§5.4); row deletion is _symmetric_ or _not applicable_; value-flow check clean (§5.3a); no unsupported assertion or table construct; strong-eligible provenance for both tests; no unresolved facet input | Human scenario review; removal only through a cleanup PR with the removal check (§6) | +| **Repeat candidate (weak evidence)** | Path, checks, context, facet and kills match as above, but one disqualifier applies: provenance is not strong-eligible, an unresolved facet input, inconclusive row deletion, an unsupported construct, the full isolation pass was not run (§5.4), or a test is not oracle-validated (§5.4a). A value-flow veto never yields a repeat verdict; it yields Review | Review queue only | +| **Row** | Same canonical path, checks, context and facet, but kill sets differ or row deletion is _asymmetric_ | Fold into `it.each`; keep every row | +| **Review** | Same canonical path, checks, context and kill set, but a different input facet, **or** the same facet with a value-flow veto or unknown (§5.3a) | Human decides; mutation cannot tell them apart | +| **Distinct** | Anything else | Keep | +| _Subsumed_ (property) | Non-empty kill set strictly inside another test's, over the common universe | Reported as "no unique protection among evaluated mutants"; never a removal suggestion alone | + +Phase 1 adds two weak-evidence disqualifiers. Both can only demote: V8 block sets that are not identical (§5.2), and a hook-attributed mutant on the pair's path or kills (§5.4 kill validity). A residual body difference makes two equal facets unequal (§5.3), so such a pair with equal kills ends in Review, never in a repeat. + +## 3. Goals, non-goals, anti-goals + +**Goals** + +- Report repeat candidates, rows, weak tests and evaluated coverage gaps for the whole `gitnexus/` suite. Unit tests first, then the integration suite (tree-sitter, resolver and LadybugDB tests) through the Vitest 5 runner on the repo itself (§5.4, §5.4a). +- Every verdict carries its evidence: canonical paths, checks, context, facet differences, kill-set differences, row-deletion outcome, exclusions, and inconclusive causes. +- Report precision per stage, with sample sizes and confidence intervals (§8), and never claim more than those numbers support. +- Re-runnable from the CLI and on a schedule in CI. + +**Non-goals** + +- Automatic deletion, rewriting or folding of tests. +- Per-PR execution of the expensive layers (coverage and mutation). PR-time stays the structural scanner from #3686. +- `gitnexus-web/` (deferred, maintainer decision 2026-10-23) and Python tests (`eval/`). This effort covers the **CLI package `gitnexus/`** only: CLI, MCP server and core, with their tests and benches. +- Proving semantic equivalence. Identical kill sets mean "no evaluated mutant tells these apart", not "equivalent under every bug". +- Whole-suite protection claims from candidate-limited runs. + +**Anti-goals** (failure even if it technically works) + +- Any verdict that calls two genuinely distinct scenarios the same with nothing flagging it. This is the error that destroys protection. +- Similarity scores (embeddings, text similarity, path similarity) presented as evidence for a verdict. +- A report so noisy that people stop reading it. + +## 4. Evidence this design rests on + +All measured on this repo on 2026-10-22/23. Scratch artifacts were in `/tmp/test-audit/`; the labeled sets are to be committed (§8). + +**Limits of this evidence (read first).** + +- One author produced all the labels and also tuned the rules on them. +- The holdouts were drawn from a filtered slice: "same path + checks, different data" groups over 47 coverage files. +- 23/23 on the one untouched holdout gives a two-sided 95% exact lower bound of about 85% for distinct-scenario recognition. It measures **facet discrimination only**, not end-to-end precision of repeat candidates. + +§8 adds independent labels and prospective holdouts before any precision claim is made. + +### 4.1 Audit + +1,350 test files, 22,678 cases, 42 bench dirs. Hand-verified: + +- about 160 removable cases (~0.7%) +- 22 redundant pipeline runs +- 3 bench scripts and 7 tripwire tests +- defects: a fail-open bench guard, 5 orphan bench gates, tests that test nothing + +### 4.2 Tool bake-off + +83 labeled duplicate pairs (48 found by manual audit) and 15 look-alikes. + +| Tool | Audit-found pairs caught | Look-alikes wrongly flagged | +| ------------------ | ----------------------------------------------------------- | --------------------------- | +| #3686 scanner | 39/48 | 0/15 | +| vitest-dedup 0.7.0 | 26/48 (skips 24 oversized files and 1,482 unresolved tests) | 0/15 | +| jscpd 5.4.2 | 14/48 | 5/15 | + +saidvsdid 0.1.0 (deletion guard) caught a skip, deletions and a weakened matcher, but missed a dropped `it.each` row. + +### 4.3 Embeddings (rejected) + +arctic-embed-xs over all 22,769 test bodies: + +- Retrieval was perfect: every known pair was in the top 5. +- Discrimination failed: duplicates vs look-alikes scored AUC 0.69 on bodies and 0.48 with titles. +- 0 of 16 sampled high-similarity pairs were redundant. +- GitNexus test cases are not graph nodes, and the indexes carry no embeddings. + +### 4.4 Path layer (V8 per-test block coverage) + +- AUC 0.876 for duplicates vs look-alikes; 48/55 known duplicates share path, checks and data. +- Pipeline tests drift slightly between runs (0.997 similarity). That is why similarity is used only for retrieval, and verdicts require exact canonical paths, which costs some recall in the safe direction. +- Blind to native tree-sitter parsing, regex alternatives and lookup-table rows. + +### 4.5 Input facet + +- Tuned on 4 labeled sets of 30 pairs. +- On a fifth, untouched set: **23/23 distinct scenarios** recognised (95% lower bound about 85%) and 4/7 repeats/rows. Every miss was on the safe side. + +### 4.6 Mutation layer (Stryker 10, per-test) + +_Historical: these numbers come from the spike, which ran a temporary Vitest 4 copy harness. The design uses Vitest 5 only (§5.4a)._ + +- **Runtimes:** + - `shared.ts` alone: 1,232 mutants in 26 s. + - 166 self-contained unit files (2,980 tests): 55,878 mutants in 65 min on 12 workers, static mutants skipped. + - 48 candidate files with static mutants: 12–14 min. +- **Blind spots found and handled:** + - Static mutants are required: 26 of 84 "repeats" were table rows. + - `as const` array entries are never mutated: the spike stripped them in its copy (§5.4 keeps this as a pre-step). + - Object-literal keys are never mutated: added a row-deletion operator. + - Per-test results must be grouped by path and checks first. +- **Result in the 48-file scope:** 57 pairs passed all layers. + - 23 are verbatim copies. + - Of the 34 non-identical pairs, 33 were judged genuine repeats by the same author. 1 is debatable and is counted as not genuine. + - This is a single-author judgement, not an adjudicated precision figure. +- **Weak tests:** about 16 kill no evaluated mutants, e.g. the 2048-character guard tests, which pass without the guard. + +## 5. Architecture + +Four layers. Layers 0–2 run over every test in scope, and Layer 0 agreement is **not** required for any verdict (Layer 0 misses real repeats, e.g. pairs whose `afterEach` differs). Layer 3 runs on candidate groups produced by Layers 1–2. The value-flow check (§5.3a) runs on candidate pairs whose concrete values differ. + +### 5.1 Layer 0 — structural scan (exists, PR-time) + +`gitnexus/tools/test-dupes/test-dupes.ts` from #3686 checks: + +- same body + same setup +- literal-assertion pins +- repeated fixture builds +- copied helpers +- orphan bench gates + +It stays the per-PR advisory comment. Planned small fixes (the bot findings on #3686, renamed locals, `afterEach` relaxation) are tracked there. + +### 5.2 Layer 1 — candidate retrieval (path + checks + context) + +V8 per-test coverage hook from the spike: a Vitest setup file using `node:inspector/promises`, run with `--repeats 1` (keep the warm run). Attribution rules: + +- Coverage between a test's `beforeEach` and `afterEach` belongs to the test. +- A root `beforeAll` discards module-load coverage. +- `beforeAll` work is attributed per suite level: the coverage taken at the first `beforeEach` of each nested suite belongs to that suite. +- Tests under `describe.concurrent`/`test.concurrent` cannot be attributed. Their provenance is _unknown_, so any pair involving them is **Inconclusive** (reason: path unattributable). + +Retrieval: + +- **Metric:** Jaccard similarity over block identities. Pairs at ≥ 0.99 become candidates, together with pairs with identical checks + context. +- **Not transitive:** candidate groups are connected components and are used only to schedule Layer 3, never as verdicts. +- **Block identity (Phase 1, demote only):** a pair whose V8 block sets are not identical caps at weak evidence. The 0.99 tolerance would otherwise hide one test that reaches extra mutant-free code. + +### 5.3 Layer 2 — input facet + +Computed statically per test. Each rule was added to fix a measured miss; removing any of them reintroduces that miss. An input the rules cannot resolve is recorded as _unresolved_ and blocks strong evidence. + +1. **Inline source code** is detected by language (`SupportedLanguages.X`, grammar identifiers, file-name literals, test path) and parsed with GitNexus's `createParserForLanguage`. Its anonymised tree shape is hashed: + - **Locally declared names become `ID`.** Only declaration nodes count, including for/range/enhanced-for/assignment targets and Go `type_spec`. + - **External types and GitNexus builtin names stay**, even when a fixture stubs them. + - **Leaf rules apply only to leaves.** Composites such as `qualified_identifier` and `compound_literal_expression` keep their structure. + - **Literal values are dropped; literal kinds are kept.** + - **Comment text is shaped** for inline inputs and ignored in fixture files. +2. **COBOL** gets a reserved-word profile. +3. **Data literals, including expected values,** map to their production table by best fit. Search order: + 1. tables on the executed path + 2. the import closure, up to 2 hops + 3. property columns + + Never pool these. A table is a string array, Set/Map keys, object keys, switch cases or a regex alternation. A literal in no table is reduced to a boundary-aware shape: `0`, `1`, `-1`, negatives and empty strings stay exact. + +4. **Selector literals** in an `expect(...)` subject call stay exact. +5. **Object-literal keys** count as input. +6. **Literals and builder calls keep their source order,** as shapes plus called function names. +7. **Shared-fixture tests** resolve their literals, and word tokens inside them, against the describe's fixture. The facet takes: + - the matched definition's shape + - the shapes of the fixture types it references, with their bases + - the declared types of the fields it uses + + Presence-only assertions use the declaration kind instead. + +8. **Fixture files** named anywhere under `test/fixtures/` contribute their shape. Only synthetic paths fall back to extension tables. + +**Phase 1 additions (demote only):** + +- **Numeric literals** follow rule 3 too: `0`, `1`, `-1` and negatives stay exact. +- **±1 rule:** a numeric literal within ±1 of a production numeric constant stays exact. Stryker has no numeric-literal mutator, so a boundary value such as `LIMIT + 1` would otherwise collapse with an interior value. The constants come from the test's executed production files and its static import closure, because a constants-only module runs only during the discarded module load. Statically evaluable initialisers such as `2 * 1024` count. +- **Residual-difference guard:** for two instances with equal facets, the check diffs the instance bodies. Any differing node that is neither a facet part nor a recorded discarded value marks both instances unresolved and makes the two facets unequal. This catches pairs that differ only in an enum member, a regex, a boolean, `null` or an operator. + +### 5.3a Value-flow check (semantic veto) + +**Why:** the facet drops literal values (rules 1 and 3). Two tests can then share facet, path and kills while protecting different behaviour inside code mutation cannot see. Example: a wrapper around `Math.round` tested with `2.4 → 2` and `2.5 → 3`. Non-empty kills show some sensitivity, not sensitivity to the erased difference. + +**Scope:** every candidate pair whose discarded values differ (§2 input facet). Phase 1 also runs step 4 on every facet-equal candidate pair, as a cheap backstop for differences the facet cannot see. The check can only send a pair to **Review**; it never promotes. It uses four sources, and all must come back clean: + +1. **Test side (Babel).** Bind each differing value to where it enters production code: a call argument, a fixture field or a table key. A differing expected value follows the input that produces it. A value that cannot be bound is _unknown_. +2. **Types and resolution (TypeScript 7 checker).** Through `typescript/unstable/sync` on `gitnexus/tsconfig.test.json`, resolve each entry call through aliases (`getAliasedSymbol`) to its declaration and read the parameter and contextual types. + - An `any`/`unknown`-typed or unresolved entry is _unknown_. + - Literal-union or enum members are recorded for the reviewer. + - Probe (2026-10-23, TypeScript 7.0.2): the 2,401-file project loads in about 9 s; `Math.round` resolves to `lib.es5.d.ts`. The API is unstable: pin the TypeScript version, and treat any API failure as _unknown_. +3. **Static value flow (GitNexus PDG).** From each entry parameter, follow the value inter-procedurally to its sinks, and classify each sink with the checker: + - **repo code** (branch, comparison, arithmetic, call into a repo function): coverage and mutation see it. Clean only inside the pair's mutated production files (§5.4 mutate scope). Repo code outside them is _unknown_. + - **supported table read** (§5.4 row deletion): row deletion tests it. Clean. + - **pass-through** (stored, returned unchanged, compared for identity only): clean. + - **opaque:** a call declared in `lib.*.d.ts`, `@types/node` or `node_modules`, or any regex use. Veto. + - **unknown:** truncated flow, dynamic dispatch, or a file without a PDG layer. Veto (fail closed). + - Classes apply in that order. Reads from supported tables and storage into built-in containers (`push`, `Map.set`, `Set.add`) are table reads and pass-through, even though they are declared in `lib.*.d.ts`. The flow continues from wherever the stored value is read back; if the PDG cannot follow it, it is _unknown_. + - **Stitched walk (Phase 1, `gitnexus/tools/scenario-map/value-flow/pdg-walk.ts`).** GitNexus `pdg_query` flows are intra-procedural, so the check stitches the walk itself: + - It reads each function's REACHING_DEF and `CALL_SUMMARY` edges through LocalBackend's `cypher` tool, scoped to the function's `BasicBlock:::` id prefix. + - The TypeScript checker resolves each callee. The argument position in the production AST gives the parameter. `CALL_SUMMARY` covers return values. + - A binding derived from the value (`const { ratio } = opts`, `x = v`) is walked under its own name. A store into a property or module state is _unknown_. + - Any opaque or unknown use in a statement vetoes the whole statement. + - A truncated per-function query, an ambiguous binding or the depth cap (4 functions, entry included) is _unknown_. +4. **Dynamic trace (Vitest 5).** One extra run of the pair with a setup file that wraps value-sensitive built-ins: `Math`, string transforms and comparisons, `RegExp` methods, number parsing and formatting, `JSON`, `Intl`, `Date`, sort comparators, plus every opaque sink step 3 found. Storage and table reads (`push`, `Map`/`Set` methods) are not wrapped. Each call from a production frame (`gitnexus/src`, `gitnexus-shared/src`) records its call site, its receiver (`this`), its arguments and a call count per site. + - If the two tests reach the same call site with a different receiver or different arguments, the differing data reached opaque code. Veto. This also catches derived values (`x * 10`) and flows step 3 missed. + - Asymmetric call sites, or asymmetric call counts at a site, veto too. + - Phase 1 runs the trace for every facet-equal candidate pair, not only for value-differing ones (demote only). + - The trace run is separate from mutation runs. If a test's outcome changes under tracing, the pair is Inconclusive. + +**Lifting a veto:** only an explicit equivalence rule for that built-in (e.g. `Math.round` on two integers), each with its own test. The list starts empty; add a rule only when measured Review noise justifies it. + +**Cost:** one checker load per run, PDG queries per value-differing candidate pair, and one traced run over the tests of every facet-equal candidate pair. Phase 1 runs value flow before the mutation shards, so the isolation pass (§5.4) is spent only on pairs that can still be strong. + +### 5.4 Layer 3 — mutation evidence + +**Configuration** + +- Stryker `perTest`, `disableBail: true`, **static mutants included**. +- Runs **on the repo itself** (Stryker's sandbox copy of the checkout), through the Vitest 5 runner (§5.4a). There is no separate harness project. + - Phase 0 runs Stryker in a **prepared copy** of `gitnexus/` outside the checkout. Its `node_modules` links to the checkout's. It leaves out `vendor/`, `prebuilds/` and `dist/`, which the pure-JS category never loads. + - Phase 1: baselines, row deletion and the trace run (§5.3a) run on plain Vitest 5 under the mutation config, with a fresh Vitest instance per run. Row deletion cannot run through Stryker, because Stryker applies only its own mutators. It edits a throwaway copy of the prepared tree instead. This still honours Vitest 5 only. +- Mutate scope: the candidates' production closure **and** `gitnexus-shared/src`. `gitnexus-shared` resolves to its source via a Vitest alias in the Stryker-only config, so its tables are mutated. + - Phase 1: the mutate scope of a candidate group is whole production files: every production file in either test's V8 provenance or static import closure, with no line ranges. + - Phase 0 finding: Stryker cannot mutate files outside its working directory. The prepared copy therefore relocates `gitnexus-shared/src` inside the `gitnexus/` tree. The mutation config derives its aliases from the package `exports`, and a path map translates relocated paths back. +- **Type assertions** (Phase 0 finding, replaces the `as const`-only pre-step). Stryker's instrumenter skips every `TSAsExpression` subtree, not only `as const` tables (`gitnexus/src` alone has 317 `as const` nodes). + - The prepared copy blanks every ` as ` in the mutated files with spaces of the same length, keeping newlines. Lines and columns do not move, so the span map is an identity. + - The check is AST equality: the blanked file must parse to the original AST with every `TSAsExpression` unwrapped. "Still parses" is not enough: ASI, precedence and a parenthesised `(x) as T` can give a different tree. + - `satisfies` and the non-null `!` are not skipped, and stay. + - The edit is type-only, and Vitest does not typecheck. An upstream instrumenter option is the long-term fix. +- **`disableTypeChecks: false`.** Stryker's default prepends `// @ts-nocheck` to matching files, test files included. That shifts every test line by one and breaks test identities. + +**Kill validity** + +- Before mutating, the baseline must pass twice with identical test identities. + - Phase 1: two plain Vitest 5 baselines (a fresh instance per run) and Stryker's dry run must agree. Otherwise the group is _inconclusive_. +- Only assertion failures count (§2). Stryker `Timeout`, runtime and compile errors are recorded per test, and make any pair they touch _inconclusive_. + - A test file that fails to import under a static mutant reports no per-test result (`killed-outside-test:collection`). Every test of that file is recorded with that class, not as a missing result. When it holds for both tests of a pair, neither test ran, so the mutant cannot tell them apart and counts as a non-kill for both. +- **Full isolation pass**, required for strong evidence: rerun every mutant covered by either test (the pair's covered universe), both tests together and then each alone, comparing coverage and outcome class. Static mutants and mutants executed in `beforeAll` rerun in a fresh process with modules reset. Any disagreement makes the pair _inconclusive_. This catches order dependence and leaking mocks that hide a distinguishing mutant. + - Phase 1: the pass selects tests through the runner's `testIds` scope key (§5.4a). Each pair gets one Stryker run with both tests, and each test one run alone, shared across pairs. Mutate is restricted to the pair's covered universe. `maxTestRunnerReuse` 1 gives every mutant a fresh process. Coverage and outcome class per test and mutant must agree across the main, together and alone runs. + - Phase 1: the pass runs only for pairs that can still be strong after value flow, because value flow (§5.3a) runs before the mutation shards. A pair whose isolation does not fit the shard's time stays isolation not-run, which caps it at weak. + - **Hook-stratum cap (Phase 1, demote only):** a pair whose path or kills touch a hook-attributed mutant caps at weak. The cap holds until a gate 3 scope with a non-empty hook stratum passes (§5.4a). Today that stratum is empty in all three scopes. +- A sampled isolation pass is allowed as a cheap screen, but a pair without the full pass caps at weak evidence. +- Two passing baselines show observed stability, not absence of flakiness. The report says so. + +**Comparability and sharding** + +- Shard by **candidate group**, so both members of every compared pair are evaluated in the same run against the same mutant universe. +- Pairs are compared only over their common evaluated universe, keyed by global mutant identity. A missing result is _unknown_, never a non-kill. +- Shards merge **raw test–mutant observations**: test identity, mutant identity, selected-test scope, coverage, outcome class, validity and universe membership. +- Weak, gaps and subsumption are **recomputed** from the merged observations. Computed verdicts and counts are never merged or summed: two groups can mutate the same file, so a mutant can survive one group and be killed by another. +- Two observations of the same test and mutant with different outcome classes make it _inconclusive_. +- **Subsumption** of A by B is claimed only if B was evaluated on **every** mutant in A's evaluated universe, whether in the same shard or in a merged result covering that universe. Otherwise subsumption is _unknown_. +- Every comparison records its universe identifier in the report. + +**Row-deletion operator** (Stryker never mutates object keys) + +- Applies to candidate pairs whose differing literals are both keys of one production object literal. +- Delete each key in turn and run the pair. + - Phase 1: the operator splices one key's text out of a throwaway copy of the prepared tree and runs the pair on plain Vitest 5, a fresh instance per run. A reused instance serves cached transforms and would turn a no-op deletion into a false "neither fails". A control run comes first: both tests must pass, and the edited file must load. +- Outcomes: + - **symmetric:** both tests fail, or neither does + - **asymmetric:** exactly one fails, which means _row_ + - **not applicable:** no such table + - **inconclusive:** errors or timeouts +- **Supported tables:** object literals with plain identifier or string keys, read through ordinary lookups (`table.key`, `table['key']`, `table[variable]`, `key in table`, `Object.keys/entries(table)`); and `new Set([...])`/`new Map([...])` built from literal entries, read through `has`/`get`. Deletion removes one key or one entry. +- **Unsupported tables:** any table that is built or changed at runtime (spreads, computed keys, `Object.assign`, assignments into it, getters, proxies, duplicate keys). These make the outcome _inconclusive_, which blocks strong evidence. + +**Execution environment** _(maintainer decision 2026-10-23: Stryker runs on Vitest 5, the version `gitnexus/` uses, through a runner built in this effort)_ + +- Stryker runs on the repo checkout with its real `node_modules`, fixtures and config, so native packages, `test/fixtures/` and repo-relative paths behave as in normal test runs. +- Worker scripts are built (`node scripts/build.js`) before the run, with `GITNEXUS_WORKER_READY_TIMEOUT_MS=60000`. +- LadybugDB tests keep `lbug-db` sequential isolation **and** get a per-Stryker-process database and temp namespace, or are globally serialised across Stryker workers. +- **Runner validation** (§5.4a) replaces the harness fidelity checks. + +- **Native code is never mutated.** Loading it is not the same as testing it. + +**Worker threads** + +- Parse workers load the built `dist/` script, not the instrumented sources. +- The builder makes workers load instrumented code, and proves it with **planted worker-side mutants as positive controls**: they must be killed by a test known to depend on them. +- Until that is proven, worker-only files are mutation _exclusions_. +- Strong evidence is enforced through **execution provenance**. V8 coverage cannot see inside native addons or other worker isolates, and module-load coverage is discarded, so a clean V8 run is **not** proof of no native or excluded execution. Two checks always apply together: + 1. **V8 provenance:** excluded files, native-package entry points or worker spawns seen at runtime cap the test at weak evidence. + - **Excluded files (Phase 1):** any executed repo file under `gitnexus/` that is neither a test file (`test/**`, `*.case.ts`) nor mutated, such as `tools/` or `scripts/` code. + 2. **Static closure:** if the test's static import closure reaches any native package (tree-sitter included), any worker-spawning module, or **any mutation-excluded file**, the cap applies too. Excluded files' module initialisation runs during the discarded load phase, so V8 cannot vouch for it. +- **No other exceptions.** + - Tests depending on tree-sitter, LadybugDB, onnxruntime or any other native package top out at **weak evidence**: review queue, never a strong repeat candidate. + - Worker execution stays capped until instrumentation is proven by positive controls. A proven, validated instrumented-worker envelope removes **only** the worker-spawn disqualifier from strong-eligible provenance (§2); worker code then appears in the canonical path. The native-package, excluded-file and static-closure caps still apply. + - Consequence: most parser, resolver and LadybugDB repeats will be weak-evidence candidates. That is intended; it is the price of the top anti-goal. +- Unknown or incomplete provenance always caps at weak evidence. +- Unexecuted worker mutants never count as survived. + +**Integration cost** + +- Unknown. Benchmark three cases before scheduling: one resolver file, one parser-worker-heavy file and one LadybugDB file. Record wall clock, CPU time, peak memory and timeout rate. +- Do not extrapolate linearly from the 12-worker numbers. + +**Wording of per-test results** + +- Use "kills no evaluated mutants" and "no unique protection among evaluated mutants". +- Gaps are "uncovered by selected tests" (`NoCoverage`) and "survived the selected tests" (`Survived`), counted per production file. `Survived` includes equivalent mutants. +- A whole-suite run with static mutants is an optional manual job. + +### 5.4a Vitest 5 test runner for Stryker (Phase 0, critical path) + +**Why:** `@stryker-mutator/vitest-runner` 10.0.0 supports Vitest ≤4. Under the repo's Vitest 5.0.5, the dry run succeeds but every mutant run executes **0 tests**, so nothing can be killed. Without a working runner there is no Layer 3. + +**Root cause (Phase 0 diagnosis):** Vitest 5 matches `testNamePattern` against the full name joined with `' > '`. The runner builds space-joined names, so every test inside a `describe` is filtered out. Upstream merged the separator fix (StrykerJS PR #6214, 2026-10-23) but had not released it; `latest` was still 10.0.0. + +**Built (Phase 0):** a new runner plugin, `vitest5`, written in TypeScript in `gitnexus/tools/mutation/runner/`. It is not a fork: the upstream fix covers only the separator, and the needs below change most of the upstream file. Versions: Vitest 5.0.5, Stryker 10.0.0, Node 22. The runner: + +- selects tests by **Vitest task id**, one test specification per file, never by name pattern or related mode. It checks that the executed tests equal the requested ones, because a specification with only unknown ids silently skips every test. +- keeps the configured `forks` pool and starts fresh workers per run. It never forces threads. +- writes a **run manifest** from the dry run: test id to Vitest task id and location, and the expected test set per file. Only one Stryker process runs the dry run, so every runner process loads the manifest before its first mutant run. +- writes per-test failure classes to **sidecar files**, because Stryker's result type has no field for them. +- counts a hit in a suite's `beforeAll` as coverage for every test of that suite. Only load- and collection-time hits make a mutant static. +- refuses any Vitest outside `5.0.x`. + +**Extended (Phase 1):** + +- an optional `testIds` key in the driver scope, passed as the `vitest5.testIds` runner option. The dry run runs only the requested tests, checks the executed set against the request, and fails on an unknown id before any mutant run. +- per-test outcome classes from every sidecar attempt in `results.json` (`perTest`, `attemptsDisagree`). +- a per-mutant hook marker (`hookAttributedTests`): the tests a suite hook attributed the mutant to. + +What upstream would need before the plugin can be dropped is listed in `gitnexus/tools/mutation/UPSTREAM.md`. + +**Required behaviour:** + +- per-test coverage (`perTest`), mutant activation per run, `disableBail`, static mutants (full re-run), hit limits and timeouts +- test filtering by test identity +- **per-test failure classification:** assertion failure vs runtime error vs hook failure vs timeout, so §2's kill definition can be enforced +- stable test identities including `it.each` rows + +**Validation, before Layer 3 is trusted.** Validation is staged **per test category**: pure JS, native-backed, fixture-reading, LadybugDB, worker-backed. A category is out of scope until it passes all four gates. + +1. **Execution-path fidelity and envelope.** For each category, compare a normal Vitest 5 run with the runner's unmutated Stryker run, including the prepared sources (blanked `as` assertions) and the `gitnexus-shared` alias. Both must agree on: + - collected test identities + - outcomes + - effective configuration + - resolved paths of production modules (inside the sandbox) + + Record the validated envelope (config, environment, pool, aliases). Anything outside it is out of scope. + + **Span map check:** for every mutant in the prepared copy, applying its replacement at the mapped span in the original source must give the same code, apart from the blanked `as` assertions. + + **Phase 0 result:** pass on the pure-JS category, 246 files and 4,541 tests, 0 differences. The runtime load check found `commander` loading `child_process` at runtime; the classifier now rejects tests that import it. The span map check passed on 1,510 mutants with 0 failures. + + **Phase 1 result:** after the runner extensions, gate 1 passes on 251 files and 4,667 tests, with 0 differences. The envelope was refreshed: the classifier now rejects `conformance-compare.test.ts` and `php-namespace-extraction.test.ts`, and accepts the three oracle unit tests. + +2. **Runner conformance suite.** A small, committed fixture project pins expected per-test coverage and outcomes for: + - static and module-load mutants + - mutants executed in a suite's `beforeAll` + - several killers of one mutant under `disableBail` + - `it.each` row filtering by test identity + - mutant activation and reset between runs + - assertion vs hook vs runtime vs timeout failures + + A mutant run that unexpectedly executes zero tests invalidates that mutant's evidence. Worker-category controls must prove the workers ran the instrumented sandbox build. + + **Phase 0 result:** all 23 pinned cases hold in two profiles, about 4 min each: Stryker concurrency 2 with a fresh runner process per mutant, and concurrency 1 with unlimited runner reuse. Only this fixture exercises `beforeAll` hit attribution: the hook-hit populations of all three oracle scopes are empty. + + **Phase 1 result:** the fixture gains a `testIds` selection scope and hook-marker cases. All 26 pinned cases hold. + +3. **Vitest 5 cross-check oracle.** It is built entirely on Vitest 5, independently of the new runner. Every category gets its own frozen cross-check scopes before it is promoted: + - **Scopes (Phase 0):** the pure-JS category has three, because `shared.ts` alone exercises neither the blanking, the alias, `beforeAll` nor `it.each`: + - `gitnexus/src/core/ingestion/type-extractors/shared.ts`: 1,232 mutants, 143 tests, no hooks. + - `gitnexus-shared/src/lbug/schema-constants.ts` through `gitnexus/test/unit/schema.test.ts`: 72 mutants, all static, 42 tests. It covers the blanked `as const` tables, the alias and `it.each`. + - `gitnexus/src/server/middleware.ts` through `gitnexus/test/unit/middleware-public-origin.test.ts`: 302 mutants, 31 tests, with a `beforeAll`. The hook runs no mutated code. + - **Independence:** both oracle paths share only Stryker's instrumented sandbox. They never use the runner's test filtering, state, kill attribution or result conversion. Each run is a fresh process that installs one frozen mutant before any module import and starts clean, so nothing carries over between mutants. **Activation and execution are recorded separately:** the run must independently confirm that the intended mutant ID was installed (else it is invalid), and records whether the mutant executed. A correctly executed, passing test that never hits the mutant is a valid non-covering observation. A hit is required only where a control predicts coverage. + - **Per-mutant status:** Stryker's built-in `command` runner, which works on Vitest 5 but cannot attribute kills to tests, gives failed vs passed for every mutant. The runner's per-mutant outcome must agree on every mutant. + - Phase 0: one Stryker run cannot use two test runners, so the oracle runs **its own Stryker run** with the `command` runner and keeps its instrumented sandbox for the single-test launches. Runner and oracle share Stryker core (planning, instrumentation), the prepare step and the mutation config, and no runner code. The two runs' mutant inventories must be identical sets of global ids. + - On `shared.ts`, the command run agreed with the runner on all 1,232 mutant classes, and on the assertion kill sets of all 379 assertion-killed mutants. + - **Per-test kill sets:** each test in the scope runs alone with the mutant active, selected by `vitest run :` plus an escaped, anchored `-t "^$"`. A run must report exactly one executed test, with the intended identity; zero or several invalidate the observation. Tests that still cannot be told apart (identical expanded `it.each` titles, duplicate full titles) are listed as not oracle-validated, and their evidence caps at weak. + - **Outcome classes:** keep Vitest's JSON failure details and classify each failure as assertion, hook, runtime, timeout or infrastructure. Compare the runner and the oracle class by class, so the §2 assertion-only kill rule is checked, not just pass/fail. + - **Sample:** a seeded random 100 mutants, topped up so every stratum is present: static/module-load mutants, mutants executed in `beforeAll`, mutants with several killers, survivors and `it.each` tests. Zero differences in 100 random mutants bounds the difference rate at roughly 3% (95% upper bound). That is the claim, and it covers this category only. + - Phase 0: strata are counted across the three scopes combined. Within one scope, a stratum is required only when the scope's tests contain the construct and its population is not empty. Empty populations are recorded, never required. + - **Cost (Phase 0 benchmark):** one single-test launch takes about 3.3 s wall and 1.8 s CPU at concurrency 4, about 0.87 s of throughput. + - The sample is about 120 mutants (100 random plus top-ups) times the scope's tests: 17,160 launches for `shared.ts`, 3,024 for `schema-constants.ts` (all 72 mutants), 3,720 for `middleware.ts`. Each scope adds one command-runner pass. + - Projected on 4 vCPU: 6.18 h at best (5.52 h launches, 0.41 h command-runner passes, 0.25 h setup). That exceeds the 6 h job limit. + - So CI shards the freeze by scope: about 4.3 h for `shared.ts`, 44 min for `schema-constants.ts`, 56 min for `middleware.ts`. Comparing a runner run with the frozen manifests costs one Stryker run per scope. + - Gate 3 stays on demand (`workflow_dispatch`); it does not run on pull requests. + - **Freezing:** the oracle's source revision, configuration, operator set, test identities and global mutant identities are frozen with it. + - Phase 0 records content hashes instead of a revision, since hashes survive squash merges and shallow checkouts: the scope's import closure, the Vitest configs and the prepare tool, plus the Stryker and Vitest versions and the Node major version. Any mismatch fails with "re-freeze required". + - **Acceptance:** every supported observation must agree. The only allowed differences are an enumerated list of meaning-preserving representation normalisations. Phase 0 allows exactly four: every timeout row is one class, NoCoverage and survived are one class (the raw status stays in the report), title escaping, and path separators. Any execution, attribution or failure-class difference blocks validation **even when its cause is known**: fix it, or exclude the affected tests or category from the validated envelope. + - **Not covered by the oracle:** it shares Stryker's instrumentation, so it cannot vouch for instrumentation or the blanking span map. Gate 1 checks the span map separately. + - The spike's Vitest 4 results are history only, not an oracle. + - **Phase 0 result:** gate 3 passes on all three scopes (seed `u7-gate3-1`, frozen 2026-10-24 in `gitnexus/test/fixtures/mutation-oracle/`): every mutant class agrees (`shared.ts` 1,232, `schema-constants.ts` 72, `middleware.ts` 302; NoCoverage read as survived for 621, timeouts 5), and every sampled per-test observation agrees (14,300 + 3,024 + 3,131 = 20,455 over 273 sampled mutants), with 0 differences and 0 invalid launches. Inventories match (tests and global mutant ids). Strata across the scopes: static, multi-killer, survived, no-coverage and each-row met; hook-hit population empty. The freezes took 2 h 55 min, 1 h 25 min and 1 h 36 min locally at launch concurrency 8, 3 and 3. + - **Re-freeze (Phase 1, 2026-10-24):** merging `main` changed the import closure of the `schema-constants.ts` scope (`template-arguments.ts`), so gate 3 reported "re-freeze required". The manifest was re-frozen with seed `u7-gate3-2`: 72 sampled mutants, 3,024 valid observations, 0 invalid. Gate 3 passes on all three scopes again (1,232, 72 and 302 mutants), with 0 differences. The runner extensions changed no gate 3 hashed input, so they caused no re-freeze. + +4. **Positive controls and determinism.** Per category, a fixed list of planted mutants must be killed by the tests that depend on them and must survive under tests that don't touch them. Two runs must give identical kill sets. + + **Phase 0 result:** pass. Nine controls, three per oracle scope, picked from each run's own mutants by global id; three sit in relocated `gitnexus-shared` `as const` tables. The `shared.ts` scope uses lines 729-904 (199 mutants) to bound the run time. Profile A runs at Stryker concurrency 1 with unlimited runner reuse, profile B at concurrency 3 with a fresh runner process per mutant. Every control held in every run, and A and B gave 0 differences in outcome classes and kill sets. About 81 min locally. + + **Phase 1 result:** after the runner extensions, gate 4 passes with 0 failures. + +**Maintenance:** + +- Pin the Vitest minor version that the runner supports. Done: the runner refuses anything outside `5.0.x`, and Stryker is pinned exactly. +- A CI check runs the conformance suite and the Vitest 5 cross-check oracle whenever Vitest, Stryker or the local runner changes, so an upgrade or edit cannot silently break kill evidence. + - Phase 0: `.github/workflows/mutation-runner-validation.yml` runs the conformance suite on pull requests that touch the tooling, its fixtures, `gitnexus/package.json`, the lockfile or `gitnexus/vitest.config.ts`. The oracle, gates 1 and 4 and the benchmark run on manual dispatch, because the oracle takes hours. The workflow is advisory. +- Offer the patch upstream to `stryker-mutator/stryker-js`. Drop the local copy only once the upstream runner passes the same four gates. + - Phase 0: upstream already merged the root-cause fix (#6214), so no PR was opened. The residual deltas are listed in `gitnexus/tools/mutation/UPSTREAM.md`. + +### 5.5 Noise control and decisions + +- **Tracking issue:** strong repeat candidates, weak tests and the per-file gap summary. Weak-evidence repeats, Review pairs and Inconclusive items live in the artifact report. +- **A human "keep"** is recorded with `// dupe-ok: `, as in Layer 0. Fold and remove decisions are recorded by the PRs that make them. +- **Two identities.** Execution identity is revision-pinned (§2) and kept as provenance. **Report continuity** uses stable anchors instead, so unrelated edits do not resurface findings: + - a test anchor: file, describe path, title, row index and a body fingerprint, without the declaration line. A moved test is matched by body fingerprint; an unmatched test is new. + - a mutant fingerprint: file, operator, replacement and the normalised enclosing code, without revision or span. +- **Week over week:** the report shows items that are new, **or whose evidence hash changed**, keyed by the continuity anchors. The hash uses fingerprints, not revisions or line positions, and covers: + - canonical path and provenance + - checks and context + - facet + - kills and universe identifier + - row-deletion outcome + - validity status and runner-validation status + - exclusions + - the computed verdict, its deciding rule or disqualifiers, and the verdict-engine version (so a rule change resurfaces every pair it reclassifies) + +### 5.6 Verdict engine + +Applies §2 with its precedence. Emits evidence for each pair and each test, including which rule or disqualifier decided the verdict. A pair has exactly one verdict. + +## 6. Where it runs + +| Surface | Layers | Trigger | Output | +| ------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PR | 0 | every PR | Sticky CI comment (exists) | +| Scheduled job | 0–2 and the scenario fingerprint on `gitnexus/test/unit` and `gitnexus/test/integration` (Layer 1 in 4 CI slices) | weekly (mutation `none`) | Artifact (JSON + markdown); one tracking issue updated in place | +| Manual run | as scheduled, plus 3 at the chosen mutation scope | workflow dispatch | As the scheduled job; mutation `possible-duplicates` or `all` adds kill sets, weak tests and gaps | +| Labelled PR | as scheduled but diff-scoped (pairs with a changed test file), plus 3 over the possible duplicates | `scenario-map` label | Artifact and job summary; no issue, no baseline | +| Local CLI | any | developer | Same report | +| Cleanup PR | 3 on the touched tests and their partners | cleanup PR author | **Removal check:** mutation run over the pair's common universe, before and after the removal. The remaining suite's kill set must be unchanged, and there must be no new inconclusive results. | + +## 7. Constraints + +- **Vitest version.** The repo uses Vitest 5. The stock Stryker runner supports only Vitest ≤4, so this effort builds the Vitest 5 runner (§5.4a). It is the critical path: Layer 3, and every strong verdict, waits for it. + - **Vitest 5 only**, the version pinned in `gitnexus/package.json`. No Vitest 4 anywhere: no harness, no oracle, no `--legacy-peer-deps`. + - The runner refuses anything outside Vitest `5.0.x`. A Vitest, Stryker or Node upgrade needs the four §5.4a gates again before its results count. +- **TypeScript 7:** the stable JS API is not ready, and Stryker's tsconfig preprocessor crashes, so point `tsconfigFile` at a non-existent file. Set `disableTypeChecks: false` (§5.4). The value-flow check uses the shipped unstable API (`typescript/unstable/sync`), pinned to the repo's TypeScript version and fail-closed (§5.3a). +- **Native code and fixtures** work as in normal test runs, because Stryker runs on the repo. The spike reached only pure-JS tests (170/894 unit files) through its copy harness, so integration-scale cost is still unmeasured (§5.4). Phase 0 validated the pure-JS category only: 246 unit files, chosen by a static classifier and checked by gate 1's runtime load check. Phase 1 refreshed that envelope to 251 files (§5.4a gate 1). +- **Compute:** + - Shard by candidate group (§5.4), and merge raw test–mutant observations by global identity (§5.4). + - Must fit `ubuntu-latest` (4 vCPU, 6 h) through sharding. Size the job from the §5.4 benchmarks, not by extrapolating. + - The gate 3 oracle's full pure-JS sample does not fit one job (6.18 h projected on 4 vCPU), so its freeze is sharded by scope (§5.4a). +- **Measured budgets** (12 workers, unit scope): + - about 65 min per 55k mutants without static mutants + - 12–14 min per 30k-mutant candidate pass with them + - about 54 min of coverage for 47 integration-heavy files +- **Measured Phase 0 times** (Vitest 5 runner; a loaded 24-CPU WSL2 machine, checkout on a 9p mount; CI will differ): + - `shared.ts`, 1,232 mutants with static mutants: 5 min 12 s at Stryker concurrency 4; the oracle's command-runner pass 18 min + - gate 1 over the pure-JS category: 2 min 39 s + - gate 2: about 4 min per profile + - gate 4: about 81 min for both profiles +- **Measured Phase 1 costs** (local runs; CI will differ): + - Layer 1: 4,667 tests in about 2 min (1 min 55 s) + - Layer 2: checks for 4,600 instances in 2.9 s; input facets for 4,667 instances in about 33 s + - Layer 3 main run: about 1.5 min fixed per group, plus about 13 min per MB of mutated code, at Stryker concurrency 2. The cost does not grow with the group's tests. + - isolation pass: about 130 s fixed per run, plus about 5.5 s per mutant (fresh process per mutant) + - row deletion: about 48 s per batch, plus 2.5–5 s per pair run + - whole-repo `analyze --pdg` for value flow: 6 min 49 s wall, 8.4 GB peak RSS + - trace run: 1.19× pooled overhead on 40 eligible files, with no outcome change + - controls fixture end to end: 16 of 16 pairs match, 28 min + - predicted CI run: 9 shards at Stryker concurrency 4, 1,658–1,829 min in total, largest shard 196–253 min (budget 300 min), 0 groups over budget +- **Caching:** key results on everything that changes them: + - test and production file contents + - fixtures read + - the evaluated mutant universe identifier + - the Vitest, Stryker and runner versions, and the operator set + - the dependency lockfile + - the source revision + - relevant env vars and runtime: Node version, OS, `GITNEXUS_*` settings + - the Stryker and Vitest config used for the run +- **Determinism:** stable report ordering. Any sampling (reruns, spot audits) uses a recorded seed. + +## 8. Validation + +**Labeled data** + +- Commit the raw evidence of the 150 labeled pairs (5 sets) and the 20-pair mutation-scope set, frozen at commit `caacc3bac`: + - both test bodies + - describe paths + - fixture snippets and production table excerpts + - kill sets, for the mutation set +- Signatures are **re-derived** from this raw evidence in CI, so extraction regressions are caught. A full re-derivation at the pinned commit is a manual job. +- Rejected tools are not re-scored. + +**Independent adjudication** + +- Before any precision claim, a human who did not tune the rules relabels at least the four tuning sets, and all disagreements are resolved. +- Report agreement (Cohen's κ). +- **Strong-repeat precision** is measured on a seeded random sample of strong candidates from each scheduled run class (unit, integration): 50 per class, or all of them if fewer. Each is independently labeled by a human who did not tune the rules. The denominator is the sample size. + +**Prospective holdouts** + +- **Grouping:** holdouts are split by fixture and candidate group, so related pairs always land in the same set and cannot leak tuning information. +- **Size:** each fresh holdout has at least 30 distinct pairs and 30 repeat pairs. 30/30 gives a two-sided 95% lower bound of about 88%; 50/50 gives about 93%. These are bounds, not claims of near-zero risk. +- **Adversarial set:** pairs built to differ only where the facet or mutation layer is weakest. All must end in Review or Distinct: + - opaque built-ins: `Math.round` on `2.4`/`2.5`, `parseInt` radix, `toFixed`, `localeCompare`, Unicode `normalize`, `Date` boundaries + - regex alternatives + - Unicode + - numeric boundaries + - ordering + - native-parsed input + - worker-only code + - positive controls that must stay eligible: repo-only arithmetic (`add(1, 2)` vs `add(5, 5)`), membership in a supported `Set` table +- **Integration set:** pairs drawn once the runner reaches integration tests. + +**Metrics per stage**, each with sample size and 95% CI: + +- facet discrimination +- strong-repeat precision, adjudicated +- row/review split +- weak-test claim validity, audited as two separate questions: + - is the scoped mutation claim true? Reruns confirm no evaluated mutant is killed, and the share of likely-equivalent mutants is recorded. + - assertion quality, judged by a human. Meaningful assertions can kill no evaluated mutant, so this is reported separately. + +**Gates for changing facet or verdict rules** + +- Distinct recognition on a **fresh** untouched holdout stays at 100%. Each holdout gates one change and then retires into the tuning pool. The next gate draws a new holdout. +- **Paired precision check:** old and new rules run on the same fresh sample. Any false strong repeat that the new rule introduces blocks the change. The precision difference is reported with its 95% CI. +- Every new rule cites a newly labeled miss and is re-scored on a fresh holdout. + +**Spot audit:** each scheduled report includes 10 strong repeat candidates, selected with a recorded seed, for human verification. + +## 9. Rollout + +Each category becomes eligible for Layer 3 only after it passes the §5.4a gates. + +0. **Vitest 5 runner, pure-JS vertical slice (§5.4a):** + - diagnose and patch + - the `as const` pre-step with span mapping and its span map check, and the `gitnexus-shared` alias + - commit the conformance suite and the `shared.ts` cross-check oracle + - validate the **pure-JS** category only + - benchmark the oracle's fresh-process cost + - write the residual deltas against upstream in `gitnexus/tools/mutation/UPSTREAM.md` (upstream merged the root-cause fix, StrykerJS #6214; no PR is opened in Phase 0) +1. **Pure-JS unit tests:** Layers 1–3, the value-flow check, the kill-validity protocol, the report and the scheduled job. + - **Built 2026-10-25:** `gitnexus/tools/scenario-map/`, the `npm run test:scenario-map` CLI and `.github/workflows/test-scenario-map.yml`. The plan is `docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md`. + - The controls fixture (`gitnexus/test/fixtures/mutation-scenario-map/`) matches all 16 expected verdicts and rules. + - The first full run ran on PR #3686 through a temporary label-gated trigger (run 40183982111, 2026-10-25): complete, 9 of 9 shards, about 4 h. 4,632 eligible tests, 2,327 evaluated, 6,264 candidate pairs. All 11 strong repeat candidates call `extractElementTypeFromString` with the same input and expectation, in two files or under different titles. + - Verdict engine 1 left 4,532 pairs Inconclusive (`kills-unknown` 3,141, `non-assertion-outcome` 1,391): any unknown or runtime-class mutant decided the pair, even when the kill sets already differed. Engine 2 adds the kill witness. Re-merged from the same artifacts, Inconclusive falls to 1,469 (Distinct 1,053 to 3,952, Row 46 to 165, Weak 117 to 162). Every pair engine 1 decided keeps its verdict, rule and details, and the repeat candidates are unchanged. + - The remaining Inconclusive pairs have no witness: `kills-unknown` 977 (`missing-result` alone 675, all same-file pairs; timeouts alone 245), `non-assertion-outcome` 345, `member-inconclusive` 147. Every `missing-result` came from a static mutant whose test file failed to import (no per-test rows), not from a timeout; 966 of the 1,020 run-timeout mutants had a test past Vitest's 30 s testTimeout. Engine 3 records the import failure (§5.4). Its run (40208819019, complete, same 6,264 pairs) left 833 pairs Inconclusive (`non-assertion-outcome` 382, `kills-unknown` 302, of which 266 are timeouts, `member-inconclusive` 149). The 596 pairs import failures alone had blocked now reach the repeat stage: 595 are `csv-escaping.test.ts` table rows the trace vetoes to Review, because the arguments differ. Strong repeat candidates rose from 11 to 13. Both new ones were weak repeats that now had isolation, and both repeat a call with the same input and expectation. A timeout retry was tried and dropped: re-running the six timed-out mutants of one group locally resolved none (loop mutants that hang every time), and 214 of the 244 timeout-only pairs time out in both tests. Those pairs stay Inconclusive: a Stryker timer timeout marks every covered test, and a hung test can starve the next one, so a timeout of both tests does not show that the mutant cannot tell them apart. 2,305 eligible tests have no candidate partner, so they are never mutation-run; their Weak status is not evaluated. + - Scenario fingerprint (2026-10-25), measured locally (shared 24-core WSL2 host, concurrency 4, `nice -n 19`): the fingerprint-only run over all 4,632 eligible tests took 8.2 min (plan 5.4, of which Layer 1 3.0; trace 2.6; merge under a second; 2 GB peak RSS), against about 4 h and 30 runner-hours for the full mutation run. It names 26 duplicates, exactly the 26 pairs the evidence above predicts (the 13 strong repeats and the 13 mutation could not decide), 109 possible duplicates (all with an unresolved input) in 17 groups, 945 table rows and 5,184 distinct pairs. The controls fixture matches all 16 fingerprint expectations and all 16 mutation expectations. The deep run over the possible duplicates (the `scenario-map` label on PR #3686, run 40268120818) took 5 min 42 s on CI: plan 2.1, value flow 1.0, one shard of 5 groups, 25 tests and 844 mutants in 1.3, merge 0.5. Of the 109 possible duplicates, mutation settles 35 of the 38 it can reach: 31 are Row (the `embedding-recovery` table rows: kills differ), 1 Distinct, 3 weak-evidence repeats; 3 stay Inconclusive. The other 71 test `tools/mutation/` code, which is outside the mutation scope (Out of scope). That run's merge found no shard result: with one shard, `download-artifact` unpacks the only matching artifact into the path itself, and the placement step looked for a subdirectory; the merge above is a local re-merge of its artifacts, and the step now handles both layouts. +2. **Rest of the unit suite:** + - a frozen cross-check scope, then validation, for the **native-backed** and **fixture-reading** categories + - LadybugDB namespaces, then validation of the **LadybugDB** category +3. **Integration:** + 1. Benchmark the three cases (§5.4). + 2. Prove worker instrumentation and validate the **worker-backed** category. + 3. Shortlist by candidate group and shard. + +## 10. Decided + +- Embeddings, vitest-dedup in CI and jscpd are out (measured, §4). +- PR-time stays Layer 0. The expensive layers run on a schedule or on demand. +- Strong evidence requires all of §2's conditions, including mutation evidence. Facet agreement alone is never enough, and nothing is removed without human review plus the removal check. +- Similarity of any kind is retrieval only. +- The canonical path for verdicts is mutant-granular `coveredBy`; V8 block coverage is for retrieval. +- Mutation runs on the repo itself on Vitest 5, through a Stryker runner built in this effort. Vitest 4 is not used anywhere, including validation. +- Scope: the `gitnexus/` CLI package. `gitnexus-web/` is deferred. +- The unit suite comes first. +- Data that differs but collapses to one facet is judged by semantic tooling: Babel, the TypeScript 7 checker, GitNexus PDG and a Vitest 5 trace run (§5.3a). It can only veto a repeat. + +## 11. Left to the builder + +- Package layout and language: TypeScript under `gitnexus/scripts/` or a separate tool directory. +- Report format details and tracking-issue formatting. +- Where the Vitest 5 runner lives (an in-repo tooling package, or a pinned fork) until upstream ships support. Also the cache implementation (cache keys are fixed in §7). +- The list of supported assertion forms, as long as it is documented and anything else is reported as unsupported. +- The exact list of wrapped built-ins in the trace run (§5.3a), as long as storage and table reads stay unwrapped and the list is documented. + +Phase 1 choices: the tool lives in `gitnexus/tools/scenario-map/`. The supported matcher list and the wrapped built-ins list are documented in `TESTING.md` and in the headers of `facet/checks.ts` and `value-flow/builtins.ts`. There is no result cache yet: the report records the §7 cache-key inputs as run provenance. + +## 12. Decisions log + +Maintainer (2026-10-26): + +- **Fingerprint scope:** the fingerprint covers every unit and integration test of `gitnexus/`; mutation stays on the validated pure-JS category. Layer 1 runs in CI slices (fingerprint plan E5, E6, E7). + +Maintainer (2026-10-25): + +- **Duplicate definition:** a duplicate is the same execution path, the same input data and the same expectation (the scenario fingerprint, §2). It is the primary verdict; mutation kill sets are optional evidence. +- **Where it runs:** weekly fingerprint only; mutation on manual dispatch; a pull request gets the deep run (fingerprint plus mutation over the possible duplicates) when the `scenario-map` label is added, not automatically. +- **Cost:** optimise the deep run as far as possible and measure locally, not on CI. + +Maintainer (2026-10-23): + +- **Tests outside mutation reach:** bring native-backed, fixture-reading and integration tests into mutation scope (§5.4), rather than adding an unconfirmed verdict or waiting. Delivered through the Vitest 5 runner on the repo itself, not a harness. +- **Vitest 5 only (2026-10-23):** use Vitest 5, as `gitnexus/` does, for the runner and its validation oracle. Vitest 4 is gone from the design. +- **CLI focus (2026-10-23):** this effort covers `gitnexus/` only. `gitnexus-web/` is deferred. +- **Semantic value flow (2026-10-23):** for data that collapses to one facet, use whatever gives real semantic information (Babel, TypeScript itself, the Vitest engine) rather than a blanket rule (§5.3a). +- **Vitest 5 runner (2026-10-23):** build a Stryker test runner for Vitest 5 as part of this effort, and run mutation on the repo itself. This supersedes the earlier inference of a Vitest 4 harness: no harness, no `--legacy-peer-deps`, no cross-version fidelity checks. + +Independent review (Codex, 2026-10-23, round 1 verdict "rework"). All 11 findings were applied: + +1. The repeat verdict is split into strong and weak evidence, and is never "confirmed" without a human. +2. Kill-validity protocol: assertion-only kills, reruns, isolation checks, Inconclusive. +3. Global mutant and test identities; comparisons only over a common universe; sharding by candidate group. +4. Similarity is used for retrieval only; the canonical path is decided; attribution rules are specified. +5. Explicit context equality, snapshot handling, supported assertion forms, row-deletion outcomes. +6. Evidence limits and per-stage metrics with confidence intervals; independent adjudication; prospective holdouts. +7. Differential Vitest 4/5 fidelity checks and resolution-path verification _(superseded: Vitest 5 only; fidelity is now checked per category in §5.4a)_. +8. Worker positive controls; out-of-reach code caps a test at weak evidence. +9. LadybugDB namespaces; three-case benchmark before scheduling. +10. Scoped wording for weak tests and gaps; verdict precedence; empty kill sets never form a repeat. +11. Raw-evidence re-derivation, complete cache keys, evidence-hash resurfacing, seeded audits. + +Codex round 2 (2026-10-23, "rework": 5 resolved, 6 partially resolved, 4 new). Applied: + +- Execution provenance enforces the exclusion cap (blocker). +- Supported table reads are defined for row deletion. +- Strong-repeat evaluation sample and holdout retirement. +- Fault-positive controls and the supported harness envelope. +- Full cache and evidence-hash inputs. +- Concurrent tests go to Inconclusive. +- Cross-shard subsumption eligibility. +- Weak-test validation split into a mutation-claim audit and an assertion-quality review. +- The Vitest 4 decision is marked pending _(superseded: Vitest 5 only, decided)_. + +Codex round 3 (2026-10-23): 8 of 10 open items resolved, no new problems. Applied: + +- Provenance completeness: the static-closure check always applies alongside V8, and other natives and workers stay capped. +- The resurfacing hash includes the verdict, deciding rules and verdict-engine version. + +Codex round 4 (2026-10-23): **approve with changes**, both applied: + +- The tree-sitter exception is removed as unsound: lossy shapes cannot vouch for native lexer and parser execution, and it contradicted §2. Native-dependent tests cap at weak evidence. +- The static-closure check also covers mutation-excluded files, since their module initialisation is invisible to V8. + +Codex round 5 (2026-10-23), reviewing the Vitest 5 runner revision: **approve with changes**, all applied: + +- per-category execution-path fidelity and envelope checks +- a runner conformance suite (coverage attribution, zero-test runs, failure classes, worker build) +- a frozen, mapped regression oracle with a blocking acceptance rule _(now the Vitest 5 cross-check oracle)_ +- CI on runner changes +- rollout prerequisites reconciled: categories are validated in stages, and the `as const` pre-step and shared alias move to Phase 1 + +Codex round 6 (2026-10-23), reviewing the Vitest-5-only oracle and the CLI scope: **approve with changes**, all applied to gate 3: + +- oracle independence from the runner's execution adapter; activation before import, proven per run +- exact test selection (`file:line` plus anchored `-t`, exactly one executed test); indistinguishable titles are not oracle-validated and cap at weak +- outcome classes compared, not just pass/fail; unexplained differences can never be accepted +- stratified sample on top of the random 100; the roughly 3% bound stated as the claim +- cost estimate, with a benchmark and CI budget before the check becomes mandatory +- CLI scope change: consistent; `gitnexus-shared/src` stays in scope as a CLI test dependency + +Codex round 7 (2026-10-23, fresh full review, `gpt-6-astra` xhigh): **rework**, 1 blocker, 6 major, 2 minor. All applied: + +1. Blocker, lossy facets: the value-flow check (§5.3a) vetoes pairs whose differing data reaches opaque code or flows unknown; the facet keeps the discarded values. +2. Full isolation pass over the pair's covered universe for strong evidence; sampled isolation caps at weak. +3. Oracle activation is confirmed separately from execution; non-covering runs are valid. +4. Known-cause defects block oracle acceptance; only enumerated normalisations pass. +5. Phase 0 is a pure-JS vertical slice with its prerequisites; per-category cross-check scopes; a separate span map check. +6. Shards merge raw observations; weak, gaps and subsumption are recomputed. +7. Holdouts are grouped by fixture and candidate group, with sizes and a paired precision check. +8. One strong-eligible provenance predicate; a validated worker envelope lifts only the worker disqualifier. +9. Report continuity anchors are separate from revision-pinned execution identity. + +Settled by the author (reviewable): + +- **Deletion guard:** tracked separately. +- **Report home:** artifact plus one tracking issue. +- §5 layer flow, §5.5 noise control, §7 sharding, §8 frozen labels. + +Phase 1 implementation (2026-10-24/25, `docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md`). Deviations from this design, all applied in place above: + +1. **Stitched value-flow walk (§5.3a step 3).** GitNexus `pdg_query` flows are intra-procedural. The check reads per-function REACHING_DEF and `CALL_SUMMARY` edges through LocalBackend's `cypher` tool. The TypeScript checker resolves callees, and AST argument positions map to parameters. Truncation, ambiguity and the depth cap of 4 are unknown. Repo code is clean only inside the pair's mutated files. Any opaque or unknown use in a statement vetoes it. +2. **Trace (§5.3a step 4).** It records receivers as well as arguments. It runs for every facet-equal candidate pair. Asymmetric call sites or call counts veto. +3. **Plain Vitest 5 outside Stryker (§5.4).** Baselines and row deletion run on plain Vitest 5 with a fresh instance per run. Row deletion cannot run through Stryker, because Stryker applies only its own mutators. It edits a throwaway copy of the prepared tree. +4. **Isolation pass (§5.4).** It selects tests through the runner's new `testIds` scope key and gives every mutant a fresh process. It runs only for pairs that can still be strong after value flow. +5. **Demote-only additions:** + - non-identical V8 block sets cap a pair at weak (§5.2); + - numeric literals keep `0`, `1`, `-1` and negatives exact (§5.3 rule 3); + - the ±1 numeric-constant rule, with constants from executed files and the static import closure and folded initialisers (§5.3); + - the residual-difference guard: a body difference the facet misses makes the facets unequal, so a pair with equal kills ends in Review (§5.3); + - excluded files: any executed repo file under `gitnexus/` that is neither a test nor mutated caps the test at weak (§5.4); + - the hook-stratum cap (§5.4); + - the trace on every facet-equal pair (§5.3a). +6. **Runner extensions (§5.4a).** `testIds` selection, per-test classes from every attempt and a per-mutant hook marker. Gates 1–4 pass after them, with no oracle re-freeze caused by them. +7. **Schema-constants re-freeze (§5.4a gate 3).** Merging `main` changed the scope's import closure. The manifest was re-frozen with seed `u7-gate3-2`, and gate 3 passes on all three scopes. +8. **CI topology (§6, §7).** Value flow runs before the mutation shards, not in parallel with them. The shard plan is rebuilt after value flow, so it counts isolation only for the pairs value flow leaves able to be strong. A group whose isolation does not fit still runs its main run and row deletion; its pairs get isolation not-run (weak cap). A group that executes no production file is not run, and its pairs are Out of scope. diff --git a/docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md b/docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md new file mode 100644 index 000000000..6f0625cc3 --- /dev/null +++ b/docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md @@ -0,0 +1,992 @@ +--- +title: Test Scenario Map, Phase 1 Pure-JS Report - Plan +type: feat +date: 2026-10-24 +origin: docs/plans/2026-10-23-test-scenario-map-design.md +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Test Scenario Map, Phase 1 Pure-JS Report - Plan + +## Goal Capsule + +- **Objective:** maintainers get a weekly report on the validated pure-JS unit tests of `gitnexus/`. It lists tests that repeat a scenario, tests that kill no evaluated mutant, and per-file gaps. No strong repeat candidate merges two distinct scenarios without a flag, and the report never claims more than its evidence supports. +- **Means:** a four-layer pipeline in a new `gitnexus/tools/scenario-map/` (KTD1). It runs on the Phase 0 vitest5 runner, extended for single-test selection (KTD2). A fail-closed value-flow veto (KTD11) and a pure verdict engine (KTD12) sit on top. +- **Authority:** this plan's R-IDs and KTDs, then the origin design (`docs/plans/2026-10-23-test-scenario-map-design.md` §2, §5, §6, §7, §9 Phase 1), then the Phase 0 plan's KTDs for runner behaviour (`docs/plans/2026-10-23-1823-feat-stryker-vitest5-runner-phase0-plan.md`), then `AGENTS.md`, `GUARDRAILS.md`, `TESTING.md`. +- **Stop conditions:** stop and report in any of these cases: + - A runner extension breaks gate 2, 3 or 4 and cannot be fixed without weakening the gate. + - The controls fixture ends any adversarial pair as a repeat candidate. + - The eligible category cannot fit the 6 h job through sharding. Report the measured costs instead of narrowing the scope silently. + Never relax a strictness rule to make a pair strong. +- **Execution profile:** + - New tool code under `gitnexus/tools/scenario-map/`, with unit tests under `gitnexus/test/unit/scenario-map/`. + - A controls fixture under `gitnexus/test/fixtures/mutation-scenario-map/`. + - New pinned cases in `gitnexus/test/fixtures/mutation-conformance/`. + - Extensions to `gitnexus/tools/mutation/{runner,driver}` and `gitnexus/tools/test-dupes/test-dupes.ts`. + - The `test:scenario-map` script in `gitnexus/package.json`. + - One new workflow and one job in an existing workflow, plus docs. + - No change under `gitnexus/src`. + - No edit to any existing test of production code. The tooling's own tests (`gitnexus/test/unit/mutation/`, `gitnexus/test/unit/test-dupes.test.ts`) may gain cases. +- **Tail ownership:** PR #3686 (branch `feat/test-dupe-detector`) owns all of it. Phase 2 (the other test categories) consumes it. + +--- + +## Product Contract + +### Summary + +Build the Phase 1 pipeline of the Test Scenario Map for the pure-JS unit category that Phase 0 validated: +- Layer 1: per-test V8 coverage retrieval. +- Layer 2: checks, context and input facet. +- Layer 3: per-test mutation evidence with the kill-validity protocol and the row-deletion operator. +- The value-flow check, the verdict engine, the report and one tracking issue. +- A weekly CI job. +The tool reports. It never changes a test. + +### Problem Frame + +Phase 0 made per-test kill evidence trustworthy on Vitest 5, but nothing turns that evidence into findings yet. Layer 0 (`test-dupes`) runs on every PR. It misses real repeats and cannot tell distinct scenarios apart. The spikes showed the layered method works: 57 confirmed pairs, 23/23 distinct scenarios recognised on an untouched holdout. But they ran as scratch scripts over Vitest 4 copies with hard-coded `/tmp` inputs. Without a repeatable pipeline the suite keeps its repeated scenarios, and nobody can tell which tests protect nothing. + +### Key Decisions + +- **Vitest 5 only** (session-settled: user-directed — chosen over a Vitest 4 harness or a cross-version oracle: the repo runs Vitest 5). Governs R6, R17. +- **Scope is the CLI package `gitnexus/`, with `gitnexus-shared/src` as a mutated dependency** (session-settled: user-directed — chosen over including `gitnexus-web/`: the maintainer deferred it). Governs R1. +- **The tool reports only, and CI is advisory** (session-settled: user-approved — chosen over automatic deletion or folding, or a blocking gate: losing protection is the top anti-goal). Governs R14, R15. +- **Phase 1 lands on PR #3686** (session-settled: user-directed — chosen over a new branch or a stacked PR: the user asked for it on this PR). Governs R15. +- **Similarity is retrieval only; the verdict path is mutant-granular `coveredBy`** (session-settled: user-approved — chosen over V8 coverage similarity as verdict evidence: measured run-to-run drift would produce false repeats). Governs R3, R10. +- **One artifact plus one tracking issue** (session-settled: user-approved — chosen over per-finding issues or PR comments: noise is an anti-goal). Governs R12, R13. +- **Only the pure-JS category validated in Phase 0 is eligible** (session-settled: user-approved — chosen over running native-backed, fixture-reading, LadybugDB or integration tests now: they have not passed the four gates). Governs R1. +- **Embeddings, vitest-dedup and jscpd stay out** (session-settled: user-approved — chosen over embedding similarity: AUC 0.69, and 0 of 16 sampled high-similarity pairs were redundant). Governs R3. + +### Requirements + +**Scope and identity** + +- R1. Eligible tests are the files in the Phase 0 envelope (`gitnexus/test/fixtures/mutation-oracle/envelope.json`, `accepted.files`) that the current classifier still accepts. Every other test is Out of scope. The report lists the delta: newly accepted files not yet validated, and envelope files now rejected. +- R2. Every test instance has an execution identity and a continuity anchor (design §2, §5.5). A static-to-runtime join that is not one-to-one makes the test unsupported for strong evidence. + +**Layer 1: candidate retrieval** + +- R3. Per-test V8 block coverage, attributed per design §5.2, yields candidate pairs. A pair is a candidate at Jaccard ≥ 0.99, or when both tests have identical checks and context. Connected components schedule Layer 3 and never decide a verdict. +- R4. Each test records execution provenance: executed files, native-package entry points, worker spawns, and concurrent mode. Strong-eligible provenance follows design §2. + +**Layer 2: checks, context, facet** + +- R5. Each test instance records checks, context and input facet per design §2 and §5.3. These are computed per runtime instance (an `it.each`/`test.for` row, a loop iteration), and the discarded values are kept per position. An unresolved input or an unsupported assertion form blocks strong evidence. For two instances with equal facets, any body difference that is neither a facet part nor a recorded discarded value marks both instances unresolved. + +**Layer 3: mutation evidence** + +- R6. Per-test kill sets over the evaluated universe come from the Phase 0 runner with `perTest`, `disableBail` and static mutants included. Only the outcome class `killed-by-test:assertion` counts as a kill. +- R7. Kill validity follows design §5.4: + - two passing baselines with identical identities; + - agreement across every attempt; + - the full isolation pass for strong evidence. + A timeout, runtime, hook or infrastructure outcome on a mutant relevant to a pair makes the pair Inconclusive. +- R8. The row-deletion operator classifies a pair as symmetric, asymmetric, not applicable or inconclusive (design §5.4). + +**Value flow** + +- R9. A pair whose discarded values differ must pass the value-flow check (design §5.3a). A veto or an unknown sends the pair to Review and never yields a repeat verdict. + +**Verdicts** + +- R10. The verdict engine applies design §2 with its precedence. It gives exactly one verdict per pair, plus the rule or disqualifiers that decided it. It reports Weak tests, the Subsumed property and gaps, using the scoped wording of design §5.4. +- R11. Shards merge raw test–mutant observations by global identity. Verdicts, weak tests, gaps and subsumption are recomputed from the merged observations. A pair whose tests never ran together in one Layer 3 run is Inconclusive with the reason "not scheduled". + +**Report and CI** + +- R12. The report is one JSON artifact plus a markdown rendering. It carries: + - every verdict's evidence; + - the run's provenance and limits; + - a seeded spot audit of 10 strong candidates; + - new, changed and resolved items against the last complete run. +- R13. One tracking issue holds strong repeat candidates, weak tests and per-file gaps. Only complete runs from the default branch update it in place. +- R14. A weekly and manually dispatched GitHub Actions job runs Layers 0–3 on the eligible tests. Through sharding, it fits `ubuntu-latest` limits. It never blocks a merge. +- R15. The tool never edits, deletes or folds a test. A `// dupe-ok: ` comment on either member of a pair records a human keep (design §5.5). +- R16. A local CLI produces the same report as CI. + +**Runner integrity** + +- R17. After the Phase 0 runner extensions, gates 1, 2, 3 and 4 still pass. + +### Success Criteria + +- On the controls fixture, every pure-JS adversarial pair of the design §8 set ends in Review or Distinct, and every positive control stays eligible for strong evidence. The verbatim duplicate becomes a strong repeat candidate, and the object-key pair becomes a Row. The native-parsed and worker-only adversarial pairs wait for the phases that make those categories eligible (design §9 Phases 2–3). +- A complete local run over the eligible category writes the report with no unexplained error. Its Inconclusive count and causes are recorded in the PR. +- Gates 1, 2, 3 and 4 pass after the runner extensions. +- The new workflow passes actionlint, zizmor and the repo's concurrency check. + +### Scope Boundaries + +- Native-backed, fixture-reading, LadybugDB and worker-backed test categories are out of scope until they pass the Phase 0 gates (design §9 Phases 2–3). +- `gitnexus-web/` and the Python `eval/` tests are out of scope. +- No precision claim. The report says that strong-repeat precision is not yet measured (design §8). + +#### Deferred to Follow-Up Work + +- The removal check for cleanup PRs (design §6, "Cleanup PR" row). +- Design §8 labeled-data commit, independent adjudication, prospective holdouts and the paired precision check. +- A result cache keyed on the design §7 inputs. Phase 1 records those inputs as provenance only (KTD15). +- An optional whole-suite mutation run with static mutants. +- Equivalence rules that lift a value-flow veto. The list starts empty (design §5.3a). +- Upstreaming the runner's `testIds` scope extension to StrykerJS. Add it to `gitnexus/tools/mutation/UPSTREAM.md` only. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **New tool package `gitnexus/tools/scenario-map/`, one CLI with stages.** + - The CLI imports Phase 0 modules from `gitnexus/tools/mutation/` and adds no new dependency. Already installed: Babel 8 parser/types/traverse, TypeScript 7.0.2 `typescript/unstable/sync`, the tree-sitter loader in `gitnexus/src/core/tree-sitter/parser-loader.ts`, the Vitest node API, and Stryker 10. + - Stages: `plan`, `shard`, `value-flow`, `merge`, `all`, `controls`. They map one-to-one onto CI jobs, so a local `all` run and CI produce the same report (R16). +- KTD2. **Mutation evidence runs through the Phase 0 vitest5 runner, with three extensions** (session-settled: user-approved — chosen over the stock `@stryker-mutator/vitest-runner`: it runs 0 tests per mutant on Vitest 5). The extensions: + 1. An optional `testIds` key in the driver scope (`driver/stryker-config.ts`), passed to the plugin as a `vitest5.testIds` runner option in the generated Stryker config. The dry run first collects the scope files, assigns Phase 0 ids with `assignTestIds`, and maps the requested ids to Vitest task ids. It then runs one `createSpecification(file, { testIds })` per file and checks the executed set against the request. + 2. Per-test outcome classes from every sidecar attempt in `results.json`. + 3. A per-test marker for mutants attributed through a suite hook. + + The extensions never touch gate 3's hashed inputs (`tools/mutation/scope-file.ts`, `tools/mutation/vitest.mutation.config.ts`, `tools/mutation/prepare/*`). A change there forces a re-freeze of all three oracle manifests, which took about 6 h. + Baselines, row deletion and the trace run use plain Vitest 5 through `createVitest` and `tools/mutation/vitest.mutation.config.ts`, as in `tools/mutation/fidelity/fidelity.ts`. + **Conflict call-out:** the brief says mutation runs through the Phase 0 runner. Row deletion cannot, because Stryker applies only its own mutators. It runs on plain Vitest 5 in a throwaway copy of the prepared tree, which still honours Vitest 5 only. The isolation pass also needs extension 1 before it can select single tests. +- KTD3. **Identities.** + - The execution identity is the Phase 0 test id (`tools/mutation/runner/test-ids.ts`) plus the manifest location plus the row ordinal. + - The continuity anchor (design §5.5) comes from static extraction: file, describe path, title, a row fingerprint over the row's values, and a body fingerprint. A body fingerprint matches a moved test only when it is unique. Identical bodies are exactly the repeats and the rows of one `it.each`. +- KTD4. **Layer 1 runs in a prepared copy under the mutation config.** That way `gitnexus-shared` executes from source and every path maps back through `tools/mutation/prepare/path-map.ts`. + - The coverage hook ports `gitnexus/test/spike-coverage.setup.ts` to the runner's hook pattern: file-level `aroundAll` with the `{}` parameter, and `TestRunner.getCurrentTest()`. + - It records the task id, full name and location so that `assignTestIds` produces Phase 0 ids. + - Block ids are keyed by repo path. +- KTD5. **Candidate retrieval uses exact Jaccard with size and prefix filters**, plus buckets of identical checks-and-context hashes. + - A pair whose V8 block sets are not identical caps at weak. This is stricter than the design, and it can only demote. + - It closes the hole where one test reaches extra mutant-free code that the 0.99 tolerance hides. +- KTD6. **Layer 2 reuses Layer 0's AST walk.** + - `tools/test-dupes/test-dupes.ts` exports its case extraction, context closure and `dupe-ok` lookup. Layer 0's PR output must not change. + - Checks support a documented matcher list in `tools/scenario-map/facet/checks.ts`. Any other form is unsupported. + - The input facet ports `gitnexus/spike-input-facet.mts`. It covers rules 1–8, with rule 6 (source order) taken from the spike's `analyze-slice.mjs`. It adds numeric literals, per-instance evaluation of `each` tables and static loops, discarded values per position, and an `unresolved` marker. + - A numeric literal within ±1 of a production numeric constant stays exact. Stryker has no numeric-literal mutator, so a boundary value such as `LIMIT + 1` would otherwise collapse with an interior value. Constants come from the test's executed production files and its static import closure, because a constants-only module runs only during the discarded module load. Statically evaluable initialisers such as `2 * 1024` count, through Babel's `evaluate()`. + - Residual-difference guard (R5): for two instances with equal facets, diff the instance body ASTs. Any differing node that is neither a facet part nor a recorded discarded value marks both instances unresolved. This catches pairs that differ only in an enum member, a regex, a boolean or `null`, or an operator. +- KTD7. **The mutate scope of a candidate group is whole production files.** + - Production files are defined once, in `tools/scenario-map/types.ts`, as path-map-mapped files outside test files. That means `gitnexus/src/`, relocated `gitnexus-shared/src/` and, for an extra root, its `src/` subtree. The Layer 1 block filter, provenance, the mutate scope and the trace frame filter all use this one definition. + - The mutate scope is every production file in either test's V8 provenance or static import closure, with no line ranges. + - An excluded file is any executed repo file under `gitnexus/` that is neither a test file (`test/**`, `*.case.ts`) nor mutated, such as `tools/` or `scripts/` code. It caps the test at weak (design §5.4 worker threads). + - The mutated universe is never truncated silently. +- KTD8. **Kill validity mechanics.** + - Two plain Vitest 5 baseline runs of the group's tests must pass with identical identities, and so must Stryker's dry run. Otherwise the group is Inconclusive. + - Observations come from every sidecar attempt, not the latest. Any class disagreement between attempts or runs is inconclusive. + - A missing result is unknown, never a non-kill (design §5.4). + - Every generated Stryker config must carry `perTest` coverage, `disableBail` and static mutants. The run fails before mutating if one is missing. + - A test with no assertion kills but some runtime, hook or timeout kills is per-test Inconclusive, never Weak. +- KTD9. **The full isolation pass runs per pair, with alone runs shared.** + - It applies to each pair that reaches the repeat stage. That means one Stryker run with `testIds` set to both tests, and one run per test alone. + - Mutate is restricted to the pair's covered universe: Stryker ranges, then filtered by global id. + - `--max-test-runner-reuse 1` gives each mutant a fresh process with modules reset (design §5.4). + - Coverage and outcome class per test and mutant must agree across the main, together and alone runs. + - A pair whose path or kills touch a hook-attributed mutant caps at weak. The cap holds until a gate 3 scope with a non-empty hook stratum passes; today that stratum is empty in all three scopes. +- KTD10. **Row deletion.** + - Supported tables follow design §5.4. For each of the two differing keys in turn, the key is spliced out of the source text in a throwaway copy of the prepared tree. + - A control run comes first: both tests must pass, and V8 or `evaluatedModules` must show that the edited file was loaded. + - Outcomes are classified with `tools/mutation/runner/classify-outcome.ts`. + - Each edited run uses a fresh Vitest instance, because a reused instance serves cached transforms and would turn a no-op deletion into a false "neither fails". + - The global id comes from `formatGlobalId`, with mutator name `RowDeletion` and an empty replacement. +- KTD11. **Value-flow check** (session-settled: user-directed — chosen over a blanket rule that sends every value-differing pair to Review or ignores the discarded values: the user asked for real semantic information). The four sources of design §5.3a must all come back clean. Local deltas: + - **Test side (Babel).** A differing expected value with no differing bound input is unknown. + - **Checker (TypeScript).** The checker takes its tsconfig path as an input: `tsconfig.test.json` by default, the fixture's own tsconfig for `controls`. It refuses to run unless `typescript` resolves to exactly 7.0.2. A version mismatch marks the whole run degraded, like a missing PDG layer, because Dependabot can bump the lockfile. Any other API failure is unknown for that pair. + - **PDG.** The layer comes from `gitnexus analyze --pdg`, run with the checkout's own build. If it is missing or unreadable, the run is marked degraded, and every value-differing pair is vetoed with the reason "no PDG layer". Every degraded flag appears in the report's completeness block and in the issue header. + - **Trace.** It records the receiver (`this`) as well as the arguments of every wrapped call. It vetoes when the receiver or any argument differs at a shared call site, and on asymmetric sets or counts of opaque call sites. Production frames are recognised through the KTD7 definition. The trace runs for every facet-equal candidate pair, not only value-differing ones. This is a cheap backstop for differences that the facet cannot see. + + **Conflict call-out:** GitNexus `pdg_query` flows are intra-procedural (`gitnexus/src/mcp/tools.ts`, `_pdgQueryImpl` in `gitnexus/src/mcp/local/local-backend.ts`). Its output also loses the exact def/use lines, caps every file-wide query at 200 edges, and never exposes `CALL_SUMMARY`. No edge maps an argument to a parameter. Step 3 therefore stitches the walk itself: + 1. It reads REACHING_DEF and `CALL_SUMMARY` edges through LocalBackend's `cypher` tool, scoped to the function's `BasicBlock:::` id prefix. It decodes the def/use line pairs with `decodeReachingDefReason`. + 2. It classifies each use at the consuming expression. Any opaque or unknown use in a statement vetoes the whole statement. + 3. A new binding derived from the value (`const { ratio } = opts`, `this.x = v`) is walked under its own name, or marked unknown. + 4. Repo code is a clean sink only inside the pair's mutated production files (KTD7). + 5. The checker resolves each callee, and the argument position in the production AST gives the parameter. `CALL_SUMMARY` covers return values. + 6. Truncation of the per-function query, ambiguity or a depth cap is unknown. + + The check stays fail-closed. +- KTD12. **The verdict engine is a pure function over merged observations.** + - It applies the value-flow veto as a gate before any repeat category, because design §2 ranks Repeat (weak) above Review. + - It carries a `VERDICT_ENGINE_VERSION` constant into the evidence hash. + - It reads unscheduled pairs as Inconclusive. +- KTD13. **Report identity.** + - The universe identifier of a pair is a hash of the sorted mutant fingerprints of its common universe. Regrouping alone does not resurface a pair. + - The evidence hash follows design §5.5. + - The baseline is a dedicated artifact, `scenario-map-baseline`. The merge job uploads it only for complete default-branch runs, with `retention-days: 90`. The next merge job takes the newest artifact of that name through the Actions artifacts REST API, excluding its own run. A run that finds none shows a "first run, no baseline" banner. A baseline with a different report schema version shows an "incompatible baseline" banner, and no delta is computed. + - The issue body records the run id it came from. An upsert skips the update when the issue already records a newer run. + - The spot audit picks 10 strong candidates with a recorded seed. +- KTD14. **CI topology.** A new workflow, `.github/workflows/test-scenario-map.yml`, runs on a weekly cron and on dispatch. One workflow-wide concurrency group with `cancel-in-progress: false` serialises weekly and manual runs. Jobs: + 1. `plan`: Layer 0 scan over the eligible files, classify, prepare, Layers 1–2, candidates, shard plan and the run manifest. + 2. Mutation shards: a matrix with `fail-fast: false`. + 3. `value-flow`, in parallel with the shards: static check and trace run for the facet-equal candidate pairs. + 4. `merge`, with `if: always()`. + 5. `upsert-issue`: default branch only, complete runs only. A partial run adds a comment and leaves the body alone. + + Every job is `continue-on-error`, with a timeout and SHA-pinned actions. Artifact names carry the run attempt. + The run manifest from `plan` holds the eligible tests, their ids and the prepared inputs' content hashes. Each shard and the `value-flow` job verify their own prepared copy against it. A mismatch makes that job's results invalid, and its groups' pairs Inconclusive. + The controls run is a PR job in `.github/workflows/mutation-runner-validation.yml`, with `build: 'true'`. It is path-gated on `gitnexus/tools/scenario-map/**`, `gitnexus/tools/mutation/**`, `gitnexus/tools/test-dupes/**` and the fixture. The workflow's paths are workflow-level, so scenario-map changes also run the gate 2 conformance jobs, which is an accepted extra cost. +- KTD15. **No result cache in Phase 1.** The report records the design §7 cache-key inputs as run provenance, so a later cache has its keys. +- KTD16. **Runner extensions re-run gates 1, 2, 3 and 4.** Gate 1 covers the changed dry run. Gate 2 gains pinned cases for `testIds` selection, per-test classes across attempts, and the hook marker. +- KTD17. **Shard planning is deterministic.** + - A candidate group is never split across shards, so every pair runs in one run. + - Groups are bin-packed by estimated cost, ordered by group id. The estimate is a fixed per-run overhead, plus mutated file bytes times group tests, plus an isolation term: candidate pairs × covered mutants × 3 runs at the measured fresh-process cost. + - A group whose estimate exceeds one shard's budget is not run. Its pairs are Inconclusive with reason `group-over-budget`, and the report lists the group. + - A shard starts a group only when its estimate fits the remaining time before a 300-minute deadline. A group it never ran leaves its pairs Inconclusive (not scheduled, R11). + - Each finished group's observations are written to the work directory at once and uploaded with `if: always()`. + +### High-Level Technical Design + +Pipeline data flow: + +```mermaid +flowchart TB + E[Eligible tests: envelope intersect classifier] --> S[Static extraction per runtime instance] + E --> P[Prepared copy] + P --> L1[Layer 1: V8 per-test blocks + provenance] + S --> L2[Layer 2: checks, context, facet, discarded values] + L1 --> C[Candidate pairs + groups] + L2 --> C + C --> SH[Shard plan] + SH --> L3[Layer 3 per group: baselines, Stryker perTest run] + L3 --> ISO[Isolation pass for repeat-stage pairs] + L3 --> RD[Row deletion for key-table pairs] + C --> VF[Value-flow: Babel, checker, PDG walk, trace] + L3 --> M[Merge raw observations] + ISO --> M + RD --> M + VF --> M + M --> V[Verdict engine] + V --> R[Report JSON + markdown + issue body] +``` + +Verdict decision for one pair (design §2 precedence, KTD12 veto gate): + +```mermaid +flowchart TB + A{Any inconclusive cause?} -->|yes| I[Inconclusive] + A -->|no| B{Both tests eligible and covering evaluated mutants?} + B -->|no| O[Out of scope] + B -->|yes| W{Either test kills nothing?} + W -->|yes| WK[Weak per test; pair Distinct] + W -->|no| PC{Same coveredBy path, checks, context?} + PC -->|no| D[Distinct] + PC -->|yes| F{Same facet?} + F -->|no| K1{Same kill set?} + K1 -->|yes| RV[Review] + K1 -->|no| D + F -->|yes| K2{Same kill set and row deletion symmetric or n/a?} + K2 -->|no| RW[Row] + K2 -->|yes| TR{Trace clean?} + TR -->|veto| RV + TR -->|clean| DV{Discarded values differ?} + DV -->|no| DQ + DV -->|yes| VG{Static value-flow clean?} + VG -->|veto or unknown| RV + VG -->|clean| DQ{Any weak disqualifier?} + DQ -->|yes| RWK[Repeat, weak evidence] + DQ -->|no| RS[Repeat, strong evidence] +``` + +Directional data sketch of one raw observation, the unit that shards merge (R11): + +```text +Observation { + run: { kind: main | together | alone-A | alone-B, group, shard, attempt } + test: executionId, mutant: globalId, mutantFingerprint + covered: boolean, hookAttributed: boolean, static: boolean + outcome: OutcomeClass // from runner/classify-outcome.ts + valid: boolean, reason? +} +``` + +Value-flow check for one value-differing pair (KTD11). The trace step also runs alone for facet-equal pairs whose discarded values are equal: + +```mermaid +flowchart TB + B1[Babel: bind each differing value to an entry call argument] -->|unbound or expected-only| U[unknown: veto] + B1 --> T1[Checker: resolve entry through aliases, read parameter types] + T1 -->|any, unknown, unresolved, version mismatch| U + T1 --> P1[PDG walk: per-function flows, stitched across calls] + P1 -->|opaque sink or regex| X[veto] + P1 -->|truncated, dynamic, no layer, depth cap| U + P1 --> TR[Trace run: wrapped built-ins + opaque sinks] + TR -->|different receiver or args at a shared site, asymmetric sites, outcome changed| X + TR --> OK[clean] +``` + +CI job topology (KTD14): + +```mermaid +flowchart TB + PL[plan: Layer 0, classify, prepare, Layer 1, Layer 2, candidates, shard plan, run manifest] --> SH1[shard 1..N: Layer 3, isolation, row deletion] + PL --> VF[value-flow: build, analyze --pdg, static check, trace] + SH1 --> MG[merge if always: verdicts, report, completeness] + VF --> MG + MG --> UP{default branch and complete?} + UP -->|yes| IS[update issue body] + UP -->|no, default branch| CM[comment: partial run] +``` + +### Output Structure + +```text +gitnexus/tools/scenario-map/ + scenario-map.ts CLI and stage orchestration + types.ts shared record types and schema version + inventory/ eligible set, static case extraction, identity join, anchors + coverage/ Layer 1 setup file, coverage run, candidate retrieval + facet/ checks, context, input facet, production tables + mutation/ shard plan, group runs, observations, isolation, row deletion, plain Vitest runs + value-flow/ Babel binding, checker, PDG walk, trace setup and run, wrapped built-ins + verdict/ verdict engine + report/ JSON, markdown, issue body, baseline diff, spot audit +gitnexus/test/unit/scenario-map/ unit tests per module +gitnexus/test/fixtures/mutation-scenario-map/ controls fixture project and expected verdicts +.github/workflows/test-scenario-map.yml +``` + +### Assumptions + +These are unconfirmed bets made in pipeline mode. + +- The weekly cron runs Monday 04:17 UTC, outside the busy CI hours. +- The tracking issue is never closed automatically. It is found by label in any state and updated in place. A complete run with no findings says so in the body. +- `gitnexus analyze --pdg` on this repo fits a 4 vCPU job well under its timeout. Measure it in U10. If it does not fit, the run is marked degraded under KTD11. +- Eight mutation shards are enough for the eligible category. U7 measures the cost and adjusts the count. +- The ±1 numeric-constant rule and residual-difference guard (KTD6), the block-identity cap (KTD5), the excluded-file definition (KTD7), the hook-stratum cap (KTD9) and the trace on every facet-equal pair (KTD11) are additions that only demote. They are not in the design text; U16 records them there. +- Running `together` per pair, with `alone` runs shared across pairs, satisfies design §5.4's isolation pass. + +### Risks and Dependencies + +| Risk | Mitigation | +| --- | --- | +| Whole-file mutate scopes (KTD7) make static mutants expensive: each one re-runs every selected test | Selecting group tests with `testIds` bounds the run; U7 measures per-group cost. Narrowing a scope only adds weak caps, never false strong results | +| The TypeScript unstable API changes | Exact version guard; any failure is unknown (KTD11) | +| The PDG layer is slow or missing in CI | Degraded run with an explicit veto reason; never silent (KTD11) | +| A high Inconclusive rate leaves few strong verdicts | Report the rate and causes; this is the intended price of the top anti-goal | +| Runner extensions regress Phase 0 evidence | Gates 1, 2, 3 and 4 re-run (KTD16, R17); gate 3's hashed inputs stay untouched (KTD2) | +| The issue body exceeds GitHub's 65,536-character limit | Truncate at 60,000 characters in a fixed order, with counts and a run link (U13) | +| A candidate group chains into a component too large for one shard | `group-over-budget` Inconclusive with the group listed (KTD17); the largest group size is measured in U4 | +| Wrapping string built-ins slows string-heavy tests into trace timeouts | A changed outcome under tracing is Inconclusive, never clean; U11 measures the overhead on the eligible category | +| A dispatch from a feature branch overwrites main's findings | Issue upsert only from the default branch (KTD14) | +| Scheduled and dispatch workflows cannot run from this PR branch before merge | The local `all` run and the PR-gated controls job carry the proof; the first scheduled run follows the merge | + +### System-Wide Impact + +- **Phase 0 runner and gates:** U1 changes `tools/mutation/driver/stryker-config.ts`, `runner/plugin.ts`, `runner/coverage-attribution.ts`, `runner/setup.ts`, `runner/protocol.ts`, `runner/sidecar.ts` and `driver/normalise-results.ts`. These are the gate 1–4 surfaces, re-validated by KTD16. +- **Layer 0 PR job:** U2 changes only exports of `tools/test-dupes/test-dupes.ts`. Its findings on the repo must stay byte-identical. +- **CI load:** one weekly job of up to about 6 h wall time across shards, plus a short path-gated PR job. +- **Contributors:** the tracking issue changes no workflow. A human keep is still a `// dupe-ok:` comment. + +### Sources and Research + +- Phase 0 outputs: + - `results.json` schema: `gitnexus/tools/mutation/driver/normalise-results.ts` (`RunResults`, `NormalisedMutant`, `NormalisedTest`). + - Outcome classes: `gitnexus/tools/mutation/runner/classify-outcome.ts`. + - Scope keys, which reject unknown keys today: `gitnexus/tools/mutation/driver/stryker-config.ts`. + - Dry-run spec globbing: `runner/plugin.ts`. + - Hook-hit folding: `runner/coverage-attribution.ts` (`attributeHookHits`). +- Per-test selection pattern: `project.createSpecification(file, { testIds })` in `runner/plugin.ts` mutant runs, documented in `gitnexus/tools/mutation/UPSTREAM.md`. +- Plain Vitest 5 runs: `createVitest` plus `runTestSpecifications` in `gitnexus/tools/mutation/fidelity/fidelity.ts`. +- Identity helpers: + - `gitnexus/tools/mutation/prepare/mutant-identity.ts` (`formatGlobalId`, `contentHash`); + - `prepare/path-map.ts` (`toRepoPath`, `toPreparedPath`, `buildPathMap`, which accepts only `gitnexus/test/fixtures/mutation-/` extra roots). +- Spikes to port, which hard-code `/tmp` and Vitest 4 paths: + - `gitnexus/test/spike-coverage.setup.ts`; + - `gitnexus/spike-input-facet.mts`; + - `/tmp/test-audit/spike5/analyze-slice.mjs` (grouping, subsumption, rows); + - `/tmp/test-audit/spike5/row-delete.mjs` (object tables only, regenerates files: do not port as-is). +- PDG access: + - `LocalBackend.callTool('pdg_query', …)`, with the script pattern in `gitnexus/bench/impact-pdg/real-code.mjs`. + - Layer status: `pdgLayerStatus` in `gitnexus/src/mcp/local/pdg-impact.ts`. + - There is no CLI equivalent. No local index has a PDG layer today. +- TypeScript checker: the probe at `/tmp/test-audit/tsprobe/probe2.mjs`. `getAliasedSymbol` and `getResolvedSignature` exist in the API typings but were not probed. +- Issue upsert template: `.github/workflows/tree-sitter-upgrade-readiness.yml` (`actions/github-script`, a separate `issues: write` job). +- Advisory workflow shape: `.github/workflows/mutation-runner-validation.yml`. Nightly `--pdg` precedent: `.github/workflows/impact-pdg-mutation-report.yml`. +- External research was skipped. Local patterns are strong: Phase 0 tooling, the spikes and the probes. + +--- + +## Implementation Units + +| U-ID | Title | Key files | Depends on | +| --- | --- | --- | --- | +| U1 | Runner single-test selection and per-test classes | `tools/mutation/driver/stryker-config.ts`, `runner/plugin.ts`, `runner/coverage-attribution.ts`, `runner/protocol.ts`, `runner/sidecar.ts`, `driver/normalise-results.ts` | none | +| U2 | Layer 0 extraction API | `tools/test-dupes/test-dupes.ts` | none | +| U3 | Inventory, identity and anchors | `tools/scenario-map/inventory/*`, `tools/scenario-map/types.ts` | U2 | +| U17 | Controls fixture project | `test/fixtures/mutation-scenario-map/*` | none | +| U5 | Layer 2 checks and context | `tools/scenario-map/facet/checks.ts`, `facet/context.ts` | U2, U3 | +| U4 | Layer 1 coverage and candidate retrieval | `tools/scenario-map/coverage/*` | U3, U5 | +| U6 | Layer 2 input facet | `tools/scenario-map/facet/input-facet.ts`, `facet/tables.ts` | U3, U4 | +| U7 | Layer 3 group runs and observations | `tools/scenario-map/mutation/*` | U1, U3, U4, U17 | +| U8 | Full isolation pass | `tools/scenario-map/mutation/isolation.ts` | U1, U5, U6, U7, U17 | +| U9 | Row-deletion operator | `tools/scenario-map/mutation/row-deletion.ts` | U6, U7, U17 | +| U10 | Value-flow static check | `tools/scenario-map/value-flow/{bind,checker,pdg-walk,sinks}.ts` | U5, U6, U17 | +| U11 | Value-flow trace run | `tools/scenario-map/value-flow/{trace.setup,trace,builtins}.ts` | U4, U10, U17 | +| U12 | Verdict engine | `tools/scenario-map/verdict/verdict.ts` | U5–U11 | +| U13 | Report and issue body | `tools/scenario-map/report/*` | U12 | +| U14 | CLI orchestration and controls run | `tools/scenario-map/scenario-map.ts` | U1–U13, U17 | +| U15 | Scheduled workflow and PR controls job | `.github/workflows/test-scenario-map.yml`, `mutation-runner-validation.yml` | U14 | +| U16 | Documentation and design corrections | design doc, `TESTING.md`, `UPSTREAM.md` | U14 | + +All `tools/` and `test/` paths are under `gitnexus/`. + +### U1. Runner single-test selection and per-test classes + +- **Goal:** the Phase 0 runner can run a chosen subset of tests, and `results.json` carries every per-test class and the hook marker that Phase 1 needs. +- **Requirements:** R6, R7, R17. KTD2, KTD9, KTD16. +- **Dependencies:** none. +- **Files:** + - Modify: + - `gitnexus/tools/mutation/driver/stryker-config.ts` + - `gitnexus/tools/mutation/runner/plugin.ts` + - `gitnexus/tools/mutation/runner/coverage-attribution.ts` + - `gitnexus/tools/mutation/runner/setup.ts` + - `gitnexus/tools/mutation/runner/protocol.ts` (`FileMeta` carries hook-attributed tests per mutant) + - `gitnexus/tools/mutation/runner/sidecar.ts` (the dry-run record carries them; bump `SIDECAR_SCHEMA_VERSION`) + - `gitnexus/tools/mutation/driver/normalise-results.ts` + - `gitnexus/tools/mutation/UPSTREAM.md` + - Never modify: `gitnexus/tools/mutation/scope-file.ts`, `gitnexus/tools/mutation/vitest.mutation.config.ts`, `gitnexus/tools/mutation/prepare/*` (gate 3 hashed inputs, KTD2). + - Fixture: `gitnexus/test/fixtures/mutation-conformance/` (new `*.case.ts` and `expected.json` entries). + - Tests: + - `gitnexus/test/unit/mutation/stryker-config.test.ts` + - `gitnexus/test/unit/mutation/normalise-results.test.ts` + - `gitnexus/test/unit/mutation/coverage-attribution.test.ts` +- **Approach:** + 1. Accept an optional `testIds` array in the driver scope. Each id must be a Phase 0 test id of one of the scope's `testFiles`. Pass it to the plugin as the `vitest5.testIds` runner option (KTD2). + 2. In the dry run, collect the scope files, assign ids with `assignTestIds`, map the requested ids to task ids, run one specification per file, and check the executed set equals the request. + 3. Keep the raw hook bucket in the per-file metadata, next to the attributed per-test coverage. Persist it through `FileMeta` and the dry-run sidecar record. Expose `hookAttributedTests` per mutant. + 4. In normalisation, keep every sidecar attempt's per-test rows as `perTest: { testId, outcome, attempt }[]`. Mark a mutant `attemptsDisagree` when attempts differ. + 5. Bump the results `schemaVersion`, and keep gate 3 compare reading the fields it uses today. +- **Execution note:** run GitNexus impact analysis on `parseDriverScope`, `attributeHookHits`, `normaliseResults`, the `FileMeta` and sidecar record types, and the plugin's dry-run method before editing. These are gate surfaces. +- **Patterns to follow:** + - The mutant-run specification building in `runner/plugin.ts`. + - The scope validation in `stryker-config.ts`. + - The conformance case style in `gitnexus/test/fixtures/mutation-conformance/`. +- **Test scenarios:** + - A scope file with `testIds` naming two of five tests in one file: the parsed scope keeps them; the generated dry-run selection contains exactly those two. + - A `testIds` entry not in any scope file: the parser fails with a message naming the id. + - An empty `testIds` array: rejected, so that "all tests" never happens by accident. + - Normalising a mutant with two sidecar attempts, `killed-by-test:assertion` then `killed-by-test:runtime` for the same test: both rows are kept, and `attemptsDisagree` is true. + - Normalising a mutant whose coverage came only from a `beforeAll` hit: `hookAttributedTests` lists every test of that suite. + - New conformance case: a dry run with `testIds` set to one of two tests reports coverage and outcomes for that test only. + - New conformance case: a mutant hit in `beforeAll` carries the hook marker for each test below the suite. + - A `testIds` entry naming a test the collection did not find: the dry run fails before any mutant run, naming the id. + - Covers R17: gate 3 compare on fresh runner runs of the three oracle scopes still passes with 0 differences, with no re-freeze required. +- **Verification:** unit tests pass; gate 1 passes over the pure-JS category with 0 differences; gate 2 passes with the new pinned cases; gate 3 compare passes on all three scopes; gate 4 passes. + +### U2. Layer 0 extraction API + +- **Goal:** Phase 1 reuses Layer 0's case extraction, context closure and `dupe-ok` lookup without changing Layer 0's findings. +- **Requirements:** R5, R15. KTD6. +- **Dependencies:** none. +- **Files:** + - Modify: `gitnexus/tools/test-dupes/test-dupes.ts`. + - Test: `gitnexus/test/unit/test-dupes.test.ts`. +- **Approach:** + 1. Export the case extraction, frame building, context closure and the `suppressed` lookup. + 2. Add a per-case record with file, line, describe path, title, context hash, body text, `each` table node and enclosing loop head. Today's `recordCase` folds context into one hash; keep that hash for Layer 0 and expose the parts. + 3. Change no detection rule. +- **Execution note:** run impact analysis on `extract`, `buildFrame`, `closure` and `scan` before editing. `recordCase` is nested in `extract` and covered by its analysis. Capture `npm run test:dupes -- --all --json ` before and after, with the same set of test files on disk, and require the two files to be identical. +- **Patterns to follow:** the existing exported `scan` and `orphanBenchGates` signatures and their tests. +- **Test scenarios:** + - Extracting a file with a nested describe and two tests returns two records. Each has the full describe path, the title, the declaration line and distinct context hashes when the `beforeEach` differs. + - A test with `// dupe-ok: reason` on the line above is reported as suppressed with that reason. + - An `it.each` call returns one record, with the table node and the title template. + - Layer 0 scan output on the existing test fixtures is unchanged. +- **Verification:** the `test-dupes` unit tests pass, and the `--all` scan JSON is byte-identical before and after. + +### U3. Inventory, identity and anchors + +- **Goal:** one inventory of eligible test instances, with execution identities joined one-to-one to the static records, and continuity anchors. +- **Requirements:** R1, R2, R15. KTD3. +- **Dependencies:** U2. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/inventory/eligible.ts` + - `gitnexus/tools/scenario-map/inventory/cases.ts` + - `gitnexus/tools/scenario-map/inventory/identity.ts` + - `gitnexus/tools/scenario-map/types.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/eligible.test.ts` + - `gitnexus/test/unit/scenario-map/cases.test.ts` + - `gitnexus/test/unit/scenario-map/identity.test.ts` +- **Approach:** + 1. The eligible set is the envelope's accepted files intersected with a fresh `classify()` run. The delta goes into the inventory (R1). In `controls` mode, the fixture's test files are enrolled instead, and its `src/` modules are production files through `extraRoots` (KTD7). Normal runs always apply R1. + 2. Static cases expand `it.each`/`test.each`/`test.for` tables and `for…of` loops over literal arrays into one instance per row, with the row's bound values. A table that cannot be evaluated statically gives instances marked `unresolved` (design §5.3). + 3. The join takes the runtime records from Layer 1 (task id, full name, location, row order within a location) through `assignTestIds`. It matches them to static instances by file, location and row order. Any count mismatch makes every instance at that location unsupported. + 4. Anchors per KTD3. +- **Patterns to follow:** `classify()` in `gitnexus/tools/mutation/classify/classify.ts`; `assignTestIds` in `runner/test-ids.ts`. +- **Test scenarios:** + - The envelope lists files A, B and C, and the classifier now accepts A, C and D: eligible is A and C; the delta lists D as not validated and B as rejected. + - An `it.each([[1, 2], [3, 4]])` test expands to two instances, with bound values `[1, 2]` and `[3, 4]` and row ordinals 0 and 1. + - An `it.each(buildRows())` test gives instances marked `unresolved`. + - Three runtime rows at one location against two static instances: all three are unsupported, with reason `join-mismatch`. + - Two tests with identical bodies in different files: the body fingerprints are equal and the anchors stay distinct by file. A moved test with a unique body keeps its anchor. + - `controls` mode with the fixture root: the fixture's case files are eligible, and the envelope is not consulted. + - The production-file predicate: `gitnexus/src/a.ts` and relocated `gitnexus-shared/src/b.ts` are production; `gitnexus/tools/x.ts`, `gitnexus/test/unit/y.test.ts` and a fixture `*.case.ts` are not; a fixture `src/` module is production only in `controls` mode. +- **Verification:** unit tests pass; an inventory run over the repo's eligible files completes and reports the join-mismatch count. + +### U17. Controls fixture project + +- **Goal:** a committed fixture project holds one pair per pure-JS adversarial and positive-control case, with expected verdicts written before any run, so U7–U11 can verify against it. +- **Requirements:** Success Criteria. KTD7. +- **Dependencies:** none. +- **Files:** + - Create: `gitnexus/test/fixtures/mutation-scenario-map/` with `src/` production modules, `*.case.ts` tests, `vitest.config.ts`, `tsconfig.json` (extends `gitnexus/tsconfig.test.json` and includes the fixture's `src/` and `*.case.ts`), and `expected.json`. +- **Approach:** + 1. The fixture holds one pair per design §8 pure-JS adversarial and positive-control case: + - `Math.round` 2.4/2.5; + - `parseInt` radix; + - `localeCompare`; + - Unicode `normalize`; + - `Date` boundaries; + - regex alternatives; + - numeric boundary ±1, with the constant in a separate constants-only module; + - expected-only difference; + - derived value reaching `toFixed`; + - an enum-member-only difference; + - an order-dependent pair; + - an object-key row; + - a verbatim duplicate; + - `add(1, 2)` against `add(5, 5)`; + - membership in a supported `Set` table. + 2. `expected.json` maps each pair to its verdict: every adversarial pair Review or Distinct, the order-dependent pair Inconclusive, the object-key pair Row, the duplicate and positive controls strong repeats. + 3. No repo Vitest project collects the fixture, as with `mutation-conformance`. +- **Execution note:** write `expected.json` from the design's stated outcomes, not from observed output. +- **Patterns to follow:** `gitnexus/test/fixtures/mutation-conformance/` (own `vitest.config.ts` project, `*.case.ts`, `expected.json`). +- **Test scenarios:** Test expectation: none -- fixture data. U14's `controls` stage exercises it end to end. +- **Verification:** the fixture's own Vitest project passes unmutated; its tsconfig type-checks; the main suite does not collect it. + +### U5. Layer 2 checks and context + +- **Goal:** checks and context per test instance, with unsupported forms marked. +- **Requirements:** R5. KTD6. +- **Dependencies:** U2, U3. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/facet/checks.ts` + - `gitnexus/tools/scenario-map/facet/context.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/checks.test.ts` + - `gitnexus/test/unit/scenario-map/context.test.ts` +- **Approach:** + 1. Checks are the shape of each `expect(subject)[.not|.resolves|.rejects].matcher(args)` with literals abstracted. Selector literals inside the subject call stay exact (design §2). + 2. Snapshot assertions hash the inline snapshot text or the `.snap` entry. + 3. The supported matcher list is a documented constant. Anything else is `unsupported`: `assert.*`, custom matchers, `expect` inside helpers not inlined in the body. + 4. Context is the Layer 0 closure: hooks, `vi.mock`/`vi.spyOn`/`vi.stubEnv`, env writes, wrapper-call arguments. It is compared by exact hash. +- **Patterns to follow:** `assertionSignatures`, `closure` and `buildFrame` in `tools/test-dupes/test-dupes.ts`. +- **Test scenarios:** + - `expect(fn('a')).toBe(1)` and `expect(fn('b')).toBe(2)`: same checks shape. + - `expect(getNodesByLabel(r, 'Function')).toHaveLength(1)` and the same call with `'Class'`: different checks, because the selector literal stays exact. + - `expect(x).not.toBe(1)` and `expect(x).toBe(1)`: different checks. + - `toMatchInlineSnapshot` with different snapshot text: different checks. + - A body using `assert.equal`: marked unsupported, with the form named. + - Two tests that differ only in an `afterEach` body: different context. +- **Verification:** unit tests pass; the supported matcher list appears in the module header and in `TESTING.md` (U16). + +### U4. Layer 1 coverage and candidate retrieval + +- **Goal:** per-test V8 blocks and provenance for every eligible test, then candidate pairs and groups. +- **Requirements:** R3, R4. KTD4, KTD5. +- **Dependencies:** U3, U5 (checks-and-context hashes for the second candidate source). +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/coverage/coverage.setup.ts` + - `gitnexus/tools/scenario-map/coverage/run-coverage.ts` + - `gitnexus/tools/scenario-map/coverage/candidates.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/candidates.test.ts` + - `gitnexus/test/unit/scenario-map/coverage-records.test.ts` +- **Approach:** + 1. Prepare a copy with `prepare()` and run the eligible files with `createVitest` under the mutation config. Prepend the coverage setup file. + 2. Attribute per design §5.2. A root `beforeAll` discards module-load coverage. A suite's `beforeAll` delta is taken at its first `beforeEach`. Mark `concurrent` tests. + 3. Keep the blocks of production files (KTD7), translated to repo paths. + 4. Provenance lists every executed script URL, flags known native-package entry points, and records worker spawns through wrapped `worker_threads.Worker` and `child_process` spawn functions. + 5. Candidates come from exact Jaccard ≥ 0.99 with a size-ratio filter and a rare-block prefix filter, plus pairs sharing a checks-and-context hash from U5. Groups are connected components with a deterministic group id. + 6. Record the largest group's size and test count for KTD17. +- **Patterns to follow:** + - The hook pattern in `gitnexus/tools/mutation/runner/setup.ts` and `fidelity/recorder.setup.ts`. + - The spike `gitnexus/test/spike-coverage.setup.ts`. +- **Test scenarios:** + - Jaccard of two block sets that differ by 1 in 200: a candidate at the 0.99 threshold. + - Two sets that differ by 3 in 200: not a candidate. + - Two tests with Jaccard 0.5 but an identical checks-and-context hash: a candidate. + - Pairs A–B and B–C with A–C below threshold: one group of three containing exactly two candidate pairs. + - A coverage record from a `concurrent` test: its provenance is unknown, and its pairs are flagged path-unattributable. + - A record whose executed URLs include `node_modules/tree-sitter`: native-package entry flagged. + - Integration: run the hook on the controls fixture. A test whose `beforeAll` calls production code shows that code in its suite blocks, not in module-load blocks. +- **Verification:** unit tests pass; a coverage run over the eligible files finishes. Record its wall time and candidate counts in the PR. + +### U6. Layer 2 input facet + +- **Goal:** an input facet per test instance, with discarded values per position and unresolved inputs marked. +- **Requirements:** R5, R9. KTD6. +- **Dependencies:** U3, U4. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/facet/input-facet.ts` + - `gitnexus/tools/scenario-map/facet/tables.ts` + - `gitnexus/tools/scenario-map/facet/source-shape.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/input-facet.test.ts` + - `gitnexus/test/unit/scenario-map/tables.test.ts` +- **Approach:** + 1. Port the spike's rules 1–8 (design §5.3). Production tables come from the test's executed files (U4), then its 2-hop import closure, then property columns. Tables are never pooled. + 2. Add rule 6 (source order of literal shapes and builder-call names) and numeric literals. + 3. Add the ±1 numeric-constant rule (KTD6), with constants from executed production files and the static import closure. + 4. Keep each discarded concrete value per position, with its source location, for U10. + 5. Add the residual-difference guard (KTD6) over each facet-equal candidate pair. + 6. Remove every hard-coded path from the spike. The spike file stays untracked and out of the commit. +- **Patterns to follow:** `gitnexus/spike-input-facet.mts` (`syntaxProfile`, `declaredIn`, `shapeOf`, `tableOf`, `fixturesOfTest`); `createParserForLanguage` from `gitnexus/src/core/tree-sitter/parser-loader.ts`. +- **Test scenarios:** + - Two tests feeding `'Function'` and `'Class'`, both members of one production string-array table: equal facets, and discarded values `['Function']` and `['Class']` at position 0. + - Two tests feeding inline Rust and inline Go source: different facets. + - Two tests feeding inline TypeScript that differs only in a local variable name: equal facets. + - `parse(101)` and `parse(150)` against a production `LIMIT = 100`: different facets, because 101 stays exact under the ±1 rule. + - `parse(150)` and `parse(170)` against the same constant: equal facets. + - `LIMIT = 100` declared in a separate constants-only module that the test never executes per test: `parse(101)` and `parse(150)` still get different facets. + - `LIMIT = 2 * 50`: the folded constant 100 applies, and `parse(101)` stays exact. + - Two tests that differ only in an enum-member argument (`NodeLabel.Function` against `NodeLabel.Method`) outside the expect subject: equal facets, both instances unresolved by the residual-difference guard. + - Two tests that differ only in a regex-literal argument: both instances unresolved. + - Two textually identical test bodies: no residual difference, nothing marked unresolved. + - A literal `0` against a literal `5`: different facets (boundary shape). + - A fixture lookup the rules cannot resolve: the facet is marked unresolved. + - Literal order `[a, b]` against `[b, a]` with different shapes: different facets. + - Reproduce the spike's untouched holdout-4: 23/23 distinct pairs keep different facets. Holdout data comes from `/tmp/test-audit/spike3` if present. This is a one-off local check, not a committed test. +- **Verification:** unit tests pass; a facet run over the eligible files completes and reports the unresolved count. + +### U7. Layer 3 group runs and observations + +- **Goal:** per candidate group, valid baselines, one runner run over the group's tests, and raw observations ready to merge across shards. +- **Requirements:** R6, R7, R11. KTD7, KTD8, KTD14, KTD17. +- **Dependencies:** U1, U3, U4, U17. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/mutation/plan-shards.ts` + - `gitnexus/tools/scenario-map/mutation/vitest-run.ts` + - `gitnexus/tools/scenario-map/mutation/run-group.ts` + - `gitnexus/tools/scenario-map/mutation/observations.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/plan-shards.test.ts` + - `gitnexus/test/unit/scenario-map/observations.test.ts` +- **Approach:** + 1. The mutate scope follows KTD7. Static closure files come from the classifier's closure output. + 2. `vitest-run.ts` runs given test ids in a given prepared tree, with a fresh `createVitest` instance per call. Baselines, row deletion and the trace run share it. + 3. A shard first verifies its prepared copy against the run manifest (KTD14). + 4. A group run is two baselines, then `runScope` with `testFiles`, `testIds` and the whole-file `mutate` list. Before mutating, it checks that the generated config carries `perTest`, `disableBail` and static mutants (KTD8). Then it extracts observations from every per-test row (U1). + 5. A raw observation is keyed by group, run kind, attempt, test execution id and global mutant id. Two observations of one test and mutant in one run lineage with different classes become inconclusive (design §5.4). Observations from different groups stay separate. + 6. A pair is compared only inside one group run's common universe (R11). A covered mutant with no observation for one test is unknown, never a non-kill. + 7. Each finished group's observations are written out at once (KTD17). +- **Patterns to follow:** `runScope` in `gitnexus/tools/mutation/driver/run-scope.ts`; `RunResults` in `driver/normalise-results.ts`. +- **Test scenarios:** + - Shard planning over five groups with costs 10, 9, 5, 3 and 1 into two shards: deterministic assignment, identical across two calls, and no group split across shards. + - A group whose estimate exceeds one shard's budget: not assigned; its pairs are Inconclusive with reason `group-over-budget`. + - A shard with 20 minutes left and a next group estimated at 30 minutes: the group is not started; its pairs are `not-scheduled`. + - A shard result that omits group G: every pair in G is Inconclusive with reason `not-scheduled`. + - A shard whose prepared-copy hashes differ from the run manifest: its results are invalid, and its groups' pairs are Inconclusive. + - A generated Stryker config without `disableBail`: the group run fails before mutating. + - Test A has an observation for covered mutant M and test B has none: M is unknown for the pair, and the kill sets are not compared as equal. + - Baselines with different identity sets: the group is Inconclusive with reason `baseline-unstable`, and no observations are emitted. + - Baseline 2 has a failing test: the group is Inconclusive. + - Merging two observations of test T and mutant M from two attempts, `killed-by-test:assertion` and `survived`: one inconclusive observation. + - Mutant M evaluated in group X (killed by T1) and in group Y (survived T2): both observations are kept, and each pair compares only within its own group. + - A test executed `src/a.ts` while the mutate list lacks it: the test is marked `excluded-file`. +- **Verification:** unit tests pass; one group run on the controls fixture produces observations for every test and mutant pair in its universe. + +### U8. Full isolation pass + +- **Goal:** strong evidence requires that per-test outcomes agree when the pair runs together and when each test runs alone, each mutant in a fresh process. +- **Requirements:** R7. KTD9. +- **Dependencies:** U1, U5, U6, U7, U17. +- **Files:** + - Create: `gitnexus/tools/scenario-map/mutation/isolation.ts`. + - Test: `gitnexus/test/unit/scenario-map/isolation.test.ts`. +- **Approach:** + 1. Select pairs with the same `coveredBy` path, checks, context, facet and kill set from the main run. + 2. Build the covered universe of each pair, and the Stryker ranges for its mutants. + 3. Run `together` once per pair. Run `alone` once per test, over the union of its pairs' universes. + 4. Compare per test and mutant: covered flag and outcome class across main, together and alone. Any difference makes the pair Inconclusive with reason `isolation-disagrees`. + 5. A hook-attributed mutant in the pair's path or kills caps the pair at weak (KTD9). +- **Patterns to follow:** gate 3 per-test comparison in `gitnexus/tools/mutation/oracle/compare.ts`. +- **Test scenarios:** + - A pair whose main, together and alone observations all agree: isolation `agrees`. + - Test A kills M in main and together, but survives M alone: Inconclusive, `isolation-disagrees`, naming M. + - Coverage of M by B appears in main but not alone: Inconclusive. + - A pair that never reached the repeat stage: no isolation run is scheduled. + - Pairs A–B and A–C: `alone(A)` is scheduled once, over the union universe. + - A pair touching a hook-attributed mutant: capped at weak with disqualifier `hook-stratum-unvalidated`. +- **Verification:** unit tests pass; on the controls fixture, the order-dependent control pair ends Inconclusive. + +### U9. Row-deletion operator + +- **Goal:** pairs whose differing literals are keys of one supported production table get a symmetric, asymmetric, not-applicable or inconclusive outcome. +- **Requirements:** R8. KTD10. +- **Dependencies:** U6, U7, U17. +- **Files:** + - Create: `gitnexus/tools/scenario-map/mutation/row-deletion.ts`. + - Test: `gitnexus/test/unit/scenario-map/row-deletion.test.ts`. +- **Approach:** + 1. Find the table from U6's table index. + 2. Reject unsupported constructs: spreads, computed keys, `Object.assign`, writes into the table, getters, proxies, duplicate keys. The outcome is `inconclusive`, with the construct named. + 3. Splice one key's property or entry text, including its trailing comma. Never regenerate the file. + 4. Run the control, then one run per deleted key, through `vitest-run.ts` in a throwaway copy. Restore the file after each run. +- **Patterns to follow:** + - The identity build in `prepare/mutant-identity.ts`. + - The spike `/tmp/test-audit/spike5/row-delete.mjs` for the table search only. +- **Test scenarios:** + - An object table `{ a: 1, b: 2, c: 3 }`, with tests reading `a` and `b`: two deletions planned, with global ids that carry mutator `RowDeletion`. + - A `new Set(['x', 'y'])` table: deleting `'x'` splices only that element, and the file still parses. + - A table with a spread: `inconclusive`, reason `unsupported-table: spread`. + - Differing literals in no common table: `not-applicable`. + - Classification: only test A fails when `a` is deleted, giving `asymmetric`. Both fail, or neither fails, giving `symmetric`. A runtime error gives `inconclusive`. + - The control run has a failing test: `inconclusive`, reason `control-failed`. + - The control run never loads the edited file: `inconclusive`, reason `edit-not-loaded`. + - Two deletion runs in a row: each uses a new Vitest instance, so the second run sees the second edit, not a cached transform. +- **Verification:** unit tests pass; on the controls fixture, the object-key pair ends as Row. + +### U10. Value-flow static check + +- **Goal:** for each value-differing pair, the test-side binding, checker resolution and PDG walk give a clean, veto or unknown result with reasons. +- **Requirements:** R9. KTD11. +- **Dependencies:** U5, U6, U17. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/value-flow/bind.ts` + - `gitnexus/tools/scenario-map/value-flow/checker.ts` + - `gitnexus/tools/scenario-map/value-flow/pdg-walk.ts` + - `gitnexus/tools/scenario-map/value-flow/sinks.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/bind.test.ts` + - `gitnexus/test/unit/scenario-map/sinks.test.ts` + - `gitnexus/test/unit/scenario-map/pdg-walk.test.ts` +- **Approach:** + 1. `bind.ts` maps each differing discarded value to an entry call argument, fixture field or table key, with Babel and `@babel/traverse` scope bindings. An expected-only difference is unknown (KTD11). + 2. `checker.ts` opens the given tsconfig (KTD11) through `typescript/unstable/sync`, checks the version, and resolves each entry through `getAliasedSymbol` to its declaration path. It records parameter types and literal-union members. A version mismatch marks the run degraded. + 3. `pdg-walk.ts` reads REACHING_DEF and `CALL_SUMMARY` edges through LocalBackend's `cypher` tool, scoped to the function's block-id prefix, and decodes them with `decodeReachingDefReason`. It applies the KTD11 walk rules. A repo callee recurses with the parameter at the argument's position. Depth is capped at 4, and the cap is unknown. + 4. `pdgLayerStatus` gates the walk: a missing layer marks the run degraded (KTD11). + 5. `sinks.ts` holds the pure classification, the statement rule and the storage/table-read exemptions, so it is unit-testable without a PDG. + 6. The static check covers every facet-equal candidate pair whose discarded values differ. It runs in the `value-flow` job, in parallel with the shards (KTD14). +- **Execution note:** measure `gitnexus analyze --pdg` on the repo at the checkout's build, and record wall time and peak memory in the PR before sizing the CI job. +- **Patterns to follow:** + - `gitnexus/bench/impact-pdg/real-code.mjs` (backend construction under tsx). + - The probe `/tmp/test-audit/tsprobe/probe2.mjs`. + - Write output with `writeSync(1, …)`, because LadybugDB captures stdout (`gitnexus/src/cli/tool.ts`). +- **Test scenarios:** + - Covers design §8 adversarial "Math.round": the differing values reach `Math.round`, declared in `lib.es5.d.ts`. The result is `veto`, sink `opaque: Math.round`. + - Values reach `a + b` inside a repo function only: `clean`, sink `repo-code`. + - A value stored with `Map.set` and read back with `Map.get` into a repo comparison: `clean`, classified as storage and table read. + - A value used in `new RegExp(v)` or `v.match(...)`: `veto`, regex. + - Only the expected values differ (`toContain('X')` against `toContain('Y')`) and the inputs are equal: `unknown`, reason `expected-only`. + - The entry parameter is typed `any`: `unknown`. + - The PDG returns `truncated: true`: `unknown`. + - Recursion depth 5: `unknown`, reason `depth-cap`. + - `if (path.extname(v) === '.ts')`: `veto`, because the opaque `path.extname` use vetoes the whole statement even though the comparison is repo code. + - `const { ratio } = opts` followed by `Math.round(ratio)`: `veto`, because the derived binding is walked under its own name. + - The value reaches a repo function in `gitnexus/tools/`, outside the pair's mutated files: `unknown`, not `clean`. + - The value is returned unchanged and the caller compares it: `CALL_SUMMARY` continues the walk in the caller. + - The checker reports TypeScript 7.0.3: the run is degraded, and every value-differing pair is `unknown` with reason `checker-version`. + - `pdgLayerStatus` returns `no-layer`: the run is degraded, and every value-differing pair is vetoed with reason `no-pdg-layer`. +- **Verification:** unit tests pass; on the controls fixture, indexed with `--pdg` and checked with the fixture's tsconfig, each adversarial value pair gets the expected veto or clean result. + +### U11. Value-flow trace run + +- **Goal:** one traced run per candidate group catches differing data that reaches opaque built-ins, including derived values and flows that static analysis missed. +- **Requirements:** R9. KTD11. +- **Dependencies:** U4, U10, U17. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/value-flow/builtins.ts` + - `gitnexus/tools/scenario-map/value-flow/trace.setup.ts` + - `gitnexus/tools/scenario-map/value-flow/trace.ts` + - Test: `gitnexus/test/unit/scenario-map/trace.test.ts`. +- **Approach:** + 1. `builtins.ts` is the documented wrap list (design §11): `Math`, string transforms and comparisons, `RegExp` methods, number parsing and formatting, `JSON`, `Intl`, `Date` and sort comparators. Add every opaque sink that U10 found. Storage and table reads stay unwrapped. + 2. The setup file wraps each listed built-in, prototype methods included. It records call site, receiver, arguments and count, per current test, only for production frames (KTD7). + 3. The trace run uses `vitest-run.ts` with the tests of every facet-equal candidate pair (KTD11). A test whose outcome differs from the baseline under tracing makes its pairs Inconclusive. + 4. Measure the trace overhead on the eligible category and record it in the PR. +- **Patterns to follow:** the setup-file prepend in `runner/plugin.ts`; `TestRunner.getCurrentTest()` attribution in `runner/setup.ts`. +- **Test scenarios:** + - Two traces reach `Math.round` at `src/x.ts:12`, with arguments `2.4` and `2.5`: `veto`. + - Same site with equal arguments: `clean`. + - `(x * 10).toFixed(1)` reached with receivers 24 and 25 and equal arguments: `veto`, reason `receiver-differs`. + - Two facet-equal pairs with equal discarded values and identical traces: `clean`. + - Test A calls `toFixed` at a site that test B never reaches: `veto`, reason `asymmetric-sites`. + - Same site, A calls it twice and B once: `veto`, reason `asymmetric-counts`. + - A call from a `node_modules` frame only: ignored. + - A test passes at baseline and fails under tracing: the pair is Inconclusive, reason `trace-changed-outcome`. + - `Map.prototype.set` calls are never recorded. +- **Verification:** unit tests pass; on the controls fixture, the derived-value control (`x * 10` reaching `toFixed`) is vetoed. + +### U12. Verdict engine + +- **Goal:** one verdict per pair, and per-test Weak, Inconclusive and Subsumed properties, from merged observations and the Layer 1/2 and value-flow evidence. +- **Requirements:** R10, R11, R15. KTD5, KTD8, KTD9, KTD12. +- **Dependencies:** U5–U11. +- **Files:** + - Create: `gitnexus/tools/scenario-map/verdict/verdict.ts`. + - Test: `gitnexus/test/unit/scenario-map/verdict.test.ts`. +- **Approach:** + 1. Encode design §2 precedence as an ordered list of rules. Each rule returns a verdict or passes. The first decisive rule names itself, and every weak disqualifier is listed. + 2. Weak disqualifiers: provenance not strong-eligible, an unresolved facet input, inconclusive row deletion, an unsupported construct, isolation not run, a test not oracle-validated, non-identical V8 blocks (KTD5), an excluded file (KTD7), a hook stratum (KTD9). + 3. Subsumption holds only when B was evaluated on every mutant in A's universe within one group run. Otherwise it is unknown. + 4. Gaps are recomputed per production file from all observations. A mutant is killed if any assertion kill exists for it; it is "survived the selected tests" when covered somewhere and never killed; it is "uncovered by selected tests" otherwise. + 5. A `dupe-ok` comment on either member moves the pair to a kept list and keeps its evidence. +- **Patterns to follow:** the grouping and subsumption logic in `/tmp/test-audit/spike5/analyze-slice.mjs`. +- **Test scenarios:** + - Covers design §8 positive control: `add(1, 2)` against `add(5, 5)` with identical path, checks, context, facet and kills, clean value flow, isolation agreeing, and all strong conditions met: Repeat (strong evidence). + - The same pair with isolation not run: Repeat (weak evidence), disqualifier `isolation-not-run`. + - Same facet, kill sets differing by one mutant: Row. + - Same facet and kills, row deletion `asymmetric`: Row. + - Same kills, different facet: Review. + - Same facet and kills, value-flow veto: Review, never a repeat. + - Same facet and kills, equal discarded values, trace veto (asymmetric sites): Review. + - Same facet and kills, equal discarded values, clean trace: the static value-flow check is not required, and the pair can be a strong repeat. + - A test that executed `gitnexus/tools/x.ts`: the pair caps at weak, disqualifier `excluded-file`. + - Both kill sets empty: never a repeat; each test is Weak. + - Test A has no assertion kills but has a runtime kill: per-test Inconclusive, not Weak. + - A timeout on a mutant covered by B: the pair is Inconclusive. + - The pair is `not-scheduled`: Inconclusive. + - A's kills are a strict subset of B's, and B was evaluated on all of A's universe: A is Subsumed. When B misses one mutant: subsumption unknown. + - Mutant M survives group X and is killed in group Y: the gap list counts M as killed. + - A pair with `dupe-ok` on test B: listed as kept, absent from the findings. + - Running the engine twice on the same input gives byte-identical output. +- **Verification:** unit tests pass with one test per precedence rule and per disqualifier. + +### U13. Report and issue body + +- **Goal:** the JSON artifact, its markdown rendering and the issue body, with continuity and week-over-week change. +- **Requirements:** R12, R13. KTD13, KTD15. +- **Dependencies:** U12. +- **Files:** + - Create: + - `gitnexus/tools/scenario-map/report/report.ts` + - `gitnexus/tools/scenario-map/report/markdown.ts` + - `gitnexus/tools/scenario-map/report/issue-body.ts` + - `gitnexus/tools/scenario-map/report/baseline.ts` + - Tests: + - `gitnexus/test/unit/scenario-map/report.test.ts` + - `gitnexus/test/unit/scenario-map/issue-body.test.ts` + - `gitnexus/test/unit/scenario-map/baseline.test.ts` +- **Approach:** + 1. The JSON carries `schemaVersion`, run provenance (KTD15 inputs, seed, engine version), completeness (shards complete, degraded flags), eligibility delta, the Layer 0 findings over the eligible files, every verdict with its evidence, per-test properties, gaps, spot audit and change status. + 2. The mutant fingerprint is file, operator, replacement and normalised enclosing statement (design §5.5). + 3. `baseline.ts` compares against a previous report: new, changed (evidence hash differs), unchanged, resolved. With no baseline, a "first run" banner appears. With a different schema version, an "incompatible baseline" banner appears and no delta is computed (KTD13). + 4. The issue body has a header: run link and run id marker, completeness, every degraded flag, "N of M eligible tests evaluated", and a precision-not-measured statement. Then come strong repeats, weak tests and gaps. It is truncated at 60,000 characters with counts. +- **Patterns to follow:** the upsert body shape in `.github/workflows/tree-sitter-upgrade-readiness.yml`; the markdown rendering in `tools/test-dupes/test-dupes.ts`. +- **Test scenarios:** + - A previous report has pair P with evidence hash h1, and the current report has P with h1: unchanged, absent from "new or changed". + - Same P with h2: changed. + - P only in previous: counted as resolved. + - No previous report: every item is new, and the banner reads "first run, no baseline". + - A previous report with an older schema version: the "incompatible baseline" banner, and no new/changed/resolved counts. + - A run degraded by `no-pdg-layer`: the issue header names the flag. + - The issue body carries a run id marker that a later upsert can compare. + - A verdict-engine version bump alone changes every evidence hash. + - A regrouping that leaves a pair's common universe unchanged keeps its evidence hash. + - The spot audit with seed `s1` picks the same 10 strong candidates on two runs. With fewer than 10 strong candidates, it picks all of them. + - An issue body with 5,000 gap rows: truncated below 60,000 characters, keeping all strong repeats first, then weak tests, and ending with a count line. + - A complete run with no findings: the body states "no strong repeat candidates, weak tests or gaps found". +- **Verification:** unit tests pass; the markdown rendered from the controls run reads correctly. + +### U14. CLI orchestration and controls run + +- **Goal:** one CLI runs every stage locally or as CI jobs, and the controls run proves the pipeline end to end. +- **Requirements:** R14, R16; Success Criteria. KTD1, KTD17. +- **Dependencies:** U1–U13, U17. +- **Files:** + - Create: `gitnexus/tools/scenario-map/scenario-map.ts`. + - Modify: `gitnexus/package.json` (script `test:scenario-map`). + - Test: `gitnexus/test/unit/scenario-map/cli.test.ts`. +- **Approach:** + 1. Stages per KTD1. Each stage reads and writes JSON files in a work directory, so CI jobs exchange them as artifacts. + 2. The `plan` stage also runs the Layer 0 scan over the eligible files and writes the run manifest (KTD14). + 3. `controls` runs every stage on the U17 fixture through `extraRoots`, with the fixture's tsconfig for the checker, and compares the verdicts with `expected.json`. Its exit codes: 0 holds, 1 differs, 2 usage. + 4. The fixture's PDG index follows `gitnexus/bench/impact-pdg/measure.mjs`: copy the fixture to a temp directory and run `node dist/cli/index.js analyze --pdg --skip-git --index-only` under a temporary `GITNEXUS_HOME`. It needs `dist/` built, and leaves the developer's `~/.gitnexus` untouched. +- **Patterns to follow:** `gitnexus/tools/mutation/conformance/run-conformance.ts` (fixture project, compare-only mode, exit codes); `gitnexus/bench/impact-pdg/measure.mjs` (fixture PDG index); the npm script form in `gitnexus/package.json`. +- **Test scenarios:** + - Parsing `shard --index 2 --of 8 --work dir` gives the stage options. + - `--index 9 --of 8`: usage error, exit 2. + - `merge` with a missing shard result: the run is marked partial, and that shard's pairs are Inconclusive. + - Covers the Success Criteria: the `controls` stage on the fixture matches `expected.json`. Every adversarial pair is Review or Distinct; the duplicate is a strong repeat; the object-key pair is a Row; the order-dependent pair is Inconclusive. + - `controls` without a built `dist/`: exits 2 with a message naming the build step, rather than running degraded. +- **Verification:** `controls` exits 0. A local `all` run over the eligible category completes. Its counts, Inconclusive causes and wall time are recorded in the PR. + +### U15. Scheduled workflow and PR controls job + +- **Goal:** a weekly advisory job produces the artifact and updates the tracking issue, and PRs touching the tooling run the controls. +- **Requirements:** R13, R14. KTD14, KTD17. +- **Dependencies:** U14. +- **Files:** + - Create: `.github/workflows/test-scenario-map.yml`. + - Modify: `.github/workflows/mutation-runner-validation.yml` (`scenario-map-controls` job with `build: 'true'`; paths per KTD14). +- **Approach:** + 1. Triggers: `schedule` (Assumptions) and `workflow_dispatch`, with inputs `shards` and `seed`. + 2. Top level: `contents: read`, one workflow-wide concurrency group with `cancel-in-progress: false` (KTD14), and `defaults.run` in `gitnexus`. Every job has `timeout-minutes` and `continue-on-error: true`, and uses `./.github/actions/setup-gitnexus`. + 3. Jobs per KTD14. `value-flow` uses `build: 'true'`, because `analyze --pdg` spawns workers from `dist/`. + 4. Merge fetches the newest `scenario-map-baseline` artifact through the Actions artifacts REST API, under `actions: read`. It uploads a new one only for complete default-branch runs, with `retention-days: 90` (KTD13). + 5. Upsert runs `actions/github-script` with `issues: write`. It finds the issue by label in any state, skips when the issue records a newer run id, and passes the body as a file. + 6. Artifact names carry `github.run_attempt`. Shard artifacts upload with `if: always()`. +- **Execution note:** this is CI configuration. Verify with actionlint, zizmor and `python3 .github/scripts/check-workflow-concurrency.py`; the job cannot be dispatched before the merge. +- **Patterns to follow:** + - `.github/workflows/tree-sitter-upgrade-readiness.yml` (upsert job). + - `.github/workflows/mutation-runner-validation.yml` (pinned SHAs, artifacts, advisory jobs). + - `.github/workflows/impact-pdg-mutation-report.yml` (`--pdg` with a build). +- **Test scenarios:** Test expectation: none -- workflow configuration. Its logic lives in U13 and U14, which carry the tests; workflow lint covers the YAML. +- **Verification:** actionlint, zizmor (high severity) and the concurrency check pass; the PR's controls job runs green. + +### U16. Documentation and design corrections + +- **Goal:** the design, the testing guide and the upstream notes match what Phase 1 built. +- **Requirements:** R12, R16, R17. +- **Dependencies:** U14. +- **Files:** + - Modify: + - `docs/plans/2026-10-23-test-scenario-map-design.md` + - `TESTING.md` + - `gitnexus/tools/mutation/UPSTREAM.md` +- **Approach:** in the design: + - correct §5.3a step 3 (intra-procedural PDG, stitched walk; KTD11); + - correct §5.4 (row deletion and the isolation pass on plain Vitest 5 and `testIds`; KTD2, KTD9); + - record the demote-only additions (KTD5 block identity, KTD6 ±1 rule and residual-difference guard, KTD7 excluded files, KTD9 hook cap, KTD11 trace on every facet-equal pair); + - record the schema-constants re-freeze already committed on this branch (merging `main` changed the scope's import closure; re-frozen with seed `u7-gate3-2`, gate 3 passing on all three scopes); + - add the Phase 1 results: the full-run counts, Inconclusive causes and costs from U14. + + In `TESTING.md`: the `test:scenario-map` stages, the supported matcher list, the wrapped built-ins list, the controls fixture. In `UPSTREAM.md`: the `testIds` scope delta. +- **Test scenarios:** Test expectation: none -- documentation only. +- **Verification:** the docs name only files and flags that exist; prettier check passes. + +--- + +## Verification Contract + +Commands run from `gitnexus/` unless noted. + +| Check | Command | When | +| --- | --- | --- | +| Types | `npm run typecheck:tests` | every unit | +| Unit tests | `npx vitest run test/unit/scenario-map test/unit/mutation test/unit/test-dupes.test.ts` | every unit | +| Lint and format | `npx eslint tools/scenario-map tools/mutation tools/test-dupes test/unit/scenario-map` and `npx prettier --check` on changed files (repo root config) | every unit | +| Layer 0 unchanged | `npm run test:dupes -- --all --json ` before and after, same test files on disk, files identical | U2 | +| Gate 1 | `npm run test:mutation:fidelity` | U1 | +| Gate 2 | `npm run test:mutation:conformance` | U1 | +| Gate 3 | fresh runner runs of the three oracle scopes, then `npm run test:mutation:oracle -- --scope ,` ×3 | U1 | +| Gate 4 | `npm run test:mutation:controls` | U1 | +| Controls end to end | `npm run test:scenario-map -- controls` | U14 onward | +| Full local run | `npm run test:scenario-map -- all --work ` | U14 | +| Workflow lint | `actionlint`, `zizmor` and `python3 .github/scripts/check-workflow-concurrency.py` (repo root) | U15 | +| Graph safety | GitNexus `impact` before editing existing symbols; `node .gitnexus/run.cjs detect-changes --scope all --repo .` before each commit (repo root) | U1, U2, every commit | + +The full unit suite (`npm run test:unit`) runs before the PR is pushed. The known load flake in `test/unit/cli-index-help.test.ts` is rerun alone before it is treated as a failure. + +--- + +## Definition of Done + +- Every unit's Verification holds, and every command in the Verification Contract passes. +- Gates 1, 2, 3 and 4 pass after U1 (R17), with no oracle re-freeze caused by U1. +- `controls` matches `expected.json`, and one full local run over the eligible category completes. Its counts, Inconclusive causes and costs are in the PR description and in design §9. +- No file under `gitnexus/src` changes. No existing test of production code is edited; only the tooling's own tests gain cases. +- The spike files (`gitnexus/spike-input-facet.mts`, `gitnexus/test/spike-coverage.setup.ts`, `gitnexus/vitest.spike-cov.config.ts`, `gitnexus/vitest.stryker-bake.config.ts`) stay uncommitted. +- No `any` in new code. No `if` inside test bodies; table cases use `it.each` with unique titles. Every sampled choice uses a recorded seed. +- Abandoned-attempt code, debug output and scratch scripts are removed from the diff. diff --git a/docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md b/docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md new file mode 100644 index 000000000..ac2d31927 --- /dev/null +++ b/docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md @@ -0,0 +1,192 @@ +--- +title: Scenario fingerprint — mutation-free duplicate verdicts for the test scenario map +date: 2026-10-25 +status: implemented +origin: docs/plans/2026-10-23-test-scenario-map-design.md +branch: feat/test-dupe-detector (PR #3686) +--- + +# Scenario fingerprint + +## Problem + +Two tests are duplicates when they run the **same execution path** on the **same input data** and +check the **same expectation** (maintainer definition, 2026-10-25). The scenario map decides +repeats from mutation kill sets: about 4 h and 30 runner-hours per full run, and most pairs end +Inconclusive on runtime-class kills and hangs. + +## Evidence (run 40208819019, verdict engine 3, mutation as reference) + +- Pairs whose V8 block sets are identical, whose checks-and-context keys match, whose input facets + and discarded values are equal with nothing unresolved, and whose trace is clean: **26**. + Mutation calls 13 of them strong repeats (all 13 of the run); the other 13 it could not decide + (timeouts, runtime-class kills), and their titles show the same input and expectation. **None** + is Distinct or Row. +- The static `same-test` scan flags 25 of the 26; the one it misses is a renamed clone + (`method-props.test.ts` lines 47 and 185, `findString` renamed `findStr`). +- Every triple-equal pair whose kills differ carries an unresolved input (`it.each` rows whose + value only shapes filesystem state): the fail-closed rule already holds them out. +- Mutation is only informative where inputs are unresolved: 1,066 pairs in 38 groups, about + 1.4 h of the 42.9 h estimated cost. + +## Decisions + +- **F1 (maintainer):** the fingerprint is the primary verdict; mutation is optional evidence. +- **F2 (maintainer):** the weekly run is fingerprint only; mutation runs on manual dispatch. +- **F3 (maintainer):** a labelled pull request (`scenario-map`) gets the deep run (fingerprint + plus mutation over the possible duplicates); no automatic PR run. +- **F4 (maintainer):** optimise the deep run as far as possible and measure it locally, not on CI. +- **F5:** different input data is not a duplicate. A pair with equal shape and different values + is a table-row candidate, decided without mutation. +- **F6:** an input the static extraction cannot resolve, a missing facet or checks key, a residual + body difference, an unsupported construct, or a trace that is not clean makes a pair at most a + possible duplicate, never a duplicate (fail-closed, design R2, D1). +- **F7:** the deep run mutates only groups with a possible duplicate, and in each such group only + the tests of those pairs, over the files those tests executed. + +## Fingerprint verdicts (pure, `verdict/fingerprint.ts`) + +Per candidate pair, first rule that holds: + +| Verdict | Rule | When | +| ------------------ | ---------------------------------- | -------------------------------------------------------------------- | +| out-of-scope | `not-eligible` | a test is outside the eligible category | +| inconclusive | `path-unattributable` | a test ran concurrently | +| distinct | `path-differs` | the V8 block sets differ | +| possible-duplicate | `checks-unknown` / `facet-unknown` | a checks key or facet is missing and nothing known differs | +| different-checks | `same-input-different-checks` | same path and input (equal, resolved), checks differ | +| distinct | `checks-differ` | checks differ otherwise | +| distinct | `input-shape-differs` | facet fingerprints differ | +| table-row | `input-values-differ` | same shape, discarded values differ | +| table-row | `runtime-values-differ` | equal static values, trace veto | +| possible-duplicate | `input-unresolved` | equal, with an unresolved input (F6) | +| possible-duplicate | `trace-unknown` | equal and resolved, trace not clean (inconclusive, missing, not run) | +| duplicate | `same-path-input-expectation` | everything equal, resolved, trace clean | + +A `// dupe-ok:` reason on either test moves the pair to `kept`. + +## Units + +- **U1** `verdict/fingerprint.ts` and its tests (one case per rule). +- **U2** report: `fingerprint` section and `mutation` scope; markdown and issue body lead with + duplicates and possible duplicates; weak tests and gaps only when mutation ran. Report schema + bump. +- **U3** CLI: `--mutation none|possible-duplicates|all` on `plan` and `all`; value flow skips the + PDG static check unless `all`; the re-plan keeps only the groups F7 names, trimmed; merge + computes fingerprints always and mutation verdicts only for the mutated pairs; zero shards is a + valid plan. +- **U4** controls: fingerprint expectations in `expected.json`, written from the rules above + before any run, and checked by the controls stage. +- **U5** workflow: weekly fingerprint; dispatch input `mutation`; `pull_request: labeled` + (`scenario-map`) with mutation `possible-duplicates`; per-ref concurrency for PR runs; shard jobs + skipped at zero shards; PR runs write no issue and no baseline. +- **U6** docs: design doc, workflow and CLI headers. +- **U7** local measurement (bounded, `nice -n 19`, concurrency at most 4): the full fingerprint + run, and the deep run over the possible duplicates. + +## Verification + +`tsc` (src and tests), eslint, prettier, the scenario-map unit tests, the local controls run, and +U7's timings recorded in the design doc. + +## Results (2026-10-25) + +- U1-U5 committed on PR #3686. The controls fixture matches all 16 fingerprint and all 16 mutation + expectations (local run, 43 min). +- Fingerprint-only run, all 4,632 eligible tests, locally: 8.2 min (plan 5.4, trace 2.6), 2 GB + peak RSS. 26 duplicates (the 26 the evidence predicted), 109 possible duplicates in 17 groups, + 945 table rows, 5,184 distinct. +- Deep run over the possible duplicates, on CI (run 40268120818, the `scenario-map` label): 5 min 42 s + end to end, the shard 1.3 min for 5 groups and 844 mutants. It settles 35 of the 38 possible + duplicates mutation can reach (31 Row, 1 Distinct, 3 weak repeats; 3 Inconclusive); 71 test + `tools/mutation/` code outside the mutation scope. Its merge missed the single shard artifact + (unpacked into the download path itself); fixed in the workflow's placement step. + +## Extension: every unit and integration test (maintainer, 2026-10-26) + +The fingerprint needs no Stryker run, so its scope is no longer bound to the mutation runner's +validated category. **F8:** the fingerprint covers every `*.test.ts` under `gitnexus/test/unit` +and `gitnexus/test/integration`; mutation stays on the gate-validated pure-JS envelope. + +- **E1** Eligibility carries `fingerprintFiles` besides the mutation-eligible `files`; a test's + verdict input carries `eligible` (mutation), `fingerprintEligible` and `passed`. +- **E2** Layer 1 runs the files of the base config's `fileParallelism: false` projects (`lbug-db`, + `cli-e2e`) with one worker, the rest with `--concurrency` workers, and joins both into one work + file. +- **E3** The trace run uses the same two partitions. +- **E4** Fingerprint rule `not-passing`: a test that did not pass in Layer 1 makes its pairs + Inconclusive. The mutation target keeps only pairs whose two tests are mutation-eligible. +- **E5** Measure Layer 1 on a sample of integration files locally; shard Layer 1 on CI when the + whole set does not fit one plan job. + - Result (2026-10-26): a sample of 44 integration files (every tenth) ran past 35 min at + concurrency 4 on the shared host, against 8.2 min for all 4,632 unit tests. That host was + loaded by other suites (see E7), so the sample overstated the cost; Layer 1 still runs in + slices, which keeps the plan job small and the wall clock short. The `coverage` stage runs one + slice (`--index i --of n`, + `path-coverage/slices.ts`: split like the CI test shards by `test/helpers/shard-balance.ts`), + and `plan --coverage ` merges the slice files; a slice that did not report leaves its + files unrecorded. The workflow runs 4 slices before the plan (first sized at 12 from an + over-pessimistic estimate; corrected under E7: the whole suite with coverage runs in three + 7-8 minute shards of the test workflow, ci.yml run 30014110469). +- **E6** (found by E5) The prepare step (gate-3 hashed) leaves `vendor/` and `dist/` out of the + prepared copy, so half the sampled integration files failed to load (leiden, the parse worker, + the built CLI). Layer 1 and the trace copy both from the checkout into their prepared copy in + normal mode; every stage that runs tests (plan, value-flow, all, controls) requires a built + `dist/`, and the workflow's plan and value-flow jobs build it. +- **E7** (optimisation, maintainer 2026-10-26: "can we optimise the tool?") Layer 1 skips the files + no fingerprint finding can come from. Past the path rule a pair is a duplicate, a possible + duplicate or a table row only when the two checks-and-context keys are equal or one is unknown, + and the key is static, so `path-coverage/prefilter.ts` keeps a file only when a test's key is + shared or unknown, a call's instance count is not static, a body reads a file snapshot, the file + may register tests the inventory cannot see (a call at registration level that is not the test + API, such as `defineRenameFixtureTests`, `describeGrammarPresence` or an `it` alias), or it did + not parse. Given up: `different-checks` between a test of a skipped file and another test (0 on + the last full unit run). `--mutation all` and the controls run every file. + - Evidence: on the 250 unit files of the last full fingerprint run, the static keys reproduce + all 3,313 runtime keys and select exactly the 172 files the runtime keys select. On the current + checkout the prefilter skips 449 of 1,399 files (149 integration), about 24% of the weighted + Layer 1 cost (`test/helpers/shard-balance.ts`); Vitest's runtime collection + (`vitest list --no-static-parse`) of the skipped files finds no test the static inventory + misses (434 equal counts, 15 with fewer runtime tests: benchmarks skipped at collection). + - Layer 1 overhead, measured as ratios inside one instrumented run (absolute times on the shared + host are meaningless: a plain run of `resolvers/rust` skipped all 255 tests after 798 s): the + coverage snapshots of `resolvers/rust` (1,022 takes) cost 10.1 s against about 190 s for the + file, whose work sits in `beforeAll` (not repeated, not snapshotted per test), and its repeat + attempts re-ran 0.1 s of test bodies; a unit file of many tiny tests (`scenario-map/cli`) + spends 2.5 s on snapshots against 0.3 s of test bodies. So Layer 1 costs little more than a + plain run, and the slices are sized from the test workflow's shards: 4. + - Visible gaps: the report counts the Layer 1 tests without a fingerprint, those skipped at run + time (`skip`, `skipIf`, `todo`; the `GITNEXUS_BENCH` benchmark suites run only in the test + workflow's benchmarks job, as wall-clock and heap guards) and those that never finished + (their file's run ended first). +- **E8** (optimisation) A labelled pull request's run is diff-scoped: `--changed-since ` (the + workflow passes the merge commit's first parent to the coverage slices; a local `all` takes it + directly) keeps only candidate pairs with a test of a test file the diff added or modified, and + Layer 1 runs only those files (when the prefilter would keep them), the files sharing their + tests' checks-and-context keys and the files below. A changed `__snapshots__/.snap` counts as a change of its test file; + changed helper modules and fixtures do not. Full recall (maintainer, 2026-10-26): every file + with a test of no static key (helper-registered, dynamic `it.each` counts, file snapshots, no + assertion key, unparsed) runs too, since such a test can pair with any changed test. That floor + is 230 of 1,407 files, about 59% of the suite's weighted Layer 1 cost with 59 sequential + files, too much for the plan job alone, so a labelled pull request runs the coverage slices as + well: `coverage --changed-since` records the changed files in each slice, and the plan takes + the diff scope from the slices. A pull request that changes no test file still runs the floor; + the report names the changed files. +- Review (2026-10-26, eight-angle code review of E1-E8, findings verified against the code): + fixed a suite callback passed by name (`describe('x', defineCases)`) slipping past the + registration guard, an empty coverage slice failing its job, `--changed-since` accepted with the + controls fixture, the report counting unreported slices' files as run (it now counts unrecorded + files), snapshot-only diffs scoping nothing, and the diff-scope report claiming more than it + checks. Not changed: mutation shards never get integration tests (`mutationTarget` keeps only + pairs of two mutation-eligible tests, the pure-JS envelope); a slice that disagrees with the + plan stops the merge on purpose. +- Cleanup review (2026-10-26, reuse, simplification, efficiency and altitude): a shard's + row-deletion batch ran every planned pair of a complete group, including pairs whose tests the + mutation target trimmed away; it now runs only pairs of tests the trimmed group holds. Setup + cost: the coverage jobs list the fingerprint's files without the Phase 0 classifier (about + 33 s each, twice per job); slice files carry Layer 1's file list, mutation scope and revision, + so the plan checks them instead of recomputing the prefilter (about 30 s); the + unseen-registration check moved into the static inventory (`incomplete`), which parses each + file once and uses test-dupes' `roleOf`. Not changed: `fingerprintEligible` is always true at + its only producer, so the fingerprint's `not-eligible` rule cannot fire (kept for a future scope + narrower than Layer 1's files). diff --git a/eslint.config.mjs b/eslint.config.mjs index e0779b71c..959560892 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -52,6 +52,8 @@ export default [ '**/dist/**', '**/node_modules/**', '**/coverage/**', + '**/.stryker-tmp/**', + 'gitnexus/reports/mutation/**', 'gitnexus/vendor/**', 'gitnexus-web/src/vendor/**', 'gitnexus/test/fixtures/**', diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index df2040933..6fe3badb6 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -73,6 +73,8 @@ "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@mermaid-js/tiny": "11.17.2", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/core": "10.0.0", "@types/busboy": "^1.5.4", "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", @@ -132,6 +134,48 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/compat-data": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.9.tgz", + "integrity": "sha512-wXtyKlpR/pXf/u0vmib+OBakQaIzLglLcRoKpWxCHW/whqq7YfQYPD0IAAl9H/evi40MyLIjYYdvb6N+9DI2yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-compilation-targets": "^8.0.7", + "@babel/helpers": "^8.0.8", + "@babel/parser": "^8.0.8", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.7", + "@babel/types": "^8.0.8", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^3.0.0", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, "node_modules/@babel/generator": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz", @@ -150,6 +194,58 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz", + "integrity": "sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "flru": "1.0.2", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.7.tgz", + "integrity": "sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.5", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.7", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz", @@ -160,10 +256,114 @@ "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helper-string-parser": { + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.5.tgz", + "integrity": "sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-transforms": { "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", - "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.6.tgz", + "integrity": "sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.6", + "@babel/traverse": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.8.tgz", + "integrity": "sha512-fVM+gSuKbdv9cpmyRNh5EvsDHUZCdxQH3FdnFKSkNYHpbsn95bbrxm+ES3MVeQjcqwi2O5ur/DjLfmTQaO60ng==", "dev": true, "license": "MIT", "engines": { @@ -180,14 +380,38 @@ "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/parser": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", - "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.8.tgz", + "integrity": "sha512-FBmhiDWs625wHg03OKiLQDeT6LOc2lWBbW97Yqs2iI1tZcucjpmYnO29NHsQ83XxKvredcvUymW/AAn2UtnakA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^8.0.6" + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.8" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.8.tgz", + "integrity": "sha512-Gq65JogAr0S2p0BzvfSJboyuiRnxDQApyOqHkKmkrvm+VlLK4gC3pKsiOKEdnhFBemrFbSnOu90mZZWDpiJnmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -196,6 +420,251 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-8.0.2.tgz", + "integrity": "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-decorators": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-8.0.1.tgz", + "integrity": "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz", + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.5.tgz", + "integrity": "sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.5.tgz", + "integrity": "sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-8.0.1.tgz", + "integrity": "sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-8.0.1.tgz", + "integrity": "sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-8.0.1.tgz", + "integrity": "sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-8.0.1.tgz", + "integrity": "sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.6.tgz", + "integrity": "sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.6", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-syntax-typescript": "^8.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-8.0.1.tgz", + "integrity": "sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-react-display-name": "^8.0.1", + "@babel/plugin-transform-react-jsx": "^8.0.1", + "@babel/plugin-transform-react-jsx-development": "^8.0.1", + "@babel/plugin-transform-react-pure-annotations": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.7.tgz", + "integrity": "sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-typescript": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, "node_modules/@babel/template": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", @@ -231,13 +700,13 @@ } }, "node_modules/@babel/types": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", - "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.8.tgz", + "integrity": "sha512-iXSSZAKQWmaeAwGtFw10v9fg5hUiUs0L+w+Z2K11kym3pDNK+SF0VcAQPsEFvWcTSZ7LMgClMXDBvTbH0IlICg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-string-parser": "^8.0.8", "@babel/helper-validator-identifier": "^8.0.6" }, "engines": { @@ -823,6 +1292,350 @@ "hono": "^4" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.7.tgz", + "integrity": "sha512-/+QmGE/9bvE/EobaZDnzYe+T+QWoJrnmxwBQNtoi2OVSUR9d1znYsNyEoUrOZvFSxDYttrhzRIpXpaLkN+iIsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.4.tgz", + "integrity": "sha512-ZwnaZZDa1zxsyVrN32s447ydMnKcw7e8WWNT+R+Qsu0zAn8Rs6yi9d1B80Kob1Ul2cfUi7t8JD2t4/K7uEZpqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.5.tgz", + "integrity": "sha512-c0ZSnO9oVk/IlEGoZK3u8b3IZQeu9I6jUkXJSQnQEVRXsjKvtAq3AGqFL2LgfJhlzMZne08pZHWRWasIcI3Rew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.5.tgz", + "integrity": "sha512-6lU3/pPx5z3lHWejFW061MV4qZooBQYv/+EeGhNOPiP/i+hrYsHfxxBnnDGsAfg8YdLUd69YckoeOjtr3QuAUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/external-editor": "^3.0.6", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.7.tgz", + "integrity": "sha512-eiJ31YFCaB6fLoJCqLIn8S6eqUyME6vwr8KcP5h8cxmA+bV9RRVPO+kywoxb7G63HduDXV6sN/SM7o2R+ahEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.6.tgz", + "integrity": "sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.8.tgz", + "integrity": "sha512-psOSTgkL8y/OtgJJYbqJ0oISr2QBE0U1Ro/qQdA/EKjeJiwiUTQgQmq8o4ESNzVOWthIeh3YLD7BCUR9dKnR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.5.tgz", + "integrity": "sha512-lEy7LWsdF5qHx5QjHZqabUqM9P7i5uTMCt8GRzqxmwXXKbapKJDA+ucVflVVPAyj4snD5DTAD4L+sct85XOlOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.4.tgz", + "integrity": "sha512-T7ir3fflSSVCYEVqNyMBq+pIMsilQRtbZcD5gxn6Ewsin5TJWBxrGBdXveWtmeXVAeoInfhS+BhBUZaL4sFSDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.4", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.4.tgz", + "integrity": "sha512-SAJt+QhY5rOJAIgZBw9TrHWfgrp6wsb9cqRCyqax+Hl6EiFfRXmD9EsIWkST8yq/dQ8H46Is2Sr/JG7Ls/LQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.7", + "@inquirer/confirm": "^6.3.4", + "@inquirer/editor": "^5.3.5", + "@inquirer/expand": "^5.1.7", + "@inquirer/input": "^5.1.8", + "@inquirer/number": "^4.2.5", + "@inquirer/password": "^5.2.4", + "@inquirer/rawlist": "^5.3.7", + "@inquirer/search": "^4.3.5", + "@inquirer/select": "^5.2.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.7.tgz", + "integrity": "sha512-XveEe8v3BHj9ec00n+9i8FHjWhXjS6+jCTizYvXCqKUKtgkrq9xid8S5j3mYGW96qfxG3mwNoud3m3v8GDk9Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.5.tgz", + "integrity": "sha512-kIUecMugEN2YTvTmff+uYz0nAE0f9RFNDVZknfrh264vAQM9P8tnsXiUp7z8XbGuNIBEtFMJhvc9QquawpC46A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.7.tgz", + "integrity": "sha512-pRDWO4dTqSAvFQdO8yclKMtXNDm9K9HmvWB5UZmT3clw2OlOBNXveHzhyFmO/0d0yJMqlPcNITus1Jtpm6t8VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/core": "^12.0.5", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1325,6 +2138,146 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/api": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz", + "integrity": "sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-10.0.0.tgz", + "integrity": "sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/instrumenter": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "ajv": "~8.20.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.8.4", + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.3.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@stryker-mutator/core/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@stryker-mutator/core/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-10.0.0.tgz", + "integrity": "sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~8.0.0", + "@babel/generator": "~8.0.0", + "@babel/parser": "~8.0.0", + "@babel/plugin-proposal-decorators": "~8.0.0", + "@babel/plugin-transform-explicit-resource-management": "^8.0.0", + "@babel/preset-react": "~8.0.0", + "@babel/preset-typescript": "~8.0.0", + "@babel/traverse": "~8.0.4", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "angular-html-parser": "~10.11.0", + "semver": "~7.8.0", + "tslib": "2.8.1", + "weapon-regex": "~2.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-10.0.0.tgz", + "integrity": "sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1447,6 +2400,13 @@ "@types/send": "*" } }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1970,9 +2930,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2002,6 +2962,16 @@ } } }, + "node_modules/angular-html-parser": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.11.0.tgz", + "integrity": "sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2138,6 +3108,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", + "integrity": "sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/body-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", @@ -2187,6 +3170,40 @@ "node": "20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -2236,6 +3253,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -2277,6 +3315,13 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -2313,6 +3358,16 @@ "node": ">=4" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2443,6 +3498,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2592,6 +3654,17 @@ "node": ">= 0.8" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2603,6 +3676,13 @@ "node": ">=8" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2623,12 +3703,29 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2803,6 +3900,33 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2906,6 +4030,23 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", @@ -2922,6 +4063,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fast-xml-builder": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", @@ -2979,6 +4130,22 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3023,6 +4190,16 @@ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3079,6 +4256,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3125,6 +4312,23 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gitnexus-shared": { "resolved": "../gitnexus-shared", "link": true @@ -3332,6 +4536,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -3357,6 +4571,17 @@ "node": ">= 4" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3396,6 +4621,19 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3409,6 +4647,32 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-unsafe": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", @@ -3448,6 +4712,13 @@ "node": ">=10" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -3538,6 +4809,13 @@ "node": ">=0.8" } }, + "node_modules/json-rpc-2.0": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.8.1.tgz", + "integrity": "sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3550,6 +4828,19 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -3847,6 +5138,13 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -3983,6 +5281,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -4043,6 +5348,53 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.1.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mutation-testing-elements": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.8.4.tgz", + "integrity": "sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.8.4.tgz", + "integrity": "sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.8.4" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.8.4.tgz", + "integrity": "sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -4100,6 +5452,46 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.28", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", @@ -4202,6 +5594,19 @@ "obliterator": "^2.0.1" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4269,8 +5674,7 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", @@ -4412,6 +5816,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4428,6 +5848,16 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -4634,6 +6064,16 @@ "dev": true, "license": "MIT" }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -4834,6 +6274,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/smol-toml": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", @@ -4855,6 +6308,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -4924,6 +6387,19 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -5110,6 +6586,16 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tree-sitter": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.25.1.tgz", @@ -5389,6 +6875,16 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -5420,6 +6916,49 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", + "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "6.15.1", + "tunnel": "0.0.6", + "underscore": "^1.13.8" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/typed-rest-client/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -5464,6 +7003,13 @@ "node": ">=12.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -5471,6 +7017,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -5489,6 +7048,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -5517,6 +7107,19 @@ "node": ">= 0.8" } }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", @@ -5692,6 +7295,13 @@ "node": ">=18" } }, + "node_modules/weapon-regex": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.6.tgz", + "integrity": "sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -5912,6 +7522,19 @@ "node": ">=12" } }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index f90a8820d..c849ea300 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -62,6 +62,18 @@ "test:coverage": "vitest run --coverage", "test:cross-platform": "tsx scripts/run-cross-platform.ts", "test:benchmarks": "node --import tsx scripts/run-benchmarks.ts", + "test:dupes": "node --import tsx tools/test-dupes/test-dupes.ts", + "test:mutation:prepare": "node --import tsx tools/mutation/prepare/prepare.ts", + "test:mutation:run": "node --import tsx tools/mutation/driver/run-scope.ts", + "test:mutation:conformance": "node --import tsx tools/mutation/conformance/run-conformance.ts", + "test:mutation:classify": "node --import tsx tools/mutation/classify/classify.ts", + "test:mutation:fidelity": "node --import tsx tools/mutation/fidelity/fidelity.ts", + "test:mutation:oracle": "node --import tsx tools/mutation/oracle/compare.ts", + "test:mutation:oracle:command": "node --import tsx tools/mutation/oracle/command-run.ts", + "test:mutation:oracle:freeze": "node --import tsx tools/mutation/oracle/freeze.ts", + "test:mutation:bench": "node --import tsx tools/mutation/bench/oracle-cost.ts", + "test:mutation:controls": "node --import tsx tools/mutation/controls/run-controls.ts", + "test:scenario-map": "node --import tsx tools/scenario-map/scenario-map.ts", "postinstall": "node scripts/prepare-tree-sitter-bundle.cjs && node scripts/build-tree-sitter-grammars.cjs", "assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs", "prepare": "node scripts/build.js", @@ -125,6 +137,8 @@ "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0", "@mermaid-js/tiny": "11.17.2", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/core": "10.0.0", "@types/busboy": "^1.5.4", "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", diff --git a/gitnexus/test/fixtures/mutation-conformance/expected.json b/gitnexus/test/fixtures/mutation-conformance/expected.json new file mode 100644 index 000000000..71fa8ab53 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/expected.json @@ -0,0 +1,985 @@ +{ + "schemaVersion": 1, + "profiles": [ + { + "name": "concurrency-2-reuse-1", + "concurrency": 2, + "maxTestRunnerReuse": 1 + }, + { + "name": "concurrency-1-reuse-unlimited", + "concurrency": 1, + "maxTestRunnerReuse": 0 + } + ], + "scopes": [ + { + "name": "main", + "testFiles": [ + "static.case.ts", + "import-failure.case.ts", + "hooks.case.ts", + "runtime.case.ts", + "rows.case.ts" + ], + "mutate": [ + "src/static-table.ts", + "src/boot.ts", + "src/hooks.ts", + "src/runtime.ts", + "src/counter.ts", + "src/rows.ts" + ], + "testIds": null, + "tests": [ + { + "id": "hooks.case.ts#records loaded in beforeAll > loads one record", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "hooks.case.ts#records loaded in beforeAll > names the record", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "hooks.case.ts#a suite whose afterAll checks the connection > runs before the closing check", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "hooks.case.ts#runs outside every suite with hooks", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "import-failure.case.ts#boots in strict mode", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "skipped": false, + "notOracleValidated": true + }, + { + "id": "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1", + "skipped": false, + "notOracleValidated": true + }, + { + "id": "runtime.case.ts#doubles two", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#doubles three", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#reads the first character of nothing", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#counts three items", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#schedules no audit when quiet", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#settles without waiting", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#issues the first ticket", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "runtime.case.ts#runs while the feature is enabled", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "static.case.ts#reads the unit table", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "static.case.ts#a suite whose body calls production code > keeps the label computed during collection", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "static.case.ts#row alpha", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "static.case.ts#row beta", + "skipped": false, + "notOracleValidated": false + } + ] + }, + { + "name": "retry", + "testFiles": [ + "retry.case.ts" + ], + "mutate": [ + "src/retry.ts" + ], + "testIds": null, + "tests": [ + { + "id": "retry.case.ts#waits ten milliseconds per attempt", + "skipped": false, + "notOracleValidated": false + }, + { + "id": "retry.case.ts#uses the linear policy", + "skipped": false, + "notOracleValidated": false + } + ] + }, + { + "name": "selection", + "testFiles": [ + "selection.case.ts" + ], + "mutate": [ + "src/selection.ts" + ], + "testIds": [ + "selection.case.ts#scaling > scales two" + ], + "tests": [ + { + "id": "selection.case.ts#scaling > scales one", + "skipped": true, + "notOracleValidated": false + }, + { + "id": "selection.case.ts#scaling > scales two", + "skipped": false, + "notOracleValidated": false + } + ] + } + ], + "cases": [ + { + "kind": "mutant", + "name": "static: a mutant in a module-level table is killed with the module re-imported fresh", + "scope": "main", + "mutant": { + "file": "src/static-table.ts", + "location": "7:62-7:76", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "Killed", + "static": true, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [], + "selected": "all", + "killedBy": [ + "static.case.ts#reads the unit table" + ], + "assertionKilledBy": [ + "static.case.ts#reads the unit table" + ], + "tests": { + "static.case.ts#reads the unit table": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "static: a mutant hit in a describe body during collection is static", + "scope": "main", + "mutant": { + "file": "src/static-table.ts", + "location": "12:10-12:21", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "Killed", + "static": true, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [], + "selected": "all", + "killedBy": [ + "static.case.ts#a suite whose body calls production code > keeps the label computed during collection" + ], + "assertionKilledBy": [ + "static.case.ts#a suite whose body calls production code > keeps the label computed during collection" + ], + "tests": { + "static.case.ts#a suite whose body calls production code > keeps the label computed during collection": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "static: a mutant hit in a production-computed it.each table is static, and its changed test set is invalid, not a non-kill", + "scope": "main", + "mutant": { + "file": "src/static-table.ts", + "location": "20:5-20:11", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "RuntimeError", + "static": true, + "outcome": "invalid", + "outcomeSource": "sidecar", + "coveredBy": [], + "selected": "all", + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [], + "reason": "has full name \"row \", the run manifest expects \"row beta\"", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "hooks: a mutant executed only in a suite's beforeAll is attributed to every test of that suite", + "scope": "main", + "mutant": { + "file": "src/hooks.ts", + "location": "7:11-7:19", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "hooks.case.ts#records loaded in beforeAll > loads one record", + "hooks.case.ts#records loaded in beforeAll > names the record" + ], + "selected": [ + "hooks.case.ts#records loaded in beforeAll > loads one record", + "hooks.case.ts#records loaded in beforeAll > names the record" + ], + "killedBy": [ + "hooks.case.ts#records loaded in beforeAll > names the record" + ], + "assertionKilledBy": [ + "hooks.case.ts#records loaded in beforeAll > names the record" + ], + "tests": { + "hooks.case.ts#records loaded in beforeAll > names the record": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [ + "hooks.case.ts#records loaded in beforeAll > loads one record", + "hooks.case.ts#records loaded in beforeAll > names the record" + ], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: under disableBail two tests kill the same mutant and both are reported", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "7:10-7:19", + "mutatorName": "ArithmeticOperator", + "replacement": "value / 2" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#doubles three", + "runtime.case.ts#doubles two" + ], + "selected": [ + "runtime.case.ts#doubles three", + "runtime.case.ts#doubles two" + ], + "killedBy": [ + "runtime.case.ts#doubles three", + "runtime.case.ts#doubles two" + ], + "assertionKilledBy": [ + "runtime.case.ts#doubles three", + "runtime.case.ts#doubles two" + ], + "tests": { + "runtime.case.ts#doubles three": "killed-by-test:assertion", + "runtime.case.ts#doubles two": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "rows: it.each rows whose titles truncate to one name keep distinct ids by row ordinal", + "scope": "main", + "mutant": { + "file": "src/rows.ts", + "location": "6:40-6:51", + "mutatorName": "ArithmeticOperator", + "replacement": "sum - value" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1" + ], + "selected": [ + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1" + ], + "killedBy": [ + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1" + ], + "assertionKilledBy": [ + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1" + ], + "tests": { + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0": "killed-by-test:assertion", + "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: a mutant that throws a TypeError is Killed in Stryker's report and runtime in the sidecar", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "13:10-13:22", + "mutatorName": "OptionalChaining", + "replacement": "text.charAt" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:runtime", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#reads the first character of nothing" + ], + "selected": [ + "runtime.case.ts#reads the first character of nothing" + ], + "killedBy": [ + "runtime.case.ts#reads the first character of nothing" + ], + "assertionKilledBy": [], + "tests": { + "runtime.case.ts#reads the first character of nothing": "killed-by-test:runtime" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "hooks: a failing beforeAll is killed-outside-test:hook, attributed to the suite's tests", + "scope": "main", + "mutant": { + "file": "src/hooks.ts", + "location": "13:7-13:12", + "mutatorName": "BooleanLiteral", + "replacement": "open" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-outside-test:hook", + "outcomeSource": "sidecar", + "coveredBy": [ + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check" + ], + "selected": [ + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check" + ], + "killedBy": [ + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check" + ], + "assertionKilledBy": [], + "tests": { + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check": "killed-outside-test:hook", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check": "killed-outside-test:hook" + }, + "outsideTest": [ + { + "file": "hooks.case.ts", + "outcome": "killed-outside-test:hook", + "attributedTo": [ + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check" + ] + } + ], + "reason": null, + "flags": [], + "hookAttributedTests": [ + "hooks.case.ts#a suite whose beforeAll checks the connection > runs a second test after the opening check", + "hooks.case.ts#a suite whose beforeAll checks the connection > runs after the opening check" + ], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "hooks: a failing afterAll leaves its tests passing and is killed-outside-test:hook", + "scope": "main", + "mutant": { + "file": "src/hooks.ts", + "location": "19:7-19:14", + "mutatorName": "BooleanLiteral", + "replacement": "closed" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-outside-test:hook", + "outcomeSource": "sidecar", + "coveredBy": [ + "hooks.case.ts#a suite whose afterAll checks the connection > runs before the closing check" + ], + "selected": [ + "hooks.case.ts#a suite whose afterAll checks the connection > runs before the closing check" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [ + { + "file": "hooks.case.ts", + "outcome": "killed-outside-test:hook", + "attributedTo": [] + } + ], + "reason": null, + "flags": [], + "hookAttributedTests": [ + "hooks.case.ts#a suite whose afterAll checks the connection > runs before the closing check" + ], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "static: a mutant that makes a test file fail to import is killed-outside-test:collection, never invalid or Survived", + "scope": "main", + "mutant": { + "file": "src/boot.ts", + "location": "10:38-10:46", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "Killed", + "static": true, + "outcome": "killed-outside-test:collection", + "outcomeSource": "sidecar", + "coveredBy": [], + "selected": "all", + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [ + { + "file": "import-failure.case.ts", + "outcome": "killed-outside-test:collection", + "attributedTo": [] + } + ], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: an infinite loop whose try/catch swallows the hit-limit error is a timeout through the hit count", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "21:47-21:54", + "mutatorName": "UpdateOperator", + "replacement": "index--" + }, + "expect": { + "status": "Timeout", + "static": false, + "outcome": "timeout", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#counts three items" + ], + "selected": [ + "runtime.case.ts#counts three items" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": { + "runtime.case.ts#counts three items": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": "Hit limit reached (301/300)", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: an unhandled rejection during a mutant run is infrastructure", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "33:7-33:13", + "mutatorName": "BooleanLiteral", + "replacement": "quiet" + }, + "expect": { + "status": "RuntimeError", + "static": false, + "outcome": "infrastructure", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#schedules no audit when quiet" + ], + "selected": [ + "runtime.case.ts#schedules no audit when quiet" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [], + "reason": "unhandled error during the run: Error: runtime: the audit failed", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: a mutant covered by no test is NoCoverage in Stryker and survived when normalised", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "52:10-52:21", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "NoCoverage", + "static": false, + "outcome": "survived", + "outcomeSource": "no-coverage", + "coveredBy": [], + "selected": null, + "killedBy": [], + "assertionKilledBy": [], + "tests": null, + "outsideTest": null, + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: a test failing with Vitest's own testTimeout error is class timeout", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "39:7-39:12", + "mutatorName": "BooleanLiteral", + "replacement": "fast" + }, + "expect": { + "status": "Timeout", + "static": false, + "outcome": "timeout", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#settles without waiting" + ], + "selected": [ + "runtime.case.ts#settles without waiting" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": { + "runtime.case.ts#settles without waiting": "timeout" + }, + "outsideTest": [], + "reason": "Test timed out in 2000ms", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "state: the first of two consecutive mutants in one process is killed", + "scope": "main", + "mutant": { + "file": "src/counter.ts", + "location": "10:3-10:14", + "mutatorName": "AssignmentOperator", + "replacement": "issued -= 1" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#issues the first ticket" + ], + "selected": [ + "runtime.case.ts#issues the first ticket" + ], + "killedBy": [ + "runtime.case.ts#issues the first ticket" + ], + "assertionKilledBy": [ + "runtime.case.ts#issues the first ticket" + ], + "tests": { + "runtime.case.ts#issues the first ticket": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "state: the second of two consecutive mutants shows no state from the first and survives", + "scope": "main", + "mutant": { + "file": "src/counter.ts", + "location": "12:15-12:33", + "mutatorName": "StringLiteral", + "replacement": "``" + }, + "expect": { + "status": "Survived", + "static": false, + "outcome": "survived", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#issues the first ticket" + ], + "selected": [ + "runtime.case.ts#issues the first ticket" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "runtime: a mutant run that selects tests but runs zero of them is invalid", + "scope": "main", + "mutant": { + "file": "src/runtime.ts", + "location": "46:10-46:15", + "mutatorName": "BooleanLiteral", + "replacement": "true" + }, + "expect": { + "status": "RuntimeError", + "static": false, + "outcome": "invalid", + "outcomeSource": "sidecar", + "coveredBy": [ + "runtime.case.ts#runs while the feature is enabled" + ], + "selected": [ + "runtime.case.ts#runs while the feature is enabled" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": {}, + "outsideTest": [], + "reason": "did not run (state skipped); zero tests ran", + "flags": [ + "zero-tests" + ], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "retry: a test with retry: 1 makes the run of a mutant it covers inconclusive", + "scope": "retry", + "mutant": { + "file": "src/retry.ts", + "location": "7:10-7:22", + "mutatorName": "ArithmeticOperator", + "replacement": "attempt / 10" + }, + "expect": { + "status": "RuntimeError", + "static": false, + "outcome": "inconclusive", + "outcomeSource": "sidecar", + "coveredBy": [ + "retry.case.ts#waits ten milliseconds per attempt" + ], + "selected": [ + "retry.case.ts#waits ten milliseconds per attempt" + ], + "killedBy": [], + "assertionKilledBy": [], + "tests": { + "retry.case.ts#waits ten milliseconds per attempt": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": "retry.case.ts#waits ten milliseconds per attempt has retry 1", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "retry: a static mutant selects every test, the retry test too, so its run is inconclusive", + "scope": "retry", + "mutant": { + "file": "src/retry.ts", + "location": "11:29-11:37", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "RuntimeError", + "static": true, + "outcome": "inconclusive", + "outcomeSource": "sidecar", + "coveredBy": [], + "selected": "all", + "killedBy": [], + "assertionKilledBy": [], + "tests": { + "retry.case.ts#uses the linear policy": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": "retry.case.ts#waits ten milliseconds per attempt has retry 1", + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "selection: with testIds set to one of two covering tests, only the selected test covers, runs and kills the mutant", + "scope": "selection", + "mutant": { + "file": "src/selection.ts", + "location": "8:10-8:19", + "mutatorName": "ArithmeticOperator", + "replacement": "value / 3" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "selection.case.ts#scaling > scales two" + ], + "selected": "all", + "killedBy": [ + "selection.case.ts#scaling > scales two" + ], + "assertionKilledBy": [ + "selection.case.ts#scaling > scales two" + ], + "tests": { + "selection.case.ts#scaling > scales two": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [], + "attemptsDisagree": false + } + }, + { + "kind": "mutant", + "name": "selection: a mutant hit only in the suite beforeAll carries the hook marker for the selected test only", + "scope": "selection", + "mutant": { + "file": "src/selection.ts", + "location": "14:10-14:13", + "mutatorName": "StringLiteral", + "replacement": "\"\"" + }, + "expect": { + "status": "Killed", + "static": false, + "outcome": "killed-by-test:assertion", + "outcomeSource": "sidecar", + "coveredBy": [ + "selection.case.ts#scaling > scales two" + ], + "selected": "all", + "killedBy": [ + "selection.case.ts#scaling > scales two" + ], + "assertionKilledBy": [ + "selection.case.ts#scaling > scales two" + ], + "tests": { + "selection.case.ts#scaling > scales two": "killed-by-test:assertion" + }, + "outsideTest": [], + "reason": null, + "flags": [], + "hookAttributedTests": [ + "selection.case.ts#scaling > scales two" + ], + "attemptsDisagree": false + } + }, + { + "kind": "tests", + "name": "rows: it.each rows whose titles truncate to one name are flagged not oracle-validated", + "scope": "main", + "tests": [ + { + "id": "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:0", + "skipped": false, + "notOracleValidated": true + }, + { + "id": "rows.case.ts#totals a row label long enough for Vitest to t…:11:3:1", + "skipped": false, + "notOracleValidated": true + } + ] + }, + { + "kind": "tests", + "name": "selection: a dry run with testIds runs the selected test and lists the other one as skipped", + "scope": "selection", + "tests": [ + { + "id": "selection.case.ts#scaling > scales one", + "skipped": true, + "notOracleValidated": false + }, + { + "id": "selection.case.ts#scaling > scales two", + "skipped": false, + "notOracleValidated": false + } + ] + }, + { + "kind": "processes", + "name": "processes: mutants handled by processes that never ran the dry run still select the right tests through the run manifest", + "profile": "concurrency-2-reuse-1", + "rule": "one-mutant-run-per-process", + "consecutive": null + }, + { + "kind": "processes", + "name": "processes: one runner process handles two consecutive mutants, and the second run shows no state from the first", + "profile": "concurrency-1-reuse-unlimited", + "rule": "every-run-in-the-dry-run-process", + "consecutive": [ + "state: the first of two consecutive mutants in one process is killed", + "state: the second of two consecutive mutants shows no state from the first and survives" + ] + }, + { + "kind": "environment", + "name": "environment: exporting __STRYKER_ACTIVE_MUTANT__ in the parent environment changes no result", + "profile": "concurrency-1-reuse-unlimited", + "activeMutantOf": "static: a mutant in a module-level table is killed with the module re-imported fresh", + "localId": "60" + } + ] +} diff --git a/gitnexus/test/fixtures/mutation-conformance/hooks.case.ts b/gitnexus/test/fixtures/mutation-conformance/hooks.case.ts new file mode 100644 index 000000000..3e5c407bd --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/hooks.case.ts @@ -0,0 +1,47 @@ +// Conformance fixture (gate 2): mutants hit only in suite hooks. +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { assertClosed, assertOpen, loadRecords } from './src/hooks.js'; + +describe('records loaded in beforeAll', () => { + let records: string[] = []; + + beforeAll(() => { + records = loadRecords(); + }); + + it('loads one record', () => { + expect(records).toHaveLength(1); + }); + + it('names the record', () => { + expect(records[0]).toBe('record'); + }); +}); + +describe('a suite whose beforeAll checks the connection', () => { + beforeAll(() => { + assertOpen(true); + }); + + it('runs after the opening check', () => { + expect(true).toBe(true); + }); + + it('runs a second test after the opening check', () => { + expect(false).toBe(false); + }); +}); + +describe('a suite whose afterAll checks the connection', () => { + afterAll(() => { + assertClosed(true); + }); + + it('runs before the closing check', () => { + expect(true).toBe(true); + }); +}); + +it('runs outside every suite with hooks', () => { + expect(loadRecords).toBeTypeOf('function'); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/import-failure.case.ts b/gitnexus/test/fixtures/mutation-conformance/import-failure.case.ts new file mode 100644 index 000000000..1cbbfece4 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/import-failure.case.ts @@ -0,0 +1,7 @@ +// Conformance fixture (gate 2): the static mutant of src/boot.ts makes this file fail to import. +import { expect, it } from 'vitest'; +import { BOOT_MODE } from './src/boot.js'; + +it('boots in strict mode', () => { + expect(BOOT_MODE).toBe('strict'); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/retry.case.ts b/gitnexus/test/fixtures/mutation-conformance/retry.case.ts new file mode 100644 index 000000000..3fade67e6 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/retry.case.ts @@ -0,0 +1,11 @@ +// Conformance fixture (gate 2), retry scope: one test declares `retry: 1`. +import { expect, it } from 'vitest'; +import { RETRY_POLICY, retryDelay } from './src/retry.js'; + +it('waits ten milliseconds per attempt', { retry: 1 }, () => { + expect(retryDelay(2)).toBe(20); +}); + +it('uses the linear policy', () => { + expect(RETRY_POLICY).toBe('linear'); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/rows.case.ts b/gitnexus/test/fixtures/mutation-conformance/rows.case.ts new file mode 100644 index 000000000..4823ff55a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/rows.case.ts @@ -0,0 +1,13 @@ +// Conformance fixture (gate 2): `it.each` rows whose titles truncate to the same name (Vitest +// truncates `$label` at 40 characters), so their ids need the row ordinal. +import { expect, it } from 'vitest'; +import { total } from './src/rows.js'; + +const PREFIX = 'a row label long enough for Vitest to truncate it: '; + +it.each([ + { label: `${PREFIX}first`, values: [1, 2], sum: 3 }, + { label: `${PREFIX}second`, values: [2, 3], sum: 5 }, +])('totals $label', ({ values, sum }) => { + expect(total(values)).toBe(sum); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/runtime.case.ts b/gitnexus/test/fixtures/mutation-conformance/runtime.case.ts new file mode 100644 index 000000000..6ef07472c --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/runtime.case.ts @@ -0,0 +1,52 @@ +// Conformance fixture (gate 2): runtime mutants, each covered by the tests that call it. +import { expect, it } from 'vitest'; +import { nextTicket } from './src/counter.js'; +import { + countItems, + double, + featureDisabled, + firstChar, + scheduleAudit, + settleDelay, +} from './src/runtime.js'; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms).unref(); + }); + +it('doubles two', () => { + expect(double(2)).toBe(4); +}); + +it('doubles three', () => { + expect(double(3)).toBe(6); +}); + +it('reads the first character of nothing', () => { + expect(firstChar(null)).toBe(''); +}); + +it('counts three items', () => { + expect(countItems([1, 2, 3])).toBe(3); +}); + +it('schedules no audit when quiet', async () => { + scheduleAudit(true); + await sleep(20); + expect(true).toBe(true); +}); + +it('settles without waiting', async () => { + await sleep(settleDelay(true)); + expect(true).toBe(true); +}); + +it('issues the first ticket', () => { + expect(nextTicket()).toBe(1); +}); + +it('runs while the feature is enabled', (context) => { + context.skip(featureDisabled(), 'the feature is disabled'); + expect(true).toBe(true); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/selection.case.ts b/gitnexus/test/fixtures/mutation-conformance/selection.case.ts new file mode 100644 index 000000000..fce4b6d22 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/selection.case.ts @@ -0,0 +1,21 @@ +// Conformance fixture (gate 2), selection scope: the scope's testIds select "scales two" only, so +// the dry run skips "scales one" and every coverage, outcome and hook marker names only the +// selected test. +import { beforeAll, describe, expect, it } from 'vitest'; +import { scale, unit } from './src/selection.js'; + +describe('scaling', () => { + let suffix = ''; + + beforeAll(() => { + suffix = unit(); + }); + + it('scales one', () => { + expect(`${scale(1)}${suffix}`).toBe('3x'); + }); + + it('scales two', () => { + expect(`${scale(2)}${suffix}`).toBe('6x'); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/src/boot.ts b/gitnexus/test/fixtures/mutation-conformance/src/boot.ts new file mode 100644 index 000000000..bac10eb82 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/boot.ts @@ -0,0 +1,10 @@ +// Stryker disable all +// Conformance fixture (gate 2): a module whose mutated form throws while it is imported. + +function requireMode(mode: string): string { + if (mode === '') throw new Error('boot: the mode must not be empty'); + return mode; +} + +// Stryker restore next-line StringLiteral +export const BOOT_MODE = requireMode('strict'); diff --git a/gitnexus/test/fixtures/mutation-conformance/src/counter.ts b/gitnexus/test/fixtures/mutation-conformance/src/counter.ts new file mode 100644 index 000000000..4f74cb552 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/counter.ts @@ -0,0 +1,18 @@ +// Stryker disable all +// Conformance fixture (gate 2): module-level state that would show carry-over between two +// mutant runs in one runner process. + +let issued = 0; +let lastLabel = ''; + +export function nextTicket(): number { + // Stryker restore next-line AssignmentOperator + issued += 1; + // Stryker restore next-line StringLiteral + lastLabel = `ticket ${issued}`; + return issued; +} + +export function lastTicketLabel(): string { + return lastLabel; +} diff --git a/gitnexus/test/fixtures/mutation-conformance/src/hooks.ts b/gitnexus/test/fixtures/mutation-conformance/src/hooks.ts new file mode 100644 index 000000000..8b1825d9c --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/hooks.ts @@ -0,0 +1,20 @@ +// Stryker disable all +// Conformance fixture (gate 2): production code called only from suite hooks. + +/** Called only in a suite's `beforeAll`. */ +export function loadRecords(): string[] { + // Stryker restore next-line StringLiteral + return ['record']; +} + +/** Called in a `beforeAll`: the mutant makes the hook throw. */ +export function assertOpen(open: boolean): void { + // Stryker restore next-line BooleanLiteral + if (!open) throw new Error('hooks: the connection is not open'); +} + +/** Called in an `afterAll`: the mutant makes the hook throw after the tests passed. */ +export function assertClosed(closed: boolean): void { + // Stryker restore next-line BooleanLiteral + if (!closed) throw new Error('hooks: the connection is still open'); +} diff --git a/gitnexus/test/fixtures/mutation-conformance/src/retry.ts b/gitnexus/test/fixtures/mutation-conformance/src/retry.ts new file mode 100644 index 000000000..20e7ecc87 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/retry.ts @@ -0,0 +1,11 @@ +// Stryker disable all +// Conformance fixture (gate 2), retry scope: a test with `retry: 1` makes every run that selects +// it inconclusive, static runs (which select every test) included. + +export function retryDelay(attempt: number): number { + // Stryker restore next-line ArithmeticOperator + return attempt * 10; +} + +// Stryker restore next-line StringLiteral +export const RETRY_POLICY = 'linear'; diff --git a/gitnexus/test/fixtures/mutation-conformance/src/rows.ts b/gitnexus/test/fixtures/mutation-conformance/src/rows.ts new file mode 100644 index 000000000..584d4853d --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/rows.ts @@ -0,0 +1,7 @@ +// Stryker disable all +// Conformance fixture (gate 2): summed by `it.each` rows whose titles truncate to one name. + +export function total(values: readonly number[]): number { + // Stryker restore next-line ArithmeticOperator + return values.reduce((sum, value) => sum + value, 0); +} diff --git a/gitnexus/test/fixtures/mutation-conformance/src/runtime.ts b/gitnexus/test/fixtures/mutation-conformance/src/runtime.ts new file mode 100644 index 000000000..4cfcac08b --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/runtime.ts @@ -0,0 +1,53 @@ +// Stryker disable all +// Conformance fixture (gate 2): runtime mutants, each hit only while a test runs. + +/** Two tests kill its mutant. */ +export function double(value: number): number { + // Stryker restore next-line ArithmeticOperator + return value * 2; +} + +/** The mutant drops the optional chain, so a null argument throws a TypeError. */ +export function firstChar(text: string | null): string { + // Stryker restore next-line OptionalChaining + return text?.charAt(0) ?? ''; +} + +/** The mutant never ends the loop; the catch swallows the hit-limit error too. */ +export function countItems(items: readonly number[]): number { + let counted = 0; + try { + // Stryker restore next-line UpdateOperator + for (let index = 0; index < items.length; index++) { + counted += 1; + } + } catch { + // Swallowed on purpose: the run must still end as a timeout through the hit count. + } + return counted; +} + +/** The mutant leaves a rejected promise without a handler. */ +export function scheduleAudit(quiet: boolean): void { + // Stryker restore next-line BooleanLiteral + if (!quiet) void Promise.reject(new Error('runtime: the audit failed')); +} + +/** The mutant turns the delay into one far above the fixture's testTimeout. */ +export function settleDelay(fast: boolean): number { + // Stryker restore next-line BooleanLiteral + if (!fast) return 10_000; + return 0; +} + +/** The mutant makes the only test that covers it skip itself. */ +export function featureDisabled(): boolean { + // Stryker restore next-line BooleanLiteral + return false; +} + +/** No test calls it. */ +export function neverCalled(): string { + // Stryker restore next-line StringLiteral + return 'unreached'; +} diff --git a/gitnexus/test/fixtures/mutation-conformance/src/selection.ts b/gitnexus/test/fixtures/mutation-conformance/src/selection.ts new file mode 100644 index 000000000..a12203fcd --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/selection.ts @@ -0,0 +1,15 @@ +// Stryker disable all +// Conformance fixture (gate 2), selection scope: both tests of selection.case.ts cover this code, +// and the scope's testIds select one of them (Phase 1 KTD2). + +/** Called in each test. */ +export function scale(value: number): number { + // Stryker restore next-line ArithmeticOperator + return value * 3; +} + +/** Called only in the suite's `beforeAll`. */ +export function unit(): string { + // Stryker restore next-line StringLiteral + return 'x'; +} diff --git a/gitnexus/test/fixtures/mutation-conformance/src/static-table.ts b/gitnexus/test/fixtures/mutation-conformance/src/static-table.ts new file mode 100644 index 000000000..25e1c8648 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/src/static-table.ts @@ -0,0 +1,22 @@ +// Stryker disable all +// Conformance fixture (gate 2). Every mutant except the ones restored below is disabled, so each +// planted mutant is one pinned case of expected.json. + +// Static: the table is built while the module loads. +// Stryker restore next-line StringLiteral +export const UNITS: Readonly> = { ms: 'milliseconds' }; + +/** Called from a `describe` body, so its mutant is hit while tests are collected. */ +export function suiteLabel(): string { + // Stryker restore next-line StringLiteral + return 'collected'; +} + +/** Rows of an `it.each` table: a mutant here changes the collected test set. */ +export function rowNames(): string[] { + return [ + 'alpha', + // Stryker restore next-line StringLiteral + 'beta', + ]; +} diff --git a/gitnexus/test/fixtures/mutation-conformance/static.case.ts b/gitnexus/test/fixtures/mutation-conformance/static.case.ts new file mode 100644 index 000000000..774af268a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/static.case.ts @@ -0,0 +1,19 @@ +// Conformance fixture (gate 2): static mutants, hit while modules load or tests are collected. +import { describe, expect, it } from 'vitest'; +import { UNITS, rowNames, suiteLabel } from './src/static-table.js'; + +it('reads the unit table', () => { + expect(UNITS.ms).toBe('milliseconds'); +}); + +describe('a suite whose body calls production code', () => { + const label = suiteLabel(); + + it('keeps the label computed during collection', () => { + expect(label).toBe('collected'); + }); +}); + +it.each(rowNames())('row %s', (name) => { + expect(['alpha', 'beta']).toContain(name); +}); diff --git a/gitnexus/test/fixtures/mutation-conformance/vitest.config.ts b/gitnexus/test/fixtures/mutation-conformance/vitest.config.ts new file mode 100644 index 000000000..563399d7b --- /dev/null +++ b/gitnexus/test/fixtures/mutation-conformance/vitest.config.ts @@ -0,0 +1,37 @@ +/** + * Vitest config of the gate 2 conformance fixture (U4). The conformance driver + * (tools/mutation/conformance/run-conformance.ts) runs it through the scope driver with the + * vitest5 runner: `configFile` is this file and the project is `conformance`. Only the settings + * the runner requires (forks, isolation, task locations) and the fixture's short timeouts are set, + * so a change to gitnexus/vitest.config.ts cannot move the pinned results. + * + * Like tools/mutation/vitest.mutation.config.ts, the include list is the `testFiles` of the scope + * file named by GITNEXUS_MUTATION_SCOPE. The fixture's tests are `*.case.ts` files, which no repo + * Vitest project collects. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { + parseMutationScope, + resolveMutationScopePath, +} from '../../../tools/mutation/scope-file.js'; + +const packageDir = fileURLToPath(new URL('../../../', import.meta.url)); +const scope = parseMutationScope( + readFileSync(resolveMutationScopePath(process.env, packageDir), 'utf8'), +); + +export default defineConfig({ + test: { + pool: 'forks', + isolate: true, + includeTaskLocation: true, + // Short limits keep the timeout cases quick; every passing test finishes in milliseconds. + testTimeout: 2000, + hookTimeout: 2000, + teardownTimeout: 1000, + dangerouslyIgnoreUnhandledErrors: false, + projects: [{ extends: true, test: { name: 'conformance', include: scope.testFiles } }], + }, +}); diff --git a/gitnexus/test/fixtures/mutation-controls/planted.json b/gitnexus/test/fixtures/mutation-controls/planted.json new file mode 100644 index 000000000..f56838626 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-controls/planted.json @@ -0,0 +1,245 @@ +{ + "schemaVersion": 1, + "kind": "gitnexus-mutation-controls", + "description": "Gate 4 positive controls (U8, KTD19): Stryker mutants of the scopes, named by global id. In every determinism profile each must be killed with an assertion by exactly its expected killers, and every listed covering non-killer must run with it and pass. Regenerate with `reselect` when a control or test goes missing (tools/mutation/controls/run-controls.ts).", + "reselect": "npm run test:mutation:controls -- --select --work ", + "profiles": [ + { + "name": "A", + "concurrency": 1, + "maxTestRunnerReuse": 0 + }, + { + "name": "B", + "concurrency": 3, + "maxTestRunnerReuse": 1 + } + ], + "timeoutMS": 10000, + "selection": { + "seed": "u8-gate4-1", + "perScope": 3, + "maxKillers": 3, + "maxNonKillers": 3, + "selectedFrom": [ + "A" + ] + }, + "scopes": [ + { + "name": "shared", + "scope": { + "testFiles": [ + "test/unit/shared-type-extractors.test.ts", + "test/unit/extract-element-type-from-string.test.ts" + ], + "mutate": [ + "gitnexus/src/core/ingestion/type-extractors/shared.ts:729-904" + ] + }, + "mutants": 199, + "tests": 143, + "controls": [ + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:5e06ba59d5c022fc", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips Rust mutable reference prefix: &mut User → User" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns base type for non-wrapper generic: List → List", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for bare wrapper: Option", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"int\"" + ], + "notRun": [ + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Go slice prefix > []string → string", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for Vec has get in valueMethods", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for get on Map (value method)" + ] + } + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:856:19-861:8:MethodExpression:16c687aaf4579f1a", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips nullable union: User | null → User" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for genuine union: User | Repo" + ], + "notRun": [ + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Dict[str, List[int]] → str (pos=first: key arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > empty array suffix → undefined", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for non-key method with no container" + ] + } + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:862:9-862:27:ConditionalExpression:b5bea41b6c623f7c", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for genuine union: User | Repo" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips nullable union: User | null → User" + ], + "notRun": [ + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for just brackets: []", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"void\"", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > skips Rust lifetime parameters in Result: Result<'_, User, Error> is unusual but lifetime in first pos is skipped" + ] + } + } + ] + }, + { + "name": "schema-constants", + "scope": { + "testFiles": [ + "test/unit/schema.test.ts" + ], + "mutate": [ + "gitnexus-shared/src/lbug/schema-constants.ts" + ] + }, + "mutants": 72, + "tests": 42, + "controls": [ + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:12:3-12:9:StringLiteral:12ae32cb1ec02d01", + "tags": [ + "relocated-shared", + "as-const" + ], + "static": true, + "expectedKillers": [ + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > includes all core node types" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/schema.test.ts#LadybugDB Schema > embedding schema > creates CodeEmbedding table", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > FILE_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE File (\n id STRING,\n name STRING,\n filePath STRING,\n content STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Function schema has startLine and endLine" + ], + "notRun": [] + } + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:40:3-40:16:StringLiteral:12ae32cb1ec02d01", + "tags": [ + "relocated-shared", + "as-const" + ], + "static": true, + "expectedKillers": [ + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > includes multi-language node types" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes the DI collection-injection edge type (#2200)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > has type, confidence, reason, step properties", + "test/unit/schema.test.ts#LadybugDB Schema > schema query ordering > NODE_SCHEMA_QUERIES has correct count" + ], + "notRun": [] + } + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:95:3-95:18:StringLiteral:12ae32cb1ec02d01", + "tags": [ + "relocated-shared", + "as-const" + ], + "static": true, + "expectedKillers": [ + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes the control-dependence edge types (issue #2085 M5)" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > INTERFACE_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Interface (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Property schema preserves declaredType", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects Function to Function (CALLS)" + ], + "notRun": [] + } + } + ] + }, + { + "name": "middleware", + "scope": { + "testFiles": [ + "test/unit/middleware-public-origin.test.ts" + ], + "mutate": [ + "gitnexus/src/server/middleware.ts" + ] + }, + "mutants": 302, + "tests": 31, + "controls": [ + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:ConditionalExpression:fcbcf165908dd18a", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > treats an elided default port as that port" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > rejects the bound host on the default port when it is not the bound one" + ], + "notRun": [ + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > is a no-op when no public origin is configured", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > names the variable and the value in the failure, and points at both remedies", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits an exact scheme/host/port match on a wildcard bind" + ] + } + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:33-51:58:MethodExpression:faa1c3dbc55de26e", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits a bracketed IPv6 literal, on any port when configured without one" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > still warns on a wildcard bind with a valid public origin, and names it" + ], + "notRun": [ + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > treats a blank value (\"\") as unset", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > keeps loopback port-agnostic — the dev UI runs on its own port", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > names the resolved hostname at info when a public origin is configured" + ] + } + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:93:18-93:35:Regex:0d604fc840a74414", + "tags": [], + "static": false, + "expectedKillers": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits http for a bare host only when http:// is spelled out" + ], + "expectedNonKillers": { + "ran": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits nothing extra when the configured value is not one reachable host", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > leaves the Origin-less passthrough alone — the CLI sends no Origin", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > does not report a trailing-dot hostname as a working origin" + ], + "notRun": [ + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > treats a blank value (\" \") as unset", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > matches any port when no bound port is given", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > warns on a wildcard bind with no public origin, pointing at both remedies" + ] + } + } + ] + } + ] +} diff --git a/gitnexus/test/fixtures/mutation-oracle/envelope.json b/gitnexus/test/fixtures/mutation-oracle/envelope.json new file mode 100644 index 000000000..8cb582e99 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-oracle/envelope.json @@ -0,0 +1,642 @@ +{ + "schemaVersion": 1, + "description": "Gate 1 (U6): the vitest5 runner’s unmutated run matched a normal Vitest 5 run on the pure-JS category under these versions and settings. Results from other Vitest or Stryker versions are unvalidated until gates 2 and 3 pass again (KTD2). Regenerate with `npm run test:mutation:fidelity` (from gitnexus/).", + "versions": { + "vitest": "5.0.5", + "stryker": "10.0.0", + "node": "v22.23.3" + }, + "platform": { + "os": "linux", + "release": "6.6.114.1-microsoft-standard-WSL2", + "arch": "x64" + }, + "stryker": { + "concurrency": 2, + "maxTestRunnerReuse": 0, + "testRunner": "vitest5", + "coverageAnalysis": "perTest", + "disableTypeChecks": false, + "timeoutMS": 10000 + }, + "config": { + "base": { + "file": "gitnexus/vitest.config.ts", + "project": "default" + }, + "run": { + "file": "gitnexus/tools/mutation/vitest.mutation.config.ts", + "project": "mutation" + }, + "settings": { + "pool": "forks", + "isolate": true, + "maxWorkers": 23, + "globals": true, + "environment": "node", + "testTimeout": 30000, + "hookTimeout": 120000, + "teardownTimeout": 3000, + "clearMocks": false, + "mockReset": false, + "restoreMocks": false, + "unstubEnvs": false, + "unstubGlobals": false, + "includeTaskLocation": true, + "env": { + "GITNEXUS_ATOMIC_INCREMENTAL": "", + "GITNEXUS_LANG": "en", + "GITNEXUS_MEMORY": "off" + }, + "sequence": { + "groupOrder": 3, + "hooks": "stack", + "sequencer": "[function PerfSequencer]" + } + }, + "allowedDifferences": [ + { + "name": "setupFiles", + "paths": [ + "project.setupFiles" + ], + "reason": "The runner prepends its own setup file (tools/mutation/runner/setup.ts, loaded from the prepared copy next to the plugin) for coverage and mutant activation (KTD4). The gate 1 recorder is installed in both runs the same way." + }, + { + "name": "coverage", + "paths": [ + "global.coverage.exclude", + "project.coverage.exclude" + ], + "reason": "Vitest coverage is off in both runs; its resolved exclude list names the config file. Stryker records its own per-test coverage through the runner setup file." + }, + { + "name": "KTD8 aliases", + "paths": [ + "vite.resolve.alias" + ], + "reason": "The mutation config resolves `gitnexus-shared` imports to the relocated sources inside the sandbox (KTD8); the base run resolves the package to its built `dist`. The module comparison checks both resolve to the same modules." + }, + { + "name": "dangerouslyIgnoreUnhandledErrors", + "paths": [ + "global._rawTestConfig.dangerouslyIgnoreUnhandledErrors", + "global.dangerouslyIgnoreUnhandledErrors", + "project.dangerouslyIgnoreUnhandledErrors" + ], + "reason": "Off in the mutation config so an unhandled error fails the run (KTD9); gate 1 reports any unhandled error of the base run separately." + }, + { + "name": "cacheDir", + "paths": [ + "global.cache", + "global.cliOptions.cache", + "global.cliOptions.fsModuleCache", + "global.fsModuleCache", + "global.providedOptions.fsModuleCache", + "global.viteOverrides.cacheDir", + "project.cache.dir", + "project.fsModuleCache", + "vite.cacheDir" + ], + "reason": "The runner uses a per-process Vite cache directory inside the sandbox and turns the results and fs module caches off (KTD5), so no run reuses another run’s transforms." + }, + { + "name": "project name", + "paths": [ + "global.cliOptions.project", + "global.project", + "project.name" + ], + "reason": "The runner runs the single `mutation` project; Vitest task ids depend on the name, which is why tests are compared by file, titles and location instead." + }, + { + "name": "include and exclude lists", + "paths": [ + "env.GITNEXUS_MUTATION_SCOPE", + "project.exclude", + "project.include" + ], + "reason": "The mutation project includes exactly the scope file’s test files (named by GITNEXUS_MUTATION_SCOPE); gate 1 runs the same file list on both sides." + }, + { + "name": "projects array", + "paths": [ + "global.projects", + "global.resolvedProjects" + ], + "reason": "The mutation config replaces the three repo projects with one (KTD9); the resolved project list follows it. The run project’s own config is compared key by key." + }, + { + "name": "sequence.groupOrder", + "paths": [ + "project.sequence.groupOrder" + ], + "reason": "Orders projects against each other; a single-project run has nothing to order." + }, + { + "name": "config file (addition)", + "paths": [ + "global.cliOptions.config", + "global.config" + ], + "reason": "Addition to the plan’s list: the name of the config file. The Stryker run loads tools/mutation/vitest.mutation.config.ts (KTD9) through the gate 1 wrapper tools/mutation/fidelity/vitest.fidelity.config.ts; their settings are compared key by key." + } + ], + "ignoredVolatileValues": [ + { + "name": "sequence.seed", + "paths": [ + "global.sequence.seed", + "project.sequence.seed" + ], + "reason": "Defaults to the start time when unset and only drives shuffling; ignored only while shuffling is off in both runs." + } + ] + }, + "envReadsAtLoad": [ + { + "name": "DEBUG", + "set": false, + "value": null, + "readers": [ + "node_modules/debug/src/node.js" + ] + }, + { + "name": "FORCE_COLOR", + "set": false, + "value": null, + "readers": [ + "node_modules/supports-color/index.js" + ] + }, + { + "name": "GITNEXUS_CONTENT_RETENTION", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/content-retention.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_API_KEY", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_DIMS", + "set": false, + "value": null, + "readers": [ + "gitnexus/src/core/lbug/schema.ts", + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_MAX_ATTEMPTS", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_MIN_INTERVAL_MS", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_MODEL", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_RETRY_CAP_MS", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_EMBEDDING_URL", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/http-embedding-body-retry.test.ts" + ] + }, + { + "name": "GITNEXUS_HOME", + "set": true, + "value": "/home/node/.gitnexus", + "readers": [ + "gitnexus/test/unit/storage-resolver.test.ts" + ] + }, + { + "name": "GITNEXUS_MAX_CALLABLE_VALUE_TARGETS", + "set": false, + "value": null, + "readers": [ + "gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts" + ] + }, + { + "name": "GITNEXUS_MAX_FILE_SIZE", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/max-file-size.test.ts" + ] + }, + { + "name": "GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT", + "set": false, + "value": null, + "readers": [ + "gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts" + ] + }, + { + "name": "GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT", + "set": false, + "value": null, + "readers": [ + "gitnexus/src/core/ingestion/scope-resolution/passes/property-dispatch.ts" + ] + }, + { + "name": "GITNEXUS_PUBLIC_ORIGIN", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/middleware-public-origin.test.ts" + ] + }, + { + "name": "GITNEXUS_RD_FUZZ_N", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts" + ] + }, + { + "name": "GITNEXUS_STORAGE_PATH", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/storage-resolver.test.ts" + ] + }, + { + "name": "GITNEXUS_STORAGE_ROOT", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/storage-resolver.test.ts" + ] + }, + { + "name": "GITNEXUS_TRUST_PROXY", + "set": false, + "value": null, + "readers": [ + "gitnexus/test/unit/middleware-trust-proxy.test.ts" + ] + }, + { + "name": "NODE_ENV", + "set": true, + "value": null, + "readers": [ + "gitnexus/src/core/ingestion/utils/env.ts", + "gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts" + ] + }, + { + "name": "NODE_V8_COVERAGE", + "set": false, + "value": null, + "readers": [ + "node_modules/thread-stream/index.js" + ] + }, + { + "name": "VALIDATE_SEMANTIC_MODEL", + "set": false, + "value": null, + "readers": [ + "gitnexus/src/core/ingestion/utils/env.ts", + "gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts" + ] + } + ], + "accepted": { + "fileCount": 251, + "testCount": 4667, + "ran": 4666, + "skipped": 1, + "files": [ + "test/unit/ai-context-read-path-must.test.ts", + "test/unit/ai-context-unknown-risk-policy.test.ts", + "test/unit/analysis-features.test.ts", + "test/unit/analyze-job.test.ts", + "test/unit/analyze-worker-ipc.test.ts", + "test/unit/binding-accumulator.test.ts", + "test/unit/c-static-linkage-side-channel.test.ts", + "test/unit/call-processor-routes.test.ts", + "test/unit/call-routing/ruby.test.ts", + "test/unit/cfg-callees-of-block.test.ts", + "test/unit/cfg/cfg-builder.test.ts", + "test/unit/cfg/opt-in.test.ts", + "test/unit/cfg/post-dominators.test.ts", + "test/unit/cfg/reaching-def-reason-codec.test.ts", + "test/unit/cfg/reaching-defs-equivalence.test.ts", + "test/unit/cfg/synthetic-escape.test.ts", + "test/unit/cjk-segmentation.test.ts", + "test/unit/cli-group-status-format.test.ts", + "test/unit/cli-i18n.test.ts", + "test/unit/cli-int-option.test.ts", + "test/unit/cli-message.test.ts", + "test/unit/clone-safety-cloneable.test.ts", + "test/unit/clone-safety-payloads.test.ts", + "test/unit/clone-safety.test.ts", + "test/unit/cobol-copy-expander.test.ts", + "test/unit/cobol-copy-external-imports.test.ts", + "test/unit/cobol-preprocessor.test.ts", + "test/unit/compatible-stdio-transport.test.ts", + "test/unit/conn-lock.test.ts", + "test/unit/constant-resolver.test.ts", + "test/unit/content-retention.test.ts", + "test/unit/csharp-namespace-extraction.test.ts", + "test/unit/csv-escaping.test.ts", + "test/unit/cypher-escape.test.ts", + "test/unit/dart-package-dependencies.test.ts", + "test/unit/definition-properties.test.ts", + "test/unit/django-root-discovery.test.ts", + "test/unit/embedding-checkpoint.test.ts", + "test/unit/embedding-count.test.ts", + "test/unit/embedding-dims.test.ts", + "test/unit/embedding-recovery.test.ts", + "test/unit/embedding-restore-spill.test.ts", + "test/unit/env.test.ts", + "test/unit/eval-server-bind-restriction.test.ts", + "test/unit/expo-routes.test.ts", + "test/unit/extension-load-error.test.ts", + "test/unit/extract-element-type-from-string.test.ts", + "test/unit/fastapi-router-bindings.test.ts", + "test/unit/fetch-reason-parsing.test.ts", + "test/unit/fetch-wrapper-index.test.ts", + "test/unit/format-elapsed.test.ts", + "test/unit/format-path.test.ts", + "test/unit/framework-route-paths.test.ts", + "test/unit/fts-rss-verdict.test.ts", + "test/unit/fts-schema.test.ts", + "test/unit/git-ref.test.ts", + "test/unit/gitnexus-rc-embeddings.test.ts", + "test/unit/go-route-handler.test.ts", + "test/unit/graph.test.ts", + "test/unit/group/config-parser.test.ts", + "test/unit/group/fs-utils.test.ts", + "test/unit/group/group-path-utils.test.ts", + "test/unit/group/group-tools.test.ts", + "test/unit/group/manifest-extractor.test.ts", + "test/unit/group/matching.test.ts", + "test/unit/group/service-boundary-detector.test.ts", + "test/unit/group/storage.test.ts", + "test/unit/group/types.test.ts", + "test/unit/hf-env.test.ts", + "test/unit/http-client-safe-url.test.ts", + "test/unit/http-embedding-body-retry.test.ts", + "test/unit/impact-risk.test.ts", + "test/unit/import-cycles.test.ts", + "test/unit/import-path-candidates.test.ts", + "test/unit/import-resolvers/csharp-csproj-parity.test.ts", + "test/unit/import-resolvers/suffix-index-lazy-dir-map.test.ts", + "test/unit/incremental-derived-writeback.test.ts", + "test/unit/incremental-escalation-gate.test.ts", + "test/unit/incremental-file-hash.test.ts", + "test/unit/incremental-parse-cache.test.ts", + "test/unit/incremental-shadow-candidates.test.ts", + "test/unit/incremental-subgraph-extract.test.ts", + "test/unit/index-freshness-graph-collapse.test.ts", + "test/unit/ingestion/di.test.ts", + "test/unit/ingestion/heritage-marker.test.ts", + "test/unit/ingestion/non-value-definition-keys.test.ts", + "test/unit/ingestion/qualified-name-utils.test.ts", + "test/unit/integrations/circuit-breaker.test.ts", + "test/unit/integrations/resilient-fetch.test.ts", + "test/unit/integrations/retry.test.ts", + "test/unit/ipynb-extractor.test.ts", + "test/unit/java-module-resolution.test.ts", + "test/unit/jcl-parser.test.ts", + "test/unit/kotlin-module-resolution.test.ts", + "test/unit/language-provider-route-constants.test.ts", + "test/unit/lbug-query-result-utils.test.ts", + "test/unit/lbug/graph-emit-sink.test.ts", + "test/unit/lbug/pdg-emit-sink.test.ts", + "test/unit/line-base-conversion.test.ts", + "test/unit/local-symbol-pruner.test.ts", + "test/unit/logger.test.ts", + "test/unit/max-file-size.test.ts", + "test/unit/mcp-output-budget.test.ts", + "test/unit/mcp-response-filter.test.ts", + "test/unit/mcp-stdout-sentinel.test.ts", + "test/unit/mcp-tool-arguments.test.ts", + "test/unit/mcp/query-process-attaches.test.ts", + "test/unit/method-props.test.ts", + "test/unit/middleware-public-origin.test.ts", + "test/unit/middleware-trust-proxy.test.ts", + "test/unit/model/field-registry.test.ts", + "test/unit/model/method-registry.test.ts", + "test/unit/model/owned-members-lookup.test.ts", + "test/unit/model/registration-table.test.ts", + "test/unit/model/semantic-model.test.ts", + "test/unit/model/type-registry.test.ts", + "test/unit/mutation/blank-assertions.test.ts", + "test/unit/mutation/classify-outcome.test.ts", + "test/unit/mutation/conformance-fingerprint.test.ts", + "test/unit/mutation/coverage-attribution.test.ts", + "test/unit/mutation/mutant-identity.test.ts", + "test/unit/mutation/normalise-results.test.ts", + "test/unit/mutation/oracle-boundary.test.ts", + "test/unit/mutation/oracle-classify.test.ts", + "test/unit/mutation/oracle-util.test.ts", + "test/unit/mutation/scope-file.test.ts", + "test/unit/mutation/sidecar.test.ts", + "test/unit/mutation/span-map-check.test.ts", + "test/unit/mutation/stryker-config.test.ts", + "test/unit/mutation/test-ids.test.ts", + "test/unit/mutation/version-guard.test.ts", + "test/unit/objective-c-macro-marker-preprocess.test.ts", + "test/unit/package-version.test.ts", + "test/unit/parsedfile-store.test.ts", + "test/unit/pdg-callee-id-capture.test.ts", + "test/unit/phase-timer.test.ts", + "test/unit/pipeline-runner.test.ts", + "test/unit/private-ip.test.ts", + "test/unit/process-detection-budget.test.ts", + "test/unit/python-relative-imports.test.ts", + "test/unit/query-batch.test.ts", + "test/unit/query-params.test.ts", + "test/unit/receiver-chain-codec.test.ts", + "test/unit/rel-pair-routing.test.ts", + "test/unit/rename-family.test.ts", + "test/unit/resolve-route-handler-symbols.test.ts", + "test/unit/result-merge.test.ts", + "test/unit/route-tool-detection.test.ts", + "test/unit/schema-fingerprint.test.ts", + "test/unit/schema-pair-coverage.test.ts", + "test/unit/schema.test.ts", + "test/unit/scope-extraction-failures.test.ts", + "test/unit/scope-index-store.test.ts", + "test/unit/scope-resolution/answered-receiver-drops.test.ts", + "test/unit/scope-resolution/c/c-header-scan.test.ts", + "test/unit/scope-resolution/c3-linearize-strategy.test.ts", + "test/unit/scope-resolution/callable-value-flow-env.test.ts", + "test/unit/scope-resolution/callable-value-flow-worklist.test.ts", + "test/unit/scope-resolution/callable-value-target-index.test.ts", + "test/unit/scope-resolution/cobol-import-target-parity.test.ts", + "test/unit/scope-resolution/cpp/cpp-adl-seq-coverage.test.ts", + "test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts", + "test/unit/scope-resolution/cpp/cpp-qualified-ns-index.test.ts", + "test/unit/scope-resolution/csharp/csharp-hooks.test.ts", + "test/unit/scope-resolution/csharp/csharp-interpret.test.ts", + "test/unit/scope-resolution/csharp/csharp-nullable-field-interpret.test.ts", + "test/unit/scope-resolution/def-index.test.ts", + "test/unit/scope-resolution/definition-id.test.ts", + "test/unit/scope-resolution/emit-references.test.ts", + "test/unit/scope-resolution/finalize-algorithm.test.ts", + "test/unit/scope-resolution/finalize-orchestrator.test.ts", + "test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts", + "test/unit/scope-resolution/go/go-build-constraints.test.ts", + "test/unit/scope-resolution/go/go-workspace-owners-warning.test.ts", + "test/unit/scope-resolution/graph-bridge-label-split.test.ts", + "test/unit/scope-resolution/imports-to-edges-deferred.test.ts", + "test/unit/scope-resolution/imports-to-edges-type-only.test.ts", + "test/unit/scope-resolution/java/java-interpret.test.ts", + "test/unit/scope-resolution/lexical-name-claims.test.ts", + "test/unit/scope-resolution/method-dispatch-index.test.ts", + "test/unit/scope-resolution/module-scope-index.test.ts", + "test/unit/scope-resolution/name-fallback-summary.test.ts", + "test/unit/scope-resolution/namespace-channel-lookup.test.ts", + "test/unit/scope-resolution/namespace-target-cache.test.ts", + "test/unit/scope-resolution/namespace-targets-import-path.test.ts", + "test/unit/scope-resolution/overload-narrowing.test.ts", + "test/unit/scope-resolution/parse-worker-scope-integration.test.ts", + "test/unit/scope-resolution/pick-implicit-this-overload.test.ts", + "test/unit/scope-resolution/pick-unique-global-callable.test.ts", + "test/unit/scope-resolution/pick-unique-global-class.test.ts", + "test/unit/scope-resolution/position-index.test.ts", + "test/unit/scope-resolution/property-dispatch-fanout-env.test.ts", + "test/unit/scope-resolution/python/python-generic-annotation-reduction.test.ts", + "test/unit/scope-resolution/qualified-name-index.test.ts", + "test/unit/scope-resolution/reconcile-ownership.test.ts", + "test/unit/scope-resolution/references-to-edges-call-form.test.ts", + "test/unit/scope-resolution/registries.test.ts", + "test/unit/scope-resolution/resolve-ambiguous-inheritance-base.test.ts", + "test/unit/scope-resolution/resolve-references.test.ts", + "test/unit/scope-resolution/resolve-same-namespace-inheritance-base.test.ts", + "test/unit/scope-resolution/resolve-type-ref.test.ts", + "test/unit/scope-resolution/rust/rust-call-return-path.test.ts", + "test/unit/scope-resolution/rust/rust-dyn-type-normalization.test.ts", + "test/unit/scope-resolution/rust/rust-module-path.test.ts", + "test/unit/scope-resolution/scope-extractor.test.ts", + "test/unit/scope-resolution/scope-id.test.ts", + "test/unit/scope-resolution/scope-tree.test.ts", + "test/unit/scope-resolution/strip-cast-wrappers.test.ts", + "test/unit/scope-resolution/swift/callable-visibility.test.ts", + "test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts", + "test/unit/scope-resolution/undecided-satisfaction.test.ts", + "test/unit/scope-resolution/unresolved-receiver-files.test.ts", + "test/unit/scope-resolution/validate-bindings-immutability.test.ts", + "test/unit/scope-resolution/value-ref-namespace-precedence.test.ts", + "test/unit/scope-resolution/walkers-augmentations.test.ts", + "test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts", + "test/unit/semantic-chunk-search.test.ts", + "test/unit/server-validation.test.ts", + "test/unit/shape-check.test.ts", + "test/unit/shard-arg.test.ts", + "test/unit/shared-type-extractors.test.ts", + "test/unit/spring-annotation-arguments.test.ts", + "test/unit/spring-aop.test.ts", + "test/unit/spring-config-drift.test.ts", + "test/unit/spring-destinations.test.ts", + "test/unit/spring-non-http-handlers.test.ts", + "test/unit/storage-resolver.test.ts", + "test/unit/storage/fs-atomic.test.ts", + "test/unit/structure-processor.test.ts", + "test/unit/suffix-index-ambiguity.test.ts", + "test/unit/swift-conditional-directive-preprocess.test.ts", + "test/unit/symbol-table.test.ts", + "test/unit/taint/call-summary-codec.test.ts", + "test/unit/taint/interproc-solver.test.ts", + "test/unit/taint/path-codec.test.ts", + "test/unit/taint/source-sink-registry.test.ts", + "test/unit/taint/summary-model.test.ts", + "test/unit/temp-dir-pool-cleanup.test.ts", + "test/unit/text-generator.test.ts", + "test/unit/topological-sort.test.ts", + "test/unit/trpc-route-extractor.test.ts", + "test/unit/utils.test.ts", + "test/unit/v8-sidecar.test.ts", + "test/unit/vendored-extension-path.test.ts", + "test/unit/vue-sfc-extractor.test.ts", + "test/unit/watch-queue.test.ts", + "test/unit/wiki-mermaid-sanitizer.test.ts", + "test/unit/windows-long-path-prefix.test.ts", + "test/unit/worker-entry.test.ts", + "test/unit/workers/quarantine.test.ts", + "test/unit/write-reconciliation.test.ts" + ] + }, + "skippedOnThisPlatform": [ + "test/unit/storage-resolver.test.ts > storage resolver > matches a registered missing repository through a Windows extended-length path" + ], + "runtimeLoads": { + "workerThreadsLoadedBy": [ + "gitnexus/test/unit/ai-context-read-path-must.test.ts", + "gitnexus/test/unit/ai-context-unknown-risk-policy.test.ts", + "gitnexus/test/unit/cfg/opt-in.test.ts", + "gitnexus/test/unit/cli-message.test.ts", + "gitnexus/test/unit/cobol-copy-expander.test.ts", + "gitnexus/test/unit/cobol-copy-external-imports.test.ts", + "gitnexus/test/unit/cobol-preprocessor.test.ts", + "gitnexus/test/unit/group/manifest-extractor.test.ts", + "gitnexus/test/unit/incremental-parse-cache.test.ts", + "gitnexus/test/unit/ingestion/di.test.ts", + "gitnexus/test/unit/local-symbol-pruner.test.ts", + "gitnexus/test/unit/logger.test.ts", + "gitnexus/test/unit/max-file-size.test.ts", + "gitnexus/test/unit/middleware-public-origin.test.ts", + "gitnexus/test/unit/middleware-trust-proxy.test.ts", + "gitnexus/test/unit/parsedfile-store.test.ts", + "gitnexus/test/unit/pipeline-runner.test.ts", + "gitnexus/test/unit/scope-index-store.test.ts", + "gitnexus/test/unit/scope-resolution/cobol-import-target-parity.test.ts", + "gitnexus/test/unit/scope-resolution/finalize-orchestrator.test.ts", + "gitnexus/test/unit/scope-resolution/free-call-fallback-guess-taint.test.ts", + "gitnexus/test/unit/scope-resolution/name-fallback-summary.test.ts", + "gitnexus/test/unit/scope-resolution/namespace-target-cache.test.ts", + "gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts", + "gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts", + "gitnexus/test/unit/v8-sidecar.test.ts" + ] + }, + "gate": { + "result": "pass", + "differences": [] + } +} diff --git a/gitnexus/test/fixtures/mutation-oracle/middleware.json b/gitnexus/test/fixtures/mutation-oracle/middleware.json new file mode 100644 index 000000000..b36701f78 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-oracle/middleware.json @@ -0,0 +1,1427 @@ +{ + "schemaVersion": 1, + "kind": "gitnexus-mutation-oracle-manifest", + "name": "middleware", + "description": "Gate 3 (U7): the oracle's frozen outcomes for the middleware scope: a seeded sample of mutants with strata top-ups, the oracle class of every mutant (U13 command run) and the per-test outcome of every sampled mutant with every test, each test launched alone (U12). Check a vitest5 runner run with `npm run test:mutation:oracle -- --scope test/fixtures/mutation-oracle/middleware.json,` (from gitnexus/). When gate 3 reports \"re-freeze required\", run the refreeze command.", + "refreeze": "npm run test:mutation:oracle:freeze -- --from-manifest test/fixtures/mutation-oracle/middleware.json --seed --work ", + "scope": { + "testFiles": ["test/unit/middleware-public-origin.test.ts"], + "mutate": ["gitnexus/src/server/middleware.ts"] + }, + "versions": { + "stryker": "10.0.0", + "vitest": "5.0.5", + "nodeMajor": 22 + }, + "operators": { + "mutator": null, + "names": [ + "ArithmeticOperator", + "ArrowFunction", + "BlockStatement", + "BooleanLiteral", + "CallExpression", + "ConditionalExpression", + "EqualityOperator", + "LogicalOperator", + "MethodExpression", + "ObjectLiteral", + "OptionalChaining", + "Regex", + "StringLiteral" + ] + }, + "inputs": { + "closure": { + "gitnexus/src/core/logger.ts": "eb73af1d85104a44070c1aeb4c6696a0e35979b443f569712ec116d83d108e9e", + "gitnexus/src/server/middleware.ts": "424f95b28e60eab6b2ab92b18309555b6a7b92102f716e5cc7006829fc279e8c", + "gitnexus/test/unit/middleware-public-origin.test.ts": "cd9986e295a22729719149feab8d58594b2bf1e3916f19ed4ee18067bf49d81b" + }, + "vitestConfig": { + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "mutationConfig": { + "gitnexus-shared/package.json": "a0100a5b0858d80622846788097a2e596723fdf2b86602ffd93d1076f82dee13", + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/scope-file.ts": "2582a4071c46d7f412013cd7036bd5fbbd46d341e0f268a8fe4a93ceeaa69195", + "gitnexus/tools/mutation/vitest.mutation.config.ts": "9e234252881e0cd5347a3f413320135c59226ac8923465a57f58290079906585", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "prepareTool": { + "gitnexus/tools/mutation/prepare/blank-assertions.ts": "895eb697d123cbd915543be411945055655e39963e6abb88e221ca3b570939f1", + "gitnexus/tools/mutation/prepare/mutant-identity.ts": "8887c9290cca144b6e547f5f0b74bc593a9e56ba989598c09cd573f787db2943", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/prepare/prepare.ts": "a8a703718cdb99ac9af6347009a068de26b199b976b9806061f031a2982ba0bf", + "gitnexus/tools/mutation/prepare/span-map-check.ts": "30b768d4386631ebeb9547ca624162a034dc8fdb0bb7cbe4bbca8a86b52fa4b4" + } + }, + "sample": { + "seed": "u7-gate3-1", + "method": "sha256(seed + \"\\n\" + globalId) ascending; the size lowest ranks, then strata top-ups", + "size": 100, + "topUpMin": 3, + "random": 100, + "topUps": [ + { + "globalId": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:69:34-69:58:StringLiteral:12ae32cb1ec02d01", + "stratum": "static" + } + ] + }, + "strata": [ + { + "name": "static", + "construct": null, + "constructPresent": null, + "population": 3, + "sampled": 3, + "toppedUp": 1, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "hook-hit", + "construct": "beforeAll", + "constructPresent": true, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 31, + "required": false, + "status": "empty-population" + }, + { + "name": "multi-killer", + "construct": null, + "constructPresent": null, + "population": 147, + "sampled": 42, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "survived", + "construct": null, + "constructPresent": null, + "population": 39, + "sampled": 16, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "no-coverage", + "construct": null, + "constructPresent": null, + "population": 72, + "sampled": 31, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "each-row", + "construct": "each", + "constructPresent": true, + "population": 15, + "sampled": 7, + "toppedUp": 0, + "affectedTests": 5, + "required": true, + "status": "met" + } + ], + "baseline": { + "tests": 31, + "passedAlone": 31 + }, + "tests": [ + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > admits the bound host on the bound port", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > rejects the bound host on a different port with origin_not_allowed", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > rejects the bound host on the default port when it is not the bound one", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > treats an elided default port as that port", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > matches any port when no bound port is given", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — bound host is matched on its port > keeps loopback port-agnostic — the dev UI runs on its own port", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits an exact scheme/host/port match on a wildcard bind", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > rejects a port mismatch with a 403 and origin_not_allowed", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > rejects a scheme mismatch when the configured value carries a scheme", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits any port for a bare configured host, but only over https", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits http for a bare host only when http:// is spelled out", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits a bracketed IPv6 literal, on any port when configured without one", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits nothing for a trailing-dot hostname", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > admits nothing extra when the configured value is not one reachable host", + "test/unit/middleware-public-origin.test.ts#createWriteOriginGuard — public origin is matched on its port > leaves the Origin-less passthrough alone — the CLI sends no Origin", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > says nothing on a specific bind with no public origin", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > names the resolved hostname at info when a public origin is configured", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > warns on a wildcard bind with no public origin, pointing at both remedies", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > still warns on a wildcard bind with a valid public origin, and names it", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > diagnoses an unusable public origin rather than reporting one", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > diagnoses an unusable public origin on a specific bind too", + "test/unit/middleware-public-origin.test.ts#logOriginPolicy > does not report a trailing-dot hostname as a working origin", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > is a no-op when no public origin is configured", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > treats a blank value (\" \") as unset", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > treats a blank value (\"\") as unset", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > throws when a public origin is configured and no auth is", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > names the variable and the value in the failure, and points at both remedies", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > throws on an unusable value too: a.com,b.com", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > throws on an unusable value too: *", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > throws on an unusable value too: 8080", + "test/unit/middleware-public-origin.test.ts#assertServeAuthForPublicOrigin > reports no auth configured, since serve has none" + ], + "notOracleValidated": [], + "mutants": [ + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:100:7-100:16:BooleanLiteral:7063dece7cccf374","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:100:7-100:16:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:100:7-100:16:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:108:28-108:51:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:108:31-108:48:LogicalOperator:8e77a0551dd878d6","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:108:41-108:48:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:110:23-126:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:110:7-110:21:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:110:7-110:21:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:112:9-117:6:BlockStatement:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:116:28-116:80:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:117:13-119:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:29-124:32:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:9-124:32:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:9-124:32:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:9-124:32:EqualityOperator:589f4fbfbcf2b436","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:128:10-134:4:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:12:42-15:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:130:14-133:77:ArrowFunction:1193b7f146e45559","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-131:35:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-131:35:EqualityOperator:f431e663688c861a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-132:43:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-132:43:LogicalOperator:af8c50a7112202d9","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-133:77:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-133:77:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-133:77:LogicalOperator:ade298b9b429a4ce","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:132:7-132:43:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:132:7-132:43:EqualityOperator:c9c81d179661beee","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:38-133:76:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:38-133:76:EqualityOperator:c6f2d2f624fa45fa","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:8-133:34:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:8-133:34:EqualityOperator:87b1664a4d468fcf","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:8-133:76:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:133:8-133:76:LogicalOperator:023ccf30d9e43224","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:13:7-13:15:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:13:7-13:15:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:141:89-160:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:0f7414d664535b52","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:4ba47c471cf35685","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:515b40b7f1a48c62","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:91ebf65ddf22b950","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:961c41d0b35a13c4","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:abe37084430d897b","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:d0d0f2a0f981e109","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:143:25-143:67:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:143:7-143:16:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:143:7-143:16:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:49-148:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:EqualityOperator:cf9c78c8cd97889d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:EqualityOperator:fcd6b6db761a0a8d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:8-146:37:OptionalChaining:986cfabae6b60815","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:8-146:42:LogicalOperator:f177ba2dafc07ea6","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:7e72fc3dde62e309","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:b34f8504671675b6","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:c5b26e962e858e29","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:e43ae463e7cc8e42","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:49-147:75:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:57-147:73:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:EqualityOperator:4947bf4c29ef494f","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:27-14:35:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:38-14:43:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:46-14:50:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:017e0f509058803a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:5fa3caad0367e16d","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:995742ef39d93999","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:b9954fadbee78077","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:c070ac4bcafff088","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:e6828e1370f49882","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:f94fa842e6772e0d","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:151:7-151:13:BooleanLiteral:d887db09649dab0d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:151:7-151:13:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:151:7-151:13:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:2a9d1381c12c8daf","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:6ae98966f1c83ec0","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:7d91c1204a4fdbde","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:90cd31c07c345567","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:59-158:77:MethodExpression:f2ecabab5aa90ee0","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:22:Regex:b6b4f595145d7915","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:22:Regex:d19d62c4a768b9e5","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:55:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:55:LogicalOperator:27e5da867ec3ec64","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:77:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:77:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:77:LogicalOperator:d331ccd2701e3c2c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:73-158:76:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:159:10-159:34:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:181:80-214:2:BlockStatement:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:183:31-183:54:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:183:31-183:54:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:183:31-183:54:EqualityOperator:146fcdcdb3f310c4","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:187:93-213:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:189:31-192:6:BlockStatement:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:189:9-189:29:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:189:9-189:29:ConditionalExpression:fcbcf165908dd18a","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:189:9-189:29:EqualityOperator:d87d1c381d6ce151","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:190:7-190:14:CallExpression:41b805ea7ac014e2","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:193:9-206:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:31:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:31:EqualityOperator:ed9a353eda4e125d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:56:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:56:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:56:LogicalOperator:b15294d04fd9e4a8","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:24-196:31:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:35-196:56:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:35-196:56:EqualityOperator:0afb7216a4c38ed6","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:48-196:56:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:58-198:8:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:197:25-197:54:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-200:41:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-200:41:EqualityOperator:0f6957b49f77cbce","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:LogicalOperator:0aa1a3fdef70e970","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:10-201:43:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:10-201:43:EqualityOperator:77c99d25f28eef89","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:10-201:92:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:10-201:92:LogicalOperator:e258e8207785828d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:47-201:92:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:47-201:92:EqualityOperator:198d9b40f8592e99","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:59:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:59:LogicalOperator:9728fba7913d8cfd","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:92:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:92:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:92:LogicalOperator:bac1d726523a97e1","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:63-202:84:OptionalChaining:3788c0c27623e205","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:94-205:8:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:203:9-203:16:CallExpression:41b805ea7ac014e2","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:209:26-212:6:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:210:14-210:70:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:211:13-211:33:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:228:50-230:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:229:10-229:15:BooleanLiteral:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:246:56-257:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:247:15-247:51:OptionalChaining:4fd6736427517539","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:247:15-247:53:MethodExpression:3397143e931251e8","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:11:BooleanLiteral:d7439bee24773bcb","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:38:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:38:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:38:LogicalOperator:aadf58ae46f06b00","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:250:5-250:93:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:251:7-251:95:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:252:7-252:99:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:253:7-253:89:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:254:7-254:99:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:255:7-255:35:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:25:68-27:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:265:59-292:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:266:15-266:51:OptionalChaining:4fd6736427517539","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:266:15-266:53:MethodExpression:3397143e931251e8","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:268:21-273:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:268:7-268:19:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:268:7-268:19:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:34:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:34:EqualityOperator:fc3f91f5631835a1","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:62:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:62:LogicalOperator:e91dc8cc85fbced4","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:86:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:86:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:10-26:86:LogicalOperator:c5c5cbb173724f6a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:23-26:34:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:38-26:62:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:38-26:62:EqualityOperator:117c5666a98c526e","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:51-26:62:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:66-26:86:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:66-26:86:EqualityOperator:90f7aec506e80b6a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:79-26:86:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:270:7-270:68:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:271:7-271:95:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:273:14-273:17:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:273:14-273:17:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:273:19-279:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:275:7-275:35:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:276:7-276:96:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:277:9-277:89:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:21-281:64:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:21-281:64:EqualityOperator:05de228a930e1250","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:17:BooleanLiteral:354843e9ec3cedb9","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:64:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:64:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:64:LogicalOperator:3fe5d6e4a134774c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:283:7-283:94:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:284:7-284:30:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:285:7-285:99:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:286:7-286:93:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:287:7-287:55:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:289:5-289:24:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:290:5-290:100:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:295:36-295:70:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:298:32-298:54:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:324:76-359:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:325:17-325:26:OptionalChaining:9b01e3189d236b9f","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:325:17-325:28:MethodExpression:d7439bee24773bcb","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:326:7-326:13:BooleanLiteral:cd42404d52ad55cc","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:326:7-326:13:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:326:7-326:13:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:39-335:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:7-328:25:Regex:0de49f4a7221efe2","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:7-328:25:Regex:49ec526fa936c76a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:7-328:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:7-328:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:331:7-331:84:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:332:9-332:95:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:333:9-333:91:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:47-336:52:BooleanLiteral:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:26:Regex:6b3407f87a3f4170","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:26:Regex:fe58c449c85aa018","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:38:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:28-348:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:14:Regex:2a9d1381c12c8daf","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:14:Regex:6ae98966f1c83ec0","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:14:Regex:7d91c1204a4fdbde","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:14:Regex:90cd31c07c345567","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:26:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:26:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:28-343:33:BooleanLiteral:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:9-343:19:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:9-343:19:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:9-343:19:EqualityOperator:2c23b2b4fb656128","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:35-346:63:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:35-346:63:EqualityOperator:3ff21898110ded52","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:35-346:63:EqualityOperator:8f7384bc95c0f195","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:9-346:63:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:9-346:63:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:9-346:63:LogicalOperator:7d0701aa1bb2c088","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:347:36-347:88:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:350:7-355:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:354:35-354:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:354:44-354:67:ArrowFunction:1193b7f146e45559","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:354:55-354:67:MethodExpression:923fe53966c6cd93","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:355:17-357:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:376:71-389:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:41:OptionalChaining:3096ba3777e7e175","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:43:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:43:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:43:MethodExpression:3054837045109075","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:378:7-378:17:BooleanLiteral:354843e9ec3cedb9","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:378:7-378:17:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:378:7-378:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:381:7-381:56:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:381:7-381:56:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:383:5-383:57:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:384:5-384:97:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:385:7-385:98:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:386:7-386:100:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:387:7-387:82:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:391:66-398:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:393:5-393:33:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:394:5-394:90:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:395:7-395:34:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:47:76-63:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:48:7-48:17:BooleanLiteral:354843e9ec3cedb9","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:48:7-48:17:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:48:7-48:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:24-51:27:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:32-51:58:BooleanLiteral:ff072c5f3cb7775a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:33-51:58:MethodExpression:faa1c3dbc55de26e","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:5-51:58:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:5-51:58:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:5-51:58:LogicalOperator:9e646c44776cf5cc","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:54-51:57:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:51:61-51:77:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:53:7-55:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:54:24-54:45:StringLiteral:059b850298ae3352","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:55:11-57:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:20-59:22:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:26-59:48:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:26-59:48:EqualityOperator:47c7a2050284806d","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:39-59:48:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:52-59:71:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:52-59:71:EqualityOperator:f5b719b156f4520b","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:65-59:71:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:22:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:22:EqualityOperator:d116dac6257983c3","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:48:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:48:LogicalOperator:c3562abc1ee84c50","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:71:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:71:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:71:LogicalOperator:a1e1d4753bb7161f","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:73-61:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:69:34-69:58:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:89:96-135:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:90:19-90:34:OptionalChaining:7b85ff3d32002b3e","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:90:19-90:36:MethodExpression:4238ca4d78472450","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:91:7-91:15:BooleanLiteral:073f3c701722d652","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:91:7-91:15:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:91:7-91:15:ConditionalExpression:fcbcf165908dd18a","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:93:18-93:35:Regex:0d604fc840a74414","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:93:18-93:35:Regex:9248d019a28e8fe5","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:93:18-93:54:OptionalChaining:385b2927819bb0f2","killed-by-test:runtime"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:93:18-93:68:MethodExpression:e07a9e35f96e6cf0","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:95:25-95:57:MethodExpression:073f3c701722d652","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:95:39-95:56:ArithmeticOperator:b0213638bba48a62","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:95:77-95:82:Regex:13da386de755eed9","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:95:84-95:86:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:97:7-97:17:BooleanLiteral:8f76fd501bb68ef7","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:97:7-97:17:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/server/middleware.ts@424f95b28e60eab6:97:7-97:17:ConditionalExpression:fcbcf165908dd18a","killed-by-test:runtime"] + ], + "sampled": [ + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:66-26:86:ConditionalExpression:fcbcf165908dd18a", + "strata": [], + "covering": 14, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:c5b26e962e858e29", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-200:41:EqualityOperator:0f6957b49f77cbce", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [[0,"killed-by-test:assertion"],[3,"killed-by-test:assertion"],[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:4ba47c471cf35685", + "strata": ["survived"], + "covering": 14, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:354:55-354:67:MethodExpression:923fe53966c6cd93", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:ConditionalExpression:fcbcf165908dd18a", + "strata": [], + "covering": 2, + "outcomes": [[3,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:26:23-26:34:StringLiteral:12ae32cb1ec02d01", + "strata": [], + "covering": 14, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:325:17-325:28:MethodExpression:d7439bee24773bcb", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:22:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 22, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:41:OptionalChaining:3096ba3777e7e175", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:90cd31c07c345567", + "strata": ["multi-killer"], + "covering": 10, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:255:7-255:35:StringLiteral:059b850298ae3352", + "strata": ["survived","each-row"], + "covering": 5, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:37-158:44:Regex:2a9d1381c12c8daf", + "strata": ["survived"], + "covering": 10, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:26:Regex:6b3407f87a3f4170", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:270:7-270:68:ObjectLiteral:44136fa355b3678a", + "strata": [], + "covering": 2, + "outcomes": [[16,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:151:7-151:13:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:324:76-359:2:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:247:15-247:53:MethodExpression:3397143e931251e8", + "strata": ["each-row"], + "covering": 8, + "outcomes": [[23,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:131:7-132:43:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 6, + "outcomes": [ + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:391:66-398:2:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:277:9-277:89:StringLiteral:059b850298ae3352", + "strata": ["survived"], + "covering": 3, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:328:7-328:25:Regex:0de49f4a7221efe2", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:325:17-325:26:OptionalChaining:9b01e3189d236b9f", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:EqualityOperator:cf9c78c8cd97889d", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:35-196:56:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 9, + "outcomes": [ + [3,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:378:7-378:17:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:7-158:55:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [[19,"killed-by-test:assertion"],[20,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:28-343:33:BooleanLiteral:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:189:31-192:6:BlockStatement:44136fa355b3678a", + "strata": [], + "covering": 1, + "outcomes": [[14,"killed-by-test:runtime"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:c070ac4bcafff088", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [[6,"killed-by-test:assertion"],[16,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:350:7-355:4:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:73-61:4:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [ + [17,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:254:7-254:99:StringLiteral:059b850298ae3352", + "strata": ["each-row"], + "covering": 5, + "outcomes": [[26,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:47-201:92:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [[0,"killed-by-test:assertion"],[3,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:9-346:63:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:298:32-298:54:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 31, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:11:BooleanLiteral:d7439bee24773bcb", + "strata": ["multi-killer","each-row"], + "covering": 8, + "outcomes": [ + [22,"killed-by-test:assertion"], + [23,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [25,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [27,"killed-by-test:assertion"], + [28,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:100:7-100:16:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 9, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:150:17-150:43:Regex:017e0f509058803a", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:268:21-273:4:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 2, + "outcomes": [[16,"killed-by-test:assertion"],[18,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:9-124:32:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [ + [6,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:22:EqualityOperator:d116dac6257983c3", + "strata": ["multi-killer"], + "covering": 22, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:376:71-389:2:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:63-202:84:OptionalChaining:3788c0c27623e205", + "strata": ["survived"], + "covering": 11, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:355:17-357:4:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:331:7-331:84:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:386:7-386:100:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:336:7-336:26:Regex:fe58c449c85aa018", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:90:19-90:36:MethodExpression:4238ca4d78472450", + "strata": ["survived"], + "covering": 22, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:273:19-279:4:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 3, + "outcomes": [ + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [21,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:354:35-354:38:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:295:36-295:70:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 31, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:197:25-197:54:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:183:31-183:54:ConditionalExpression:fcbcf165908dd18a", + "strata": [], + "covering": 15, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:289:5-289:24:ObjectLiteral:44136fa355b3678a", + "strata": ["survived"], + "covering": 4, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:48:7-48:17:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 22, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:56:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 14, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:92:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:229:10-229:15:BooleanLiteral:b5bea41b6c623f7c", + "strata": ["multi-killer","each-row"], + "covering": 6, + "outcomes": [ + [25,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [27,"killed-by-test:assertion"], + [28,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"], + [30,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:100:7-100:16:BooleanLiteral:7063dece7cccf374", + "strata": ["multi-killer"], + "covering": 9, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:124:9-124:32:EqualityOperator:589f4fbfbcf2b436", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [ + [6,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:110:7-110:21:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 9, + "outcomes": [ + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:158:59-158:77:MethodExpression:f2ecabab5aa90ee0", + "strata": ["multi-killer"], + "covering": 10, + "outcomes": [[12,"killed-by-test:assertion"],[21,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:394:5-394:90:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:143:7-143:16:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [6,"killed-by-test:runtime"], + [7,"killed-by-test:runtime"], + [8,"killed-by-test:runtime"], + [9,"killed-by-test:runtime"], + [10,"killed-by-test:runtime"], + [12,"killed-by-test:runtime"], + [13,"killed-by-test:runtime"], + [14,"killed-by-test:runtime"], + [16,"killed-by-test:runtime"], + [18,"killed-by-test:runtime"], + [19,"killed-by-test:runtime"], + [20,"killed-by-test:runtime"], + [21,"killed-by-test:runtime"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [[0,"killed-by-test:assertion"],[3,"killed-by-test:assertion"],[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:247:15-247:51:OptionalChaining:4fd6736427517539", + "strata": ["each-row"], + "covering": 8, + "outcomes": [[22,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:202:11-202:92:LogicalOperator:bac1d726523a97e1", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:35-346:63:EqualityOperator:3ff21898110ded52", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:210:14-210:70:StringLiteral:12ae32cb1ec02d01", + "strata": ["survived"], + "covering": 10, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:130:14-133:77:ArrowFunction:1193b7f146e45559", + "strata": ["multi-killer"], + "covering": 9, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:196:11-196:56:LogicalOperator:b15294d04fd9e4a8", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:151:7-151:13:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 13, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:384:5-384:97:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:13:7-13:15:ConditionalExpression:b5bea41b6c623f7c", + "strata": [], + "covering": 8, + "outcomes": [[3,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [1,"killed-by-test:assertion"], + [2,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:183:31-183:54:EqualityOperator:146fcdcdb3f310c4", + "strata": ["multi-killer"], + "covering": 15, + "outcomes": [ + [1,"killed-by-test:assertion"], + [2,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:248:7-248:38:LogicalOperator:aadf58ae46f06b00", + "strata": ["multi-killer","each-row"], + "covering": 8, + "outcomes": [ + [22,"killed-by-test:assertion"], + [23,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:146:7-146:47:EqualityOperator:fcd6b6db761a0a8d", + "strata": ["multi-killer"], + "covering": 13, + "outcomes": [[6,"killed-by-test:assertion"],[16,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:64:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 7, + "outcomes": [ + [17,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:285:7-285:99:StringLiteral:059b850298ae3352", + "strata": ["survived"], + "covering": 3, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:326:7-326:13:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:142:21-142:60:Regex:abe37084430d897b", + "strata": [], + "covering": 14, + "outcomes": [[11,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:89:96-135:2:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 22, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:281:7-281:64:LogicalOperator:3fe5d6e4a134774c", + "strata": ["multi-killer"], + "covering": 7, + "outcomes": [ + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:159:10-159:34:ObjectLiteral:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 8, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:59:7-59:48:LogicalOperator:c3562abc1ee84c50", + "strata": ["multi-killer"], + "covering": 22, + "outcomes": [[17,"killed-by-test:assertion"],[19,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:338:7-338:14:Regex:2a9d1381c12c8daf", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:48:7-48:17:BooleanLiteral:354843e9ec3cedb9", + "strata": ["multi-killer"], + "covering": 22, + "outcomes": [ + [0,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:346:9-346:63:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:377:7-377:43:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:147:12-147:30:Regex:b34f8504671675b6", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:14:10-14:35:EqualityOperator:4947bf4c29ef494f", + "strata": [], + "covering": 2, + "outcomes": [[3,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:201:47-201:92:EqualityOperator:198d9b40f8592e99", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [ + [0,"killed-by-test:assertion"], + [1,"killed-by-test:assertion"], + [2,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:266:15-266:53:MethodExpression:3397143e931251e8", + "strata": ["survived"], + "covering": 7, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:55:11-57:4:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:9-343:19:EqualityOperator:2c23b2b4fb656128", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:200:9-201:93:LogicalOperator:0aa1a3fdef70e970", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [[1,"killed-by-test:assertion"],[2,"killed-by-test:assertion"],[3,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:343:9-343:19:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:112:9-117:6:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [ + [6,"killed-by-test:runtime"], + [7,"killed-by-test:runtime"], + [11,"killed-by-test:runtime"], + [16,"killed-by-test:runtime"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/server/middleware.ts@424f95b28e60eab6:69:34-69:58:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","multi-killer"], + "covering": 31, + "outcomes": [ + [6,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [21,"killed-by-test:assertion"], + [25,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [27,"killed-by-test:assertion"], + [28,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"] + ], + "invalid": [] + } + ], + "launches": { + "total": 3162, + "valid": 3131, + "invalid": 0, + "wallClockLimitMs": 180000 + }, + "warnings": [], + "problems": [] +} diff --git a/gitnexus/test/fixtures/mutation-oracle/schema-constants.json b/gitnexus/test/fixtures/mutation-oracle/schema-constants.json new file mode 100644 index 000000000..f80142324 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-oracle/schema-constants.json @@ -0,0 +1,820 @@ +{ + "schemaVersion": 1, + "kind": "gitnexus-mutation-oracle-manifest", + "name": "schema-constants", + "description": "Gate 3 (U7): the oracle's frozen outcomes for the schema-constants scope: a seeded sample of mutants with strata top-ups, the oracle class of every mutant (U13 command run) and the per-test outcome of every sampled mutant with every test, each test launched alone (U12). Check a vitest5 runner run with `npm run test:mutation:oracle -- --scope test/fixtures/mutation-oracle/schema-constants.json,` (from gitnexus/). When gate 3 reports \"re-freeze required\", run the refreeze command.", + "refreeze": "npm run test:mutation:oracle:freeze -- --from-manifest test/fixtures/mutation-oracle/schema-constants.json --seed --work ", + "scope": { + "testFiles": ["test/unit/schema.test.ts"], + "mutate": ["gitnexus-shared/src/lbug/schema-constants.ts"] + }, + "versions": { + "stryker": "10.0.0", + "vitest": "5.0.5", + "nodeMajor": 22 + }, + "operators": { + "mutator": null, + "names": ["ArrayDeclaration","StringLiteral"] + }, + "inputs": { + "closure": { + "gitnexus-shared/src/impact-risk.ts": "1a2233d9b0bc8fb5b9bcc0293703fd14047709a3df973519e7b049fd1b725946", + "gitnexus-shared/src/index.ts": "9c7c37cd2627aeed468df6a7587afdb623407fb875326ec7a15e0d8ef09d377d", + "gitnexus-shared/src/integrations/circuit-breaker.ts": "55d930f5823a35ade27ecb478b6698dad131f0bc68c69dd35820b0dd79a7d25d", + "gitnexus-shared/src/integrations/resilient-fetch.ts": "f40ca1c4bef5ddabbbf04a283b341a019cbbeaf657f67b3f31631f63a9c98c3b", + "gitnexus-shared/src/integrations/retry.ts": "b4692a9a7c38a5ec0191ed50e29e5b85bbb6cca831f7e10cddecf1010946c227", + "gitnexus-shared/src/integrations/understand-quickly.ts": "17704a5eeb2c270ca4bd6955a4828dfe65a080254a861a6c33d6128f7eea442d", + "gitnexus-shared/src/language-detection.ts": "b3b909c5b7cd07d794cd499d07cc3e8c8439a90af47941825be8896ca2324e6c", + "gitnexus-shared/src/languages.ts": "1ae6948327d84d1fd13439eb21bf842ee7f960f60f7430f07d1c84c47940a29d", + "gitnexus-shared/src/lbug/schema-constants.ts": "edf2130f7f8f5f90078f55ddae29ceefd8d05ddb447bf229d06ac77cdbc18c7f", + "gitnexus-shared/src/rename/edits.ts": "b345e588bb9f7dd266d45b7420e1a67bb952bf3f59a7a3f56445250384d3bd5a", + "gitnexus-shared/src/rename/family.ts": "25f980bbd4ec302c78372bfae3ab25962232a78e4883a215369258801aea33e7", + "gitnexus-shared/src/rename/positions.ts": "c17c2d7f17bfb900c086c9607e84234082b1bc148da6c05ece25800805503efa", + "gitnexus-shared/src/rename/results.ts": "ee289c0b68cf075e83aee3896d9b657525410c3ea9ab904ad161b5b2856d1a8b", + "gitnexus-shared/src/rename/types.ts": "c39c5cc43e2f6f40c32febac142d1557c6266e088381b13202993be812167146", + "gitnexus-shared/src/scope-resolution/def-index.ts": "92c884d9c6b393940353980a104a3293fca97b75dac85d03b1724499ac08ab88", + "gitnexus-shared/src/scope-resolution/evidence-weights.ts": "13e775c1a4cbe36263c952251aeed67774df67dfdd3487cdf2fd4bb508da7be7", + "gitnexus-shared/src/scope-resolution/finalize-algorithm.ts": "f92fc086c103e8f0a5ff17175132c57494eff12db38a619592c5bd7f7e1455f2", + "gitnexus-shared/src/scope-resolution/language-classification.ts": "d9f4a7da95c6b39ef91298c481d6b0a3a4e419a55b845438e3c8305236fe2e84", + "gitnexus-shared/src/scope-resolution/method-dispatch-index.ts": "90d3003848324661ad52fe5244ad2302de5b5cbb15910a4e46da4cad6b2972dd", + "gitnexus-shared/src/scope-resolution/module-scope-index.ts": "2205ff236a2aebd74346ae1a9e3d8ff24fbdbc13d99063bac05ddd92ebdf0850", + "gitnexus-shared/src/scope-resolution/name-claims.ts": "01a5e40b6d507e75117db5566292135f3e36ebae55f2a46732137b3354f0d741", + "gitnexus-shared/src/scope-resolution/origin-priority.ts": "0f334497f2d999d895d03f73d30c26f3e484306da6d2c11518b3a9d5f24b59ef", + "gitnexus-shared/src/scope-resolution/position-index.ts": "298bebc52fec08469db72952fb299cd46445cba5556ac5001bf81f6020169f21", + "gitnexus-shared/src/scope-resolution/qualified-name-index.ts": "e5c84085142cb978a5635dd753ca7750e6626574990a1f9232ee81a657753f17", + "gitnexus-shared/src/scope-resolution/registries/class-registry.ts": "e8d80638503ba9c3d9d1eada38708b19faa71a40cf7a85912cf41d84830139c8", + "gitnexus-shared/src/scope-resolution/registries/context.ts": "7544e9e62add75a7469d4167094bebb62bee43abd1e6fcb2956386aba89220f7", + "gitnexus-shared/src/scope-resolution/registries/evidence.ts": "b40951e441460720e1ded5c400d07b66f2e4f46ab61e734dad07a0817ea733a2", + "gitnexus-shared/src/scope-resolution/registries/field-registry.ts": "7f6f879da23b802fb8c2556ca22d9269ec2ddc07b19bdc6d064123c3e35f222b", + "gitnexus-shared/src/scope-resolution/registries/lookup-core.ts": "1f9871447c6cf0c3090d1626f0b4ad543c52a30a863ef49d71b16506e92803c7", + "gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts": "0a59cbc7c5fb3d1dd117209994b01f9b8fa625fe9d7421c9aae2928ac42f0c52", + "gitnexus-shared/src/scope-resolution/registries/macro-registry.ts": "6051812b34519649d5fdbf7540da2b1cb71cd139ed29f673262ec2b76ed1b421", + "gitnexus-shared/src/scope-resolution/registries/method-registry.ts": "ece8e14b03376f1f5a7c88d035b80ddeb631996db4801e04022859c0ab42ef60", + "gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts": "61e86fe8abe75585fdbc3e6a2c5c3bb1935ca868bfc69ec5f046e63e451295ab", + "gitnexus-shared/src/scope-resolution/resolve-type-ref.ts": "087882b52ba380e8137d018e79b06d351a4407d381e0916748948c64148c949a", + "gitnexus-shared/src/scope-resolution/scope-id.ts": "9c942d4397ab620e335ed3b9b5458c30f328bdd6d1205b880600250a0c0bf743", + "gitnexus-shared/src/scope-resolution/scope-tree.ts": "66347a2b1bd61ff2089ace80c494747a87ec3cde2823019be693ef554acfb536", + "gitnexus/src/core/ingestion/model/symbol-table.ts": "773f44217a3805098c07157eab87f1a11be1a685b5d050b5a668b32073843005", + "gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts": "b94d6612a30ec6314791f0bb2fc28612fb4b152e6ab430c7fabf837f5833ee80", + "gitnexus/src/core/ingestion/utils/callable-labels.ts": "a81d5b15bb810632d7f04550f5203ae6e15fa0c00c9b1a2a50cf299e5988dcb8", + "gitnexus/src/core/ingestion/utils/method-props.ts": "827ede5ea1f9fb79bbbe85ad51064a36d74d1c3d01243658a904f777d97c1fa6", + "gitnexus/src/core/ingestion/utils/template-arguments.ts": "1cd981b2666c68e3215df9f3b29ea5edefce6bab85b5c5d2afea939404ffcbda", + "gitnexus/src/core/lbug/rel-pair-routing.ts": "83a6d374d129eaa2685a3e98c94d8da24818321e6f86626727af00f2a468741e", + "gitnexus/src/core/lbug/schema.ts": "289aa7b015e5c9a40fe5a58b7cef06c9bdb2d9165373e3ecae0ffe0e9f46dfdf", + "gitnexus/src/lib/utils.ts": "5c0f6b00074f239adf99d4a760a62efba80b6ba6ff309de616c99d0a36d09276", + "gitnexus/test/unit/schema.test.ts": "b077372fe9e016c4488840f8f1079aa7234ccd28ab000cc5822a3bf77da763ba" + }, + "vitestConfig": { + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "mutationConfig": { + "gitnexus-shared/package.json": "a0100a5b0858d80622846788097a2e596723fdf2b86602ffd93d1076f82dee13", + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/scope-file.ts": "2582a4071c46d7f412013cd7036bd5fbbd46d341e0f268a8fe4a93ceeaa69195", + "gitnexus/tools/mutation/vitest.mutation.config.ts": "9e234252881e0cd5347a3f413320135c59226ac8923465a57f58290079906585", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "prepareTool": { + "gitnexus/tools/mutation/prepare/blank-assertions.ts": "895eb697d123cbd915543be411945055655e39963e6abb88e221ca3b570939f1", + "gitnexus/tools/mutation/prepare/mutant-identity.ts": "8887c9290cca144b6e547f5f0b74bc593a9e56ba989598c09cd573f787db2943", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/prepare/prepare.ts": "a8a703718cdb99ac9af6347009a068de26b199b976b9806061f031a2982ba0bf", + "gitnexus/tools/mutation/prepare/span-map-check.ts": "30b768d4386631ebeb9547ca624162a034dc8fdb0bb7cbe4bbca8a86b52fa4b4" + } + }, + "sample": { + "seed": "u7-gate3-2", + "method": "sha256(seed + \"\\n\" + globalId) ascending; the size lowest ranks, then strata top-ups", + "size": 100, + "topUpMin": 3, + "random": 72, + "topUps": [] + }, + "strata": [ + { + "name": "static", + "construct": null, + "constructPresent": null, + "population": 72, + "sampled": 72, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "hook-hit", + "construct": "beforeAll", + "constructPresent": false, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "construct-absent" + }, + { + "name": "multi-killer", + "construct": null, + "constructPresent": null, + "population": 5, + "sampled": 5, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "survived", + "construct": null, + "constructPresent": null, + "population": 22, + "sampled": 22, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "no-coverage", + "construct": null, + "constructPresent": null, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "empty-population" + }, + { + "name": "each-row", + "construct": "each", + "constructPresent": true, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 9, + "required": false, + "status": "empty-population" + } + ], + "baseline": { + "tests": 42, + "passedAlone": 42 + }, + "tests": [ + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > includes all core node types", + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > includes multi-language node types", + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > includes the BasicBlock taint/PDG substrate node (issue #2080)", + "test/unit/schema.test.ts#LadybugDB Schema > NODE_TABLES > has expected total count", + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes all expected relationship types", + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes the taint/PDG substrate edge types (issue #2080)", + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes the control-dependence edge types (issue #2085 M5)", + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes the DI collection-injection edge type (#2200)", + "test/unit/schema.test.ts#LadybugDB Schema > REL_TYPES > includes Spring condition and auto-configuration edge types (#2415)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > FILE_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE File (\n id STRING,\n name STRING,\n filePath STRING,\n content STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > FOLDER_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Folder (\n id STRING,\n name STRING,\n filePath STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > FUNCTION_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Function (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n convexEndpointFactory STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > CLASS_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Class (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n frameworkAnnotations STRING[],\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > INTERFACE_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Interface (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > METHOD_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Method (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n parameterCount INT32,\n returnType STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > CODE_ELEMENT_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE CodeElement (\n id STRING,\n name STRING,\n filePath STRING,\n startLine INT64,\n endLine INT64,\n isExported BOOLEAN,\n content STRING,\n description STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > COMMUNITY_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Community (\n id STRING,\n label STRING,\n heuristicLabel STRING,\n keywords STRING[],\n description STRING,\n enrichedBy STRING,\n cohesion DOUBLE,\n symbolCount INT32,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > PROCESS_SCHEMA contains CREATE NODE TABLE for \nCREATE NODE TABLE Process (\n id STRING,\n label STRING,\n heuristicLabel STRING,\n processType STRING,\n stepCount INT32,\n communities STRING[],\n entryPointId STRING,\n terminalId STRING,\n PRIMARY KEY (id)\n)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Function schema has startLine and endLine", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Function schema has isExported", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Property schema preserves declaredType", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > BasicBlock schema is wired into SCHEMA_QUERIES (issue #2080, F1 guard)", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Community schema has heuristicLabel and cohesion", + "test/unit/schema.test.ts#LadybugDB Schema > node schema DDL > Process schema has processType and stepCount", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > creates a single REL TABLE named CodeRelation", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > has type, confidence, reason, step properties", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects Function to Function (CALLS)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects File to Function (CONTAINS/DEFINES)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects symbols to Community (MEMBER_OF)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects symbols to Process (STEP_IN_PROCESS)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > persists class-like framework edges to synthetic declarations (#2416)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > declares the Swift enum/property member-containment pairs (#2769)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > has all FROM/TO pairs needed for HAS_METHOD edges", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > has FROM/TO pairs for HAS_METHOD edges whose member is minted as Function, not Method (#2769)", + "test/unit/schema.test.ts#LadybugDB Schema > relation schema > has FROM/TO pairs for HAS_METHOD edges owned by a JS/TS object-literal binding (#2769)", + "test/unit/schema.test.ts#LadybugDB Schema > embedding schema > creates CodeEmbedding table", + "test/unit/schema.test.ts#LadybugDB Schema > embedding schema > has vector index query", + "test/unit/schema.test.ts#LadybugDB Schema > schema query ordering > NODE_SCHEMA_QUERIES has correct count", + "test/unit/schema.test.ts#LadybugDB Schema > schema query ordering > REL_SCHEMA_QUERIES has one relation table", + "test/unit/schema.test.ts#LadybugDB Schema > schema query ordering > SCHEMA_QUERIES includes all node + rel + embedding schemas", + "test/unit/schema.test.ts#LadybugDB Schema > schema query ordering > node schemas come before relation schemas in SCHEMA_QUERIES" + ], + "notOracleValidated": [], + "mutants": [ + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:100:37-100:52:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:11:28-49:2:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:12:3-12:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:13:3-13:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:14:3-14:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:15:3-15:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:16:3-16:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:17:3-17:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:18:3-18:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:19:3-19:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:20:3-20:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:21:3-21:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:22:3-22:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:23:3-23:12:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:24:3-24:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:25:3-25:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:26:3-26:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:27:3-27:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:28:3-28:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:29:3-29:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:30:3-30:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:31:3-31:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:32:3-32:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:33:3-33:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:34:3-34:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:35:3-35:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:36:3-36:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:37:3-37:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:38:3-38:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:39:3-39:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:40:3-40:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:41:3-41:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:42:3-42:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:43:3-43:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:44:3-44:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:46:3-46:16:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:48:3-48:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:53:31-53:45:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:55:26-96:2:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:56:3-56:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:57:3-57:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:58:3-58:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:59:3-59:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:60:3-60:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:61:3-61:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:62:3-62:15:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:63:3-63:17:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:64:3-64:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:65:3-65:21:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:66:3-66:14:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:67:3-67:22:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:68:3-68:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:69:3-69:20:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:70:3-70:18:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:71:3-71:17:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:72:3-72:18:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:73:3-73:12:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:74:3-74:17:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:75:3-75:19:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:76:3-76:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:77:3-77:12:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:78:3-78:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:79:3-79:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:80:3-80:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:81:3-81:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:85:3-85:8:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:86:3-86:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:87:3-87:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:88:3-88:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:89:3-89:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:94:3-94:8:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:95:3-95:18:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"] + ], + "sampled": [ + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:80:3-80:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[8,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:13:3-13:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:18:3-18:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:70:3-70:18:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:64:3-64:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:25:3-25:9:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:30:3-30:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:65:3-65:21:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:20:3-20:16:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:26:3-26:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:86:3-86:17:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:23:3-23:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:22:3-22:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:62:3-62:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:17:3-17:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:57:3-57:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:94:3-94:8:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[6,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:63:3-63:17:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:69:3-69:20:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:56:3-56:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:72:3-72:18:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:60:3-60:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:48:3-48:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[2,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:85:3-85:8:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:24:3-24:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:16:3-16:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:28:3-28:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:35:3-35:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:39:3-39:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:68:3-68:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:81:3-81:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[8,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:19:3-19:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","multi-killer"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"],[29,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:11:28-49:2:ArrayDeclaration:4f53cda18c2baa0c", + "strata": ["static","multi-killer"], + "covering": 42, + "outcomes": [ + [0,"killed-by-test:assertion"], + [1,"killed-by-test:assertion"], + [2,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [28,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:33:3-33:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:38:3-38:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:89:3-89:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:14:3-14:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","multi-killer"], + "covering": 42, + "outcomes": [ + [0,"killed-by-test:assertion"], + [28,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:21:3-21:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:40:3-40:16:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:71:3-71:17:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:100:37-100:52:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:37:3-37:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:66:3-66:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:27:3-27:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:53:31-53:45:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:75:3-75:19:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:76:3-76:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:77:3-77:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:44:3-44:9:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:34:3-34:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:46:3-46:16:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:41:3-41:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:59:3-59:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:73:3-73:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:79:3-79:19:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[8,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:67:3-67:22:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:42:3-42:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:29:3-29:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:55:26-96:2:ArrayDeclaration:4f53cda18c2baa0c", + "strata": ["static","multi-killer"], + "covering": 42, + "outcomes": [ + [4,"killed-by-test:assertion"], + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:88:3-88:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:61:3-61:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:87:3-87:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[5,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:36:3-36:13:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:31:3-31:9:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:74:3-74:17:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:15:3-15:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","multi-killer"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"],[28,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:78:3-78:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[7,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:43:3-43:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 42, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:12:3-12:9:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[0,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:32:3-32:14:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[1,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:58:3-58:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[4,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus-shared/src/lbug/schema-constants.ts@edf2130f7f8f5f90:95:3-95:18:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 42, + "outcomes": [[6,"killed-by-test:assertion"]], + "invalid": [] + } + ], + "launches": { + "total": 3066, + "valid": 3024, + "invalid": 0, + "wallClockLimitMs": 180000 + }, + "warnings": [], + "problems": [] +} diff --git a/gitnexus/test/fixtures/mutation-oracle/shared.json b/gitnexus/test/fixtures/mutation-oracle/shared.json new file mode 100644 index 000000000..c0c4d3709 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-oracle/shared.json @@ -0,0 +1,2523 @@ +{ + "schemaVersion": 1, + "kind": "gitnexus-mutation-oracle-manifest", + "name": "shared", + "description": "Gate 3 (U7): the oracle's frozen outcomes for the shared scope: a seeded sample of mutants with strata top-ups, the oracle class of every mutant (U13 command run) and the per-test outcome of every sampled mutant with every test, each test launched alone (U12). Check a vitest5 runner run with `npm run test:mutation:oracle -- --scope test/fixtures/mutation-oracle/shared.json,` (from gitnexus/). When gate 3 reports \"re-freeze required\", run the refreeze command.", + "refreeze": "npm run test:mutation:oracle:freeze -- --from-manifest test/fixtures/mutation-oracle/shared.json --seed --work ", + "scope": { + "testFiles": [ + "test/unit/shared-type-extractors.test.ts", + "test/unit/extract-element-type-from-string.test.ts" + ], + "mutate": ["gitnexus/src/core/ingestion/type-extractors/shared.ts"] + }, + "versions": { + "stryker": "10.0.0", + "vitest": "5.0.5", + "nodeMajor": 22 + }, + "operators": { + "mutator": null, + "names": [ + "ArithmeticOperator", + "ArrayDeclaration", + "ArrowFunction", + "BlockStatement", + "BooleanLiteral", + "CallExpression", + "ConditionalExpression", + "EqualityOperator", + "LogicalOperator", + "MethodExpression", + "ObjectLiteral", + "OptionalChaining", + "Regex", + "StringLiteral", + "UnaryOperator", + "UpdateOperator" + ] + }, + "inputs": { + "closure": { + "gitnexus/src/core/ingestion/type-extractors/shared.ts": "02e17f5524a110b2333a818d160c25028c5d668277af65751ed7449ae671695c", + "gitnexus/test/unit/extract-element-type-from-string.test.ts": "ac4e2e3d83dd6237d0ccf8423b8d7f18f3c5cf5cb95b23dba0da44ae19d1ff74", + "gitnexus/test/unit/shared-type-extractors.test.ts": "2712731e018d4409dfc198ff02a84e76fb39311764bc1e76f92c764c7dfd1a2f" + }, + "vitestConfig": { + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "mutationConfig": { + "gitnexus-shared/package.json": "a0100a5b0858d80622846788097a2e596723fdf2b86602ffd93d1076f82dee13", + "gitnexus/test/helpers/perf-sequencer.ts": "3aeeedffc8cf940af43c54a28c142da859d78eded2c2c7881b8094175dca94f3", + "gitnexus/test/helpers/shard-balance.ts": "5f27cc347b560e40dd6e03d7c299afaddf02975964476187ab97a2ade816c140", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/scope-file.ts": "2582a4071c46d7f412013cd7036bd5fbbd46d341e0f268a8fe4a93ceeaa69195", + "gitnexus/tools/mutation/vitest.mutation.config.ts": "9e234252881e0cd5347a3f413320135c59226ac8923465a57f58290079906585", + "gitnexus/vitest.config.ts": "bea4e1ccad48d4742325095b53b5819a480671f01a753d5cbcc65deb2fc0e609" + }, + "prepareTool": { + "gitnexus/tools/mutation/prepare/blank-assertions.ts": "895eb697d123cbd915543be411945055655e39963e6abb88e221ca3b570939f1", + "gitnexus/tools/mutation/prepare/mutant-identity.ts": "8887c9290cca144b6e547f5f0b74bc593a9e56ba989598c09cd573f787db2943", + "gitnexus/tools/mutation/prepare/path-map.ts": "d595cbebc2058cfbf09597b0889ae7f7cf10f46ccbc2e2900ed063fdacb5dbe7", + "gitnexus/tools/mutation/prepare/prepare.ts": "a8a703718cdb99ac9af6347009a068de26b199b976b9806061f031a2982ba0bf", + "gitnexus/tools/mutation/prepare/span-map-check.ts": "30b768d4386631ebeb9547ca624162a034dc8fdb0bb7cbe4bbca8a86b52fa4b4" + } + }, + "sample": { + "seed": "u7-gate3-1", + "method": "sha256(seed + \"\\n\" + globalId) ascending; the size lowest ranks, then strata top-ups", + "size": 100, + "topUpMin": 3, + "random": 100, + "topUps": [] + }, + "strata": [ + { + "name": "static", + "construct": null, + "constructPresent": null, + "population": 245, + "sampled": 15, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "hook-hit", + "construct": "beforeAll", + "constructPresent": false, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "construct-absent" + }, + { + "name": "multi-killer", + "construct": null, + "constructPresent": null, + "population": 268, + "sampled": 22, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "survived", + "construct": null, + "constructPresent": null, + "population": 292, + "sampled": 21, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "no-coverage", + "construct": null, + "constructPresent": null, + "population": 549, + "sampled": 49, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "combined" + }, + { + "name": "each-row", + "construct": "each", + "constructPresent": false, + "population": 0, + "sampled": 0, + "toppedUp": 0, + "affectedTests": 0, + "required": false, + "status": "construct-absent" + } + ], + "baseline": { + "tests": 143, + "passedAlone": 143 + }, + "tests": [ + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > array suffix (TypeScript / Java / C#) > User[] → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > array suffix (TypeScript / Java / C#) > string[] → string", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > array suffix (TypeScript / Java / C#) > int[] → int", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Go slice prefix > []User → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Go slice prefix > []string → string", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Swift array sugar > [User] → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Swift array sugar > [String] → String", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > Array → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > Vec → User (Rust)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > vector → User (C++)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > Set → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > List → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > generic angle-bracket containers > IEnumerable → User (C#)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Python subscript-style generics > List[User] → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > Python subscript-style generics > Set[User] → User", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Map → User (default: last/value arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Map → String (pos=first: key arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Map> → undefined (last arg is nested generic)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Map> → String (pos=first: key arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Dict[str, User] → User (default: last/value arg, Python)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > multi-argument generics — default returns last (value) arg > Dict[str, User] → str (pos=first: key arg, Python)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > nested generics as element type — returns undefined > Array> → undefined (element is itself generic)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > nested generics as element type — returns undefined > Vec> → undefined (element is itself generic)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Dict[str, List[int]] → undefined (default: last arg is nested generic)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Dict[str, List[int]] → str (pos=first: key arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Map> → undefined (default: last arg is nested generic)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > Map> → String (pos=first: key arg)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > cross-bracket nesting (bracket depth fix) > mismatched close bracket at depth 0 → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > empty string → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > plain type name (no container) → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > bare angle bracket with no close → undefined (malformed)", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > bare [] prefix with spaces only → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > empty array suffix → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > [] suffix with no base → undefined", + "test/unit/extract-element-type-from-string.test.ts#extractElementTypeFromString > edge cases — return undefined > empty Swift sugar [] → undefined", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips array suffix: User[] → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips array suffix for primitive: string[] → string", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for just brackets: []", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined when base is not a simple word: user model[]", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips Go slice prefix: []User → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > strips Go slice prefix for primitive: []string → string", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps Swift array sugar: [User] → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Array → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: C++ vector → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps single-arg generic angle: Rust Vec → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > unwraps Python subscript: List[User] → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns last arg by default for Map: → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns first arg with pos=first for Map: → String", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns last arg explicitly for Map: → User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for nested generics: List>", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for empty string", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for string longer than 2048 chars", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined at exactly the 2048-char limit", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for malformed brackets: Map returns undefined for just closing angle brackets: >", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined when no brackets are present: User", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns undefined for complex non-word base before []: user model[]", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > does not treat [User] as Swift if it contains angle bracket (impossible case sanity-check)", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > returns same result for Vec with pos=first", + "test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > handles whitespace in generic args: Array< User > → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > strips \"| null\": User | null → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > strips \"| undefined\": User | undefined → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > strips both \"| null | undefined\": User | null | undefined → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > strips nullable suffix: User? → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for genuine union: User | Repo", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"null\"", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"undefined\"", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"None\"", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"nil\"", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for bare \"void\"", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for empty string", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined for whitespace-only string", + "test/unit/shared-type-extractors.test.ts#stripNullable > trims surrounding whitespace: \" User \" → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns the type name unchanged when no nullable markers: User → User", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns undefined when the union is all nullable keywords: null | undefined", + "test/unit/shared-type-extractors.test.ts#stripNullable > returns \"User?\" when ? is embedded before pipe: User? | null", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns simple type: User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > unwraps Promise: Promise → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > unwraps Option: Option → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > unwraps Result (first arg): Result → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips nullable union: User | null → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips nullable suffix: User? → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips Go pointer prefix: *User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips Rust reference prefix: &User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips Rust mutable reference prefix: &mut User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns base type for non-wrapper generic: List → List", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > extracts last segment of dot-qualified name: models.User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > extracts last segment of PHP namespace: \\App\\Models\\User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > extracts last segment of Rust path: models::User → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"string\"", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"int\"", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"boolean\"", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for primitive \"void\"", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for lowercase identifier: user", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for empty string", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for bare wrapper without type arg: Promise", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for bare wrapper: Option", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > recursively unwraps nested wrappers: Future> → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > unwraps Rc: Rc → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > unwraps Arc: Arc → User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for input longer than 2048 chars", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined when depth > 10", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > skips Rust lifetime parameters in Result: Result<'_, User, Error> is unusual but lifetime in first pos is skipped", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > skips Rust anonymous lifetime: Result<'_, User>", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > returns undefined for genuine union: User | Repo", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > accepts underscore-start identifier: _User", + "test/unit/shared-type-extractors.test.ts#extractReturnTypeName > strips multiple pointer sigils: **User → User", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for known single-element container method: get on Array", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for iter on Vec (single-element)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keys on Map", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for values on Map", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for get on Map (value method)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keySet on LinkedHashMap (Java keyMethods)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keySet on HashMap (Java keyMethods via STD_KEY_METHODS — note: HashMap uses STD_KEY_METHODS, not JAVA_KEY_METHODS)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keySet on TreeMap (JAVA_KEY_METHODS)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for Keys on Dictionary (C# key method)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for Values on Dictionary (C# value method)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keys on unknown container: keys on MyMap", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for get on unknown container: get on MyMap", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keySet on unknown container (fallback heuristic)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for Keys on unknown container (fallback heuristic)", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last when method and container are both undefined", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last when method is undefined and container is known map", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for unknown method on unknown container", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns last for non-key method with no container", + "test/unit/shared-type-extractors.test.ts#methodToTypeArgPosition > returns first for keys with no container", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 2 for Map", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for Map has keys in keyMethods", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for Map has get in valueMethods", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 2 for HashMap", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 2 for Dictionary (C#)", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 1 for Vec", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for Vec has empty keyMethods", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for Vec has get in valueMethods", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 1 for Array", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor with arity 1 for List", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns undefined for unknown type: FooBar", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns undefined for empty string", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns undefined for lowercase unknown: mycontainer", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor for lowercase list (Python)", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > returns descriptor for lowercase dict (Python)", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for TreeMap has keySet in keyMethods", + "test/unit/shared-type-extractors.test.ts#getContainerDescriptor > descriptor for ConcurrentHashMap has keySet in keyMethods" + ], + "notOracleValidated": [], + "mutants": [ + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:100:28-100:99:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:100:3-100:100:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:100:4-100:26:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:101:19-101:90:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:101:3-101:91:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:101:4-101:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:102:17-102:88:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:102:3-102:89:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:102:4-102:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:103:14-103:85:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:103:3-103:86:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:103:4-103:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:104:19-104:90:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:104:3-104:91:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:104:4-104:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:105:18-105:89:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:105:3-105:90:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:105:4-105:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:106:21-106:92:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:106:3-106:93:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:106:4-106:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:107:18-107:89:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:107:3-107:90:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:107:4-107:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:108:21-108:92:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:108:3-108:93:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:108:4-108:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:109:18-109:92:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:109:3-109:93:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:109:4-109:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:110:12-110:83:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:110:3-110:84:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:110:4-110:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:111:11-111:82:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:111:3-111:83:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:111:4-111:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:112:13-112:84:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:112:3-112:85:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:112:4-112:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:113:17-113:88:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:113:3-113:89:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:113:4-113:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:127:20-144:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:128:26-138:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:128:7-128:24:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:128:7-128:24:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:130:15-137:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:130:9-130:13:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:130:9-130:13:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:132:11-132:27:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:132:11-132:27:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:132:11-132:27:EqualityOperator:47a98ac6fc6e0b02","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:132:36-132:42:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:134:11-134:56:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:134:11-134:56:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:134:11-134:56:LogicalOperator:899f84ad05e94cc6","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:134:65-134:72:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:136:14-136:20:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:43:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:43:EqualityOperator:3f170004088c39a0","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:70:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:70:LogicalOperator:010f1169757921e4","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:95:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:22-140:95:LogicalOperator:09136fddc18e8fa9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:37-140:43:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:47-140:70:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:47-140:70:EqualityOperator:6988c28c14ea93a0","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:62-140:70:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:7-140:96:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:7-140:96:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:7-140:96:LogicalOperator:96a869b64b8d22e7","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:74-140:95:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:74-140:95:EqualityOperator:dd7f6528404e0624","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:89-140:95:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:140:98-142:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:141:12-141:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:143:10-143:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:147:91-149:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:176:33-176:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:177:23-195:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:180:30-180:57:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:180:5-181:79:LogicalOperator:8e9a089ac7f11468","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:16-181:18:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:46-181:65:StringLiteral:059b850298ae3352","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:6-181:18:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:6-181:18:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:6-181:18:EqualityOperator:5fd9f7fdbe3b2578","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:182:17-185:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:182:7-182:15:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:182:7-182:15:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:184:9-184:10:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:184:9-184:10:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:188:21-191:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:188:7-188:19:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:188:7-188:19:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:190:9-190:11:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:190:9-190:11:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:193:7-193:27:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:193:7-193:27:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:201:40-205:2:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:202:3-202:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:203:3-203:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:204:3-204:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:213:95-349:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:21-214:48:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:21-214:48:EqualityOperator:89ac6aa4da460c64","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:21-214:48:EqualityOperator:a3b479651070930e","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:17:EqualityOperator:74af5652310846f6","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:17:EqualityOperator:7d67626a419b4bbe","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:48:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:48:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:214:7-214:48:LogicalOperator:ef4d323e70afd984","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:23-217:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-217:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-217:40:EqualityOperator:fe970fc74137a405","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-218:35:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-218:35:LogicalOperator:e9db1f1ea266246b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-219:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-219:42:LogicalOperator:0604709fe77e803c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-220:33:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-220:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-220:33:LogicalOperator:5c9a68f0e3ce632f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:218:23-218:35:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:218:5-218:35:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:218:5-218:35:EqualityOperator:1265180a051fc4df","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:219:23-219:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:219:5-219:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:219:5-219:42:EqualityOperator:ec3b5a725305f1a3","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:220:23-220:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:220:5-220:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:220:5-220:33:EqualityOperator:60be9a38bdd50f6f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:221:5-223:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:23-227:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-227:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-227:42:EqualityOperator:6f01a568cea11bb3","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-228:45:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-228:45:LogicalOperator:78f07391708568bd","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-229:47:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-229:47:LogicalOperator:d0733494b0e56196","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-230:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-230:39:LogicalOperator:48ddcff22f04a33d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-231:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-231:39:LogicalOperator:d3d6ba801dbc13fb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-232:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-232:42:LogicalOperator:b755ee0555032c79","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-233:49:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-233:49:LogicalOperator:32cbb0a68e485b71","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-234:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-234:34:LogicalOperator:520bc4bfecf87b68","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-235:41:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-235:41:LogicalOperator:a198bee7bc215b90","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-236:44:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-236:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-236:44:LogicalOperator:3a848d1dfe671284","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:228:23-228:45:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:228:5-228:45:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:228:5-228:45:EqualityOperator:ebf34bcba8f143b6","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:229:23-229:47:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:229:5-229:47:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:229:5-229:47:EqualityOperator:a97944e7b1c5ceb5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:230:23-230:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:230:5-230:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:230:5-230:39:EqualityOperator:7fe12665e1bac464","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:231:23-231:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:231:5-231:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:231:5-231:39:EqualityOperator:32f3299a99cd723e","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:232:23-232:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:232:5-232:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:232:5-232:42:EqualityOperator:8d4d50ad55800016","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:233:23-233:49:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:233:5-233:49:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:233:5-233:49:EqualityOperator:1c61b47622d4b2c8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:234:23-234:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:234:5-234:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:234:5-234:34:EqualityOperator:eb5996c4f992cc57","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:235:23-235:41:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:235:5-235:41:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:235:5-235:41:EqualityOperator:f9f37d23152119bf","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:236:23-236:44:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:236:5-236:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:236:5-236:44:EqualityOperator:bcf587d1432b21be","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:237:5-251:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:240:7-247:42:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:240:7-247:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:240:7-247:42:LogicalOperator:919adb81984bfdb3","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:22-241:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-241:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-241:39:EqualityOperator:b55592b6439aa184","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-242:35:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-242:35:LogicalOperator:f8b410290876e026","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-243:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-243:42:LogicalOperator:8d917baa0ce81e79","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-244:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-244:29:LogicalOperator:4facc5072aa7a86b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-245:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-245:33:LogicalOperator:926c1a31bab7772c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-246:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-246:44:LogicalOperator:f213309936fa9559","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-247:41:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-247:41:LogicalOperator:4e738e8d4ed28c81","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:242:23-242:35:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:242:9-242:35:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:242:9-242:35:EqualityOperator:8d11de6804029bf7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:243:23-243:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:243:9-243:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:243:9-243:42:EqualityOperator:414772d90403a08d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:244:23-244:29:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:244:9-244:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:244:9-244:29:EqualityOperator:fcc87beb370d656b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:245:23-245:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:245:9-245:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:245:9-245:33:EqualityOperator:cc65a29dff9104f5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:246:23-246:44:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:246:9-246:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:246:9-246:44:EqualityOperator:863ecb82fb1b6362","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:247:23-247:41:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:247:9-247:41:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:247:9-247:41:EqualityOperator:e72b5a3e2872413d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:248:7-250:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:254:25-254:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:254:42-257:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:254:7-254:40:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:254:7-254:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:254:7-254:40:EqualityOperator:a2f8b0e2d8661bbb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:255:18-255:80:LogicalOperator:07f6d2fc3f0d671b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:255:45-255:51:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:256:50-256:59:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:256:9-256:13:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:256:9-256:13:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:25-261:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:43-261:75:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:43-261:75:EqualityOperator:6cabcab3959532ba","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:61-261:75:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:7-261:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:7-261:39:EqualityOperator:9df7d81f4f74e68f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:7-261:75:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:7-261:75:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:7-261:75:LogicalOperator:31257c8e6a831f90","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:77-274:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:263:34-263:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:263:7-264:41:LogicalOperator:355846eb73beaf30","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:263:7-265:31:LogicalOperator:4faac390d39801a1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:264:34-264:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:266:9-266:14:BooleanLiteral:cae662172fd450bb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:266:9-266:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:266:9-266:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:267:50-267:59:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:269:59-272:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:269:9-269:57:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:269:9-269:57:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:269:9-269:57:LogicalOperator:e9f18bb64bbf0cd0","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:271:11-271:27:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:271:11-271:27:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:271:11-271:27:EqualityOperator:91165a06843f80dd","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:271:11-271:27:EqualityOperator:b176d896195290df","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:277:25-277:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:277:42-280:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:277:7-277:40:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:277:7-277:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:277:7-277:40:EqualityOperator:792125d7c6f1b005","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:279:52-279:61:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:279:9-279:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:279:9-279:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:25-284:37:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:39-298:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:7-284:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:7-284:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:7-284:37:EqualityOperator:bbd5d7dee3164f0e","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:285:40-285:42:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:21-286:49:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:21-286:49:EqualityOperator:204c24a9e6ea91bf","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:21-286:49:EqualityOperator:501d1b862a8c1a1d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:51-286:54:UpdateOperator:ed8688c690933851","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:56-293:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:288:11-288:17:BooleanLiteral:ddc9e669194254ce","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:288:11-288:17:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:288:11-288:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:26:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:26:EqualityOperator:501c468817036186","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:50:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:50:LogicalOperator:cf63d70cd9992cd8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:69:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:69:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:69:LogicalOperator:d8495b4503e1827d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:20-291:26:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:30-291:50:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:30-291:50:EqualityOperator:7a541431b7c7ce7a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:39-291:50:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:54-291:69:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:54-291:69:EqualityOperator:8513f99160a7fa65","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:63-291:69:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:292:7-292:32:CallExpression:41b805ea7ac014e2","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:295:36-297:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:295:9-295:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:295:9-295:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:295:9-295:34:EqualityOperator:7f4e2cabb327a64f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:296:53-296:62:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:23-302:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-302:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-302:40:EqualityOperator:4cdd2394f1c909f1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-303:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-303:29:LogicalOperator:42d339d65e23b400","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-304:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-304:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:302:5-304:34:LogicalOperator:81eaa1ad0e7ed0c1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:303:23-303:29:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:303:5-303:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:303:5-303:29:EqualityOperator:789766ddb856ac87","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:304:23-304:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:304:5-304:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:304:5-304:34:EqualityOperator:38e9f7619efbfcdc","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:305:5-308:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:307:52-307:61:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:307:9-307:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:307:9-307:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:25-311:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:43-311:77:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:43-311:77:EqualityOperator:012a440ab63c1167","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:61-311:77:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:7-311:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:7-311:39:EqualityOperator:37406888c35a1278","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:7-311:77:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:7-311:77:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:7-311:77:LogicalOperator:280944c08e03c6a9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:311:79-320:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:314:21-314:49:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:314:21-314:49:EqualityOperator:204c24a9e6ea91bf","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:314:21-314:49:EqualityOperator:501d1b862a8c1a1d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:314:51-314:54:UpdateOperator:ed8688c690933851","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:314:56-319:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:11-316:54:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:11-316:54:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:11-316:54:LogicalOperator:72ed837ab1085218","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:20-316:54:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:20-316:54:EqualityOperator:b75a5946c814bb0d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:35-316:54:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:56-318:8:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:317:45-317:54:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:31:54-31:62:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:31:55-31:61:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:23-327:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-327:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-327:39:EqualityOperator:1b2bf758a1fb5772","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-328:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-328:40:LogicalOperator:ef2571c1947a5853","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-329:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-329:38:LogicalOperator:501748ce9365a272","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-330:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-330:44:LogicalOperator:e8471122f4507e90","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-331:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-331:37:LogicalOperator:5da0a3755bbb9379","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-332:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-332:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:327:5-332:34:LogicalOperator:3de290dd4e8adb4f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:328:23-328:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:328:5-328:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:328:5-328:40:EqualityOperator:07e2ce4aa77e8f58","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:329:23-329:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:329:5-329:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:329:5-329:38:EqualityOperator:35a4ad3bb80e9d99","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:32:55-32:65:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:32:56-32:64:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:330:23-330:44:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:330:5-330:44:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:330:5-330:44:EqualityOperator:fe049090f8aa1cb7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:331:23-331:37:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:331:5-331:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:331:5-331:37:EqualityOperator:92ae14460b511aed","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:332:23-332:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:332:5-332:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:332:5-332:34:EqualityOperator:ab28557047d7c897","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:333:5-335:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:25-338:37:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:41-338:74:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:41-338:74:EqualityOperator:2971edc0c750d86d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:59-338:74:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:37:EqualityOperator:65d17d601e1eb000","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:74:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:74:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:74:LogicalOperator:d7d6251f8fac4fce","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:76-341:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:339:19-339:81:LogicalOperator:07f6d2fc3f0d671b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:339:46-339:52:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:33:57-33:65:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:33:58-33:64:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:340:52-340:61:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:340:9-340:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:340:9-340:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:344:25-344:31:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:344:33-346:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:344:7-344:31:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:344:7-344:31:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:344:7-344:31:EqualityOperator:cf97039d7dd3de36","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:355:73-382:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:19-357:31:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-357:31:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-357:31:EqualityOperator:1dbbdc1939cd2bd9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-358:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-358:38:LogicalOperator:fe7e45e41af953f1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-359:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-359:34:LogicalOperator:c101a9d6cc20c1eb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-360:25:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-360:25:LogicalOperator:451561f2f4b10cda","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-361:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-361:29:LogicalOperator:5f8ace4013a430e7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-362:40:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-362:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-362:40:LogicalOperator:1927b3f1eff3a93c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:358:19-358:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:358:5-358:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:358:5-358:38:EqualityOperator:a2916e00cf02afd0","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:359:19-359:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:359:5-359:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:359:5-359:34:EqualityOperator:bef1697fafaf665a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:360:19-360:25:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:360:5-360:25:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:360:5-360:25:EqualityOperator:3606cd9b2371902c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:361:19-361:29:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:361:5-361:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:361:5-361:29:EqualityOperator:a3869503af118e31","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:362:19-362:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:362:5-362:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:362:5-362:40:EqualityOperator:7706387f26e7fec9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:363:5-365:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:21-367:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:44-370:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:7-367:42:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:7-367:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:7-367:42:EqualityOperator:38431e5f123a2140","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:368:46-368:52:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:369:9-369:18:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:369:9-369:18:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:56-36:90:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:57-36:65:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:67-36:72:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:74-36:79:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:81-36:89:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:21-372:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:36-375:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:7-372:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:7-372:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:7-372:34:EqualityOperator:25d6a0d1cf976058","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:374:9-374:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:374:9-374:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:21-377:30:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:32-380:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:7-377:30:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:7-377:30:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:7-377:30:EqualityOperator:314ba241fea22ad4","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:379:9-379:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:379:9-379:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:37:59-37:84:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:37:60-37:68:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:37:70-37:83:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:385:46-395:2:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:386:3-386:23:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:387:3-387:23:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:388:3-388:21:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:389:3-389:14:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:38:61-51:2:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:390:3-390:20:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:391:3-391:26:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:392:3-392:21:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:393:3-393:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:394:3-394:22:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:39:3-39:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:40:3-40:14:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:415:86-483:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:26-416:28:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:7-416:17:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:7-416:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:7-416:17:EqualityOperator:74af5652310846f6","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:7-416:17:EqualityOperator:7d67626a419b4bbe","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:41:3-41:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:23-423:40:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-423:40:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-423:40:EqualityOperator:4cdd2394f1c909f1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-424:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-424:29:LogicalOperator:42d339d65e23b400","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-425:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-425:38:LogicalOperator:185b5528fb698a95","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-426:38:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-426:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:423:5-426:38:LogicalOperator:d8e965225621afed","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:424:23-424:29:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:424:5-424:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:424:5-424:29:EqualityOperator:789766ddb856ac87","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:425:23-425:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:425:5-425:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:425:5-425:38:EqualityOperator:792125d7c6f1b005","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:426:23-426:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:426:5-426:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:426:5-426:38:EqualityOperator:2971edc0c750d86d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:427:5-431:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:429:53-429:62:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:429:9-429:14:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:429:9-429:14:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:42:3-42:8:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:430:12-430:14:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:23-439:37:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-439:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-439:37:EqualityOperator:a09f23c9d7d30c39","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-440:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-440:37:LogicalOperator:760006c4604d4e01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-441:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-441:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-441:34:LogicalOperator:71e4a049dc70cff0","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:43:3-43:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:440:23-440:37:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:440:5-440:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:440:5-440:37:EqualityOperator:073f6a09c3a43a42","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:441:23-441:34:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:441:5-441:34:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:441:5-441:34:EqualityOperator:d946faa4cef1b06a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:442:5-444:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:443:12-443:14:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:448:19-448:47:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:448:19-448:47:EqualityOperator:204c24a9e6ea91bf","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:448:19-448:47:EqualityOperator:501d1b862a8c1a1d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:448:49-448:52:UpdateOperator:ed8688c690933851","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:448:54-454:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:44:3-44:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:19-450:50:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:19-450:50:EqualityOperator:892e29f9a579444a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:19-450:89:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:19-450:89:LogicalOperator:83fde515dd3a4497","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:34-450:50:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:54-450:89:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:54-450:89:EqualityOperator:33626ec608da0e9f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:69-450:89:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:9-450:90:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:9-450:90:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:9-450:90:LogicalOperator:24593d7507b1ba75","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:450:92-453:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:455:18-465:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:455:7-455:16:BooleanLiteral:b44da43b25673733","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:455:7-455:16:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:455:7-455:16:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:45:3-45:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:460:27-460:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:460:40-463:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:460:9-460:38:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:460:9-460:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:460:9-460:38:EqualityOperator:38e9f7619efbfcdc","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:462:52-462:61:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:462:65-462:67:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:464:12-464:14:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:467:28-467:30:ArrayDeclaration:f1a54ff5e3ef9098","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:468:19-468:47:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:468:19-468:47:EqualityOperator:022390e105ee6b26","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:468:19-468:47:EqualityOperator:80a866e797a83216","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:468:49-468:52:UpdateOperator:ed8688c690933851","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:468:54-480:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:46:3-46:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:470:9-470:17:BooleanLiteral:8850e702e4475ef5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:470:9-470:17:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:470:9-470:17:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:26-473:43:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:45-476:6:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:9-473:43:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:9-473:43:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:9-473:43:EqualityOperator:35dd795d06da8c49","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:475:11-475:19:BooleanLiteral:8850e702e4475ef5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:475:11-475:19:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:475:11-475:19:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:479:15-479:33:CallExpression:41b805ea7ac014e2","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:479:9-479:13:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:479:9-479:13:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:47:3-47:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:48:3-48:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:492:59-515:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:493:21-493:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:493:7-493:33:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:493:7-493:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:493:7-493:33:EqualityOperator:969d169cbce7c6a2","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:494:39-494:45:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:495:40-495:47:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:16-496:22:BooleanLiteral:27042f4e6eca7d0b","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:7-496:12:BooleanLiteral:360f84035942243c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:7-496:22:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:7-496:22:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:7-496:22:LogicalOperator:9fa3a7da1bb6d4c8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:21-497:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:37-497:61:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:37-497:61:EqualityOperator:5106b1dac2de33c3","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:51-497:61:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:7-497:33:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:7-497:33:EqualityOperator:09b9bb10c8ad25c5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:7-497:61:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:7-497:61:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:7-497:61:LogicalOperator:320a1c5f2c3f9243","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:498:22-498:28:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:498:7-498:28:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:498:7-498:28:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:498:7-498:28:EqualityOperator:8f27678021cb8c54","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:499:42-499:50:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:49:3-49:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:18-500:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:18-500:39:EqualityOperator:08e03ba75754b96a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:34-500:39:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:7-500:14:BooleanLiteral:5b7e6bf2dc4a32a6","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:7-500:39:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:7-500:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:7-500:39:LogicalOperator:12bf9f79da8f96b7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:501:44-501:54:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:502:7-502:16:BooleanLiteral:81bae876b70513c9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:502:7-502:16:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:502:7-502:16:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:504:25-504:35:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:504:37-506:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:504:7-504:35:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:504:7-504:35:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:504:7-504:35:EqualityOperator:165d9e6a2981a102","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:506:14-506:50:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:506:14-506:50:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:506:14-506:50:EqualityOperator:26110f73d85c8811","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:506:32-506:50:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:506:52-511:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:18-509:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:18-509:42:EqualityOperator:d4c4390cf01e03c8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:32-509:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:9-509:14:BooleanLiteral:3547cb112ac4489a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:9-509:42:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:9-509:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:9-509:42:LogicalOperator:15d2be139c7491d0","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:50:3-50:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:511:10-513:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:514:10-514:44:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:522:65-528:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:523:30-523:36:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:523:46-523:50:BooleanLiteral:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:523:7-523:37:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:523:7-523:37:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:19-524:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:19-524:38:EqualityOperator:756ff2dde3859e28","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:19-524:38:EqualityOperator:a4cacc0792cc26d7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:40-524:43:UpdateOperator:ed8688c690933851","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:45-526:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:33-525:50:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:59-525:63:BooleanLiteral:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:9-525:28:OptionalChaining:e35ee2342fcf8546","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:9-525:50:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:9-525:50:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:525:9-525:50:EqualityOperator:c7e8b49875d762f9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:527:10-527:15:BooleanLiteral:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:35-531:79:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:36-531:42:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:44-531:55:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:57-531:63:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:65-531:71:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:73-531:78:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:53:81-114:2:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:545:72-565:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:546:14-546:29:MethodExpression:9e0e0245e41c3314","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:547:7-547:12:BooleanLiteral:982d9e3eb996f559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:547:7-547:12:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:547:7-547:12:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:549:7-549:34:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:549:7-549:34:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:21-552:24:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:34-552:51:MethodExpression:982d9e3eb996f559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:34-552:58:MethodExpression:120e349cbf2a7225","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:48-552:50:UnaryOperator:c59dc4e44ff99288","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:7-552:25:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:7-552:25:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:552:7-552:25:MethodExpression:0e03b9ee7d750ed1","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:555:21-555:24:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:555:27-562:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:555:7-555:25:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:555:7-555:25:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:556:19-559:60:MethodExpression:16c687aaf4579f1a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:557:14-557:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:558:12-558:27:ArrowFunction:1193b7f146e45559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:558:19-558:27:MethodExpression:148de9c5a7a44d19","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:15-559:59:ArrowFunction:1193b7f146e45559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:30:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:30:EqualityOperator:464e540b0a4b9daf","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:59:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:59:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:59:LogicalOperator:5f35853230e32dd5","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:28-559:30:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:34-559:59:BooleanLiteral:17f0c95c28643ee8","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:55:11-55:85:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:55:3-55:86:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:55:4-55:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:560:9-560:27:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:560:9-560:27:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:560:9-560:27:EqualityOperator:0bbc3e08ac9a0da9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:564:10-564:27:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:564:10-564:27:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:564:10-564:27:LogicalOperator:0ca9a0016ff968c7","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:56:15-56:89:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:56:3-56:90:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:56:4-56:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:571:76-574:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:572:7-572:12:BooleanLiteral:545ea538461003ef","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:572:7-572:12:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:572:7-572:12:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:573:10-573:42:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:573:10-573:42:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:573:10-573:42:EqualityOperator:089a38d0fc759c9f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:573:24-573:42:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:57:15-57:89:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:57:3-57:90:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:57:4-57:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:580:80-584:2:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:581:16-581:82:LogicalOperator:9cc741ae34ca2b67","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:581:43-581:53:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:582:7-582:12:BooleanLiteral:f63827270b16435e","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:582:7-582:12:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:582:7-582:12:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:588:48-597:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:58:16-58:90:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:58:3-58:91:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:58:4-58:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:590:19-590:34:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:590:19-590:34:EqualityOperator:0d68fc63ea416a13","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:590:19-590:34:EqualityOperator:e75a2b83b1114eee","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:590:36-590:39:UpdateOperator:ed8688c690933851","timeout"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:590:41-595:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:16-592:19:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:23-592:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:23-592:33:EqualityOperator:ed6065c8ddb21db4","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:30-592:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:35-592:42:UpdateOperator:5a19487169469f70","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:9-592:19:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:9-592:19:EqualityOperator:f100d1d1b1dbf489","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:9-592:33:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:9-592:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:9-592:33:LogicalOperator:96c157160a2a19a5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:24:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:24:EqualityOperator:cccf537adc4f0204","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:38:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:38:LogicalOperator:cfc36dec7d1c26c6","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:21-593:24:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:28-593:38:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:28-593:38:EqualityOperator:cb8f782b4008ddeb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:35-593:38:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:40-593:47:UpdateOperator:4f60843e61d637a1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:14-594:24:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:14-594:24:EqualityOperator:9ace13d6583e4fb5","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:14-594:39:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:14-594:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:14-594:39:LogicalOperator:6a881e64fb3951a9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:21-594:24:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:28-594:39:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:28-594:39:EqualityOperator:4af6108f3c776425","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:48-594:64:MethodExpression:090772cf4068973d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:594:48-594:71:MethodExpression:4b64bc7a43aa1fc8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:596:10-596:21:MethodExpression:090772cf4068973d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:59:21-59:96:ObjectLiteral:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:59:3-59:97:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:59:4-59:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:60:15-60:90:ObjectLiteral:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:60:3-60:91:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:60:4-60:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:619:26-619:32:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:61:12-61:86:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:61:3-61:87:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:61:4-61:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:620:23-700:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:19-621:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:19-621:39:EqualityOperator:7b944183b4cb1c6f","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:43-621:64:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:43-621:64:EqualityOperator:0f6888194516d74f","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:43-621:64:EqualityOperator:1fcf101adb23581a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:15:BooleanLiteral:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:39:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:39:LogicalOperator:ba21f55f656b54fa","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:64:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:64:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:64:LogicalOperator:1198eb74da6542fa","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:624:24-624:28:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:624:31-627:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:624:7-624:29:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:624:7-624:29:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:624:7-624:29:MethodExpression:077dbb2f4dfef09a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:625:18-625:38:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:625:18-625:45:MethodExpression:1e441c983022842c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:625:35-625:37:UnaryOperator:31171179540ec320","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:12-626:38:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:12-626:38:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:12-626:38:LogicalOperator:537d95deb55d729e","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:20-626:27:Regex:39b51d58e8794dd5","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:20-626:27:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:20-626:27:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:626:20-626:27:Regex:d9c0bd96ef2a61e9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:62:12-62:86:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:62:3-62:87:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:62:4-62:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:26-630:30:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:33-633:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:7-630:31:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:7-630:31:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:7-630:31:MethodExpression:8209942935b99c9c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:631:21-631:37:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:631:21-631:44:MethodExpression:bfc6e580fc38aeb2","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:12-632:44:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:12-632:44:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:12-632:44:LogicalOperator:0dc37a6ff839153d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:23-632:30:Regex:39b51d58e8794dd5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:23-632:30:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:23-632:30:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:632:23-632:30:Regex:d9c0bd96ef2a61e9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:26-638:29:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:34-638:55:MethodExpression:0aa26db133b4b072","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:51-638:54:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:59-638:81:BooleanLiteral:dd7875db5a5e1455","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:30:MethodExpression:48c4e601657632e8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:55:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:55:LogicalOperator:a9ab8f4551ae375e","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:81:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:81:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:7-638:81:LogicalOperator:752d9a608b672950","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:77-638:80:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:83-641:4:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:639:21-639:41:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:639:21-639:48:MethodExpression:1dc9e31da83c6f13","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:639:38-639:40:UnaryOperator:c59dc4e44ff99288","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:63:18-63:98:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:63:3-63:99:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:63:4-63:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:12-640:44:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:12-640:44:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:12-640:44:LogicalOperator:0dc37a6ff839153d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:23-640:30:Regex:39b51d58e8794dd5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:23-640:30:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:23-640:30:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:640:23-640:30:Regex:d9c0bd96ef2a61e9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:645:37-645:40:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:646:38-646:41:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:648:17-648:19:UnaryOperator:c59dc4e44ff99288","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:649:18-649:20:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:64:3-67:4:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:650:19-650:21:StringLiteral:35a34e489e84c0be","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:26-652:40:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:26-652:40:EqualityOperator:732a675afde944cd","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:26-652:40:EqualityOperator:f7472c2a70a3deed","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:26-652:66:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:26-652:66:LogicalOperator:a11208071860ebb2","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:44-652:66:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:44-652:66:EqualityOperator:62f7cbc98325fbbf","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:44-652:66:EqualityOperator:9d24693c36bca789","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:69-656:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:21:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:21:EqualityOperator:6447c57a72cb6efb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:21:EqualityOperator:db9b9084dc45aaf4","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:67:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:67:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:67:LogicalOperator:856d107d2d1df37d","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:654:16-654:19:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:655:17-655:20:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:656:14-656:29:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:656:14-656:29:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:656:14-656:29:EqualityOperator:aa5bda481928fd27","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:656:14-656:29:EqualityOperator:e5c11081678b7b48","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:656:31-660:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:658:16-658:19:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:659:17-659:20:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:65:5-65:23:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:662:7-662:18:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:662:7-662:18:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:662:7-662:18:EqualityOperator:ae07eda56568621e","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:662:7-662:18:EqualityOperator:fd7d9371462caaf9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:669:17-669:28:ArithmeticOperator:b248b26070ce3b70","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:66:5-66:85:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:670:22-670:24:UnaryOperator:c59dc4e44ff99288","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:671:23-671:41:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:671:23-671:41:EqualityOperator:854161b5c4199459","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:671:23-671:41:EqualityOperator:b14f56ceacde8d0d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:671:43-671:46:UpdateOperator:ed8688c690933851","timeout"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:671:48-697:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:16-673:19:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:23-673:33:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:23-673:33:EqualityOperator:ed6065c8ddb21db4","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:30-673:33:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:35-675:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:9-673:19:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:9-673:19:EqualityOperator:f100d1d1b1dbf489","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:9-673:33:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:9-673:33:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:9-673:33:LogicalOperator:96c157160a2a19a5","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:674:7-674:14:UpdateOperator:5a19487169469f70","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:26:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:26:EqualityOperator:cccf537adc4f0204","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:40:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:40:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:40:LogicalOperator:cfc36dec7d1c26c6","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:23-675:26:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:30-675:40:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:30-675:40:EqualityOperator:cb8f782b4008ddeb","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:37-675:40:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:42-689:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:676:11-676:22:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:676:11-676:22:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:676:11-676:22:EqualityOperator:4af6108f3c776425","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:676:24-687:8:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:678:13-678:29:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:678:13-678:29:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:678:13-678:29:EqualityOperator:2bcd0fac0cc9004e","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:27:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:27:EqualityOperator:b725b76c7c5618cf","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:48:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:48:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:48:LogicalOperator:fc11181e095d64d8","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:21-679:27:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:31-679:48:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:31-679:48:EqualityOperator:5030cbcab786c641","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:31-679:48:EqualityOperator:b62f0a72ee5a9ff2","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:50-683:10:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:681:27-681:61:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:681:27-681:68:MethodExpression:4bf8eeca2e5a5b66","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:681:41-681:57:ArithmeticOperator:b67af9fb96ab672b","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:18-682:50:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:18-682:50:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:18-682:50:LogicalOperator:63ce779a74c73294","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:29-682:36:Regex:39b51d58e8794dd5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:29-682:36:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:29-682:36:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:682:29-682:36:Regex:d9c0bd96ef2a61e9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:684:23-684:46:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:684:23-684:53:MethodExpression:f6d180158decc4d7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:16-686:50:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:16-686:50:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:16-686:50:LogicalOperator:acbf667d5adde071","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:28-686:35:Regex:39b51d58e8794dd5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:28-686:35:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:28-686:35:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:686:28-686:35:Regex:d9c0bd96ef2a61e9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:688:7-688:14:UpdateOperator:4f60843e61d637a1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:26:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:26:EqualityOperator:9ace13d6583e4fb5","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:41:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:41:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:41:LogicalOperator:6a881e64fb3951a9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:23-689:26:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:30-689:41:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:30-689:41:EqualityOperator:4af6108f3c776425","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:43-696:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:68:14-68:88:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:68:3-68:89:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:68:4-68:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:690:11-690:26:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:690:11-690:26:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:690:11-690:26:EqualityOperator:f9f904983ea9ced7","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:690:19-690:26:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:690:28-694:8:BlockStatement:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:692:21-692:44:MethodExpression:152e54dc21bf4752","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:692:21-692:51:MethodExpression:f6d180158decc4d7","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:16-693:40:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:16-693:40:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:16-693:40:LogicalOperator:ad5af62d283e9d85","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:23-693:30:Regex:39b51d58e8794dd5","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:23-693:30:Regex:ad3c82f593e37580","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:23-693:30:Regex:cc35be6e9eace363","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:693:23-693:30:Regex:d9c0bd96ef2a61e9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:69:19-69:93:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:69:3-69:94:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:69:4-69:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:70:3-73:4:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:717:37-721:81:ArrowFunction:1193b7f146e45559","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:71:5-71:24:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:721:16-721:23:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:721:3-721:23:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:721:3-721:23:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:721:3-721:23:EqualityOperator:1aa09117855e6be3","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:729:33-759:2:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:72:5-72:80:ObjectLiteral:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:730:3-730:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:731:3-731:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:732:3-732:12:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:733:3-733:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:734:3-734:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:735:3-735:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:736:3-736:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:737:3-737:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:738:3-738:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:739:3-739:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:740:3-740:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:741:3-741:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:742:3-742:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:743:3-743:7:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:744:3-744:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:745:3-745:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:746:3-746:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:747:3-747:7:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:748:3-748:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:749:3-749:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:74:3-77:4:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:750:3-750:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:751:3-751:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:752:3-752:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:753:3-753:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:754:3-754:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:755:3-755:14:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:756:3-756:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:757:3-757:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:758:3-758:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:75:5-75:27:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:76:5-76:85:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:774:34-800:2:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:775:3-775:12:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:776:3-776:15:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:777:3-777:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:778:3-778:22:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:779:3-779:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:780:3-780:14:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:781:3-781:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:782:3-782:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:783:3-783:13:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:784:3-784:10:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:785:3-785:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:786:3-786:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:788:3-788:7:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:789:3-789:8:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:790:3-790:9:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:791:3-791:15:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:792:3-792:20:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:793:3-793:21:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:794:3-794:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:795:3-795:11:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:796:3-796:8:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:809:55-817:2:BlockStatement:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:80:13-80:84:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:80:3-80:85:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:80:4-80:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:19-811:34:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:19-811:34:EqualityOperator:0d68fc63ea416a13","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:19-811:34:EqualityOperator:e75a2b83b1114eee","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:36-811:39:UpdateOperator:ed8688c690933851","timeout"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:41-815:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:812:21-812:24:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:812:26-812:33:UpdateOperator:5a19487169469f70","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:812:9-812:24:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:812:9-812:24:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:812:9-812:24:EqualityOperator:77a8bae520f1b6da","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:813:14-813:29:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:813:14-813:29:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:813:14-813:29:EqualityOperator:7bf40eb235c35655","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:813:26-813:29:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:813:31-813:38:UpdateOperator:4f60843e61d637a1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:14-814:29:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:14-814:29:EqualityOperator:0d9a775c9e0e9388","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:14-814:44:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:14-814:44:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:14-814:44:LogicalOperator:41490489f1442781","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:26-814:29:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:33-814:44:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:33-814:44:EqualityOperator:4af6108f3c776425","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:53-814:69:MethodExpression:090772cf4068973d","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:814:53-814:76:MethodExpression:4b64bc7a43aa1fc8","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:816:10-816:21:MethodExpression:090772cf4068973d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:81:12-81:83:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:81:3-81:84:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:81:4-81:10:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:826:52-837:2:BlockStatement:44136fa355b3678a","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:828:10-828:19:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:828:21-835:4:BlockStatement:44136fa355b3678a","timeout"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:82:17-82:88:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:82:3-82:89:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:82:4-82:15:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:10-830:31:MethodExpression:4594d44b17f31625","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:27-830:30:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:9-830:31:BooleanLiteral:f5fe7649d2010eb7","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:9-830:31:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:9-830:31:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:832:40-832:43:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:EqualityOperator:1bdea81b78b2097f","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:EqualityOperator:1c24eab924cf9181","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:834:17-834:46:MethodExpression:43e3ece73ff8f4eb","timeout"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:834:17-834:53:MethodExpression:d96c61bc2437f086","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:834:33-834:45:ArithmeticOperator:56516caf242cdf47","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:836:10-836:21:MethodExpression:090772cf4068973d","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:83:18-83:89:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:83:3-83:90:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:83:4-83:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:842:86-900:2:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:843:7-843:17:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:843:7-843:17:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:843:7-843:17:EqualityOperator:0ca82ef76988e191","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:843:7-843:17:EqualityOperator:8449a2a48cfe2065","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:844:7-844:48:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:844:7-844:48:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:844:7-844:48:EqualityOperator:50c5025d2aebb9d6","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:844:7-844:48:EqualityOperator:8cb17e421f342326","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:845:14-845:24:MethodExpression:d7439bee24773bcb","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:846:7-846:12:BooleanLiteral:982d9e3eb996f559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:846:7-846:12:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:846:7-846:12:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:071016759983bc25","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:11e9b67e199e27ee","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:4efaaf8283804a45","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:5e06ba59d5c022fc","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:9499046b3cb17e04","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:9e14c6811596d06e","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:de46b73537ff8bb3","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:23-849:43:Regex:ef81ea4e7d8be059","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:849:45-849:47:StringLiteral:35a34e489e84c0be","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:84:11-84:82:ObjectLiteral:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:84:3-84:83:ArrayDeclaration:4f53cda18c2baa0c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:84:4-84:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:852:23-852:28:Regex:3d73bdd6f7da2949","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:852:30-852:32:StringLiteral:35a34e489e84c0be","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:855:21-855:24:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:855:27-864:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:855:7-855:25:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:855:7-855:25:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:856:19-861:8:MethodExpression:16c687aaf4579f1a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:857:14-857:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:858:12-858:27:ArrowFunction:1193b7f146e45559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:858:19-858:27:MethodExpression:148de9c5a7a44d19","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:85:16-85:87:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:85:3-85:88:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:85:4-85:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:28:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:28:EqualityOperator:e6d70fe71c58ab17","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:49:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:49:LogicalOperator:ea110ff23306850d","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:65:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:65:LogicalOperator:a09d0cde73d910a6","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:81:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:81:LogicalOperator:db66fb489eb06ba8","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:96:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:96:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:96:LogicalOperator:9c029fd1b032cc47","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:22-860:28:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:32-860:49:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:32-860:49:EqualityOperator:cb9374c5de642ab8","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:38-860:49:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:53-860:65:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:53-860:65:EqualityOperator:37a58227c596f1ef","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:59-860:65:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:69-860:81:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:69-860:81:EqualityOperator:d7b990008ea4e3fc","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:75-860:81:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:85-860:96:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:85-860:96:EqualityOperator:ae1a40d250b53451","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:9-860:96:ArrowFunction:1193b7f146e45559","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:91-860:96:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:862:9-862:27:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:862:9-862:27:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:862:9-862:27:EqualityOperator:0bbc3e08ac9a0da9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:37e7deb08ae71bda","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:3c62963693e786f9","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:5836cf840d3c41bf","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:bf320cd3ac7032ff","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:e8323049b92151d9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:f86f01364ea7e9bc","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:867:35-867:53:Regex:facf5830937d1b5f","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:868:21-879:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:868:7-868:19:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:runtime"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:868:7-868:19:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:86:11-86:82:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:86:3-86:83:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:86:4-86:9:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:870:37-876:6:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:870:9-870:35:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:870:9-870:35:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:875:46-875:55:ArithmeticOperator:2cf506f334acaf32","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:878:32-878:50:MethodExpression:423d6f897169616e","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:87:15-87:86:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:87:3-87:87:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:87:4-87:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:883:7-883:33:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:883:7-883:33:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:21-886:25:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:44-886:47:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:66-886:70:StringLiteral:12ae32cb1ec02d01","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:48:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:48:LogicalOperator:30f2fce4ad641280","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:71:ConditionalExpression:b5bea41b6c623f7c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:71:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:71:LogicalOperator:619ba8f3bbd758ba","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:73-888:4:BlockStatement:44136fa355b3678a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:887:23-887:33:Regex:b79afc8a2fa1f5c4","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:88:16-88:87:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:88:3-88:88:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:88:4-88:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:891:56-891:74:MethodExpression:1b9154251ecc0582","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:891:7-891:75:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:891:7-891:75:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:891:7-891:75:LogicalOperator:9a2f6c0c54d62dad","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:7-894:32:BooleanLiteral:fd193420607fe210","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:7-894:32:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:7-894:32:ConditionalExpression:fcbcf165908dd18a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:585da3a0e35f5681","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:8c9ee0f628d24383","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:a03049293a0bc2f4","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:ebfa9b04619be79b","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:f501298e007b1f7a","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:897:7-897:43:ConditionalExpression:b5bea41b6c623f7c","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:897:7-897:43:ConditionalExpression:fcbcf165908dd18a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:897:7-897:43:EqualityOperator:35c7762fc38ecbe1","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:897:7-897:43:EqualityOperator:3c7f2ce3d9bea2d9","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:89:15-89:86:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:89:3-89:87:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:89:4-89:13:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:90:13-90:84:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:90:3-90:85:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:90:4-90:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:91:13-91:84:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:91:3-91:85:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:91:4-91:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:92:13-92:84:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:92:3-92:85:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:92:4-92:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:93:16-93:87:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:93:3-93:88:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:93:4-93:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:94:16-94:87:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:94:3-94:88:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:94:4-94:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:95:16-95:87:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:95:3-95:88:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:95:4-95:14:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:96:19-96:90:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:96:3-96:91:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:96:4-96:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:97:13-97:84:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:97:3-97:85:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:97:4-97:11:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:98:19-98:90:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:98:3-98:91:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:98:4-98:17:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:99:18-99:89:ObjectLiteral:44136fa355b3678a","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:99:3-99:90:ArrayDeclaration:4f53cda18c2baa0c","survived"], + ["gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:99:4-99:16:StringLiteral:12ae32cb1ec02d01","killed-by-test:assertion"] + ], + "sampled": [ + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:232:5-232:42:EqualityOperator:8d4d50ad55800016", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:362:5-362:40:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:679:13-679:48:LogicalOperator:fc11181e095d64d8", + "strata": ["multi-killer"], + "covering": 25, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:a03049293a0bc2f4", + "strata": ["multi-killer"], + "covering": 20, + "outcomes": [ + [76,"killed-by-test:assertion"], + [77,"killed-by-test:assertion"], + [78,"killed-by-test:assertion"], + [79,"killed-by-test:assertion"], + [80,"killed-by-test:assertion"], + [81,"killed-by-test:assertion"], + [82,"killed-by-test:assertion"], + [83,"killed-by-test:assertion"], + [84,"killed-by-test:assertion"], + [86,"killed-by-test:assertion"], + [87,"killed-by-test:assertion"], + [88,"killed-by-test:assertion"], + [97,"killed-by-test:assertion"], + [98,"killed-by-test:assertion"], + [99,"killed-by-test:assertion"], + [102,"killed-by-test:assertion"], + [103,"killed-by-test:assertion"], + [105,"killed-by-test:assertion"], + [106,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:244:9-244:29:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:573:10-573:42:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:180:30-180:57:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:377:7-377:30:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:7-630:31:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 45, + "outcomes": [ + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [39,"killed-by-test:assertion"], + [40,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:738:3-738:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:662:7-662:18:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 37, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [47,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:7-367:42:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:886:7-886:48:ConditionalExpression:fcbcf165908dd18a", + "strata": ["multi-killer"], + "covering": 24, + "outcomes": [[86,"killed-by-test:assertion"],[88,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:80:3-80:85:ArrayDeclaration:4f53cda18c2baa0c", + "strata": ["static"], + "covering": 143, + "outcomes": [[134,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:592:23-592:33:EqualityOperator:ed6065c8ddb21db4", + "strata": ["survived"], + "covering": 18, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:EqualityOperator:1bdea81b78b2097f", + "strata": ["survived"], + "covering": 2, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:111:11-111:82:ObjectLiteral:44136fa355b3678a", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:264:34-264:40:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:531:44-531:55:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","multi-killer"], + "covering": 143, + "outcomes": [ + [61,"killed-by-test:assertion"], + [62,"killed-by-test:assertion"], + [66,"killed-by-test:assertion"], + [74,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:621:7-621:64:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 60, + "outcomes": [ + [0,"killed-by-test:assertion"], + [1,"killed-by-test:assertion"], + [2,"killed-by-test:assertion"], + [3,"killed-by-test:assertion"], + [4,"killed-by-test:assertion"], + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [35,"killed-by-test:assertion"], + [36,"killed-by-test:assertion"], + [39,"killed-by-test:assertion"], + [40,"killed-by-test:assertion"], + [41,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [47,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:638:77-638:80:StringLiteral:12ae32cb1ec02d01", + "strata": ["survived"], + "covering": 5, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:106:3-106:93:ArrayDeclaration:4f53cda18c2baa0c", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:59-860:65:StringLiteral:12ae32cb1ec02d01", + "strata": ["survived"], + "covering": 2, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:62:4-62:10:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 143, + "outcomes": [[137,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:473:45-476:6:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:288:11-288:17:BooleanLiteral:ddc9e669194254ce", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:559:22-559:59:LogicalOperator:5f35853230e32dd5", + "strata": ["multi-killer"], + "covering": 6, + "outcomes": [ + [60,"killed-by-test:assertion"], + [61,"killed-by-test:assertion"], + [62,"killed-by-test:assertion"], + [75,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:240:7-247:42:LogicalOperator:919adb81984bfdb3", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:860:16-860:28:EqualityOperator:e6d70fe71c58ab17", + "strata": [], + "covering": 2, + "outcomes": [[80,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:631:21-631:44:MethodExpression:bfc6e580fc38aeb2", + "strata": ["survived"], + "covering": 4, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:96:19-96:90:ObjectLiteral:44136fa355b3678a", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:500:7-500:14:BooleanLiteral:5b7e6bf2dc4a32a6", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:60:15-60:90:ObjectLiteral:44136fa355b3678a", + "strata": ["static","multi-killer"], + "covering": 143, + "outcomes": [[114,"killed-by-test:runtime"],[141,"killed-by-test:runtime"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-245:33:LogicalOperator:926c1a31bab7772c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:367:21-367:42:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:8-246:44:LogicalOperator:f213309936fa9559", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:36:74-36:79:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:830:10-830:31:MethodExpression:4594d44b17f31625", + "strata": ["multi-killer"], + "covering": 8, + "outcomes": [[102,"killed-by-test:assertion"],[103,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:736:3-736:11:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:104:19-104:90:ObjectLiteral:44136fa355b3678a", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:555:7-555:25:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["survived"], + "covering": 9, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:234:23-234:34:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:7-652:21:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 37, + "outcomes": [ + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:24:EqualityOperator:cccf537adc4f0204", + "strata": ["survived"], + "covering": 18, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:524:19-524:38:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:103:4-103:12:StringLiteral:12ae32cb1ec02d01", + "strata": ["static"], + "covering": 143, + "outcomes": [[137,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:588:48-597:2:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 18, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:630:7-630:31:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 45, + "outcomes": [ + [5,"killed-by-test:assertion"], + [6,"killed-by-test:assertion"], + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [29,"killed-by-test:assertion"], + [41,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [47,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [55,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:372:21-372:34:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:51-497:61:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-357:31:EqualityOperator:1dbbdc1939cd2bd9", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:556:19-559:60:MethodExpression:16c687aaf4579f1a", + "strata": ["multi-killer"], + "covering": 6, + "outcomes": [ + [60,"killed-by-test:assertion"], + [61,"killed-by-test:assertion"], + [62,"killed-by-test:assertion"], + [75,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:279:52-279:61:ArithmeticOperator:2cf506f334acaf32", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:652:44-652:66:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 1, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:811:41-815:4:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 8, + "outcomes": [ + [79,"killed-by-test:assertion"], + [102,"killed-by-test:assertion"], + [103,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:439:5-441:34:LogicalOperator:71e4a049dc70cff0", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:442:5-444:4:BlockStatement:44136fa355b3678a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:497:21-497:33:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:870:9-870:35:ConditionalExpression:b5bea41b6c623f7c", + "strata": [], + "covering": 9, + "outcomes": [[85,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:316:20-316:54:EqualityOperator:b75a5946c814bb0d", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:227:5-236:44:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:43-696:6:BlockStatement:44136fa355b3678a", + "strata": ["multi-killer"], + "covering": 14, + "outcomes": [ + [15,"killed-by-test:assertion"], + [17,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [23,"killed-by-test:assertion"], + [25,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:56:3-56:90:ArrayDeclaration:4f53cda18c2baa0c", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:338:7-338:74:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:675:16-675:40:LogicalOperator:cfc36dec7d1c26c6", + "strata": ["multi-killer"], + "covering": 34, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:181:46-181:65:StringLiteral:059b850298ae3352", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:233:5-233:49:EqualityOperator:1c61b47622d4b2c8", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:416:7-416:17:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:128:26-138:4:BlockStatement:44136fa355b3678a", + "strata": [], + "covering": 16, + "outcomes": [[113,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:828:21-835:4:BlockStatement:44136fa355b3678a", + "strata": [], + "covering": 0, + "outcomes": [ + [77,"timeout"], + [78,"timeout"], + [79,"timeout"], + [97,"timeout"], + [98,"timeout"], + [99,"timeout"], + [102,"timeout"], + [103,"timeout"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:581:43-581:53:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:681:41-681:57:ArithmeticOperator:b67af9fb96ab672b", + "strata": ["multi-killer"], + "covering": 7, + "outcomes": [ + [15,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:496:7-496:22:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:291:11-291:69:LogicalOperator:d8495b4503e1827d", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:219:23-219:42:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:689:16-689:26:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["multi-killer"], + "covering": 34, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [16,"killed-by-test:assertion"], + [18,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [20,"killed-by-test:assertion"], + [24,"killed-by-test:assertion"], + [26,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [47,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:782:3-782:9:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:894:8-894:21:Regex:f501298e007b1f7a", + "strata": ["multi-killer"], + "covering": 20, + "outcomes": [ + [76,"killed-by-test:assertion"], + [77,"killed-by-test:assertion"], + [78,"killed-by-test:assertion"], + [79,"killed-by-test:assertion"], + [80,"killed-by-test:assertion"], + [81,"killed-by-test:assertion"], + [82,"killed-by-test:assertion"], + [83,"killed-by-test:assertion"], + [84,"killed-by-test:assertion"], + [86,"killed-by-test:assertion"], + [87,"killed-by-test:assertion"], + [88,"killed-by-test:assertion"], + [93,"killed-by-test:assertion"], + [97,"killed-by-test:assertion"], + [98,"killed-by-test:assertion"], + [99,"killed-by-test:assertion"], + [102,"killed-by-test:assertion"], + [103,"killed-by-test:assertion"], + [105,"killed-by-test:assertion"], + [106,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-220:33:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:286:21-286:49:EqualityOperator:204c24a9e6ea91bf", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:791:3-791:15:StringLiteral:12ae32cb1ec02d01", + "strata": ["static","survived"], + "covering": 143, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:678:13-678:29:EqualityOperator:2bcd0fac0cc9004e", + "strata": ["multi-killer"], + "covering": 27, + "outcomes": [ + [7,"killed-by-test:assertion"], + [8,"killed-by-test:assertion"], + [9,"killed-by-test:assertion"], + [10,"killed-by-test:assertion"], + [11,"killed-by-test:assertion"], + [12,"killed-by-test:assertion"], + [13,"killed-by-test:assertion"], + [14,"killed-by-test:assertion"], + [15,"killed-by-test:assertion"], + [19,"killed-by-test:assertion"], + [27,"killed-by-test:assertion"], + [42,"killed-by-test:assertion"], + [43,"killed-by-test:assertion"], + [44,"killed-by-test:assertion"], + [45,"killed-by-test:assertion"], + [46,"killed-by-test:assertion"], + [48,"killed-by-test:assertion"], + [53,"killed-by-test:assertion"], + [58,"killed-by-test:assertion"], + [59,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:255:18-255:80:LogicalOperator:07f6d2fc3f0d671b", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:509:18-509:42:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:357:5-361:29:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:673:23-673:33:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 34, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:833:9-833:21:ConditionalExpression:fcbcf165908dd18a", + "strata": ["survived"], + "covering": 2, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:236:5-236:44:EqualityOperator:bcf587d1432b21be", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:241:22-241:39:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:134:65-134:72:StringLiteral:12ae32cb1ec02d01", + "strata": ["multi-killer"], + "covering": 4, + "outcomes": [ + [109,"killed-by-test:assertion"], + [112,"killed-by-test:assertion"], + [114,"killed-by-test:assertion"], + [115,"killed-by-test:assertion"] + ], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:240:7-247:42:ConditionalExpression:b5bea41b6c623f7c", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:229:23-229:47:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:217:5-219:42:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:855:27-864:4:BlockStatement:44136fa355b3678a", + "strata": [], + "covering": 2, + "outcomes": [[80,"killed-by-test:assertion"]], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:479:15-479:33:CallExpression:41b805ea7ac014e2", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:245:9-245:33:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:261:25-261:39:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:593:14-593:38:LogicalOperator:cfc36dec7d1c26c6", + "strata": ["survived"], + "covering": 18, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:331:23-331:37:StringLiteral:12ae32cb1ec02d01", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + }, + { + "id": "gitnexus/src/core/ingestion/type-extractors/shared.ts@02e17f5524a110b2:284:7-284:37:ConditionalExpression:fcbcf165908dd18a", + "strata": ["no-coverage"], + "covering": 0, + "outcomes": [], + "invalid": [] + } + ], + "launches": { + "total": 14443, + "valid": 14300, + "invalid": 0, + "wallClockLimitMs": 180000 + }, + "warnings": [], + "problems": [] +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/date-boundary.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/date-boundary.case.ts new file mode 100644 index 000000000..551f6519c --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/date-boundary.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `date-boundary` (design §8): the +// end of February in a leap year and in a common year. The leap-year rule lives inside Date. Only +// the value-flow check separates the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { daysInMonth } from './src/date-boundary.js'; + +describe('date-boundary', () => { + it('counts the days of February in a leap year', () => { + expect(daysInMonth(2024, 1)).toBe(29); + }); + + it('counts the days of February in a common year', () => { + expect(daysInMonth(2023, 1)).toBe(28); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/derived-to-fixed.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/derived-to-fixed.case.ts new file mode 100644 index 000000000..d54ea822a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/derived-to-fixed.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `derived-to-fixed` (design §5.3a, +// §8): 2.4 and 2.5 are scaled to 24 and 25 by repo arithmetic before they reach toFixed. Only the +// value-flow check separates the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { formatRating } from './src/derived-to-fixed.js'; + +describe('derived-to-fixed', () => { + it('formats a 2.4-star rating', () => { + expect(formatRating(2.4)).toBe('24.0'); + }); + + it('formats a 2.5-star rating', () => { + expect(formatRating(2.5)).toBe('25.0'); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/enum-member.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/enum-member.case.ts new file mode 100644 index 000000000..d5dac1d96 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/enum-member.case.ts @@ -0,0 +1,20 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `enum-member` (KTD6 residual- +// difference guard): the bodies differ only in a const-object member passed outside the expect +// subject. No literal differs and no mutant tells the members apart. Expected: Review +// (expected.json, which records the plan conflict for this pair). +import { describe, expect, it } from 'vitest'; +import { MiniGraph, NodeLabel } from './src/enum-member.js'; + +describe('enum-member', () => { + it('finds a callable after adding a function node', () => { + const graph = new MiniGraph(); + graph.addNode(NodeLabel.Function); + expect(graph.hasCallableNode()).toBe(true); + }); + + it('finds a callable after adding a method node', () => { + const graph = new MiniGraph(); + graph.addNode(NodeLabel.Method); + expect(graph.hasCallableNode()).toBe(true); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/expected-only.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/expected-only.case.ts new file mode 100644 index 000000000..52d0a3085 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/expected-only.case.ts @@ -0,0 +1,17 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `expected-only` (KTD11 test side): +// the same input from the describe's shared setup, asserted against two different expected +// values. Only the value-flow check separates the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { formatTags } from './src/expected-only.js'; + +describe('expected-only', () => { + const tags = ['alpha', 'beta']; + + it('renders the first tag', () => { + expect(formatTags(tags)).toContain('alpha'); + }); + + it('renders the second tag', () => { + expect(formatTags(tags)).toContain('beta'); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/expected.json b/gitnexus/test/fixtures/mutation-scenario-map/expected.json new file mode 100644 index 000000000..a0b535607 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/expected.json @@ -0,0 +1,453 @@ +{ + "$comment": [ + "Expected verdicts of the scenario-map controls fixture (Phase 1 plan U17, docs/plans/2026-10-24-0902-feat-test-scenario-map-phase1-plan.md). They were written from the design (docs/plans/2026-10-23-test-scenario-map-design.md §2, §5.3, §5.3a, §5.4, §8) and the plan's KTD5-KTD12 before any pipeline run. Never regenerate them from observed output; a mismatch is a finding, and an adversarial pair that ends as a repeat candidate is a plan stop condition.", + "Test ids: `#`, the Phase 0 id scheme of tools/mutation/runner/test-ids.ts with the file relative to the fixture root, as in mutation-conformance/expected.json. Prefix `test/fixtures/mutation-scenario-map/` for the package-relative Phase 0 id. Every full name is unique, so no id needs a location suffix.", + "Layout: one pair per case file, and each case file imports one production module under src/ (numeric-boundary imports src/page-size.ts, which imports the constants-only src/limits.ts). No two pairs share V8 blocks, checks or a mutate scope, so no cross-pair candidate is expected. `mutate` is the pair's KTD7 mutate scope: whole production files, relative to this directory.", + "`verdict` uses the ids of `verdicts` (design §2 labels). `rule` names the deciding design rule. `stages` gives the design's expected result for each stage on the pair's decision path (Verdict decision diagram of the plan); a stage not listed is off the path and does not affect the verdict. Stage values: facet equal | differs | equal-unresolved; discardedValues equal | differ; killSets equal (always non-empty here) | differ; rowDeletion not-applicable | symmetric | asymmetric; trace clean | veto; staticValueFlow clean | veto | unknown | not-required (discarded values equal); isolation agrees | disagrees.", + "Every test passes unmutated in file order. No production mutant throws, loops or times out in any test, so every kill is an assertion failure and no pair is Inconclusive by accident; only order-dependent is built to be Inconclusive.", + "Plan conflicts, recorded on the pairs in `planConflict`: enum-member (the residual-difference guard alone yields repeat-weak; expected review), set-membership (the design yields row, not the strong repeat U17 lists for positive controls), repo-arithmetic (design §5.3 rule 3 keeps the literal 1 exact). set-non-member is a positive control added beyond the U17 list: it is the only pair that checks a table read through Set.prototype.has is not vetoed as opaque.", + "`fingerprint` is the scenario fingerprint verdict and rule (tools/scenario-map/verdict/fingerprint.ts; docs/plans/2026-10-25-1700-feat-scenario-fingerprint-plan.md), written from its rules before any run, like the rest: every pair runs one path in both tests (no expected mutation rule is path-differs), so a pair whose discarded values differ is a table row whatever the values feed (the opaque built-ins, the expected-only literal 'alpha' against 'beta', the table keys and Set members); order-dependent is Inconclusive (not-passing): Layer 1 runs every test twice in a row (repeats 1, design §5.2), the shared counter makes each test's second attempt fail, and Vitest reports a test failed when any repetition fails; numeric-boundary's facets differ (distinct); enum-member's residual difference holds it at possible-duplicate; verbatim-duplicate is the one duplicate. A fingerprint duplicate of a pair this file does not list fails the oracle." + ], + "schemaVersion": 1, + "verdicts": { + "inconclusive": "Inconclusive", + "out-of-scope": "Out of scope", + "weak": "Weak", + "repeat-strong": "Repeat candidate (strong evidence)", + "repeat-weak": "Repeat candidate (weak evidence)", + "row": "Row", + "review": "Review", + "distinct": "Distinct" + }, + "pairs": [ + { + "id": "math-round", + "kind": "adversarial", + "mechanism": "opaque built-in: Math.round on 2.4 and 2.5", + "tests": [ + "math-round.case.ts#math-round > rounds 2.4 down to 2", + "math-round.case.ts#math-round > rounds 2.5 up to 3" + ], + "mutate": [ + "src/math-round.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "Both tests take the same path through roundScore (the `raw < 0` guard is false) with the same check and context. 2.4/2.5 and 2/3 are in no table and not within ±1 of the module's only constant 0, so the facets are equal and the values are discarded. Stryker has no mutator inside Math.round: both tests kill exactly the guard forced true, `raw >= 0` and the removed body. No table, so row deletion does not apply. The trace sees Math.round called from src/math-round.ts with 2.4 against 2.5 (veto); the static check finds `raw` flowing into Math.round, declared in lib.es5.d.ts (opaque, veto). Design §2: same path, checks, context and kills with a value-flow veto is Review." + }, + { + "id": "parse-int-radix", + "kind": "adversarial", + "mechanism": "opaque built-in: parseInt with radix 8 and radix 16", + "tests": [ + "parse-int-radix.case.ts#parse-int-radix > reads 17 as an octal code", + "parse-int-radix.case.ts#parse-int-radix > reads 17 as a hexadecimal code" + ], + "mutate": [ + "src/parse-int-radix.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "The text '17' is the same; the radix 8/16 and the results 15/23 are discarded (not within ±1 of the module's constant -1). Both tests skip the NaN branch and kill the same mutants: the NaN guard forced true and the removed body. The trace sees parseInt('17', 8) against parseInt('17', 16) at one call site, and Number.isNaN(15) against Number.isNaN(23) (veto); the static check finds `radix` flowing into parseInt, declared in lib.es5.d.ts (veto). Review." + }, + { + "id": "locale-compare", + "kind": "adversarial", + "mechanism": "opaque built-in: String.prototype.localeCompare, difference in argument position", + "tests": [ + "locale-compare.case.ts#locale-compare > orders apple before Banana", + "locale-compare.case.ts#locale-compare > orders apple before Damson" + ], + "mutate": [ + "src/locale-compare.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "The receiver 'apple' is the same; only the argument differs, and 'Banana'/'Damson' have the same shape, so the facets are equal. Both comparisons are negative, so both tests take the same branch and kill the same mutants: first guard forced false, `order >= 0`, first guard body removed, `-1` to `+1`, body removed (the second guard is not covered). The trace sees localeCompare from src/locale-compare.ts with the same receiver but different arguments (veto); the static check finds `right` flowing into String.prototype.localeCompare, declared in lib (veto). Review." + }, + { + "id": "unicode-normalize", + "kind": "adversarial", + "mechanism": "opaque built-in: String.prototype.normalize, difference in receiver position", + "tests": [ + "unicode-normalize.case.ts#unicode-normalize > composes e and a combining acute into one code unit", + "unicode-normalize.case.ts#unicode-normalize > keeps a combining acute that has no composed form" + ], + "mutate": [ + "src/unicode-normalize.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "'Jose' + U+0301 composes to 4 code units; 'Josq' + U+0301 has no composed form and stays 5. Both inputs have the same shape (a capitalised word, then U+0301) and 4/5 are discarded, so the facets are equal. Both kill the same mutants: the empty-name guard forced true, `name !== ''` and the removed body. The differing value is the receiver (`this`) of normalize, which the trace records (KTD11): different receivers at one call site (veto). The static check finds `name` as the receiver of String.prototype.normalize, declared in lib (veto). Review." + }, + { + "id": "date-boundary", + "kind": "adversarial", + "mechanism": "opaque built-in: Date month-end rollover in a leap and a common year", + "tests": [ + "date-boundary.case.ts#date-boundary > counts the days of February in a leap year", + "date-boundary.case.ts#date-boundary > counts the days of February in a common year" + ], + "mutate": [ + "src/date-boundary.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "The month literal 1 is exact and equal in both tests; the years 2024/2023 and the results 29/28 are discarded (not within ±1 of the module's constants 0 and 1). The leap-year rule lives inside Date, so both tests kill the same mutants: `month + 1` to `month - 1` (31 days) and the removed body. The trace sees Date.UTC(2024, 2, 0) against Date.UTC(2023, 2, 0), and different Date and getUTCDate receivers (veto); the static check finds `year` flowing into Date.UTC, declared in lib (veto). Review." + }, + { + "id": "regex-alternatives", + "kind": "adversarial", + "mechanism": "regex: two inputs matching different alternatives of one regex", + "tests": [ + "regex-alternatives.case.ts#regex-alternatives > recognises a getter name", + "regex-alternatives.case.ts#regex-alternatives > recognises a setter name" + ], + "mutate": [ + "src/regex-alternatives.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "'getName' and 'setName' match different alternatives of /^(?:get|set)[A-Z]/ and have the same shape; whether the facet maps them to the alternation table or to shapes, they stay equal. Weapon-regex level 1 has no operator that drops one alternative, so both kill the same mutants: the character-class negation and the removed body (removing `^` survives both). A regex alternation is not a row-deletion table (design §5.4, KTD10), so row deletion does not apply. The trace sees RegExp.prototype.test with different arguments (veto); the static check treats any regex use as opaque (veto). Review." + }, + { + "id": "numeric-boundary", + "kind": "adversarial", + "mechanism": "numeric boundary: PAGE_LIMIT + 1 against an interior value, the constant in a constants-only module", + "tests": [ + "numeric-boundary.case.ts#numeric-boundary > cuts a page size one above the limit", + "numeric-boundary.case.ts#numeric-boundary > cuts a page size far above the limit" + ], + "mutate": [ + "src/page-size.ts", + "src/limits.ts" + ], + "verdict": "review", + "rule": "facet-differs-same-kills", + "fingerprint": { + "verdict": "distinct", + "rule": "input-shape-differs" + }, + "stages": { + "facet": "differs", + "killSets": "equal" + }, + "reason": "PAGE_LIMIT = 100 lives in src/limits.ts, a constants-only module that runs only while it loads, so the facet must read it through the static import closure (KTD6). 101 is within ±1 of 100 and stays exact; 150 collapses to a shape; the expected 100 is exact in both. The facets differ. Both inputs take the clamp branch and kill the same mutants: guard forced false, `requested <= PAGE_LIMIT`, clamp branch emptied, body removed (`>=` survives both; Stryker has no numeric-literal mutator). Design §2: same path, checks, context and kill set with a different facet is Review. Without the ±1 rule the facets would be equal, the trace and the static check would be clean (a comparison and a pass-through only) and the pair would wrongly become a strong repeat." + }, + { + "id": "expected-only", + "kind": "adversarial", + "mechanism": "expected-only difference: one shared input, toContain('alpha') against toContain('beta')", + "tests": [ + "expected-only.case.ts#expected-only > renders the first tag", + "expected-only.case.ts#expected-only > renders the second tag" + ], + "mutate": [ + "src/expected-only.ts" + ], + "verdict": "review", + "rule": "value-flow-unknown", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "unknown" + }, + "reason": "Both tests render the describe's shared `tags`, so path, calls and kills are identical: the `#` template emptied, the map callback removed and the removed body (each leaves no 'alpha' and no 'beta'; the separator mutant survives both). Only the expected values 'alpha'/'beta' differ; they are in no production table and have the same shape, so the facets are equal and the values are discarded. The trace sees identical calls (clean). KTD11 test side: a differing expected value with no differing bound input is unknown, so the static check vetoes. Review." + }, + { + "id": "derived-to-fixed", + "kind": "adversarial", + "mechanism": "derived value reaching an opaque built-in: (x * 10).toFixed(1) with x 2.4 and 2.5", + "tests": [ + "derived-to-fixed.case.ts#derived-to-fixed > formats a 2.4-star rating", + "derived-to-fixed.case.ts#derived-to-fixed > formats a 2.5-star rating" + ], + "mutate": [ + "src/derived-to-fixed.ts" + ], + "verdict": "review", + "rule": "value-flow-veto", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "veto", + "staticValueFlow": "veto" + }, + "reason": "2.4/2.5 and '24.0'/'25.0' are discarded (not within ±1 of the constants 10 and 1). Both kill the same mutants: `*` to `/` ('0.2'/'0.3') and the removed body. `stars * 10` is repo arithmetic, but its result is the receiver of Number.prototype.toFixed: the trace sees receivers 24 and 25 at one call site (veto; this is the derived-value case of design §5.3a step 4), and the static check finds the product of `stars` reaching toFixed, declared in lib (veto). Review." + }, + { + "id": "enum-member", + "kind": "adversarial", + "mechanism": "enum-member-only difference outside the expect subject (KTD6 residual-difference guard)", + "tests": [ + "enum-member.case.ts#enum-member > finds a callable after adding a function node", + "enum-member.case.ts#enum-member > finds a callable after adding a method node" + ], + "mutate": [ + "src/enum-member.ts" + ], + "verdict": "review", + "rule": "residual-difference", + "fingerprint": { + "verdict": "possible-duplicate", + "rule": "input-unresolved" + }, + "stages": { + "facet": "equal-unresolved", + "discardedValues": "equal", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "not-required" + }, + "reason": "The bodies differ only in NodeLabel.Function against NodeLabel.Method, a member expression passed to addNode outside the expect subject. No literal differs, so the facets are equal and nothing is discarded; the residual-difference guard (KTD6, R5) marks both instances unresolved. Mutants of NodeLabel (object emptied, member strings emptied) change the argument and the CALLABLE_LABELS Set together and survive both; both kill the same mutants: the Set's entry array emptied (static), addNode and hasCallableNode bodies removed, the `some` callback removed (`every` survives a one-element list). The differing nodes are not literals, so row deletion does not apply (design §5.4). Set.has, push and some are not wrapped, so the trace is clean. Mutation cannot tell the tests apart and the facet did not capture their difference, so the inputs are not shown equal: Review (fail closed). Removing NodeLabel.Method from the Set would fail only the second test.", + "planConflict": "As written, U6 (residual difference marks both instances unresolved) plus U12 (an unresolved facet input is only a weak disqualifier) would give repeat-weak for this pair, a repeat candidate, which U17 and the Goal Capsule stop condition forbid for an adversarial pair. Resolved by decision D1: the verdict engine treats a residual-difference pair as not facet-equal (rule residual-difference, design §2 Review: same path, checks, context and kill set with a different facet), so the pair ends Review." + }, + { + "id": "order-dependent", + "kind": "special", + "mechanism": "order dependence: the second test relies on module state the first leaks", + "tests": [ + "order-dependent.case.ts#order-dependent > labels the first ticket", + "order-dependent.case.ts#order-dependent > labels the second ticket" + ], + "mutate": [ + "src/order-dependent.ts" + ], + "verdict": "inconclusive", + "rule": "isolation-disagrees", + "fingerprint": { + "verdict": "inconclusive", + "rule": "not-passing" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "clean", + "isolation": "disagrees" + }, + "reason": "nextTicketLabel reads a module-level counter. In file order the first test leaves it at 1 and the second gets 'beta-2'; alone, the second gets 'beta-1' and fails. Run together (main run, baselines, dry run, trace run), the pair looks like a repeat: same path, checks and context; 'alpha'/'beta' and 'alpha-1'/'beta-2' have the same shapes; both kill `+=` to `-=`, the template emptied and the removed body; no table; no wrapped built-in; the value flows only into a template literal and the return. So it reaches the repeat stage, and the full isolation pass (design §5.4, KTD9) runs the second test alone: its baseline and Stryker dry run fail, and its outcomes disagree with the together run. Design §2: an isolation check that disagrees is Inconclusive, which takes precedence over every other verdict." + }, + { + "id": "object-key-row", + "kind": "special", + "mechanism": "row deletion: two keys of one three-key object-literal table", + "tests": [ + "object-key-row.case.ts#object-key-row > converts seconds to milliseconds", + "object-key-row.case.ts#object-key-row > converts minutes to milliseconds" + ], + "mutate": [ + "src/object-key-row.ts" + ], + "verdict": "row", + "rule": "row-deletion-asymmetric", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "killSets": "equal", + "rowDeletion": "asymmetric" + }, + "reason": "UNIT_FACTORS = { ms, s, min } is an object literal with plain identifier keys read through `UNIT_FACTORS[unit]`, a supported table (design §5.4). 's' and 'min' are keys of it, so both map to the table and the facets are equal; the amount 2 is exact (within ±1 of the constant 1) and equal; 2000/120000 have the same shape. Stryker never mutates object keys, and both tests kill the same mutants: the table emptied (static), the unknown-unit guard forced true, `factor !== undefined`, `*` to `/`, the removed body. Row deletion: deleting `s` fails only the seconds test and deleting `min` only the minutes test, so it is asymmetric. Design §2: same path, checks, context and facet with asymmetric row deletion is Row." + }, + { + "id": "verbatim-duplicate", + "kind": "positive-control", + "mechanism": "verbatim duplicate: byte-identical bodies, different titles", + "tests": [ + "verbatim-duplicate.case.ts#verbatim-duplicate > clamps a percentage above 100", + "verbatim-duplicate.case.ts#verbatim-duplicate > caps an oversized percentage" + ], + "mutate": [ + "src/verbatim-duplicate.ts" + ], + "verdict": "repeat-strong", + "rule": "all-strong-conditions", + "fingerprint": { + "verdict": "duplicate", + "rule": "same-path-input-expectation" + }, + "stages": { + "facet": "equal", + "discardedValues": "equal", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "not-required", + "isolation": "agrees" + }, + "reason": "The two bodies are byte-identical and share the describe, so path, V8 blocks, checks, context, facet and kills are identical by construction (both kill the first guard forced true, `value >= 0`, the second guard forced false, `value <= 100`, the clamp branch emptied and the removed body). The discarded values are equal, so only the trace is needed, and clampPercent calls no built-in. The module is a pure production file of the fixture, so provenance is strong-eligible, and the pure function lets isolation agree. Every design §2 strong condition holds." + }, + { + "id": "repo-arithmetic", + "kind": "positive-control", + "mechanism": "repo-only arithmetic: add(2, 3) against add(5, 5) (design §8; operands avoid 0, 1 and -1, which design §5.3 rule 3 keeps exact)", + "tests": [ + "repo-arithmetic.case.ts#repo-arithmetic > adds 2 and 3", + "repo-arithmetic.case.ts#repo-arithmetic > adds 5 and 5" + ], + "mutate": [ + "src/repo-arithmetic.ts" + ], + "verdict": "repeat-strong", + "rule": "all-strong-conditions", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "clean", + "isolation": "agrees" + }, + "reason": "add uses only repo arithmetic. Both tests kill exactly `+` to `-` and the removed body. The inputs and sums are discarded: none is 0, 1 or -1 (design §5.3 rule 3 keeps those exact) and the module has no numeric constant, so the ±1 rule keeps none of them exact. The static check finds `left` and `right` flowing only into `+` (repo arithmetic) and the return (pass-through): clean. No wrapped built-in is called: the trace is clean. Plan U12 lists this pair as its strong-repeat scenario." + }, + { + "id": "set-membership", + "kind": "positive-control", + "mechanism": "membership in a supported Set table: one test per member, identical kill sets (design §8)", + "tests": [ + "set-membership.case.ts#set-membership > accepts 200 as a success status", + "set-membership.case.ts#set-membership > accepts 204 as a success status" + ], + "mutate": [ + "src/set-membership.ts" + ], + "verdict": "row", + "rule": "row-deletion-asymmetric", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "killSets": "equal", + "rowDeletion": "asymmetric" + }, + "reason": "SUCCESS_STATUSES = new Set([200, 204]) is built from literal entries and read through `has`, a supported table (design §5.4). The entries are numbers, which Stryker never mutates one by one, so the kill sets are identical: the entry array emptied (static) and the removed body. 200 and 204 are entries of the table, so both map to it and the facets are equal (Set entries are not named constants, so the ±1 rule does not apply). No weak disqualifier applies. But row deletion applies to exactly this table: deleting 200 fails only the first test and deleting 204 only the second, which is asymmetric, and design §2 ranks Row before any Repeat. A Set table with one test per member is therefore a Row (fold into it.each, keep every row), the verdict the design implies.", + "planConflict": "U17 and the Success Criteria list Set membership among positive controls that end as strong repeats. With two members tested by two tests, the design can only reach Row: row deletion exists to separate them. set-non-member covers the eligibility property instead (a Set.has table read is not vetoed)." + }, + { + "id": "set-non-member", + "kind": "positive-control", + "mechanism": "table read through Set.prototype.has is clean, not opaque (design §5.3a step 3)", + "tests": [ + "set-non-member.case.ts#set-non-member > rejects banana as a reserved word", + "set-non-member.case.ts#set-non-member > rejects cherry as a reserved word" + ], + "mutate": [ + "src/set-non-member.ts" + ], + "verdict": "repeat-strong", + "rule": "all-strong-conditions", + "fingerprint": { + "verdict": "table-row", + "rule": "input-values-differ" + }, + "stages": { + "facet": "equal", + "discardedValues": "differ", + "killSets": "equal", + "rowDeletion": "not-applicable", + "trace": "clean", + "staticValueFlow": "clean", + "isolation": "agrees" + }, + "reason": "Added beyond the U17 list: it is the pair that keeps the Set-membership eligibility property under test. 'banana' and 'cherry' are not entries of RESERVED_WORDS, so they collapse to the same shape and row deletion does not apply. Both tests kill only the removed body (an emptied entry array or an emptied entry string still answers false). Their differing values reach Set.prototype.has, declared in lib.es2015.collection.d.ts, which design §5.3a step 3 classifies as a supported table read (clean), and Set methods are not wrapped by the trace (clean). Every design §2 strong condition holds. A value-flow check that vetoes this pair over-vetoes table reads." + } + ] +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/locale-compare.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/locale-compare.case.ts new file mode 100644 index 000000000..3dbd23756 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/locale-compare.case.ts @@ -0,0 +1,16 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `locale-compare` (design §8): the +// same receiver compared with two different arguments (argument position). Both orders hold in +// every Latin collation, and both fail under code-unit order. Only the value-flow check separates +// the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { compareLabels } from './src/locale-compare.js'; + +describe('locale-compare', () => { + it('orders apple before Banana', () => { + expect(compareLabels('apple', 'Banana')).toBe(-1); + }); + + it('orders apple before Damson', () => { + expect(compareLabels('apple', 'Damson')).toBe(-1); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/math-round.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/math-round.case.ts new file mode 100644 index 000000000..9855e8cc1 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/math-round.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `math-round` (design §5.3a, §8): +// 2.4 rounds down and 2.5 rounds up inside Math.round. Path, checks, context, facet and kills +// match; only the value-flow check separates the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { roundScore } from './src/math-round.js'; + +describe('math-round', () => { + it('rounds 2.4 down to 2', () => { + expect(roundScore(2.4)).toBe(2); + }); + + it('rounds 2.5 up to 3', () => { + expect(roundScore(2.5)).toBe(3); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/numeric-boundary.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/numeric-boundary.case.ts new file mode 100644 index 000000000..ca3a639e6 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/numeric-boundary.case.ts @@ -0,0 +1,16 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `numeric-boundary` (design §8, +// KTD6): 101 is PAGE_LIMIT + 1, 150 is an interior value. PAGE_LIMIT lives in a constants-only +// module two imports away. Only the facet's ±1 rule separates the two tests. Expected: Review +// (expected.json). +import { describe, expect, it } from 'vitest'; +import { clampPageSize } from './src/page-size.js'; + +describe('numeric-boundary', () => { + it('cuts a page size one above the limit', () => { + expect(clampPageSize(101)).toBe(100); + }); + + it('cuts a page size far above the limit', () => { + expect(clampPageSize(150)).toBe(100); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/object-key-row.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/object-key-row.case.ts new file mode 100644 index 000000000..95e14fac8 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/object-key-row.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), special pair `object-key-row` (design §5.4 row +// deletion, KTD10): two tests read two different keys of one three-key object-literal table. +// Deleting either key fails only the test that reads it. Expected: Row (expected.json). +import { describe, expect, it } from 'vitest'; +import { toMillis } from './src/object-key-row.js'; + +describe('object-key-row', () => { + it('converts seconds to milliseconds', () => { + expect(toMillis(2, 's')).toBe(2000); + }); + + it('converts minutes to milliseconds', () => { + expect(toMillis(2, 'min')).toBe(120000); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts new file mode 100644 index 000000000..8a184da01 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/order-dependent.case.ts @@ -0,0 +1,16 @@ +// Scenario-map controls fixture (Phase 1 U17), special pair `order-dependent` (design §5.4 full +// isolation pass, KTD9): the second test relies on the counter the first test leaves behind. In +// file order both pass; run alone, the second gets `beta-1` and fails. Expected: Inconclusive +// (expected.json). +import { describe, expect, it } from 'vitest'; +import { nextTicketLabel } from './src/order-dependent.js'; + +describe('order-dependent', () => { + it('labels the first ticket', () => { + expect(nextTicketLabel('alpha')).toBe('alpha-1'); + }); + + it('labels the second ticket', () => { + expect(nextTicketLabel('beta')).toBe('beta-2'); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/parse-int-radix.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/parse-int-radix.case.ts new file mode 100644 index 000000000..c503acd69 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/parse-int-radix.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `parse-int-radix` (design §8): the +// same text read in radix 8 and radix 16. Only the value-flow check separates the two tests. +// Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { readCode } from './src/parse-int-radix.js'; + +describe('parse-int-radix', () => { + it('reads 17 as an octal code', () => { + expect(readCode('17', 8)).toBe(15); + }); + + it('reads 17 as a hexadecimal code', () => { + expect(readCode('17', 16)).toBe(23); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/regex-alternatives.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/regex-alternatives.case.ts new file mode 100644 index 000000000..e51e9196a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/regex-alternatives.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `regex-alternatives` (design §8): +// two inputs that match different alternatives of one regex. Only the value-flow check separates +// the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { isAccessorName } from './src/regex-alternatives.js'; + +describe('regex-alternatives', () => { + it('recognises a getter name', () => { + expect(isAccessorName('getName')).toBe(true); + }); + + it('recognises a setter name', () => { + expect(isAccessorName('setName')).toBe(true); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/repo-arithmetic.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/repo-arithmetic.case.ts new file mode 100644 index 000000000..b3f866dd3 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/repo-arithmetic.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), positive control `repo-arithmetic` (design §8): +// add(2, 3) against add(5, 5) on repo-only arithmetic. Expected: Repeat, strong evidence +// (expected.json). +import { describe, expect, it } from 'vitest'; +import { add } from './src/repo-arithmetic.js'; + +describe('repo-arithmetic', () => { + it('adds 2 and 3', () => { + expect(add(2, 3)).toBe(5); + }); + + it('adds 5 and 5', () => { + expect(add(5, 5)).toBe(10); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/set-membership.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/set-membership.case.ts new file mode 100644 index 000000000..1714ccc56 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/set-membership.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), positive control `set-membership` (design §8): two +// members of one supported Set table, one test each, with identical kill sets. Expected: Row, the +// verdict the design implies (expected.json explains why it is not a repeat). +import { describe, expect, it } from 'vitest'; +import { isSuccessStatus } from './src/set-membership.js'; + +describe('set-membership', () => { + it('accepts 200 as a success status', () => { + expect(isSuccessStatus(200)).toBe(true); + }); + + it('accepts 204 as a success status', () => { + expect(isSuccessStatus(204)).toBe(true); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/set-non-member.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/set-non-member.case.ts new file mode 100644 index 000000000..fe6055018 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/set-non-member.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), positive control `set-non-member`: two different +// values reach Set.prototype.has on a supported table, a table read the value-flow check must not +// veto. Expected: Repeat, strong evidence (expected.json). +import { describe, expect, it } from 'vitest'; +import { isReservedWord } from './src/set-non-member.js'; + +describe('set-non-member', () => { + it('rejects banana as a reserved word', () => { + expect(isReservedWord('banana')).toBe(false); + }); + + it('rejects cherry as a reserved word', () => { + expect(isReservedWord('cherry')).toBe(false); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/date-boundary.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/date-boundary.ts new file mode 100644 index 000000000..95209c4bc --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/date-boundary.ts @@ -0,0 +1,8 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `date-boundary`. Day 0 of the next month rolls +// back to the last day of this one inside Date, so the leap-year rule is Date's, not this module's. + +/** Days in a month (0-based, as Date counts months) of a year, in UTC. */ +export function daysInMonth(year: number, month: number): number { + const lastDay = new Date(Date.UTC(year, month + 1, 0)); + return lastDay.getUTCDate(); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/derived-to-fixed.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/derived-to-fixed.ts new file mode 100644 index 000000000..19d67aea9 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/derived-to-fixed.ts @@ -0,0 +1,7 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `derived-to-fixed`. The input is scaled by repo +// arithmetic first, so the value that reaches Number.prototype.toFixed is derived, not the input. + +/** Formats a star rating (0 to 5) on a 0 to 50 scale with one decimal. */ +export function formatRating(stars: number): string { + return (stars * 10).toFixed(1); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/enum-member.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/enum-member.ts new file mode 100644 index 000000000..289f38741 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/enum-member.ts @@ -0,0 +1,28 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `enum-member`. The label constants feed both +// the argument a test passes and the callable-label Set, so a mutant of a constant changes both +// sides at once and no mutant tells the two members apart. + +/** Graph node labels, as a const object (an enum-like table of string members). */ +export const NodeLabel: Readonly<{ Function: 'Function'; Method: 'Method'; Class: 'Class' }> = { + Function: 'Function', + Method: 'Method', + Class: 'Class', +}; + +export type NodeLabelName = (typeof NodeLabel)[keyof typeof NodeLabel]; + +const CALLABLE_LABELS: ReadonlySet = new Set([NodeLabel.Function, NodeLabel.Method]); + +/** The node labels a graph builder has collected. */ +export class MiniGraph { + readonly #labels: NodeLabelName[] = []; + + addNode(label: NodeLabelName): void { + this.#labels.push(label); + } + + /** True when any collected node is a callable (a function or a method). */ + hasCallableNode(): boolean { + return this.#labels.some((label) => CALLABLE_LABELS.has(label)); + } +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/expected-only.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/expected-only.ts new file mode 100644 index 000000000..7938ce579 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/expected-only.ts @@ -0,0 +1,7 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `expected-only`. One rendering of a shared tag +// list; the two tests assert different parts of the same output. + +/** Renders tags as a comma-separated list of `#`-prefixed names. */ +export function formatTags(tags: readonly string[]): string { + return tags.map((tag) => `#${tag}`).join(', '); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/limits.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/limits.ts new file mode 100644 index 000000000..12994a33a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/limits.ts @@ -0,0 +1,6 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `numeric-boundary`. A constants-only module: +// it declares no function, so its code runs only while the module loads, a phase V8 per-test +// coverage discards. The facet must find PAGE_LIMIT through the static import closure (KTD6). + +/** Largest page size a listing request may ask for. */ +export const PAGE_LIMIT = 100; diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/locale-compare.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/locale-compare.ts new file mode 100644 index 000000000..d2d6df481 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/locale-compare.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `locale-compare`. The ordering is decided +// inside String.prototype.localeCompare, which Stryker cannot mutate. No locale literal is passed: +// a mutated (empty) locale string would make the call throw instead of fail an assertion. + +/** Orders two display labels with the runtime's collation: -1, 0 or 1. */ +export function compareLabels(left: string, right: string): number { + const order = left.localeCompare(right); + if (order < 0) { + return -1; + } + if (order > 0) { + return 1; + } + return 0; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/math-round.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/math-round.ts new file mode 100644 index 000000000..b754a6304 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/math-round.ts @@ -0,0 +1,11 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `math-round`. The rounding happens inside +// Math.round, which Stryker cannot mutate, so inputs on either side of the half kill the same +// mutants of this module. + +/** Rounds a raw score to whole points; a negative score counts as zero. */ +export function roundScore(raw: number): number { + if (raw < 0) { + return 0; + } + return Math.round(raw); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/object-key-row.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/object-key-row.ts new file mode 100644 index 000000000..3481464b9 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/object-key-row.ts @@ -0,0 +1,14 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `object-key-row`. A supported row-deletion +// table (design §5.4): an object literal with plain keys, read through `table[variable]`. Stryker +// never mutates object keys. + +const UNIT_FACTORS: Readonly> = { ms: 1, s: 1000, min: 60000 }; + +/** Converts an amount of a time unit to milliseconds; an unknown unit converts to 0. */ +export function toMillis(amount: number, unit: string): number { + const factor = UNIT_FACTORS[unit]; + if (factor === undefined) { + return 0; + } + return amount * factor; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/order-dependent.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/order-dependent.ts new file mode 100644 index 000000000..c19bff79d --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/order-dependent.ts @@ -0,0 +1,10 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `order-dependent`. Module-level state: every +// label depends on how many tickets earlier code in the same module instance issued. + +let issued = 0; + +/** Labels the next ticket of a queue with a process-wide running number. */ +export function nextTicketLabel(queue: string): string { + issued += 1; + return `${queue}-${issued}`; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/page-size.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/page-size.ts new file mode 100644 index 000000000..2f34acf9d --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/page-size.ts @@ -0,0 +1,11 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `numeric-boundary`. The limit comes from the +// constants-only module ./limits.ts; Stryker has no numeric-literal mutator. +import { PAGE_LIMIT } from './limits.js'; + +/** Clamps a requested page size to PAGE_LIMIT. */ +export function clampPageSize(requested: number): number { + if (requested > PAGE_LIMIT) { + return PAGE_LIMIT; + } + return requested; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/parse-int-radix.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/parse-int-radix.ts new file mode 100644 index 000000000..f0db6e04e --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/parse-int-radix.ts @@ -0,0 +1,11 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `parse-int-radix`. The radix is applied inside +// parseInt, which Stryker cannot mutate. + +/** Reads a code written in the given radix; text that is not a number reads as -1. */ +export function readCode(text: string, radix: number): number { + const value = parseInt(text, radix); + if (Number.isNaN(value)) { + return -1; + } + return value; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/regex-alternatives.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/regex-alternatives.ts new file mode 100644 index 000000000..b8d5122bf --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/regex-alternatives.ts @@ -0,0 +1,7 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `regex-alternatives`. Stryker's regex mutator +// (weapon-regex, level 1) has no operator that drops one alternative of an alternation. + +/** True when a method name reads as an accessor: `get` or `set` followed by a capital letter. */ +export function isAccessorName(name: string): boolean { + return /^(?:get|set)[A-Z]/.test(name); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/repo-arithmetic.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/repo-arithmetic.ts new file mode 100644 index 000000000..aec8a4b5a --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/repo-arithmetic.ts @@ -0,0 +1,7 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `repo-arithmetic` (design §8 positive control). +// Repo-only arithmetic: every operation is visible to coverage and mutation. + +/** Adds two numbers. */ +export function add(left: number, right: number): number { + return left + right; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/set-membership.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/set-membership.ts new file mode 100644 index 000000000..006118a74 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/set-membership.ts @@ -0,0 +1,11 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `set-membership` (design §8 positive control). +// A supported row-deletion table (design §5.4): `new Set([...])` of literal entries, read through +// `has`. The entries are numbers, which Stryker never mutates one by one, so the only table mutant +// empties the whole Set. + +const SUCCESS_STATUSES: ReadonlySet = new Set([200, 204]); + +/** True when an HTTP status code is one this client treats as success. */ +export function isSuccessStatus(status: number): boolean { + return SUCCESS_STATUSES.has(status); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/set-non-member.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/set-non-member.ts new file mode 100644 index 000000000..cc372b618 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/set-non-member.ts @@ -0,0 +1,10 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `set-non-member`. A supported Set table read +// through `has`: a table read, not an opaque call, although Set is declared in lib.*.d.ts (design +// §5.3a step 3), and not wrapped by the trace. + +const RESERVED_WORDS: ReadonlySet = new Set(['class', 'enum', 'super']); + +/** True when a word is reserved and cannot name a symbol. */ +export function isReservedWord(word: string): boolean { + return RESERVED_WORDS.has(word); +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/unicode-normalize.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/unicode-normalize.ts new file mode 100644 index 000000000..4192c9a46 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/unicode-normalize.ts @@ -0,0 +1,11 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `unicode-normalize`. The composition happens +// inside String.prototype.normalize, called on the input itself (receiver position). The form is +// left at its NFC default: a mutated (empty) form string would make the call throw. + +/** Length in UTF-16 code units of a name after canonical composition (NFC); empty names are 0. */ +export function composedLength(name: string): number { + if (name === '') { + return 0; + } + return name.normalize().length; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/src/verbatim-duplicate.ts b/gitnexus/test/fixtures/mutation-scenario-map/src/verbatim-duplicate.ts new file mode 100644 index 000000000..1fe4cba10 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/src/verbatim-duplicate.ts @@ -0,0 +1,13 @@ +// Scenario-map controls fixture (Phase 1 U17), pair `verbatim-duplicate`. Repo-only logic, no +// built-in calls. + +/** Clamps a percentage to the range 0 to 100. */ +export function clampPercent(value: number): number { + if (value < 0) { + return 0; + } + if (value > 100) { + return 100; + } + return value; +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/tsconfig.json b/gitnexus/test/fixtures/mutation-scenario-map/tsconfig.json new file mode 100644 index 000000000..dce02daa3 --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.test.json", + "include": ["src/**/*.ts", "*.case.ts"], + "exclude": [] +} diff --git a/gitnexus/test/fixtures/mutation-scenario-map/unicode-normalize.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/unicode-normalize.case.ts new file mode 100644 index 000000000..aa9aa8b6f --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/unicode-normalize.case.ts @@ -0,0 +1,16 @@ +// Scenario-map controls fixture (Phase 1 U17), adversarial pair `unicode-normalize` (design §8): +// the differing value is the receiver of String.prototype.normalize. `e` + U+0301 composes to one +// code unit; `q` + U+0301 has no composed form and stays two. Only the value-flow check separates +// the two tests. Expected: Review (expected.json). +import { describe, expect, it } from 'vitest'; +import { composedLength } from './src/unicode-normalize.js'; + +describe('unicode-normalize', () => { + it('composes e and a combining acute into one code unit', () => { + expect(composedLength('Jose\u0301')).toBe(4); + }); + + it('keeps a combining acute that has no composed form', () => { + expect(composedLength('Josq\u0301')).toBe(5); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/verbatim-duplicate.case.ts b/gitnexus/test/fixtures/mutation-scenario-map/verbatim-duplicate.case.ts new file mode 100644 index 000000000..8a5b5273c --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/verbatim-duplicate.case.ts @@ -0,0 +1,15 @@ +// Scenario-map controls fixture (Phase 1 U17), positive control `verbatim-duplicate`: two +// byte-identical test bodies with different titles. Expected: Repeat, strong evidence +// (expected.json). +import { describe, expect, it } from 'vitest'; +import { clampPercent } from './src/verbatim-duplicate.js'; + +describe('verbatim-duplicate', () => { + it('clamps a percentage above 100', () => { + expect(clampPercent(150)).toBe(100); + }); + + it('caps an oversized percentage', () => { + expect(clampPercent(150)).toBe(100); + }); +}); diff --git a/gitnexus/test/fixtures/mutation-scenario-map/vitest.config.ts b/gitnexus/test/fixtures/mutation-scenario-map/vitest.config.ts new file mode 100644 index 000000000..72ca814ba --- /dev/null +++ b/gitnexus/test/fixtures/mutation-scenario-map/vitest.config.ts @@ -0,0 +1,52 @@ +/** + * Vitest config of the scenario-map controls fixture (Phase 1 plan U17). The `controls` stage of + * tools/scenario-map (U14) runs every layer on this project, with the fixture's `src/` modules as + * production files, and compares the verdicts with expected.json. Like + * test/fixtures/mutation-conformance/vitest.config.ts, only the settings the vitest5 runner + * requires (forks, isolation, task locations) are set, so a change to gitnexus/vitest.config.ts + * cannot move the controls. Test order is pinned: the `order-dependent` pair passes only in file + * order. + * + * The fixture's tests are `*.case.ts` files, which no repo Vitest project collects. When + * GITNEXUS_MUTATION_SCOPE names a scope file (tools/mutation/scope-file.ts), its `testFiles` are + * the include list, as in tools/mutation/vitest.mutation.config.ts; without it every case file of + * the fixture runs. Include paths are relative to the `gitnexus/` package, the working directory + * of every caller. + * + * Usage, from gitnexus/: + * npx vitest run --config test/fixtures/mutation-scenario-map/vitest.config.ts + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { + MUTATION_SCOPE_ENV, + parseMutationScope, + resolveMutationScopePath, +} from '../../../tools/mutation/scope-file.js'; + +const packageDir = fileURLToPath(new URL('../../../', import.meta.url)); + +/** Every case file of the fixture, relative to the package. */ +const ALL_CASES = 'test/fixtures/mutation-scenario-map/*.case.ts'; + +const include: string[] = + (process.env[MUTATION_SCOPE_ENV] ?? '') === '' + ? [ALL_CASES] + : parseMutationScope(readFileSync(resolveMutationScopePath(process.env, packageDir), 'utf8')) + .testFiles; + +export default defineConfig({ + test: { + pool: 'forks', + isolate: true, + includeTaskLocation: true, + sequence: { shuffle: false, concurrent: false }, + // Every test finishes in milliseconds; the limits only bound a mutant that loops. + testTimeout: 5000, + hookTimeout: 5000, + teardownTimeout: 1000, + dangerouslyIgnoreUnhandledErrors: false, + projects: [{ extends: true, test: { name: 'scenario-map-controls', include } }], + }, +}); diff --git a/gitnexus/test/unit/mutation/blank-assertions.test.ts b/gitnexus/test/unit/mutation/blank-assertions.test.ts new file mode 100644 index 000000000..41e667213 --- /dev/null +++ b/gitnexus/test/unit/mutation/blank-assertions.test.ts @@ -0,0 +1,180 @@ +/** + * Locks the `as` assertion blanking of the mutation prepare step (KTD7): every + * `TSAsExpression` is replaced by spaces of equal length so line and column positions are + * unchanged, and a file is accepted only when its blanked AST equals the original AST with + * every assertion unwrapped. + */ +import assert from 'node:assert/strict'; +import { describe, expect, it } from 'vitest'; +import { blankAssertions, blankScope } from '../../../tools/mutation/prepare/blank-assertions.js'; + +const spaces = (text: string): string => ' '.repeat(text.length); +/** The blanked text of a source that must pass the AST-equality check. */ +const blankedText = (source: string): string => { + const result = blankAssertions(source); + assert.ok(result.ok, 'expected the AST-equality check to pass'); + return result.text; +}; + +describe('blankAssertions: equal-length blanking', () => { + it('blanks `as const` and keeps `]` and `;` at their original columns', () => { + const source = "const T = ['a', 'b'] as const;"; + const text = blankedText(source); + + expect(blankAssertions(source)).toEqual({ + ok: true, + assertions: 1, + text: `const T = ['a', 'b']${spaces(' as const')};`, + }); + expect([text.length, text.indexOf(']'), text.indexOf(';')]).toEqual([ + source.length, + source.indexOf(']'), + source.indexOf(';'), + ]); + }); + + it('keeps `satisfies` and blanks only the `as const` it wraps', () => { + const source = 'const T = [1, 2] as const satisfies Record;'; + + expect(blankAssertions(source)).toEqual({ + ok: true, + assertions: 1, + text: `const T = [1, 2]${spaces(' as const')} satisfies Record;`, + }); + }); + + it('blanks both assertions of a nested `x as unknown as T`', () => { + expect(blankAssertions('const y = x as unknown as T;')).toEqual({ + ok: true, + assertions: 2, + text: `const y = x${spaces(' as unknown as T')};`, + }); + }); + + it('keeps the newlines of a multi-line asserted type so later lines keep their numbers', () => { + const source = [ + 'const v = x as {', + ' a: string;', + ' b: number;', + '};', + 'const after = 1;', + ].join('\n'); + + expect(blankedText(source).split('\n')).toEqual([ + `const v = x${spaces(' as {')}`, + spaces(' a: string;'), + spaces(' b: number;'), + ' ;', + 'const after = 1;', + ]); + }); + + it('keeps CRLF line endings inside a blanked type', () => { + expect(blankAssertions('const v = x as {\r\n a: 1 };\r\nf();')).toEqual({ + ok: true, + assertions: 1, + text: `const v = x${spaces(' as {')}\r\n${spaces(' a: 1 }')};\r\nf();`, + }); + }); + + it('keeps the non-null `!` operator', () => { + expect(blankAssertions('const n = (m as M)!.k;')).toEqual({ + ok: true, + assertions: 1, + text: `const n = (m${spaces(' as M')})!.k;`, + }); + }); + + it('keeps the parentheses around a parenthesised operand and a parenthesised type', () => { + expect(blankAssertions("const p = (['a']) as const, q = x as (A | B);")).toEqual({ + ok: true, + assertions: 2, + text: `const p = (['a'])${spaces(' as const')}, q = x${spaces(' as (A | B)')};`, + }); + }); + + it('blanks a comment that sits inside the assertion', () => { + expect(blankAssertions('const c = x as /* why */ T;')).toEqual({ + ok: true, + assertions: 1, + text: `const c = x${spaces(' as /* why */ T')};`, + }); + }); +}); + +describe('blankAssertions: AST-equality check', () => { + it.each([ + [ + 'a parenthesised member access', + 'const b = (a as T).b;', + `const b = (a${spaces(' as T')}).b;`, + ], + ['an arrow body', 'const f = () => x as T;', `const f = () => x${spaces(' as T')};`], + ['an assignment target', '(x as T) = y;', `(x${spaces(' as T')}) = y;`], + ])('accepts %s', (_label, source, text) => { + expect(blankAssertions(source)).toEqual({ ok: true, assertions: 1, text }); + }); + + it.each([ + ['operator precedence', 'const r = a + b as T * c;'], + ['a next line starting with `(` after no semicolon', 'const f = g as Fn\n(h as H).call();'], + ['a next line starting with `[` after no semicolon', 'const a = b as B\n[1].forEach(f);'], + ['a next line starting with a template literal', 'const s = t as Tag\n`x`;'], + ])('rejects blanking that changes the tree: %s', (_label, source) => { + expect(blankAssertions(source)).toMatchObject({ + ok: false, + reason: expect.stringMatching(/^AST differs/), + }); + }); + + it('rejects a source that does not parse', () => { + expect(blankAssertions('const = ;')).toMatchObject({ + ok: false, + assertions: 0, + reason: expect.stringMatching(/^original does not parse/), + }); + }); +}); + +describe('blankAssertions: files without `as` assertions', () => { + it.each([ + ['plain code', 'export function f(a: number): number {\n return a + 1;\n}\n'], + ['`satisfies` and non-null `!` only', 'const c = { a: 1 } satisfies R;\nconst n = m!.k;\n'], + [ + '`as` in strings, comments and import renames', + "import { a as b } from 'm';\n// x as y\nconst s = 'p as q';\n", + ], + ['an angle-bracket assertion', 'const n = v;\n'], + ])('leaves %s byte-identical', (_label, source) => { + expect(blankAssertions(source)).toEqual({ ok: true, assertions: 0, text: source }); + }); +}); + +describe('blankScope', () => { + it('reports a file that fails the AST check instead of writing it', () => { + const scope = blankScope([ + { path: 'gitnexus/src/good.ts', source: 'export const T = [1] as const;\n' }, + { path: 'gitnexus/src/bad.ts', source: 'export const r = a + b as T * c;\n' }, + ]); + + expect(scope.written).toEqual([ + { + path: 'gitnexus/src/good.ts', + assertions: 1, + text: `export const T = [1]${spaces(' as const')};\n`, + }, + ]); + expect(scope.failed).toEqual([ + { path: 'gitnexus/src/bad.ts', reason: expect.stringMatching(/^AST differs/) }, + ]); + }); + + it('writes a file with no assertions unchanged', () => { + const source = 'export const x = 1;\n'; + + expect(blankScope([{ path: 'gitnexus/src/plain.ts', source }])).toEqual({ + written: [{ path: 'gitnexus/src/plain.ts', assertions: 0, text: source }], + failed: [], + }); + }); +}); diff --git a/gitnexus/test/unit/mutation/classify-outcome.test.ts b/gitnexus/test/unit/mutation/classify-outcome.test.ts new file mode 100644 index 000000000..fa2ad7a5a --- /dev/null +++ b/gitnexus/test/unit/mutation/classify-outcome.test.ts @@ -0,0 +1,365 @@ +/** + * Locks the KTD17 outcome-class table of the vitest5 runner: every observation row maps to its + * class and to the status the runner reports to Stryker, from synthetic task results. + */ +import { describe, expect, it } from 'vitest'; +import { + classifyRun, + type ObservedError, + type ObservedModule, + type ObservedRun, + type ObservedTest, + type RequestedTest, +} from '../../../tools/mutation/runner/classify-outcome.js'; + +const FILE = 'test/unit/a.test.ts'; + +const requested = (index: number, file = FILE): RequestedTest => ({ + id: `${file}#S > t${index}`, + taskId: `${file}_${index}`, + fullName: `S > t${index}`, + file, +}); + +const error = (name: string, message: string): ObservedError => ({ name, message }); + +const test = (index: number, overrides: Partial = {}, file = FILE): ObservedTest => ({ + taskId: `${file}_${index}`, + fullName: `S > t${index}`, + state: 'passed', + errors: [], + failedHooks: [], + retry: 0, + ...overrides, +}); + +const module = ( + tests: ObservedTest[], + overrides: Partial = {}, +): ObservedModule => ({ + file: FILE, + collected: true, + errors: [], + failedHooks: [], + suites: [ + { taskId: 'suite-S', failedHooks: [], errors: [], testTaskIds: tests.map((t) => t.taskId) }, + ], + tests, + hitCount: 0, + ...overrides, +}); + +const run = (modules: ObservedModule[], overrides: Partial = {}): ObservedRun => ({ + requested: [requested(0), requested(1)], + modules, + unhandledErrors: [], + hitLimit: 1000, + ...overrides, +}); + +const assertionError = error('AssertionError', 'expected 1 to be 2'); + +describe('classifyRun: killed-by-test rows', () => { + it.each([ + [ + 'an AssertionError', + test(1, { state: 'failed', errors: [assertionError] }), + 'killed-by-test:assertion', + ], + [ + 'another error', + test(1, { state: 'failed', errors: [error('TypeError', 'x is not a function')] }), + 'killed-by-test:runtime', + ], + [ + 'a failing beforeEach', + test(1, { state: 'failed', errors: [assertionError], failedHooks: ['beforeEach'] }), + 'killed-by-test:hook', + ], + [ + 'a failing afterEach after a passing body', + test(1, { state: 'failed', errors: [error('Error', 'cleanup')], failedHooks: ['afterEach'] }), + 'killed-by-test:hook', + ], + [ + 'a failing body followed by a failing afterEach', + test(1, { + state: 'failed', + errors: [assertionError, error('Error', 'cleanup')], + failedHooks: ['afterEach'], + }), + 'killed-by-test:assertion', + ], + ])('classifies a test that fails with %s', (_label, failing, outcome) => { + const result = classifyRun(run([module([test(0), failing])])); + + expect(result).toMatchObject({ status: 'killed', outcome, killedBy: [`${FILE}#S > t1`] }); + expect(result.tests).toEqual([ + { id: `${FILE}#S > t0`, outcome: 'survived', message: undefined }, + { id: `${FILE}#S > t1`, outcome, message: failing.errors[0].message }, + ]); + }); + + it('reports every failing test as a killer', () => { + const failing = { state: 'failed', errors: [assertionError] } as const; + + expect(classifyRun(run([module([test(0, failing), test(1, failing)])])).killedBy).toEqual([ + `${FILE}#S > t0`, + `${FILE}#S > t1`, + ]); + }); +}); + +describe('classifyRun: killed-outside-test rows', () => { + it('attributes a suite beforeAll failure to the suite tests that it skipped', () => { + const skipped = { state: 'skipped' } as const; + const result = classifyRun( + run([ + module([test(0, skipped), test(1, skipped)], { + suites: [ + { + taskId: 'suite-S', + failedHooks: ['beforeAll'], + errors: [error('Error', 'connect failed')], + testTaskIds: [`${FILE}_0`, `${FILE}_1`], + }, + ], + }), + ]), + ); + + expect(result).toMatchObject({ + status: 'killed', + outcome: 'killed-outside-test:hook', + killedBy: [`${FILE}#S > t0`, `${FILE}#S > t1`], + outsideTest: [ + { + file: FILE, + suiteTaskId: 'suite-S', + outcome: 'killed-outside-test:hook', + attributedTo: [`${FILE}#S > t0`, `${FILE}#S > t1`], + message: 'connect failed', + }, + ], + }); + }); + + it('attributes a file-level beforeAll failure to every requested test of the file', () => { + const skipped = { state: 'skipped' } as const; + const result = classifyRun( + run([ + module([test(0, skipped), test(1, skipped)], { + failedHooks: ['beforeAll'], + errors: [error('Error', 'file setup failed')], + }), + ]), + ); + + expect(result).toMatchObject({ + status: 'killed', + killedBy: [`${FILE}#S > t0`, `${FILE}#S > t1`], + }); + }); + + it('kills without test attribution when afterAll fails and the tests stay passing', () => { + const result = classifyRun( + run([ + module([test(0), test(1)], { + suites: [ + { + taskId: 'suite-S', + failedHooks: ['afterAll'], + errors: [error('Error', 'teardown failed')], + testTaskIds: [`${FILE}_0`, `${FILE}_1`], + }, + ], + }), + ]), + ); + + expect(result).toMatchObject({ + status: 'killed', + outcome: 'killed-outside-test:hook', + killedBy: [], + outsideTest: [{ suiteTaskId: 'suite-S', attributedTo: [], message: 'teardown failed' }], + }); + }); + + it('kills without test attribution when a test file fails to import', () => { + const result = classifyRun( + run([ + module([], { + collected: false, + suites: [], + errors: [error('SyntaxError', 'Unexpected token')], + hitCount: undefined, + }), + ]), + ); + + expect(result).toMatchObject({ + status: 'killed', + outcome: 'killed-outside-test:collection', + killedBy: [], + nrOfTests: 0, + outsideTest: [{ file: FILE, outcome: 'killed-outside-test:collection', attributedTo: [] }], + }); + }); +}); + +describe('classifyRun: timeout rows', () => { + it('reports Timeout when the summed hit count passes the hit limit, even with passing tests', () => { + const result = classifyRun( + run( + [ + module([test(0)], { hitCount: 60 }), + module([test(1)], { file: 'test/unit/b.test.ts', hitCount: 50 }), + ], + { + requested: [requested(0), { ...requested(1), file: 'test/unit/b.test.ts' }], + hitLimit: 100, + }, + ), + ); + + expect(result).toMatchObject({ + status: 'timeout', + outcome: 'timeout', + reason: 'Hit limit reached (110/100)', + }); + }); + + it('reports Timeout when the hit-limit error stopped collection', () => { + const result = classifyRun( + run([ + module([], { + collected: false, + suites: [], + errors: [error('Error', 'Stryker: Hit count limit reached (101)')], + hitCount: undefined, + }), + ]), + ); + + expect(result).toMatchObject({ status: 'timeout', outcome: 'timeout' }); + }); + + it.each([ + ['testTimeout', 'Test timed out in 5000ms.\nIf this is a long-running test, ...'], + ['hookTimeout', 'Hook timed out in 10000ms.\nIf this is a long-running hook, ...'], + ])('reports Timeout for a test that fails with Vitest %s', (_label, message) => { + const result = classifyRun( + run([module([test(0), test(1, { state: 'failed', errors: [error('Error', message)] })])]), + ); + + expect(result).toMatchObject({ status: 'timeout', outcome: 'timeout', killedBy: [] }); + expect(result.tests[1]).toEqual({ id: `${FILE}#S > t1`, outcome: 'timeout', message }); + }); + + it('reports Timeout for a beforeAll that hits Vitest hookTimeout', () => { + const skipped = { state: 'skipped' } as const; + const result = classifyRun( + run([ + module([test(0, skipped), test(1, skipped)], { + suites: [ + { + taskId: 'suite-S', + failedHooks: ['beforeAll'], + errors: [error('Error', 'Hook timed out in 120000ms.')], + testTaskIds: [`${FILE}_0`, `${FILE}_1`], + }, + ], + }), + ]), + ); + + expect(result).toMatchObject({ status: 'timeout', outcome: 'timeout', killedBy: [] }); + }); + + it('prefers a kill over a Vitest timeout in another test', () => { + const result = classifyRun( + run([ + module([ + test(0, { state: 'failed', errors: [error('Error', 'Test timed out in 5000ms.')] }), + test(1, { state: 'failed', errors: [assertionError] }), + ]), + ]), + ); + + expect(result).toMatchObject({ status: 'killed', killedBy: [`${FILE}#S > t1`] }); + }); +}); + +describe('classifyRun: error rows', () => { + it('reports an unhandled error as infrastructure', () => { + const result = classifyRun( + run([module([test(0), test(1)])], { + unhandledErrors: [error('Error', 'socket hang up')], + }), + ); + + expect(result).toMatchObject({ status: 'error', outcome: 'infrastructure' }); + expect(result.reason).toContain('socket hang up'); + }); + + it('reports a selected test with retry above zero as inconclusive', () => { + const result = classifyRun(run([module([test(0), test(1, { retry: 2 })])])); + + expect(result).toMatchObject({ status: 'error', outcome: 'inconclusive' }); + expect(result.reason).toContain('retry'); + }); + + it.each([ + ['zero requested tests', run([module([test(0), test(1)])], { requested: [] }), /zero tests/], + [ + 'a requested test that did not run', + run([module([test(0), test(1, { state: 'skipped' })])]), + /did not run/, + ], + [ + 'a test that ran without being requested', + run([module([test(0), test(1), test(2)])]), + /not requested/, + ], + [ + 'a task id whose full name changed', + run([module([test(0), test(1, { fullName: 'S > renamed' })])]), + /full name/, + ], + ['a requested file that produced no module', run([]), /did not run/], + ])('reports %s as invalid', (_label, observed, reason) => { + const result = classifyRun(observed); + + expect(result).toMatchObject({ status: 'error', outcome: 'invalid' }); + expect(result.reason).toMatch(reason); + }); +}); + +describe('classifyRun: survived row', () => { + it('reports Survived when every requested test passes', () => { + expect(classifyRun(run([module([test(0), test(1)])]))).toEqual({ + status: 'survived', + outcome: 'survived', + reason: undefined, + killedBy: [], + failureMessage: undefined, + nrOfTests: 2, + hitCount: 0, + tests: [ + { id: `${FILE}#S > t0`, outcome: 'survived', message: undefined }, + { id: `${FILE}#S > t1`, outcome: 'survived', message: undefined }, + ], + outsideTest: [], + }); + }); + + it('ignores tests that the selection skipped', () => { + const result = classifyRun( + run([module([test(0), test(1), test(2, { state: 'skipped' })])], { + requested: [requested(0), requested(1)], + }), + ); + + expect(result).toMatchObject({ status: 'survived', nrOfTests: 2 }); + }); +}); diff --git a/gitnexus/test/unit/mutation/classify.test.ts b/gitnexus/test/unit/mutation/classify.test.ts new file mode 100644 index 000000000..e08d5c70a --- /dev/null +++ b/gitnexus/test/unit/mutation/classify.test.ts @@ -0,0 +1,574 @@ +/** + * Locks the pure-JS category classifier (U5, KTD11): which `test/unit` files a mutation run may + * use without native code, workers, child processes, or file reads that a prepared copy would + * break. Real-repo cases run against the checkout; synthetic cases build a small repo in a temp + * directory (`gitnexus/`, `gitnexus-shared/`, `node_modules/`) and classify one test file in it. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + classify, + renderSummary, + type Classification, + type FileVerdict, +} from '../../../tools/mutation/classify/classify.js'; + +const REPO_ROOT = fileURLToPath(new URL('../../../../', import.meta.url)); + +// ── Real repo ────────────────────────────────────────────────────────────── + +const REAL_ACCEPTED = [ + 'test/unit/shared-type-extractors.test.ts', + 'test/unit/extract-element-type-from-string.test.ts', + 'test/unit/schema.test.ts', +]; + +const REAL_REJECTED: readonly (readonly [file: string, reason: string])[] = [ + ['test/unit/receiver-twin-list-drift.test.ts', 'reads source outside gitnexus/'], + ['test/unit/type-env.test.ts', 'native package: tree-sitter'], + ['test/unit/node-module-compat.test.ts', 'spawns processes: child_process'], + // Found by gate 1's runtime load check: commander loads node:child_process when imported. + ['test/unit/cli-command-banner.test.ts', 'spawns processes: child_process'], +]; + +let realResult: Classification | undefined; +const real = (): Classification => + (realResult ??= classify({ + repoRoot: REPO_ROOT, + testFiles: [...REAL_ACCEPTED, ...REAL_REJECTED.map(([file]) => file)], + })); + +const verdictIn = (result: Classification, file: string): FileVerdict | undefined => + result.files.find((verdict) => verdict.file === file); + +describe('classify on the real repo', () => { + // Walking the real import closures takes about 30 s on a busy host; do it once, with headroom. + beforeAll(() => { + real(); + }, 120_000); + + it.each(REAL_ACCEPTED)('accepts %s', (file) => { + expect(verdictIn(real(), file)).toMatchObject({ status: 'accepted', reasons: [] }); + }); + + it.each(REAL_REJECTED)('rejects %s with "%s"', (file, reason) => { + expect(verdictIn(real(), file)?.status).toBe('rejected'); + expect(verdictIn(real(), file)?.reasons).toContain(reason); + }); + + it('finds child_process in node-module-compat through the dynamic import of runtime-install', () => { + const evidence = verdictIn(real(), 'test/unit/node-module-compat.test.ts')?.evidence.find( + (entry) => entry.reason === 'spawns processes: child_process', + ); + expect(evidence).toMatchObject({ + module: 'gitnexus/src/core/embeddings/runtime-install.ts', + via: [ + 'gitnexus/test/unit/node-module-compat.test.ts', + 'gitnexus/src/core/embeddings/runtime-install.ts', + ], + }); + }); + + it('resolves the gitnexus-shared alias into gitnexus-shared/src for schema.test.ts', () => { + expect(verdictIn(real(), 'test/unit/schema.test.ts')?.closure).toContain( + 'gitnexus-shared/src/lbug/schema-constants.ts', + ); + }); +}); + +// ── Synthetic repos ──────────────────────────────────────────────────────── + +const BASE_FILES: Readonly> = { + 'gitnexus/package.json': '{ "name": "gitnexus", "type": "module" }\n', + 'gitnexus-shared/package.json': JSON.stringify({ + name: 'gitnexus-shared', + type: 'module', + exports: { + '.': { types: './dist/index.d.ts', default: './dist/index.js' }, + './test-helpers': { types: './dist/test-helpers.d.ts', default: './dist/test-helpers.js' }, + }, + }), + 'gitnexus-shared/src/index.ts': "export { SHARED } from './constants.js';\n", + 'gitnexus-shared/src/constants.ts': "export const SHARED = 'shared';\n", + 'gitnexus-shared/src/test-helpers.ts': 'export const helper = (): number => 1;\n', + 'gitnexus/src/pure.ts': 'export const add = (a: number, b: number): number => a + b;\n', + 'gitnexus/src/x.ts': 'export const x = 1;\n', + 'gitnexus/src/spawn.ts': + "import { execFileSync } from 'node:child_process';\nexport const run = () => execFileSync('true');\n", + 'gitnexus/node_modules/native-addon/package.json': + '{ "name": "native-addon", "main": "index.js" }', + 'gitnexus/node_modules/native-addon/index.js': + "module.exports = require('./build/Release/a.node');\n", + 'gitnexus/node_modules/native-addon/build/Release/a.node': 'binary', + 'gitnexus/node_modules/gyp-addon/package.json': '{ "name": "gyp-addon", "main": "index.js" }', + 'gitnexus/node_modules/gyp-addon/binding.gyp': '{}', + 'gitnexus/node_modules/gyp-addon/index.js': 'module.exports = {};\n', + 'gitnexus/node_modules/wrapper/package.json': + '{ "name": "wrapper", "main": "index.js", "dependencies": { "native-addon": "1.0.0" } }', + 'gitnexus/node_modules/wrapper/index.js': 'module.exports = {};\n', + 'gitnexus/node_modules/plain-js/package.json': + '{ "name": "plain-js", "main": "index.js", "dependencies": { "missing-optional": "1.0.0" } }', + 'gitnexus/node_modules/plain-js/index.js': 'module.exports = {};\n', +}; + +const TEST_FILE = 'test/unit/a.test.ts'; +const TEST_PATH = `gitnexus/${TEST_FILE}`; + +const roots: string[] = []; + +afterAll(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); + +function makeRepo(files: Readonly>): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-classify-')); + roots.push(root); + for (const [relative, text] of Object.entries({ ...BASE_FILES, ...files })) { + const absolute = path.join(root, relative); + mkdirSync(path.dirname(absolute), { recursive: true }); + writeFileSync(absolute, text); + } + return root; +} + +/** Classifies the single test file `gitnexus/test/unit/a.test.ts` of a synthetic repo. */ +function classifyTest( + testSource: string, + extra: Readonly> = {}, +): FileVerdict { + const root = makeRepo({ ...extra, [TEST_PATH]: testSource }); + return classify({ repoRoot: root, testFiles: [TEST_FILE] }).files[0]; +} + +const NODE_IMPORTS = [ + "import { readFileSync, mkdtempSync } from 'node:fs';", + "import os from 'node:os';", + "import path from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "import { it, expect, vi } from 'vitest';", +].join('\n'); + +const withTest = (body: string): string => + `${NODE_IMPORTS}\n${body}\nit('t', () => expect(1).toBe(1));\n`; + +const CREATE_REQUIRE_LOADER = (specifier: string, base = 'import.meta.url'): string => + [ + "import { createRequire } from 'node:module';", + `const require = createRequire(${base});`, + `export const binding = require(${specifier});`, + ].join('\n'); + +const REJECTIONS: readonly (readonly [ + label: string, + testSource: string, + extra: Readonly>, + reason: string, +])[] = [ + [ + 'reads src/x.ts as text through __dirname', + withTest("const text = readFileSync(path.join(__dirname, '../../src/x.ts'), 'utf8');"), + {}, + 'reads source under src/', + ], + [ + 'reads src/x.ts as text through import.meta.url', + withTest("const text = readFileSync(new URL('../../src/x.ts', import.meta.url), 'utf8');"), + {}, + 'reads source under src/', + ], + [ + 'reads src/x.ts by a cwd-relative literal path', + withTest("const text = readFileSync('src/x.ts', 'utf8');"), + {}, + 'reads source under src/', + ], + [ + 'reads src/x.ts through const-bound roots', + withTest( + [ + 'const here = path.dirname(fileURLToPath(import.meta.url));', + "const ROOT = path.resolve(here, '..', '..');", + "const text = readFileSync(path.join(ROOT, 'src', 'x.ts'), 'utf8');", + ].join('\n'), + ), + {}, + 'reads source under src/', + ], + [ + 'reads src/x.ts through a function-local require of path behind a type assertion', + withTest( + [ + "it('reads', () => {", + " const p = require('node:path') as typeof import('node:path');", + " const fsLocal = require('node:fs') as typeof import('node:fs');", + " const source = fsLocal.readFileSync(p.resolve(__dirname, '../../src/x.ts'), 'utf8');", + ' expect(source).toBeTruthy();', + '});', + ].join('\n'), + ), + {}, + 'reads source under src/', + ], + [ + 'reads src/x.ts through a name that another function binds differently', + withTest( + [ + 'export function temp() {', + ' const here = os.tmpdir();', + " return readFileSync(path.join(here, 'a.txt'), 'utf8');", + '}', + 'export function source() {', + ' const here = path.dirname(fileURLToPath(import.meta.url));', + " return readFileSync(path.resolve(here, '../../src/x.ts'), 'utf8');", + '}', + ].join('\n'), + ), + {}, + 'reads source under src/', + ], + [ + 'names a file under src/ with an unknown name', + withTest( + "export const read = (name: string) => readFileSync(path.join(__dirname, '../../src', name));", + ), + {}, + 'reads source under src/', + ], + [ + 'reads test fixtures', + withTest("const text = readFileSync(path.join(__dirname, '../fixtures/repo/a.ts'), 'utf8');"), + {}, + 'reads test fixtures', + ], + [ + 'reads a file outside gitnexus/', + withTest( + "const text = readFileSync(path.join(__dirname, '../../../gitnexus-shared/src/index.ts'), 'utf8');", + ), + {}, + 'reads source outside gitnexus/', + ], + [ + 'names the package root with an unknown suffix', + withTest( + "export const read = (name: string) => readFileSync(path.join(__dirname, '../..', name));", + ), + {}, + 'unresolvable file path', + ], + [ + 'concatenates an unknown suffix onto the package root', + withTest("export const read = (name: string) => readFileSync(process.cwd() + name, 'utf8');"), + {}, + 'unresolvable file path', + ], + [ + 'loads a native package through createRequire in a production module', + withTest("import { binding } from '../../src/loader.js';\nconsole.log(binding);"), + { 'gitnexus/src/loader.ts': CREATE_REQUIRE_LOADER("'native-addon'") }, + 'native package: native-addon', + ], + [ + 'loads a local .node binding through createRequire', + withTest("import { binding } from '../../src/loader.js';\nconsole.log(binding);"), + { + 'gitnexus/src/loader.ts': CREATE_REQUIRE_LOADER("'./addon.node'"), + 'gitnexus/src/addon.node': 'binary', + }, + 'native binding: gitnexus/src/addon.node', + ], + [ + 'imports a package that depends on a native package', + withTest("import wrapper from 'wrapper';\nconsole.log(wrapper);"), + {}, + 'native package: native-addon', + ], + [ + 'imports a package with binding.gyp', + withTest("import gyp from 'gyp-addon';\nconsole.log(gyp);"), + {}, + 'native package: gyp-addon', + ], + [ + 'imports a package known to load child_process when imported', + withTest("import { Command } from 'commander';\nconsole.log(new Command());"), + {}, + 'spawns processes: child_process', + ], + [ + 'imports a known native package that is not installed', + withTest("import Parser from 'tree-sitter';\nconsole.log(new Parser());"), + {}, + 'native package: tree-sitter', + ], + [ + 'mocks a native package with a factory that receives importOriginal', + withTest( + "vi.mock('tree-sitter', async (importOriginal) => ({ ...(await importOriginal()) }));", + ), + {}, + 'native package: tree-sitter', + ], + [ + 'imports a computed specifier', + withTest('export const load = (name: string) => import(name);'), + {}, + 'unresolvable dynamic import', + ], + [ + 'imports a template specifier with an expression', + withTest('export const load = (name: string) => import(`../../src/${name}.js`);'), + {}, + 'unresolvable dynamic import', + ], + [ + 'requires a computed specifier in a production module', + withTest("import { binding } from '../../src/loader.js';\nconsole.log(binding);"), + { 'gitnexus/src/loader.ts': 'export const binding = (name: string) => require(name);\n' }, + 'unresolvable dynamic import', + ], + [ + 'requires a relative specifier through createRequire with an unknown base', + withTest("import { binding } from '../../src/loader.js';\nconsole.log(binding);"), + { 'gitnexus/src/loader.ts': CREATE_REQUIRE_LOADER("'./x.js'", 'process.argv[1]') }, + 'unresolvable dynamic import', + ], + [ + 'loads a computed specifier through vi.importActual', + withTest('export const load = (name: string) => vi.importActual(name);'), + {}, + 'unresolvable dynamic import', + ], + [ + 'reaches child_process through a const-bound dynamic import', + withTest("const MOD = '../../src/spawn.js';\nexport const load = () => import(MOD);"), + {}, + 'spawns processes: child_process', + ], + [ + 'imports Worker from worker_threads', + withTest("import { Worker } from 'node:worker_threads';\nconsole.log(Worker);"), + {}, + 'spawns workers: worker_threads', + ], + [ + 'imports a module outside gitnexus/ by a relative path', + withTest( + "import { SHARED } from '../../../gitnexus-shared/src/index.js';\nconsole.log(SHARED);", + ), + {}, + 'imports outside gitnexus/', + ], + [ + 'imports a module from test/fixtures', + withTest("import { fixture } from '../fixtures/data.js';\nconsole.log(fixture);"), + { 'gitnexus/test/fixtures/data.ts': 'export const fixture = 1;\n' }, + 'imports from ignored dir: test/fixtures/', + ], + [ + 'imports a missing relative module', + withTest("import { missing } from '../../src/missing.js';\nconsole.log(missing);"), + {}, + 'unresolved import: ../../src/missing.js', + ], +]; + +const ACCEPTANCES: readonly (readonly [ + label: string, + testSource: string, + extra: Readonly>, +])[] = [ + [ + 'a production closure that imports fs while the test reads no files', + withTest("import { add } from '../../src/io.js';\nconsole.log(add(1, 2));"), + { + 'gitnexus/src/io.ts': [ + "import { readFileSync } from 'node:fs';", + "export const read = (file: string) => readFileSync(file, 'utf8');", + 'export const add = (a: number, b: number) => a + b;', + ].join('\n'), + }, + ], + [ + 'a test that reads files in its own temp directory', + withTest( + [ + "const dir = mkdtempSync(path.join(os.tmpdir(), 'x-'));", + "export const text = () => readFileSync(path.join(dir, 'a.txt'), 'utf8');", + ].join('\n'), + ), + {}, + ], + [ + 'a test that reads a data file next to itself', + withTest("const data = readFileSync(path.join(__dirname, 'data.json'), 'utf8');"), + { 'gitnexus/test/unit/data.json': '{}' }, + ], + [ + 'a path concatenated from process.cwd() and path.sep', + withTest("const pkg = readFileSync(process.cwd() + path.sep + 'package.json', 'utf8');"), + {}, + ], + [ + 'type-only and type-position imports of native packages', + withTest( + [ + "import type Parser from 'tree-sitter';", + "import { type Language } from 'tree-sitter-typescript';", + "import { SyntaxNode } from 'tree-sitter';", + 'let node: SyntaxNode | Parser | Language | undefined;', + 'console.log(node);', + ].join('\n'), + ), + {}, + ], + [ + 'worker_threads imported without Worker', + withTest("import { isMainThread } from 'node:worker_threads';\nconsole.log(isMainThread);"), + {}, + ], + [ + 'a native package mocked with a factory that does not load the original', + withTest("vi.mock('tree-sitter', () => ({ default: class {} }));"), + {}, + ], + [ + 'a const-bound dynamic import of a pure module', + withTest("const MOD = '../../src/pure.js';\nexport const load = () => import(MOD);"), + {}, + ], + [ + 'a package whose missing dependency is not installed', + withTest("import plain from 'plain-js';\nconsole.log(plain);"), + {}, + ], + [ + 'the gitnexus-shared aliases', + withTest( + [ + "import { SHARED } from 'gitnexus-shared';", + "import { helper } from 'gitnexus-shared/test-helpers';", + 'console.log(SHARED, helper);', + ].join('\n'), + ), + {}, + ], +]; + +describe('classify on synthetic repos', () => { + it.each(REJECTIONS)('rejects a test that %s', (_label, testSource, extra, reason) => { + const verdict = classifyTest(testSource, extra); + expect(verdict.status).toBe('rejected'); + expect(verdict.reasons).toContain(reason); + }); + + it.each(ACCEPTANCES)('accepts %s', (_label, testSource, extra) => { + expect(classifyTest(testSource, extra)).toMatchObject({ status: 'accepted', reasons: [] }); + }); + + it('records the local closure, packages and builtins of an accepted file', () => { + const verdict = classifyTest( + withTest( + [ + "import { SHARED } from 'gitnexus-shared';", + "import plain from 'plain-js';", + "const MOD = '../../src/pure.js';", + 'export const load = () => import(MOD);', + 'console.log(SHARED, plain, path.sep);', + ].join('\n'), + ), + ); + expect(verdict.closure).toEqual([ + 'gitnexus-shared/src/constants.ts', + 'gitnexus-shared/src/index.ts', + 'gitnexus/src/pure.ts', + TEST_PATH, + ]); + expect(verdict.packages).toEqual(['plain-js', 'vitest']); + expect(verdict.builtins).toEqual(['path']); + }); + + it('gives each rejection reason its module, line and import chain', () => { + const verdict = classifyTest( + withTest("import { load } from '../helpers/load.js';\nconsole.log(load);"), + { 'gitnexus/test/helpers/load.ts': "export { run as load } from '../../src/spawn.js';\n" }, + ); + expect(verdict.evidence).toEqual([ + { + reason: 'spawns processes: child_process', + module: 'gitnexus/src/spawn.ts', + line: 1, + detail: 'imports node:child_process', + via: [TEST_PATH, 'gitnexus/test/helpers/load.ts', 'gitnexus/src/spawn.ts'], + }, + ]); + }); + + it('counts hooks, each-calls and test call sites of the test file', () => { + const verdict = classifyTest( + [ + "import { beforeAll, describe, it, expect } from 'vitest';", + 'let n = 0;', + 'beforeAll(() => { n = 1; });', + "describe('d', () => {", + ' beforeAll(() => { n += 1; });', + " it('one', () => expect(n).toBe(2));", + " it.each([1, 2])('row %i', (v) => expect(v).toBeGreaterThan(0));", + '});', + ].join('\n'), + ); + expect(verdict).toMatchObject({ + status: 'accepted', + hooks: { beforeAll: 2, beforeEach: 0, afterAll: 0, afterEach: 0 }, + eachCalls: 1, + testCalls: 2, + }); + }); +}); + +describe('classify over a test root', () => { + const root = (): string => + makeRepo({ + 'gitnexus/test/unit/b.test.ts': withTest( + "import { add } from '../../src/pure.js';\nadd(1, 2);", + ), + 'gitnexus/test/unit/nested/c.test.ts': withTest( + "import { run } from '../../../src/spawn.js';\nbeforeAll(() => run());", + ), + 'gitnexus/test/unit/hooks.test.ts': withTest( + "import { add } from '../../src/pure.js';\nbeforeAll(() => add(1, 2));", + ), + 'gitnexus/test/unit/helper.ts': 'export const h = 1;\n', + }); + + it('lists every *.test.ts file under the root, sorted, and groups the verdicts', () => { + const result = classify({ repoRoot: root(), testRoot: 'test/unit' }); + expect(result.files.map((verdict) => verdict.file)).toEqual([ + 'test/unit/b.test.ts', + 'test/unit/hooks.test.ts', + 'test/unit/nested/c.test.ts', + ]); + expect(result.accepted).toEqual(['test/unit/b.test.ts', 'test/unit/hooks.test.ts']); + expect(result.rejected).toEqual([ + { file: 'test/unit/nested/c.test.ts', reasons: ['spawns processes: child_process'] }, + ]); + expect(result.acceptedWithBeforeAll).toEqual(['test/unit/hooks.test.ts']); + expect(result.summary).toEqual({ + total: 3, + accepted: 2, + rejected: 1, + byReason: { 'spawns processes: child_process': 1 }, + byCategory: { 'spawns processes': 1 }, + }); + }); + + it('is deterministic across runs and renders a summary', () => { + const repo = root(); + const first = classify({ repoRoot: repo, testRoot: 'test/unit' }); + expect(classify({ repoRoot: repo, testRoot: 'test/unit' })).toEqual(first); + expect(renderSummary(first)).toContain('3 test files: 2 accepted, 1 rejected'); + }); +}); diff --git a/gitnexus/test/unit/mutation/conformance-compare.test.ts b/gitnexus/test/unit/mutation/conformance-compare.test.ts new file mode 100644 index 000000000..e72bd48e4 --- /dev/null +++ b/gitnexus/test/unit/mutation/conformance-compare.test.ts @@ -0,0 +1,579 @@ +/** + * Locks the comparison logic of the gate 2 conformance check (U4): a run that matches every + * pinned case passes, a change to any pinned fact fails naming its case, and a missing or an + * unexpected mutant, a broken process rule or a wrong exported mutant id are reported. Pure + * functions only; the Stryker runs are exercised by `npm run test:mutation:conformance`. + */ +import { describe, expect, it } from 'vitest'; +import { + compareConformance, + fixtureRelative, + formatDifference, + observeScopeRun, + parseExpected, + type ExpectedCase, + type ExpectedConformance, + type MutantFacts, + type MutantSelector, + type ObservedMutant, + type ObservedScopeRun, + type ObserveInput, + type ScopeSpec, +} from '../../../tools/mutation/conformance/compare.js'; +import { driverScope } from '../../../tools/mutation/conformance/run-conformance.js'; +import type { + NormalisedMutant, + NormalisedTest, +} from '../../../tools/mutation/driver/normalise-results.js'; +import type { RunClassification } from '../../../tools/mutation/runner/classify-outcome.js'; +import { + SIDECAR_SCHEMA_VERSION, + type SidecarRecord, +} from '../../../tools/mutation/runner/sidecar.js'; + +const TWO = 'runtime.case.ts#doubles two'; +const THREE = 'runtime.case.ts#doubles three'; +const ROOT = 'test/fixtures/mutation-conformance'; +const PROFILE = 'concurrency-1-reuse-unlimited'; +const CASE = 'two tests kill the same mutant'; + +const DOUBLE: MutantSelector = { + file: 'src/runtime.ts', + location: '7:10-7:19', + mutatorName: 'ArithmeticOperator', + replacement: 'value / 2', +}; +const TICKET: MutantSelector = { + file: 'src/counter.ts', + location: '10:3-10:14', + mutatorName: 'AssignmentOperator', + replacement: 'issued -= 1', +}; + +const FACTS: MutantFacts = { + status: 'Killed', + static: false, + outcome: 'killed-by-test:assertion', + outcomeSource: 'sidecar', + coveredBy: [TWO, THREE], + selected: [TWO, THREE], + killedBy: [TWO, THREE], + assertionKilledBy: [TWO, THREE], + tests: { [TWO]: 'killed-by-test:assertion', [THREE]: 'killed-by-test:assertion' }, + outsideTest: [], + reason: null, + flags: [], + hookAttributedTests: [], + attemptsDisagree: false, +}; +const TICKET_FACTS: MutantFacts = { + ...FACTS, + coveredBy: ['runtime.case.ts#issues the first ticket'], + selected: ['runtime.case.ts#issues the first ticket'], + killedBy: ['runtime.case.ts#issues the first ticket'], + assertionKilledBy: ['runtime.case.ts#issues the first ticket'], + tests: { 'runtime.case.ts#issues the first ticket': 'killed-by-test:assertion' }, +}; + +const TESTS = [ + { id: TWO, skipped: false, notOracleValidated: false }, + { id: THREE, skipped: false, notOracleValidated: false }, +]; + +const mutantCase = (name: string, mutant: MutantSelector, facts: MutantFacts): ExpectedCase => ({ + kind: 'mutant', + name, + scope: 'main', + mutant, + expect: facts, +}); + +/** The expected document, round-tripped through the parser like expected.json. */ +const MAIN_SCOPE: ScopeSpec = { + name: 'main', + testFiles: ['runtime.case.ts'], + mutate: ['src/runtime.ts'], + testIds: null, + tests: TESTS, +}; + +const expectedWith = ( + cases: readonly ExpectedCase[], + scopes: readonly ScopeSpec[] = [MAIN_SCOPE], +): ExpectedConformance => + parseExpected( + JSON.stringify({ + schemaVersion: 1, + profiles: [{ name: PROFILE, concurrency: 1, maxTestRunnerReuse: 0 }], + scopes, + cases, + }), + ); + +const BASE_CASES: readonly ExpectedCase[] = [ + mutantCase(CASE, DOUBLE, FACTS), + mutantCase('counter carry-over', TICKET, TICKET_FACTS), + { + kind: 'processes', + name: 'one process runs every mutant, back to back', + profile: PROFILE, + rule: 'every-run-in-the-dry-run-process', + consecutive: ['counter carry-over', CASE], + }, + { + kind: 'environment', + name: 'exported active mutant', + profile: PROFILE, + activeMutantOf: CASE, + localId: '32', + }, +]; + +const observedMutant = ( + selector: MutantSelector, + localId: string, + facts: MutantFacts, + writtenAtMs: number, +): ObservedMutant => ({ ...selector, localId, facts, runs: [{ pid: 7, writtenAtMs }] }); + +const observedRun = (overrides: Partial = {}): ObservedScopeRun => ({ + profile: PROFILE, + scope: 'main', + mutants: [observedMutant(TICKET, '10', TICKET_FACTS, 1), observedMutant(DOUBLE, '32', FACTS, 2)], + tests: TESTS, + dryRunPid: 7, + runFlags: [], + spanMapFailures: 0, + exportedActiveMutant: '32', + ...overrides, +}); + +const cases = (differences: ReturnType): string[] => + differences.map((difference) => difference.case); + +describe('compareConformance', () => { + it('reports nothing when every pinned case holds', () => { + expect(compareConformance(expectedWith(BASE_CASES), [observedRun()])).toEqual([]); + }); + + const ALTERATIONS: readonly [keyof MutantFacts, Partial][] = [ + ['status', { status: 'Survived' }], + ['static', { static: true }], + ['outcome', { outcome: 'killed-by-test:runtime' }], + ['outcomeSource', { outcomeSource: 'no-coverage' }], + ['coveredBy', { coveredBy: [TWO] }], + ['selected', { selected: 'all' }], + ['killedBy', { killedBy: [THREE] }], + ['assertionKilledBy', { assertionKilledBy: [] }], + ['tests', { tests: { [TWO]: 'killed-by-test:assertion' } }], + ['outsideTest', { outsideTest: null }], + ['reason', { reason: 'Hit limit reached' }], + ['flags', { flags: ['zero-tests'] }], + ['hookAttributedTests', { hookAttributedTests: [TWO] }], + ['attemptsDisagree', { attemptsDisagree: true }], + ]; + + it('has an alteration for every pinned fact', () => { + expect(ALTERATIONS.map(([key]) => key).sort()).toEqual(Object.keys(FACTS).sort()); + }); + + it.each(ALTERATIONS)('fails naming the case when the pinned %s changes', (key, patch) => { + const altered = BASE_CASES.map((entry) => + entry.name === CASE ? mutantCase(CASE, DOUBLE, { ...FACTS, ...patch }) : entry, + ); + const differences = compareConformance(expectedWith(altered), [observedRun()]); + expect(differences.map((d) => [d.profile, d.scope, d.case, d.detail.split(':')[0]])).toEqual([ + [PROFILE, 'main', CASE, key], + ]); + }); + + it('fails naming the case when no mutant matches its selector', () => { + const run = observedRun({ mutants: [observedMutant(TICKET, '10', TICKET_FACTS, 1)] }); + const differences = compareConformance(expectedWith(BASE_CASES.slice(0, 2)), [run]); + expect(differences).toEqual([ + { + profile: PROFILE, + scope: 'main', + case: CASE, + detail: `0 mutants match src/runtime.ts:7:10-7:19 ArithmeticOperator "value / 2", expected exactly one`, + }, + ]); + }); + + it('reports a mutant that no case selects as unexpected', () => { + const extra = observedMutant({ ...DOUBLE, replacement: 'value + 2' }, '33', FACTS, 3); + const run = observedRun({ mutants: [...observedRun().mutants, extra] }); + const differences = compareConformance(expectedWith(BASE_CASES), [run]); + expect(cases(differences)).toEqual(['unexpected mutant']); + expect(differences[0].detail).toContain('local id 33'); + }); + + it('reports a run that is missing altogether', () => { + expect(compareConformance(expectedWith(BASE_CASES), [])).toEqual([ + { profile: PROFILE, scope: 'main', case: 'run', detail: 'no observed run' }, + ]); + }); + + it('fails the test inventory when a pinned test flag differs', () => { + const run = observedRun({ tests: [TESTS[0], { ...TESTS[1], notOracleValidated: true }] }); + expect(cases(compareConformance(expectedWith(BASE_CASES), [run]))).toEqual([ + 'test inventory of scope main', + ]); + }); + + it('fails a tests case when one of its tests is missing', () => { + const pinned: ExpectedCase = { + kind: 'tests', + name: 'row ordinals', + scope: 'main', + tests: TESTS, + }; + const run = observedRun({ tests: [TESTS[0]] }); + expect(cases(compareConformance(expectedWith([...BASE_CASES, pinned]), [run]))).toEqual([ + 'test inventory of scope main', + 'row ordinals', + ]); + }); + + it('fails the process case when a mutant ran in another process than the dry run', () => { + const moved = { ...observedMutant(DOUBLE, '32', FACTS, 2), runs: [{ pid: 8, writtenAtMs: 2 }] }; + const run = observedRun({ mutants: [observedMutant(TICKET, '10', TICKET_FACTS, 1), moved] }); + const differences = compareConformance(expectedWith(BASE_CASES), [run]); + expect(differences.map((d) => d.detail)).toEqual([ + "1 mutant run(s) came from other processes than the dry run's (32)", + `mutant 32 (${CASE}) did not run right after mutant 10 (counter carry-over) in process 7`, + ]); + expect(new Set(cases(differences))).toEqual( + new Set(['one process runs every mutant, back to back']), + ); + }); + + it('fails the process case when the consecutive mutants ran in the other order', () => { + const run = observedRun({ + mutants: [ + observedMutant(TICKET, '10', TICKET_FACTS, 3), + observedMutant(DOUBLE, '32', FACTS, 2), + ], + }); + expect(cases(compareConformance(expectedWith(BASE_CASES), [run]))).toEqual([ + 'one process runs every mutant, back to back', + ]); + }); + + it('checks a consecutive pair only in the scope of its mutant cases', () => { + const other: ScopeSpec = { + name: 'other', + testFiles: ['o.case.ts'], + mutate: ['src/o.ts'], + testIds: null, + tests: [], + }; + const otherRun = observedRun({ scope: 'other', mutants: [], tests: [] }); + const differences = compareConformance(expectedWith(BASE_CASES, [MAIN_SCOPE, other]), [ + observedRun(), + otherRun, + ]); + expect(differences).toEqual([]); + }); + + it('fails a one-run-per-process rule when a process handled two mutant runs', () => { + const rule: ExpectedCase = { + kind: 'processes', + name: 'fresh processes', + profile: PROFILE, + rule: 'one-mutant-run-per-process', + consecutive: null, + }; + const differences = compareConformance(expectedWith([...BASE_CASES.slice(0, 2), rule]), [ + observedRun(), + ]); + expect(differences.map((d) => d.detail)).toEqual([ + 'process 7 handled 2 mutant runs (10, 32)', + 'no mutant run came from a process that never ran the dry run', + ]); + }); + + it('fails the environment case when the exported id names another mutant', () => { + const run = observedRun({ exportedActiveMutant: '10' }); + const environment: ExpectedCase = { + kind: 'environment', + name: 'exported active mutant', + profile: PROFILE, + activeMutantOf: CASE, + localId: '10', + }; + const differences = compareConformance(expectedWith([...BASE_CASES.slice(0, 3), environment]), [ + run, + ]); + expect(differences.map((d) => [d.case, d.detail])).toEqual([ + [ + 'exported active mutant', + `the exported local id 10 is not the mutant of "${CASE}" (its local id is "32")`, + ], + ]); + }); + + it('reports run-level flags and span-map failures', () => { + const run = observedRun({ runFlags: ['dry-run-missing: no record'], spanMapFailures: 2 }); + expect(cases(compareConformance(expectedWith(BASE_CASES), [run]))).toEqual([ + 'span map', + 'run flags', + ]); + }); + + it('formats a difference with its profile, scope and case', () => { + expect( + formatDifference({ profile: PROFILE, scope: 'main', case: CASE, detail: 'status: x' }), + ).toBe(`[${PROFILE} / main] case "${CASE}": status: x`); + }); +}); + +describe('parseExpected', () => { + const valid = { + schemaVersion: 1, + profiles: [{ name: PROFILE, concurrency: 1, maxTestRunnerReuse: 0 }], + scopes: [ + { name: 'main', testFiles: ['a.case.ts'], mutate: ['src/a.ts'], testIds: null, tests: [] }, + ], + cases: [ + { kind: 'mutant', name: CASE, scope: 'main', mutant: DOUBLE, expect: FACTS }, + ] satisfies readonly ExpectedCase[], + }; + + it('accepts a valid document', () => { + expect(parseExpected(JSON.stringify(valid)).cases.map((entry) => entry.name)).toEqual([CASE]); + }); + + it.each([ + [ + 'an unknown fact key', + { ...valid, cases: [{ ...valid.cases[0], expect: { ...FACTS, outcom: 'x' } }] }, + /unknown key "outcom"/, + ], + [ + 'an unknown outcome class', + { ...valid, cases: [{ ...valid.cases[0], expect: { ...FACTS, outcome: 'killed' } }] }, + /outcome: must be one of/, + ], + [ + 'a case in an unknown scope', + { ...valid, cases: [{ ...valid.cases[0], scope: 'other' }] }, + /unknown scope other/, + ], + ['a repeated case name', { ...valid, cases: [valid.cases[0], valid.cases[0]] }, /listed twice/], + [ + 'a consecutive pair naming a missing case', + { + ...valid, + cases: [ + ...valid.cases, + { + kind: 'processes', + name: 'p', + profile: PROFILE, + rule: 'one-mutant-run-per-process', + consecutive: [CASE, 'nope'], + }, + ], + }, + /"nope" is not a mutant case/, + ], + [ + 'a malformed location', + { ...valid, cases: [{ ...valid.cases[0], mutant: { ...DOUBLE, location: '7' } }] }, + /location: must look like/, + ], + ['another schema version', { ...valid, schemaVersion: 2 }, /schemaVersion: must be 1/], + [ + 'an empty test selection', + { ...valid, scopes: [{ ...valid.scopes[0], testIds: [] }] }, + /testIds: must be null or a non-empty array/, + ], + [ + 'a scope without its testIds key', + { ...valid, scopes: [{ name: 'main', testFiles: ['a.case.ts'], mutate: [], tests: [] }] }, + /missing key "testIds"/, + ], + ])('rejects %s', (_label, document, message) => { + expect(() => parseExpected(JSON.stringify(document))).toThrow(message); + }); +}); + +describe('observeScopeRun', () => { + const ID_TWO = `${ROOT}/${TWO}`; + const ID_THREE = `${ROOT}/${THREE}`; + const classification: RunClassification = { + status: 'killed', + outcome: 'killed-by-test:assertion', + reason: undefined, + killedBy: [ID_TWO], + failureMessage: 'expected 3 to be 4', + nrOfTests: 2, + hitCount: 1, + tests: [ + { id: ID_TWO, outcome: 'killed-by-test:assertion', message: 'expected 3 to be 4' }, + { id: ID_THREE, outcome: 'survived', message: undefined }, + ], + outsideTest: [], + }; + const record: SidecarRecord = { + schemaVersion: SIDECAR_SCHEMA_VERSION, + kind: 'mutant', + mutantId: '32', + worker: '0', + attempt: 1, + pid: 7, + mutantActivation: 'static', + hitLimit: 100, + requestedTestIds: [ID_THREE, ID_TWO], + hookAttributedTests: null, + result: classification, + }; + const normalised = (overrides: Partial): NormalisedMutant => ({ + globalId: `gitnexus/${ROOT}/src/runtime.ts@0:7:10-7:19:ArithmeticOperator:0`, + localId: '32', + file: `gitnexus/${ROOT}/src/runtime.ts`, + location: { start: { line: 7, column: 10 }, end: { line: 7, column: 19 } }, + mutatorName: 'ArithmeticOperator', + replacement: 'value / 2', + static: true, + status: 'Killed', + statusReason: null, + testsCompleted: 2, + outcome: 'killed-by-test:assertion', + outcomeSource: 'sidecar', + killedBy: [ID_TWO], + coveredBy: [], + assertionKilledBy: [ID_TWO], + sidecar: { + file: '/out/sidecars/mutant-32.json', + worker: '0', + attempt: 1, + pid: 7, + writtenAtMs: 5, + status: 'killed', + outcome: 'killed-by-test:assertion', + nrOfTests: 2, + reason: null, + failureMessage: 'expected 3 to be 4', + killedBy: [ID_TWO], + hitCount: 1, + mutantActivation: 'static', + outsideTest: [], + }, + attempts: [ + { + file: '/out/sidecars/mutant-32.json', + worker: '0', + attempt: 1, + pid: 7, + writtenAtMs: 5, + status: 'killed', + outcome: 'killed-by-test:assertion', + nrOfTests: 2, + }, + ], + perTest: [ + { testId: ID_TWO, outcome: 'killed-by-test:assertion', attempt: 0 }, + { testId: ID_THREE, outcome: 'survived', attempt: 0 }, + ], + attemptsDisagree: false, + hookAttributedTests: [ID_THREE, ID_TWO], + flags: [], + ...overrides, + }); + const test = (id: string): NormalisedTest => ({ + id, + file: `${ROOT}/runtime.case.ts`, + fullName: id.split('#')[1], + taskId: 'h_0', + skipped: false, + notOracleValidated: false, + killed: [], + assertionKilled: [], + }); + const input: ObserveInput = { + profile: PROFILE, + scope: 'main', + results: { + mutants: [normalised({}), normalised({ localId: '33', status: 'Ignored', sidecar: null })], + tests: [test(ID_TWO), test(ID_THREE)], + flags: [{ kind: 'zero-tests', localId: '32', globalId: null, detail: 'per mutant' }], + spanMap: { checked: 2, failures: [] }, + }, + records: new Map([['/out/sidecars/mutant-32.json', record]]), + dryRunPid: 7, + exportedActiveMutant: null, + fixtureRoot: ROOT, + }; + const run = observeScopeRun(input); + + it('leaves Ignored mutants out and keeps only run-level flags', () => { + expect([run.mutants.map((mutant) => mutant.localId), run.runFlags]).toEqual([['32'], []]); + }); + + it('makes paths and test ids fixture-relative', () => { + expect([run.mutants[0].file, run.tests.map((entry) => entry.id)]).toEqual([ + 'src/runtime.ts', + [TWO, THREE], + ]); + }); + + it('records a selection of every manifest test as "all" and only non-survived test outcomes', () => { + expect(run.mutants[0].facts).toMatchObject({ + selected: 'all', + tests: { [TWO]: 'killed-by-test:assertion' }, + killedBy: [TWO], + }); + expect(run.mutants[0].location).toBe('7:10-7:19'); + }); + + it('reports the hook marker fixture-relative and sorted, and whether attempts disagree', () => { + expect([ + run.mutants[0].facts.hookAttributedTests, + run.mutants[0].facts.attemptsDisagree, + ]).toEqual([[THREE, TWO], false]); + }); + + it('keeps a missing hook marker as null', () => { + const unknown = observeScopeRun({ + ...input, + results: { ...input.results, mutants: [normalised({ hookAttributedTests: null })] }, + }); + expect(unknown.mutants[0].facts.hookAttributedTests).toBeNull(); + }); + + it('throws when the latest sidecar record was not read', () => { + expect(() => observeScopeRun({ ...input, records: new Map() })).toThrow(/was not read/); + }); + + it('strips the fixture directory from package- and repo-relative values only', () => { + expect( + [`${ROOT}/a.case.ts#x`, `gitnexus/${ROOT}/src/a.ts`, 'test/unit/a.test.ts#y'].map((value) => + fixtureRelative(value, ROOT), + ), + ).toEqual(['a.case.ts#x', 'src/a.ts', 'test/unit/a.test.ts#y']); + }); +}); + +describe('driverScope (run-conformance.ts)', () => { + const scope: ScopeSpec = { + name: 'selection', + testFiles: ['selection.case.ts'], + mutate: ['src/selection.ts'], + testIds: ['selection.case.ts#scales two'], + tests: [], + }; + + it('passes a scope test selection to the driver with package-relative ids', () => { + expect(driverScope(scope)).toEqual({ + testFiles: [`${ROOT}/selection.case.ts`], + mutate: [`gitnexus/${ROOT}/src/selection.ts`], + testIds: [`${ROOT}/selection.case.ts#scales two`], + }); + }); + + it('writes no testIds key for a scope that runs every test', () => { + expect(driverScope({ ...scope, testIds: null })).not.toHaveProperty('testIds'); + }); +}); diff --git a/gitnexus/test/unit/mutation/conformance-fingerprint.test.ts b/gitnexus/test/unit/mutation/conformance-fingerprint.test.ts new file mode 100644 index 000000000..4854e1f9f --- /dev/null +++ b/gitnexus/test/unit/mutation/conformance-fingerprint.test.ts @@ -0,0 +1,99 @@ +/** + * Locks the input fingerprint of gate 2 conformance runs: `--compare-only` must refuse results + * that an earlier run left behind once the runner, driver, prepare step, fixture or the installed + * Stryker and Vitest changed. Pure functions only; the file walk is exercised by + * `npm run test:mutation:conformance`. + */ +import { describe, expect, it } from 'vitest'; +import { + STALE_ARTEFACTS, + fingerprintOf, + isFingerprintInput, + staleConformance, +} from '../../../tools/mutation/conformance/fingerprint.js'; + +const VERSIONS = { stryker: '10.0.0', vitest: '5.0.5' }; +const FILES = { + 'tools/mutation/runner/plugin.ts': 'a'.repeat(64), + 'test/fixtures/mutation-conformance/src/rows.ts': 'b'.repeat(64), +}; +const current = fingerprintOf(FILES, VERSIONS); + +describe('isFingerprintInput', () => { + it.each([ + ['the runner', 'tools/mutation/runner/plugin.ts', true], + ['the driver', 'tools/mutation/driver/run-scope.ts', true], + ['the prepare step', 'tools/mutation/prepare/prepare.ts', true], + ['the shared helpers', 'tools/mutation/shared/fs.ts', true], + ['the scope file reader', 'tools/mutation/scope-file.ts', true], + ['the mutation Vitest config', 'tools/mutation/vitest.mutation.config.ts', true], + ['a fixture case file', 'test/fixtures/mutation-conformance/rows.case.ts', true], + ['a fixture production file', 'test/fixtures/mutation-conformance/src/rows.ts', true], + ['the expected file', 'test/fixtures/mutation-conformance/expected.json', false], + ['the comparison itself', 'tools/mutation/conformance/compare.ts', false], + ['the oracle', 'tools/mutation/oracle/compare.ts', false], + ['another fixture', 'test/fixtures/mutation-oracle/shared.json', false], + ['a sibling with a shared prefix', 'tools/mutation/runner-notes/x.ts', false], + ])('treats %s as %s', (_label, file, input) => { + expect(isFingerprintInput(file)).toBe(input); + }); +}); + +describe('fingerprintOf', () => { + it('does not depend on the order the files were listed in', () => { + const reversed = Object.fromEntries(Object.entries(FILES).reverse()); + expect(fingerprintOf(reversed, VERSIONS).digest).toBe(current.digest); + }); + + it.each([ + ['a file content', { ...FILES, 'tools/mutation/runner/plugin.ts': 'c'.repeat(64) }, VERSIONS], + ['a new file', { ...FILES, 'tools/mutation/driver/x.ts': 'd'.repeat(64) }, VERSIONS], + ['the Stryker version', FILES, { ...VERSIONS, stryker: '10.1.0' }], + ['the Vitest version', FILES, { ...VERSIONS, vitest: '5.1.0' }], + ])('changes the digest with %s', (_label, files, versions) => { + expect(fingerprintOf(files, versions).digest).not.toBe(current.digest); + }); +}); + +describe('staleConformance', () => { + it('accepts a run recorded with the current inputs', () => { + expect(staleConformance(JSON.parse(JSON.stringify(current)), current)).toBeNull(); + }); + + it.each([ + ['no fingerprint', undefined, 'the run records no input fingerprint'], + ['a malformed fingerprint', { digest: 1 }, 'the run records no input fingerprint'], + [ + 'a changed file', + fingerprintOf({ ...FILES, 'tools/mutation/runner/plugin.ts': 'e'.repeat(64) }, VERSIONS), + 'changed since the run: tools/mutation/runner/plugin.ts', + ], + [ + 'a file the run did not have', + fingerprintOf({ 'tools/mutation/runner/plugin.ts': 'a'.repeat(64) }, VERSIONS), + 'changed since the run: test/fixtures/mutation-conformance/src/rows.ts', + ], + [ + 'an older Stryker', + fingerprintOf(FILES, { ...VERSIONS, stryker: '9.6.0' }), + 'Stryker 9.6.0 -> 10.0.0', + ], + [ + 'a digest its own files do not produce', + { ...current, digest: '0'.repeat(64) }, + 'the recorded input digest does not match', + ], + [ + 'edited files under the current digest', + { + ...fingerprintOf({ ...FILES, 'tools/mutation/shared/fs.ts': 'f'.repeat(64) }, VERSIONS), + digest: current.digest, + }, + 'changed since the run: tools/mutation/shared/fs.ts', + ], + ])('reports stale artefacts for %s', (_label, recorded, detail) => { + const message = staleConformance(recorded, current); + expect(message).toContain(STALE_ARTEFACTS); + expect(message).toContain(detail); + }); +}); diff --git a/gitnexus/test/unit/mutation/controls.test.ts b/gitnexus/test/unit/mutation/controls.test.ts new file mode 100644 index 000000000..58f1e2d7d --- /dev/null +++ b/gitnexus/test/unit/mutation/controls.test.ts @@ -0,0 +1,523 @@ +/** + * Locks gate 4's rules (U8, KTD17, KTD19): a planted control must be killed with an assertion by + * exactly its expected killers and pass under every other test, a control or test missing from + * the run means "re-select", the two determinism profiles must agree on every mutant's outcome + * class and kill sets, and selection is deterministic and covers the required tags (relocated + * `gitnexus-shared` code, an `as const` table). Every input is synthetic. + */ +import { describe, expect, it } from 'vitest'; +import { + EXIT_FAILURES, + EXIT_PASS, + EXIT_RESELECT, + EXIT_USAGE, + PLANTED_KIND, + asConstRanges, + checkControls, + checkRequirements, + compareDeterminism, + formatPlanted, + gateExitCode, + mutantTags, + parsePlanted, + runBounded, + selectScopeControls, + type ControlFailure, + type ControlTag, + type DeterminismDifference, + type GateInput, + type PlantedControl, + type PlantedControls, + type PlantedScope, + type RunMutantView, + type RunView, +} from '../../../tools/mutation/controls/run-controls.js'; + +const FILE = 'test/unit/a.test.ts'; +const K = `${FILE}#S > kills`; +const K2 = `${FILE}#S > also kills (#2415)`; +const N = `${FILE}#S > runs and passes`; +const U = `${FILE}#S > unrelated`; +const TESTS = [K, K2, N, U]; +const SRC = 'gitnexus/src/a.ts'; +const M = `${SRC}@0123456789abcdef:3:1-3:9:StringLiteral:aaaaaaaaaaaaaaaa`; +const M2 = `${SRC}@0123456789abcdef:5:1-5:9:ConditionalExpression:bbbbbbbbbbbbbbbb`; + +const mutant = (overrides: Partial = {}): RunMutantView => ({ + globalId: M, + file: SRC, + location: { start: { line: 3, column: 1 }, end: { line: 3, column: 9 } }, + mutatorName: 'StringLiteral', + static: false, + status: 'Killed', + outcome: 'killed-by-test:assertion', + killedBy: [K], + assertionKilledBy: [K], + ran: [ + { id: K, outcome: 'killed-by-test:assertion' }, + { id: N, outcome: 'survived' }, + ], + attempts: 1, + flags: [], + ...overrides, +}); + +const run = (mutants: RunMutantView[], overrides: Partial = {}): RunView => ({ + profile: 'A', + tests: TESTS.map((id) => ({ id, skipped: false })), + mutants, + ...overrides, +}); + +const control = (overrides: Partial = {}): PlantedControl => ({ + id: M, + tags: [], + static: false, + expectedKillers: [K], + expectedNonKillers: { ran: [N], notRun: [U] }, + ...overrides, +}); + +const scope = (controls: PlantedControl[] = [control()]): PlantedScope => ({ + name: 's', + scope: { testFiles: [FILE], mutate: [SRC] }, + mutants: 2, + tests: TESTS.length, + controls, +}); + +const noTags = (): ControlTag[] => []; + +const kinds = (failures: readonly ControlFailure[]): [string, string | null][] => + failures.map((failure) => [failure.kind, failure.test]); + +const differences = ( + diffs: readonly DeterminismDifference[], +): [string, string | null, string | null][] => + diffs.map((diff) => [diff.kind, diff.mutant, diff.test]); + +describe('checkControls', () => { + it('passes a control killed by exactly its expected killers whose covering non-killer passed', () => { + expect(checkControls(scope(), run([mutant()]), noTags)).toEqual([]); + }); + + it.each([ + [ + 'a listed covering non-killer', + mutant({ + killedBy: [K, N], + assertionKilledBy: [K, N], + ran: [ + { id: K, outcome: 'killed-by-test:assertion' }, + { id: N, outcome: 'killed-by-test:assertion' }, + ], + }), + N, + ], + [ + 'an unlisted test, with a runtime error', + mutant({ + killedBy: [K, K2], + ran: [ + { id: K, outcome: 'killed-by-test:assertion' }, + { id: K2, outcome: 'killed-by-test:runtime' }, + { id: N, outcome: 'survived' }, + ], + }), + K2, + ], + ])('fails a control killed by a test outside its expected killers: %s', (_name, killed, test) => { + expect(kinds(checkControls(scope(), run([killed]), noTags))).toEqual([ + ['unexpected-killer', test], + ]); + }); + + it.each([ + [ + 'the mutant survived', + mutant({ + status: 'Survived', + outcome: 'survived', + killedBy: [], + assertionKilledBy: [], + ran: [ + { id: K, outcome: 'survived' }, + { id: N, outcome: 'survived' }, + ], + }), + ], + [ + 'the killer failed with a runtime error, not an assertion', + mutant({ + outcome: 'killed-by-test:runtime', + assertionKilledBy: [], + ran: [ + { id: K, outcome: 'killed-by-test:runtime' }, + { id: N, outcome: 'survived' }, + ], + }), + ], + ])('fails a control that survives its expected killer: %s', (_name, survived) => { + expect(kinds(checkControls(scope(), run([survived]), noTags))).toEqual([['killer-missed', K]]); + }); + + it('reports a control missing from a run of a changed source file as stale (re-select)', () => { + const changed = `${SRC}@fedcba9876543210:3:1-3:9:StringLiteral:aaaaaaaaaaaaaaaa`; + const failures = checkControls(scope(), run([mutant({ globalId: changed })]), noTags); + expect(kinds(failures)).toEqual([['stale-mutant', null]]); + expect(gateExitCode({ staleRuns: 0, problems: 0, failures, differences: 0 })).toBe( + EXIT_RESELECT, + ); + }); + + it('fails a control missing from a run whose source file is unchanged (not generated)', () => { + const failures = checkControls(scope(), run([mutant({ globalId: M2 })]), noTags); + expect(kinds(failures)).toEqual([['mutant-missing', null]]); + expect(gateExitCode({ staleRuns: 0, problems: 0, failures, differences: 0 })).toBe( + EXIT_FAILURES, + ); + }); + + it('reports a planted test missing from the run as stale and checks nothing else of it', () => { + const renamed = `${FILE}#S > renamed`; + const stale = control({ expectedKillers: [renamed] }); + expect(kinds(checkControls(scope([stale]), run([mutant()]), noTags))).toEqual([ + ['stale-test', renamed], + ]); + }); + + it('fails a listed covering non-killer that did not run with the mutant active', () => { + const notRun = mutant({ ran: [{ id: K, outcome: 'killed-by-test:assertion' }] }); + expect(kinds(checkControls(scope(), run([notRun]), noTags))).toEqual([ + ['non-killer-not-run', N], + ]); + }); + + it('fails a control whose recorded tags differ from the facts of its source', () => { + const tagged = control({ tags: ['as-const', 'relocated-shared'] }); + const facts = (): ControlTag[] => ['relocated-shared']; + expect(kinds(checkControls(scope([tagged]), run([mutant()]), facts))).toEqual([['tags', null]]); + }); + + it('names the scope, profile and control of every failure', () => { + const [failure] = checkControls(scope(), run([], { profile: 'B' }), noTags); + expect(failure).toMatchObject({ scope: 's', profile: 'B', control: M }); + }); +}); + +describe('compareDeterminism', () => { + it('passes two runs with identical outcome classes and kill sets', () => { + expect(compareDeterminism(run([mutant()]), run([mutant()], { profile: 'B' }))).toEqual([]); + }); + + it('names the mutant and the test when the kill sets differ in one test', () => { + const b = run([mutant({ killedBy: [K, K2], assertionKilledBy: [K, K2] })], { profile: 'B' }); + expect(differences(compareDeterminism(run([mutant()]), b))).toEqual([ + ['killed-by', M, K2], + ['assertion-killed-by', M, K2], + ]); + }); + + it('fails when the outcome class of a mutant differs between the profiles', () => { + const b = run( + [mutant({ status: 'Timeout', outcome: 'timeout', killedBy: [], assertionKilledBy: [] })], + { profile: 'B' }, + ); + expect(differences(compareDeterminism(run([mutant()]), b))).toEqual([ + ['outcome', M, null], + ['status', M, null], + ['killed-by', M, K], + ['assertion-killed-by', M, K], + ]); + }); + + it('fails when a mutant is in one profile only', () => { + const b = run([mutant()], { profile: 'B' }); + expect(differences(compareDeterminism(run([mutant(), mutant({ globalId: M2 })]), b))).toEqual([ + ['mutant-inventory', M2, null], + ]); + }); + + it('fails when a test is in one profile only', () => { + const b = run([mutant()], { + profile: 'B', + tests: [K, K2, N].map((id) => ({ id, skipped: false })), + }); + expect(differences(compareDeterminism(run([mutant()]), b))).toEqual([ + ['test-inventory', null, U], + ]); + }); +}); + +const TABLE_SOURCE = [ + "export const NAME = 'Code';", + 'export const TABLES = [', + " 'File',", + " 'Folder',", + '] as const;', + 'const n = 1 as number;', + '', +].join('\n'); + +describe('asConstRanges and mutantTags', () => { + it('finds the operand of each `as const` assertion and ignores other `as` assertions', () => { + const ranges = asConstRanges(TABLE_SOURCE); + expect(ranges.map((range) => TABLE_SOURCE.slice(range.start, range.end))).toEqual([ + "[\n 'File',\n 'Folder',\n]", + ]); + }); + + it.each([ + [ + 'inside an `as const` table of relocated shared code', + 'gitnexus-shared/src/lbug/t.ts', + { start: { line: 3, column: 3 }, end: { line: 3, column: 9 } }, + ['relocated-shared', 'as-const'], + ], + [ + 'outside the table, in relocated shared code', + 'gitnexus-shared/src/lbug/t.ts', + { start: { line: 1, column: 21 }, end: { line: 1, column: 27 } }, + ['relocated-shared'], + ], + [ + 'inside a plain `as` assertion under gitnexus/src', + SRC, + { start: { line: 6, column: 11 }, end: { line: 6, column: 12 } }, + [], + ], + ])('tags a mutant %s', (_name, file, location, tags) => { + expect(mutantTags({ file, location }, TABLE_SOURCE)).toEqual(tags); + }); +}); + +describe('selectScopeControls', () => { + const MT = `${SRC}@0123456789abcdef:7:1-7:9:ArrayDeclaration:cccccccccccccccc`; + const MS = `${SRC}@0123456789abcdef:9:1-9:9:BooleanLiteral:dddddddddddddddd`; + const MD = `${SRC}@0123456789abcdef:11:1-11:9:EqualityOperator:eeeeeeeeeeeeeeee`; + const MR = `${SRC}@0123456789abcdef:13:1-13:9:BlockStatement:ffffffffffffffff`; + const MW = `${SRC}@0123456789abcdef:15:1-15:9:MethodExpression:1111111111111111`; + const tagsOf = (entry: { file: string; location: unknown }): ControlTag[] => + entry.location === TAGGED ? ['as-const'] : []; + const TAGGED = { start: { line: 7, column: 1 }, end: { line: 7, column: 9 } }; + const mutants = (variant: 'A' | 'B'): RunMutantView[] => [ + mutant(), + mutant({ + globalId: MT, + location: TAGGED, + mutatorName: 'ArrayDeclaration', + static: true, + killedBy: [K, K2], + assertionKilledBy: [K, K2], + ran: [ + { id: K, outcome: 'killed-by-test:assertion' }, + { id: K2, outcome: 'killed-by-test:assertion' }, + { id: N, outcome: 'survived' }, + { id: U, outcome: 'survived' }, + ], + }), + mutant({ + globalId: MS, + status: 'Survived', + outcome: 'survived', + killedBy: [], + assertionKilledBy: [], + }), + mutant({ + globalId: MD, + mutatorName: 'EqualityOperator', + killedBy: [variant === 'A' ? K : K2], + assertionKilledBy: [variant === 'A' ? K : K2], + }), + mutant({ + globalId: MR, + mutatorName: 'BlockStatement', + outcome: 'killed-by-test:runtime', + assertionKilledBy: [], + }), + mutant({ + globalId: MW, + mutatorName: 'MethodExpression', + killedBy: [K2], + assertionKilledBy: [K2], + ran: [{ id: K2, outcome: 'killed-by-test:assertion' }], + }), + ]; + const runs = [run(mutants('A')), run(mutants('B'), { profile: 'B' })]; + const options = { seed: 'seed-1', perScope: 3, maxKillers: 3, maxNonKillers: 3 }; + + it('picks the required tag first, then stable assertion kills with small killer sets', () => { + const selection = selectScopeControls(runs, options, tagsOf); + expect(selection.candidates).toBe(3); + // Sorted by global id: line 15 sorts before lines 3 and 7. + expect(selection.controls).toEqual([ + { + id: MW, + tags: [], + static: false, + expectedKillers: [K2], + expectedNonKillers: { ran: [], notRun: [K, N, U] }, + }, + { + id: M, + tags: [], + static: false, + expectedKillers: [K], + expectedNonKillers: { ran: [N], notRun: [K2, U] }, + }, + { + id: MT, + tags: ['as-const'], + static: true, + expectedKillers: [K2, K], + expectedNonKillers: { ran: [N, U], notRun: [] }, + }, + ]); + }); + + it('selects the same controls for the same seed whatever the order of the mutants', () => { + const reversed = runs.map((entry) => ({ ...entry, mutants: [...entry.mutants].reverse() })); + expect(selectScopeControls(reversed, options, tagsOf)).toEqual( + selectScopeControls(runs, options, tagsOf), + ); + }); + + it('caps the listed non-killers at maxNonKillers', () => { + const selection = selectScopeControls(runs, { ...options, maxNonKillers: 1 }, tagsOf); + expect(selection.controls.map((entry) => entry.expectedNonKillers.notRun.length)).toEqual([ + 1, 1, 0, + ]); + }); +}); + +const planted = (scopes: PlantedScope[]): PlantedControls => ({ + schemaVersion: 1, + kind: PLANTED_KIND, + description: 'synthetic', + reselect: 'npm run test:mutation:controls -- --select --work ', + profiles: [ + { name: 'A', concurrency: 1, maxTestRunnerReuse: 0 }, + { name: 'B', concurrency: 3, maxTestRunnerReuse: 1 }, + ], + timeoutMS: 10000, + selection: { seed: 's', perScope: 3, maxKillers: 3, maxNonKillers: 3, selectedFrom: ['A', 'B'] }, + scopes, +}); + +describe('planted file', () => { + it('parses what it formats', () => { + const file = planted([scope([control({ tags: ['relocated-shared', 'as-const'] })])]); + expect(parsePlanted(formatPlanted(file))).toEqual(file); + }); + + it.each([ + ['another kind', { kind: 'gitnexus-mutation-oracle-manifest' }, /kind/], + ['another schema version', { schemaVersion: 2 }, /schemaVersion/], + ['an unknown tag', { scopes: [scope([control({ tags: [] })])].map(withTag('nope')) }, /tag/], + ])('rejects a file with %s', (_name, overrides, message) => { + const text = JSON.stringify({ ...planted([scope()]), ...overrides }); + expect(() => parsePlanted(text)).toThrow(message); + }); + + it.each([ + ['no tagged control', [control()], ['as-const', 'relocated-shared']], + ['a relocated shared control only', [control({ tags: ['relocated-shared'] })], ['as-const']], + ['both tags on one control', [control({ tags: ['relocated-shared', 'as-const'] })], []], + ])('lists the required tags no control carries: %s', (_name, controls, missing) => { + expect(checkRequirements(planted([scope(controls)]))).toEqual(missing); + }); +}); + +function withTag(tag: string): (entry: PlantedScope) => unknown { + return (entry) => ({ + ...entry, + controls: entry.controls.map((item) => ({ ...item, tags: [tag] })), + }); +} + +describe('gateExitCode', () => { + const failure = (kind: ControlFailure['kind']): ControlFailure => ({ + scope: 's', + profile: 'A', + control: M, + kind, + test: null, + detail: '', + }); + const clean: GateInput = { staleRuns: 0, problems: 0, failures: [], differences: 0 }; + + it.each([ + ['a clean gate', clean, EXIT_PASS], + ['a stale run, before everything else', { ...clean, staleRuns: 1, differences: 1 }, EXIT_USAGE], + ['a determinism difference', { ...clean, differences: 1 }, EXIT_FAILURES], + ['a run problem', { ...clean, problems: 1 }, EXIT_FAILURES], + [ + 'a failed control, before a stale one', + { ...clean, failures: [failure('stale-mutant'), failure('killer-missed')] }, + EXIT_FAILURES, + ], + ['only stale controls', { ...clean, failures: [failure('stale-test')] }, EXIT_RESELECT], + ])('%s', (_name, input, expected) => { + expect(gateExitCode(input)).toBe(expected); + }); +}); + +describe('runBounded', () => { + interface Item { + name: string; + cost: number; + } + /** Each item takes one macrotask; returns who was running when each item started. */ + const schedule = async ( + items: Item[], + budget: number, + ): Promise<{ starts: [string, string[]][]; peak: number }> => { + const active = new Map(); + const starts: [string, string[]][] = []; + let peak = 0; + await runBounded(items, budget, async (item) => { + starts.push([item.name, [...active.keys()].sort()]); + active.set(item.name, item.cost); + peak = Math.max( + peak, + [...active.values()].reduce((sum, cost) => sum + cost, 0), + ); + await new Promise((resolve) => setImmediate(resolve)); + active.delete(item.name); + }); + return { starts, peak }; + }; + + it('starts items while the running costs fit the budget', async () => { + const items = [ + { name: 'a1', cost: 1 }, + { name: 'a2', cost: 1 }, + { name: 'a3', cost: 1 }, + { name: 'b1', cost: 3 }, + { name: 'b2', cost: 3 }, + ]; + expect(await schedule(items, 3)).toEqual({ + starts: [ + ['a1', []], + ['a2', ['a1']], + ['a3', ['a1', 'a2']], + ['b1', []], + ['b2', []], + ], + peak: 3, + }); + }); + + it('runs an item costlier than the budget alone, after the items that fit', async () => { + const items = [ + { name: 'x', cost: 1 }, + { name: 'big', cost: 5 }, + { name: 'y', cost: 1 }, + ]; + expect((await schedule(items, 3)).starts).toEqual([ + ['x', []], + ['y', ['x']], + ['big', []], + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/coverage-attribution.test.ts b/gitnexus/test/unit/mutation/coverage-attribution.test.ts new file mode 100644 index 000000000..71e21ba3e --- /dev/null +++ b/gitnexus/test/unit/mutation/coverage-attribution.test.ts @@ -0,0 +1,192 @@ +/** + * Locks how the vitest5 runner's setup file buckets instrumenter hits (KTD4): load- and + * collection-time hits stay static, hits inside a test are per-test coverage, and a hit with no + * current test after collection is a hook hit, attributed to every test of the suite whose hook + * ran. Per-test coverage keys are then translated from Vitest task ids to Stryker ids (KTD3). The + * hook bucket is also kept apart, as the per-mutant hook marker (Phase 1 KTD2). + */ +import { describe, expect, it } from 'vitest'; +import { + CoverageRecorder, + attributeHookHits, + bucketForHit, + hookAttributedTests, + hookCoverage, + mergeCoverage, + rekeyCoverage, + type HitContext, +} from '../../../tools/mutation/runner/coverage-attribution.js'; + +/** Mirrors the instrumenter's `stryCov_9fa48` with no `currentTestId` set: it always counts into `static`. */ +const instrumenterHit = (recorder: CoverageRecorder, ...mutantIds: string[]): void => { + const coverage = recorder.instrumenterView; + for (const id of mutantIds) coverage.static[id] = (coverage.static[id] ?? 0) + 1; +}; + +describe('bucketForHit', () => { + it.each([ + ['a collection-time hit', { phase: 'collection' }, { kind: 'static' }], + [ + 'a hit inside a test', + { phase: 'run', testId: 'f_0_1', runningSuiteIds: ['f', 'f_0'] }, + { kind: 'test', testId: 'f_0_1' }, + ], + [ + 'a hit with no current test after collection', + { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'f_0'] }, + { kind: 'hook', suiteId: 'f_0' }, + ], + ] satisfies [string, HitContext, ReturnType][])( + 'buckets %s', + (_label, context, bucket) => { + expect(bucketForHit(context)).toEqual(bucket); + }, + ); + + it('refuses a run-phase hit with no running suite', () => { + expect(() => bucketForHit({ phase: 'run', testId: undefined, runningSuiteIds: [] })).toThrow( + /no running suite/, + ); + }); +}); + +describe('CoverageRecorder with attributeHookHits', () => { + const testsBySuite = { + f: ['f_0_0', 'f_0_1', 'f_1_0'], + f_0: ['f_0_0', 'f_0_1'], + f_1: ['f_1_0'], + }; + + it('attributes a hook hit after collection to every test of the suite whose hook ran, and keeps collection hits static', () => { + let context: HitContext = { phase: 'collection' }; + const recorder = new CoverageRecorder(() => context); + + instrumenterHit(recorder, '1', '1'); + context = { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'f_0'] }; + instrumenterHit(recorder, '2'); + context = { phase: 'run', testId: 'f_1_0', runningSuiteIds: ['f', 'f_1'] }; + instrumenterHit(recorder, '3'); + + expect(attributeHookHits(recorder.snapshot(), testsBySuite)).toEqual({ + static: { '1': 2 }, + perTest: { f_0_0: { '2': 1 }, f_0_1: { '2': 1 }, f_1_0: { '3': 1 } }, + }); + }); + + it('attributes a file-level hook hit to every test of the file', () => { + const recorder = new CoverageRecorder(() => ({ + phase: 'run', + testId: undefined, + runningSuiteIds: ['f'], + })); + + instrumenterHit(recorder, '7'); + + expect(attributeHookHits(recorder.snapshot(), testsBySuite).perTest).toEqual({ + f_0_0: { '7': 1 }, + f_0_1: { '7': 1 }, + f_1_0: { '7': 1 }, + }); + }); + + it('adds hook hits to the per-test hits of the same test', () => { + let context: HitContext = { phase: 'run', testId: 'f_1_0', runningSuiteIds: ['f', 'f_1'] }; + const recorder = new CoverageRecorder(() => context); + + instrumenterHit(recorder, '4'); + context = { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'f_1'] }; + instrumenterHit(recorder, '4'); + + expect(attributeHookHits(recorder.snapshot(), testsBySuite).perTest).toEqual({ + f_1_0: { '4': 2 }, + }); + }); + + it('refuses a hook hit for a suite it does not know', () => { + const recorder = new CoverageRecorder(() => ({ + phase: 'run', + testId: undefined, + runningSuiteIds: ['unknown'], + })); + instrumenterHit(recorder, '1'); + + expect(() => attributeHookHits(recorder.snapshot(), testsBySuite)).toThrow(/unknown/); + }); +}); + +describe('hookCoverage and hookAttributedTests (the hook marker, KTD2)', () => { + const testsBySuite = { + f: ['f_0_0', 'f_0_1', 'f_1_0'], + f_0: ['f_0_0', 'f_0_1'], + f_1: ['f_1_0'], + }; + + /** Mutant 2 is hit only in suite f_0's beforeAll, mutant 3 only inside test f_1_0. */ + const recorded = (): CoverageRecorder => { + let context: HitContext = { phase: 'collection' }; + const recorder = new CoverageRecorder(() => context); + instrumenterHit(recorder, '1'); + context = { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'f_0'] }; + instrumenterHit(recorder, '2'); + context = { phase: 'run', testId: 'f_1_0', runningSuiteIds: ['f', 'f_1'] }; + instrumenterHit(recorder, '3'); + context = { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'f_1'] }; + instrumenterHit(recorder, '3'); + return recorder; + }; + + it('keeps the hook bucket apart: only hook hits, attributed to every test of the suite', () => { + expect(hookCoverage(recorded().snapshot(), testsBySuite)).toEqual({ + f_0_0: { '2': 1 }, + f_0_1: { '2': 1 }, + f_1_0: { '3': 1 }, + }); + }); + + it('lists, per mutant, every test that a suite hook attributed it to', () => { + expect(hookAttributedTests(hookCoverage(recorded().snapshot(), testsBySuite))).toEqual({ + '2': ['f_0_0', 'f_0_1'], + '3': ['f_1_0'], + }); + }); + + it('leaves the attributed per-test coverage unchanged', () => { + expect(attributeHookHits(recorded().snapshot(), testsBySuite)).toEqual({ + static: { '1': 1 }, + perTest: { f_0_0: { '2': 1 }, f_0_1: { '2': 1 }, f_1_0: { '3': 2 } }, + }); + }); + + it('refuses a hook-bucket hit for a suite it does not know', () => { + const recorder = new CoverageRecorder(() => ({ + phase: 'run', + testId: undefined, + runningSuiteIds: ['unknown'], + })); + instrumenterHit(recorder, '1'); + + expect(() => hookCoverage(recorder.snapshot(), testsBySuite)).toThrow(/unknown/); + }); +}); + +describe('rekeyCoverage and mergeCoverage', () => { + it('translates per-test keys from task ids to Stryker ids and drops tests outside the map', () => { + expect( + rekeyCoverage( + { static: { '1': 1 }, perTest: { f_0: { '2': 1 }, f_skipped: { '3': 1 } } }, + new Map([['f_0', 'test/a.test.ts#A > b']]), + ), + ).toEqual({ static: { '1': 1 }, perTest: { 'test/a.test.ts#A > b': { '2': 1 } } }); + }); + + it('sums the coverage of several test files', () => { + const into = { static: { '1': 1 }, perTest: { a: { '2': 1 } } }; + + mergeCoverage(into, { static: { '1': 2, '5': 1 }, perTest: { a: { '2': 3 }, b: { '6': 1 } } }); + + expect(into).toEqual({ + static: { '1': 3, '5': 1 }, + perTest: { a: { '2': 4 }, b: { '6': 1 } }, + }); + }); +}); diff --git a/gitnexus/test/unit/mutation/fidelity-compare.test.ts b/gitnexus/test/unit/mutation/fidelity-compare.test.ts new file mode 100644 index 000000000..45bfabdfd --- /dev/null +++ b/gitnexus/test/unit/mutation/fidelity-compare.test.ts @@ -0,0 +1,412 @@ +/** + * Locks the comparison logic of gate 1 (U6): test identities and outcomes, the effective config + * against the allowed-difference list, the KTD19 path rule and module comparison, the runtime + * load check, and the envelope record. Pure functions on synthetic inputs; the real runs are + * exercised by `npm run test:mutation:fidelity`. + */ +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_CONFIG_DIFFS, + compareConfigs, + normalisePaths, + type ConfigView, +} from '../../../tools/mutation/fidelity/allowed-config-diffs.js'; +import { + buildEnvelope, + checkPathRule, + checkRuntimeLoads, + compareModuleSets, + compareTests, + locateModule, + runPathRoots, + basePathRoots, + type EnvelopeInput, + type ObservedTest, + type Roots, +} from '../../../tools/mutation/fidelity/fidelity.js'; +import type { + JsonValue, + LoadEvent, + WorkerRecord, +} from '../../../tools/mutation/fidelity/record.js'; + +const REPO = '/repo'; +const PREPARED = '/out/stryker/prepared'; +const SANDBOX = `${PREPARED}/gitnexus/.stryker-tmp/sandbox-AbC123`; +const ROOTS: Roots = { repoRoot: REPO, preparedRoot: PREPARED, sandbox: SANDBOX }; +const FILE = 'test/unit/schema.test.ts'; + +const test = (fullName: string, line: number, outcome: ObservedTest['outcome']): ObservedTest => ({ + file: FILE, + fullName, + location: { line, column: 5 }, + outcome, +}); + +const BASE_TESTS: ObservedTest[] = [ + test('schema > has a table', 10, 'passed'), + test('schema > has an index', 14, 'passed'), + test('schema > is skipped on win32', 20, 'skipped'), +]; + +describe('compareTests', () => { + it('passes identical identity lists', () => { + expect(compareTests(BASE_TESTS, BASE_TESTS)).toEqual([]); + }); + + it('fails a line shifted by one and names the shifted test', () => { + const run = [ + BASE_TESTS[0], + { ...BASE_TESTS[1], location: { line: 15, column: 5 } }, + BASE_TESTS[2], + ]; + const differences = compareTests(BASE_TESTS, run); + expect(differences).toHaveLength(1); + expect(differences[0]).toMatchObject({ + kind: 'location', + subject: `${FILE} > schema > has an index`, + }); + expect(differences[0].detail).toContain('14:5'); + expect(differences[0].detail).toContain('15:5'); + }); + + it('fails a test that passes in one run and is skipped in the other', () => { + const run = [BASE_TESTS[0], { ...BASE_TESTS[1], outcome: 'skipped' as const }, BASE_TESTS[2]]; + expect(compareTests(BASE_TESTS, run)).toEqual([ + { + kind: 'outcome', + subject: `${FILE} > schema > has an index`, + detail: 'base passed, run skipped', + }, + ]); + }); + + it('fails a test that only one run collected, and a file only one run has', () => { + const other: ObservedTest = { ...BASE_TESTS[0], file: 'test/unit/other.test.ts' }; + const kinds = compareTests(BASE_TESTS, [BASE_TESTS[0], BASE_TESTS[2], other]).map( + (difference) => [difference.kind, difference.subject], + ); + expect(kinds).toEqual([ + ['test-missing', `${FILE} > schema > has an index`], + ['file-extra', 'test/unit/other.test.ts'], + ]); + }); + + it('pairs tests that share a full name by location order', () => { + const rows = [test('each row', 30, 'passed'), test('each row', 30, 'failed')]; + const swapped = [rows[1], rows[0]]; + expect(compareTests(rows, swapped).map((difference) => difference.kind)).toEqual([ + 'outcome', + 'outcome', + ]); + }); +}); + +const view = (project: { [key: string]: JsonValue }, root: string): ConfigView => ({ + project: { root, testTimeout: 30000, sequence: { shuffle: false, seed: 1 }, ...project }, + global: { root }, + vite: {}, + env: { VITEST: 'true' }, +}); + +describe('compareConfigs', () => { + const base = view({ dangerouslyIgnoreUnhandledErrors: true }, ''); + + it('passes an allowed difference and lists it', () => { + const result = compareConfigs( + base, + view({ dangerouslyIgnoreUnhandledErrors: false }, ''), + ); + expect(result.failures).toEqual([]); + expect(result.allowed).toEqual([ + { + name: 'dangerouslyIgnoreUnhandledErrors', + paths: ['project.dangerouslyIgnoreUnhandledErrors'], + }, + ]); + }); + + it('fails a difference outside the allowed list', () => { + const run = view({ dangerouslyIgnoreUnhandledErrors: true, testTimeout: 5000 }, ''); + expect(compareConfigs(base, run).failures).toEqual([ + { path: 'project.testTimeout', base: 30000, run: 5000 }, + ]); + }); + + it.each([ + ['ignored while shuffling is off', false, []], + ['a failure when shuffling is on', true, ['project.sequence.seed']], + ])('treats a different sequence.seed as %s', (_label, shuffle, failures) => { + const sequence = { shuffle, seed: 2 }; + const left = view( + { dangerouslyIgnoreUnhandledErrors: true, sequence: { shuffle, seed: 1 } }, + '', + ); + const right = view({ dangerouslyIgnoreUnhandledErrors: true, sequence }, ''); + expect(compareConfigs(left, right).failures.map((failure) => failure.path)).toEqual(failures); + }); + + it.each([ + ['the sandbox maps to the checkout', SANDBOX, []], + ['a root in the prepared copy still differs', `${PREPARED}/gitnexus`, ['project.root']], + ['a root in the checkout still differs', `${REPO}/gitnexus`, ['project.root']], + ])('compares root after mapping paths: %s', (_label, runRoot, failures) => { + const left = normalisePaths(`${REPO}/gitnexus`, basePathRoots(REPO)); + const right = normalisePaths(runRoot, runPathRoots(ROOTS)); + const result = compareConfigs( + { ...base, project: { root: left, sequence: null } }, + { ...base, project: { root: right, sequence: null } }, + ); + expect(result.failures.map((failure) => failure.path)).toEqual(failures); + }); + + it('documents every allowed entry with a reason', () => { + expect(ALLOWED_CONFIG_DIFFS.filter((entry) => entry.reason.length < 40)).toEqual([]); + }); +}); + +const load = ( + source: LoadEvent['source'], + module: string, + parent: string | null, + phase: LoadEvent['phase'] = 'run', +): LoadEvent => ({ + source, + module, + parent, + phase, + test: phase === 'run' ? 'schema > has a table' : null, +}); + +const record = (file: string, root: string, loads: LoadEvent[]): WorkerRecord => ({ + schemaVersion: 1, + pid: 1, + ppid: 0, + file, + root, + project: 'default', + loads, + envReadsAtLoad: [], + env: {}, +}); + +const runRecord = (loads: LoadEvent[]): WorkerRecord => + record(`${SANDBOX}/${FILE}`, SANDBOX, [ + load('module-runner', `${SANDBOX}/${FILE}`, null), + ...loads, + ]); +const baseRecord = (loads: LoadEvent[]): WorkerRecord => + record(`${REPO}/gitnexus/${FILE}`, `${REPO}/gitnexus`, [ + load('module-runner', `${REPO}/gitnexus/${FILE}`, null), + ...loads, + ]); + +describe('path rule (KTD19)', () => { + it.each([ + ['the sandbox', `${SANDBOX}/src/core/lbug/schema.ts`, []], + [ + 'the sandbox relocated shared sources', + `${SANDBOX}/relocated/gitnexus-shared/src/index.ts`, + [], + ], + [ + 'the prepared copy outside the sandbox', + `${PREPARED}/gitnexus/src/core/lbug/schema.ts`, + ['prepared copy'], + ], + ['the original checkout', `${REPO}/gitnexus/src/core/lbug/schema.ts`, ['checkout']], + ['the checkout shared build', `${REPO}/gitnexus-shared/dist/index.js`, ['checkout']], + [ + 'the checkout, a tool module a test imports', + `${REPO}/gitnexus/tools/mutation/runner/test-ids.ts`, + ['checkout'], + ], + [ + 'the prepared copy, the runner setup file next to the plugin (harness)', + `${PREPARED}/gitnexus/tools/mutation/runner/setup.ts`, + [], + ], + ])('a Stryker-run local module from %s', (_label, module, origins) => { + const differences = checkPathRule( + runRecord([load('module-runner', module, `${SANDBOX}/${FILE}`)]), + ROOTS, + ); + expect(differences.map((difference) => difference.kind)).toEqual( + origins.map(() => 'path-rule'), + ); + expect(differences.map((difference) => difference.detail.includes(origins[0] ?? '-'))).toEqual( + origins.map(() => true), + ); + }); + + it('maps the base run shared build to the relocated sources', () => { + expect( + locateModule(`${REPO}/gitnexus-shared/dist/lbug/schema-constants.js`, 'base', ROOTS), + ).toEqual({ + key: 'gitnexus-shared/src/lbug/schema-constants.ts', + kind: 'production', + origin: 'checkout', + }); + }); + + it('finds the same production modules when the base loads dist and the run the relocated src', () => { + const base = baseRecord([ + load('module-runner', `${REPO}/gitnexus/src/core/lbug/schema.ts`, `${REPO}/gitnexus/${FILE}`), + load( + 'module-runner', + `${REPO}/gitnexus-shared/dist/index.js`, + `${REPO}/gitnexus/src/core/lbug/schema.ts`, + ), + ]); + const run = runRecord([ + load('module-runner', `${SANDBOX}/src/core/lbug/schema.ts`, `${SANDBOX}/${FILE}`), + load( + 'module-runner', + `${SANDBOX}/relocated/gitnexus-shared/src/index.ts`, + `${SANDBOX}/src/core/lbug/schema.ts`, + ), + ]); + expect(compareModuleSets(base, run, ROOTS)).toEqual([]); + }); + + it('leaves out what the harness imports, such as the runner setup file importing vitest', () => { + const vitestEntry = `${REPO}/gitnexus/node_modules/vitest/dist/index.js`; + const run = runRecord([ + load('module-runner', `${PREPARED}/gitnexus/tools/mutation/runner/setup.ts`, null), + load('module-runner', vitestEntry, `${PREPARED}/gitnexus/tools/mutation/runner/setup.ts`), + ]); + expect(compareModuleSets(baseRecord([]), run, ROOTS)).toEqual([]); + }); + + it('reports a production module only one run loads', () => { + const base = baseRecord([ + load('module-runner', `${REPO}/gitnexus/src/core/lbug/schema.ts`, `${REPO}/gitnexus/${FILE}`), + ]); + const differences = compareModuleSets(base, runRecord([]), ROOTS); + expect(differences.map((difference) => [difference.kind, difference.subject])).toEqual([ + ['module-set', `gitnexus/${FILE}`], + ]); + expect(differences[0].detail).toContain('gitnexus/src/core/lbug/schema.ts'); + }); +}); + +describe('runtime load check (KTD19)', () => { + const testModule = `${REPO}/gitnexus/${FILE}`; + const pkg = `file://${REPO}/gitnexus/node_modules/some-pkg/index.js`; + const harness = `file://${REPO}/gitnexus/node_modules/vitest/dist/module-evaluator.js`; + + it.each([ + [ + 'the test imports node:child_process', + load('module-runner', 'child_process', testModule), + ['child_process'], + ], + [ + 'a package requires child_process', + load('node-resolve', 'node:child_process', pkg), + ['child_process'], + ], + [ + 'a package loads cluster at load time', + load('node-resolve', 'node:cluster', pkg, 'load'), + ['cluster'], + ], + [ + 'a native addon is opened', + load('dlopen', `${REPO}/gitnexus/node_modules/x/x.node`, null), + ['native binding'], + ], + [ + 'a .node file is resolved', + load('node-resolve', `file://${REPO}/a/b.node`, pkg), + ['native binding'], + ], + ['a Worker is constructed', load('worker', 'file:///w.js', testModule), ['worker']], + ['the harness resolves child_process', load('node-resolve', 'node:child_process', harness), []], + [ + 'a package loads worker_threads without a Worker', + load('node-resolve', 'node:worker_threads', pkg), + [], + ], + ])('%s', (_label, event, kinds) => { + const result = checkRuntimeLoads(baseRecord([event]), 'base', ROOTS); + expect(result.failures.map((failure) => failure.kind)).toEqual(kinds.map(() => 'runtime-load')); + expect(result.failures.map((failure) => failure.detail.split(':')[0])).toEqual(kinds); + }); + + it('names the test that was running', () => { + const result = checkRuntimeLoads( + baseRecord([load('module-runner', 'node:child_process', testModule)]), + 'base', + ROOTS, + ); + expect(result.failures[0].subject).toBe(`gitnexus/${FILE} > schema > has a table`); + }); + + it('notes worker_threads loads without failing', () => { + const result = checkRuntimeLoads( + baseRecord([load('node-resolve', 'node:worker_threads', pkg, 'load')]), + 'base', + ROOTS, + ); + expect(result.workerThreadsLoaded).toBe(true); + }); +}); + +const ENVELOPE_INPUT: EnvelopeInput = { + versions: { vitest: '5.0.5', stryker: '10.0.0', node: 'v22.23.3' }, + platform: { os: 'linux', release: '6.6.0', arch: 'x64' }, + stryker: { concurrency: 2, maxTestRunnerReuse: 0 }, + config: { + base: { file: 'gitnexus/vitest.config.ts', project: 'default' }, + run: { file: 'gitnexus/tools/mutation/vitest.mutation.config.ts', project: 'mutation' }, + settings: { pool: 'forks' }, + comparison: { + failures: [], + allowed: [{ name: 'setupFiles', paths: ['project.setupFiles'] }], + volatile: [], + }, + }, + envReadsAtLoad: [], + files: [FILE], + tests: BASE_TESTS, + differences: [], + runtimeNotes: { workerThreadsLoadedBy: [] }, +}; + +describe('buildEnvelope', () => { + it('records the Vitest and Stryker versions of the run', () => { + expect(buildEnvelope(ENVELOPE_INPUT).versions).toEqual({ + vitest: '5.0.5', + stryker: '10.0.0', + node: 'v22.23.3', + }); + }); + + it('lists the allowed config differences that occurred, with their reasons', () => { + expect(buildEnvelope(ENVELOPE_INPUT).config.allowedDifferences).toEqual([ + { + name: 'setupFiles', + paths: ['project.setupFiles'], + reason: ALLOWED_CONFIG_DIFFS[0].reason, + }, + ]); + }); + + it('counts tests and lists the skips of this platform', () => { + const envelope = buildEnvelope(ENVELOPE_INPUT); + expect(envelope.accepted).toMatchObject({ fileCount: 1, testCount: 3, ran: 2, skipped: 1 }); + expect(envelope.skippedOnThisPlatform).toEqual([`${FILE} > schema > is skipped on win32`]); + }); + + it.each([ + ['pass', [], 'pass'], + [ + 'fail', + [{ kind: 'outcome' as const, subject: FILE, detail: 'base passed, run failed' }], + 'fail', + ], + ])('reports the gate result: %s', (_label, differences, result) => { + expect(buildEnvelope({ ...ENVELOPE_INPUT, differences }).gate.result).toBe(result); + }); +}); diff --git a/gitnexus/test/unit/mutation/launch-single.test.ts b/gitnexus/test/unit/mutation/launch-single.test.ts new file mode 100644 index 000000000..95a0e5c64 --- /dev/null +++ b/gitnexus/test/unit/mutation/launch-single.test.ts @@ -0,0 +1,586 @@ +/** + * Locks the oracle's single-test launch rules (U12, KTD15, KTD17): which launches are valid + * observations, how a launch's outcome is classified, how the test is selected (`:` + * plus an escaped, anchored `-t`), the launch environment, the wall-clock default, and the + * benchmark's cost projection. Everything here runs on synthetic launch results; no process is + * spawned. + */ +import { describe, expect, it } from 'vitest'; +import { + BASE_HOOK_TIMEOUT_MS, + BASE_TEST_TIMEOUT_MS, + DEFAULT_WALL_CLOCK_MS, + buildLaunchEnv, + classifyLaunch, + escapeRegExp, + locationFilter, + testNamePattern, + type LaunchEvidence, + type LaunchTarget, +} from '../../../tools/mutation/oracle/launch-single.js'; +import { + ORACLE_RECORD_SCHEMA_VERSION, + toOracleError, + type OracleError, + type OracleModuleRecord, + type OracleRunRecord, + type OracleTestRecord, +} from '../../../tools/mutation/oracle/oracle-classify.js'; +import { + PROBE_SCHEMA_VERSION, + checkActivation, + parseOracleProbe, + type OracleProbe, + type ProbeHits, +} from '../../../tools/mutation/oracle/probe.js'; +import { + evenlySpaced, + planLaunches, + projectOracleCost, + summarizeTimings, + type CostProjectionInput, +} from '../../../tools/mutation/bench/oracle-cost.js'; +import type { CommandRunMutant } from '../../../tools/mutation/oracle/launch-single.js'; + +const FILE = 'test/unit/a.test.ts'; +const TARGET: LaunchTarget = { + id: `${FILE}#S > one`, + file: FILE, + fullName: 'S > one', + location: { line: 10, column: 3 }, +}; +const OTHER_ID = `${FILE}#S > two`; + +const assertionError = toOracleError({ + message: 'expected 1 to be 2', + stack: 'AssertionError: expected 1 to be 2\n at test/unit/a.test.ts:11:15', +}); +const testTimeoutError: OracleError = { + name: 'Error', + message: + 'Test timed out in 30000ms.\nIf this is a long-running test, pass a timeout value as the ' + + 'last argument or configure it globally with "testTimeout".', +}; + +const testRecord = ( + fullName: string, + line: number, + overrides: Partial = {}, +): OracleTestRecord => ({ + fullName, + location: { line, column: 3 }, + state: 'skipped', + errors: [], + failedHooks: [], + retry: 0, + retryCount: 0, + ...overrides, +}); + +/** `S > one` (the target, line 10) and `S > two` (line 20), skipped unless overridden. */ +const twoTests = ( + one: Partial = {}, + two: Partial = {}, +): OracleTestRecord[] => [testRecord('S > one', 10, one), testRecord('S > two', 20, two)]; + +const moduleRecord = ( + tests: OracleTestRecord[], + overrides: Partial = {}, +): OracleModuleRecord => ({ + file: FILE, + state: tests.some((test) => test.state === 'failed') ? 'failed' : 'passed', + errors: [], + failedHooks: [], + suites: [{ fullName: 'S', errors: [], failedHooks: [], tests: tests.map((_, index) => index) }], + tests, + ...overrides, +}); + +/** A launch record whose Vitest JSON totals and exit code agree with its tests. */ +const runRecord = (module: OracleModuleRecord, mutantId: string | null = '7'): OracleRunRecord => { + const { tests } = module; + const failed = + tests.some((test) => test.state === 'failed') || + module.errors.length > 0 || + module.suites.some((suite) => suite.errors.length > 0); + return { + schemaVersion: ORACLE_RECORD_SCHEMA_VERSION, + kind: mutantId === null ? 'dry-run' : 'mutant', + mutantId, + attempt: 1, + pid: 4242, + root: '/sandbox', + configFile: '/sandbox/tools/mutation/vitest.mutation.config.ts', + vitestVersion: '5.0.5', + exitCode: failed ? 1 : 0, + wallClockMs: 1500, + wrapperError: null, + run: { reason: failed ? 'failed' : 'passed', unhandledErrors: [], modules: [module] }, + vitestJson: { + numTotalTests: tests.length, + numPassedTests: tests.filter((test) => test.state === 'passed').length, + numFailedTests: tests.filter((test) => test.state === 'failed').length, + }, + vitestJsonFile: '/out/mutant-7.attempt-1.vitest.json', + }; +}; + +const NO_HITS: ProbeHits = { loadAndCollection: 0, hook: 0, perTest: [] }; + +const probe = ( + hits: ProbeHits | null, + activation: { intended: string | null; installed: string | null } = { + intended: '7', + installed: '7', + }, +): OracleProbe => ({ + schemaVersion: PROBE_SCHEMA_VERSION, + pid: 4243, + activation: checkActivation({ + ...activation, + namespaceExists: false, + namespaceActiveMutant: null, + }), + hits, + complete: hits !== null, +}); + +const targetHits = (count: number): ProbeHits => ({ + loadAndCollection: 0, + hook: 0, + perTest: [{ taskId: 'abc_0_0', fullName: 'S > one', location: { line: 10, column: 3 }, count }], +}); + +const evidence = (overrides: Partial): LaunchEvidence => ({ + target: TARGET, + intendedMutantId: '7', + wallClockLimitMs: DEFAULT_WALL_CLOCK_MS, + killedByWallClock: false, + exitCode: 0, + signal: null, + record: null, + probe: null, + ...overrides, +}); + +describe('classifyLaunch: observation validity', () => { + it('a launch that executes zero tests is invalid', () => { + const observation = classifyLaunch( + evidence({ record: runRecord(moduleRecord(twoTests())), probe: probe(NO_HITS) }), + ); + expect(observation).toMatchObject({ validity: 'invalid', outcome: 'invalid', executed: [] }); + expect(observation.reason).toMatch(/zero tests executed/); + }); + + it('a launch that executes two tests is invalid', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'passed' }, { state: 'passed' }))); + const observation = classifyLaunch(evidence({ record, probe: probe(NO_HITS), exitCode: 0 })); + expect(observation).toMatchObject({ + validity: 'invalid', + outcome: 'invalid', + executed: [TARGET.id, OTHER_ID], + }); + expect(observation.reason).toMatch(/2 tests executed/); + }); + + it('a launch that executes one test other than the intended one is invalid', () => { + const record = runRecord(moduleRecord(twoTests({}, { state: 'passed' }))); + const observation = classifyLaunch(evidence({ record, probe: probe(NO_HITS) })); + expect(observation).toMatchObject({ validity: 'invalid', executed: [OTHER_ID] }); + expect(observation.reason).toMatch(/is not the intended test/); + }); + + it('a location filter that matched no test is invalid, not a collection kill', () => { + const module = moduleRecord(twoTests(), { + state: 'failed', + errors: [{ name: 'Error', message: `No test found in ${FILE} in line 11` }], + }); + const observation = classifyLaunch( + evidence({ record: runRecord(module), probe: probe(null), exitCode: 1 }), + ); + expect(observation).toMatchObject({ validity: 'invalid', outcome: 'invalid' }); + expect(observation.reason).toMatch(/location filter/); + }); + + it('a passing launch that never hits the mutant is a valid non-covering observation', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'passed' }))); + const observation = classifyLaunch(evidence({ record, probe: probe(NO_HITS) })); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'survived', + executed: [TARGET.id], + covering: false, + hits: { loadAndCollection: 0, hook: 0, test: 0, otherTests: 0 }, + killedBy: [], + }); + }); + + it('a passing launch that hits the mutant is a valid covering survivor', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'passed' }))); + const observation = classifyLaunch(evidence({ record, probe: probe(targetHits(3)) })); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'survived', + covering: true, + hits: { loadAndCollection: 0, hook: 0, test: 3, otherTests: 0 }, + }); + }); + + it('a launch whose installed mutant id differs from the intended one is invalid', () => { + const module = moduleRecord([], { + state: 'failed', + suites: [], + errors: [{ name: 'Error', message: 'oracle setup: installed mutant "8" differs from "7"' }], + }); + const observation = classifyLaunch( + evidence({ + record: runRecord(module), + probe: probe(null, { intended: '7', installed: '8' }), + exitCode: 1, + }), + ); + expect(observation).toMatchObject({ + validity: 'invalid', + outcome: 'invalid', + installedMutantId: '8', + }); + expect(observation.reason).toMatch(/installed mutant "8" differs from the intended "7"/); + }); + + it('a launch whose probe names another intended id than the launcher is invalid', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'passed' }))); + const observation = classifyLaunch( + evidence({ record, probe: probe(NO_HITS, { intended: '9', installed: '9' }) }), + ); + expect(observation).toMatchObject({ validity: 'invalid', outcome: 'invalid' }); + }); + + it('a launch without a record is infrastructure', () => { + const observation = classifyLaunch(evidence({ exitCode: 1, probe: probe(null) })); + expect(observation).toMatchObject({ validity: 'invalid', outcome: 'infrastructure' }); + }); +}); + +describe('classifyLaunch: outcome classes', () => { + it('a launch killed by the wall-clock limit is a timeout observation', () => { + const observation = classifyLaunch( + evidence({ killedByWallClock: true, exitCode: null, signal: 'SIGKILL', probe: probe(null) }), + ); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'timeout', + killedBy: [], + covering: null, + }); + expect(observation.reason).toMatch(/wall-clock limit/); + }); + + it('a wall-clock kill before the setup file ran is infrastructure', () => { + const observation = classifyLaunch( + evidence({ killedByWallClock: true, exitCode: null, signal: 'SIGKILL', probe: null }), + ); + expect(observation).toMatchObject({ validity: 'invalid', outcome: 'infrastructure' }); + }); + + it('a Vitest testTimeout failure is a timeout observation', () => { + const record = runRecord( + moduleRecord(twoTests({ state: 'failed', errors: [testTimeoutError] })), + ); + const observation = classifyLaunch( + evidence({ record, probe: probe(targetHits(1)), exitCode: 1 }), + ); + expect(observation).toMatchObject({ validity: 'valid', outcome: 'timeout', killedBy: [] }); + }); + + it('an assertion failure is killed-by-test assertion', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'failed', errors: [assertionError] }))); + const observation = classifyLaunch( + evidence({ record, probe: probe(targetHits(2)), exitCode: 1 }), + ); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'killed-by-test:assertion', + killedBy: [TARGET.id], + assertionKilledBy: [TARGET.id], + covering: true, + }); + }); + + it('a failing beforeAll that skips the intended test is killed outside the test', () => { + const module = moduleRecord(twoTests(), { + state: 'failed', + failedHooks: ['beforeAll'], + errors: [{ name: 'TypeError', message: 'boom' }], + }); + const observation = classifyLaunch( + evidence({ record: runRecord(module), probe: probe({ ...NO_HITS, hook: 1 }), exitCode: 1 }), + ); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'killed-outside-test:hook', + killedBy: [TARGET.id], + assertionKilledBy: [], + hits: { hook: 1 }, + }); + }); + + it('an unmutated baseline launch that passes is a valid survivor without hits', () => { + const record = runRecord(moduleRecord(twoTests({ state: 'passed' })), null); + const observation = classifyLaunch( + evidence({ + intendedMutantId: null, + record, + probe: probe(NO_HITS, { intended: null, installed: null }), + }), + ); + expect(observation).toMatchObject({ + validity: 'valid', + outcome: 'survived', + mutantId: null, + covering: false, + }); + }); +}); + +describe('test selection', () => { + const META_TITLE = 'a|b (c) [d] e? f. g* h+ ^i$ {j} \\k'; + + it.each([ + ['every metacharacter', META_TITLE, `${META_TITLE}!`], + ['a dot', 'S > a.c', 'S > abc'], + ['a plus', 'S > a+', 'S > aa'], + ['parentheses', 'S > (x)', 'S > x'], + ['an alternation', 'S > a|b', 'S > b'], + ['a character class', 'S > [ab]', 'S > a'], + ['a quantifier brace', 'S > a{2}', 'S > aa'], + ['a trailing backslash', 'S > back\\', 'S > back\\\\'], + ['a prefix of a longer name', 'S > one', 'S > one two'], + ])('the anchored pattern for %s matches only the exact name', (_label, name, other) => { + const pattern = new RegExp(testNamePattern(name)); + expect([pattern.test(name), pattern.test(other), pattern.test(`x${name}`)]).toEqual([ + true, + false, + false, + ]); + }); + + it('escapes each regex metacharacter with a backslash', () => { + expect(escapeRegExp('|()[]?.*+^${}\\')).toBe('\\|\\(\\)\\[\\]\\?\\.\\*\\+\\^\\$\\{\\}\\\\'); + }); + + it('anchors the escaped full name', () => { + expect(testNamePattern('S > a.b')).toBe('^S > a\\.b$'); + }); + + it('filters by the test file and the line of the test', () => { + expect(locationFilter(TARGET)).toBe(`${FILE}:10`); + }); + + it('refuses a target without a location', () => { + expect(() => locationFilter({ ...TARGET, location: null })).toThrow(/has no location/); + }); +}); + +describe('launch environment and wall clock', () => { + const base = { + PATH: '/usr/bin', + __STRYKER_ACTIVE_MUTANT__: '99', + STRYKER_MUTATOR_WORKER: '3', + GITNEXUS_MUTATION_SCOPE: '/old/scope.json', + }; + + it('installs the mutant and the probe settings and drops stale Stryker variables', () => { + const env = buildLaunchEnv(base, { + scopeFile: '/run/scope.json', + probeFile: '/launch/probe.json', + intendedMutantId: '7', + installedMutantId: '7', + }); + expect(env).toEqual({ + PATH: '/usr/bin', + __STRYKER_ACTIVE_MUTANT__: '7', + GITNEXUS_MUTATION_SCOPE: '/run/scope.json', + GITNEXUS_ORACLE_PROBE_FILE: '/launch/probe.json', + GITNEXUS_ORACLE_INTENDED_MUTANT: '7', + }); + }); + + it('installs no mutant for a baseline launch', () => { + const env = buildLaunchEnv(base, { + scopeFile: '/run/scope.json', + probeFile: '/launch/probe.json', + intendedMutantId: null, + installedMutantId: null, + }); + expect([env.__STRYKER_ACTIVE_MUTANT__, env.GITNEXUS_ORACLE_INTENDED_MUTANT]).toEqual([ + undefined, + '', + ]); + }); + + it('defaults the wall-clock limit above testTimeout plus hookTimeout', () => { + expect(DEFAULT_WALL_CLOCK_MS).toBeGreaterThan(BASE_TEST_TIMEOUT_MS + BASE_HOOK_TIMEOUT_MS); + }); +}); + +describe('probe file', () => { + it('round-trips through JSON', () => { + const value = probe(targetHits(4)); + expect(parseOracleProbe(JSON.stringify(value))).toEqual(value); + }); + + it('rejects another schema version', () => { + expect(() => parseOracleProbe(JSON.stringify({ ...probe(null), schemaVersion: 2 }))).toThrow( + /schemaVersion/, + ); + }); + + it.each([ + ['matching ids', '7', '7', false, true], + ['differing ids', '7', '8', false, false], + ['instrumented code before the setup file', '7', '7', true, false], + ['a baseline launch', null, null, false, true], + ])('activation check with %s', (_label, intended, installed, namespaceExists, matches) => { + expect( + checkActivation({ intended, installed, namespaceExists, namespaceActiveMutant: null }) + .matches, + ).toBe(matches); + }); +}); + +describe('oracle cost projection', () => { + const input: CostProjectionInput = { + model: { + vcpus: 4, + launchWallMs: 4000, + launchCpuMs: 2000, + wallClockLimitMs: 180000, + jobLimitMs: 6 * 3600 * 1000, + fixedOverheadMs: 600000, + }, + scopes: [ + { + name: 'a', + population: 1000, + tests: 100, + sampleSize: 100, + topUps: 20, + timeoutMutants: 10, + hangingTestsPerTimeoutMutant: 5, + commandRunnerMs: 1200000, + commandRunnerConcurrency: 4, + }, + { + name: 'b', + population: 50, + tests: 10, + sampleSize: 100, + topUps: 20, + timeoutMutants: 0, + hangingTestsPerTimeoutMutant: 0, + commandRunnerMs: 60000, + commandRunnerConcurrency: 2, + }, + ], + }; + + it('is deterministic for a fixed list of timings', () => { + expect(projectOracleCost(input)).toEqual(projectOracleCost(structuredClone(input))); + }); + + it('projects launches, hangs, command passes and the job-limit verdict', () => { + const projection = projectOracleCost(input); + // a: 120 mutants x 100 tests; 120 x (10 / 1000) x 5 = 6 hanging launches. + // launches: 11994 x 4000 / 4 + 6 x 180000 / 4 = 11994000 + 270000. + // b: the whole population of 50 mutants x 10 tests = 500 launches; command 60000 x 2 / 2. + expect(projection.scopes).toEqual([ + { + name: 'a', + sampledMutants: 120, + launches: 12000, + hangingLaunches: 6, + launchSerialMs: 11994 * 4000 + 6 * 180000, + launchMs: 12264000, + commandMs: 1200000, + totalMs: 13464000, + }, + { + name: 'b', + sampledMutants: 50, + launches: 500, + hangingLaunches: 0, + launchSerialMs: 2000000, + launchMs: 500000, + commandMs: 60000, + totalMs: 560000, + }, + ]); + expect(projection).toMatchObject({ + launches: 12500, + totalMs: 13464000 + 560000 + 600000, + cpuBoundFloorMs: (11994 + 500) * 500 + 270000 + 1260000 + 600000, + fits: true, + // (6 h - 600000 overhead - 1260000 command - 270000 hanging) x 4 vCPU / 12494 launches. + breakEvenLaunchMs: Math.round(((21600000 - 600000 - 1260000 - 270000) * 4) / 12494), + }); + }); + + it('reports a projection above the job limit as not fitting', () => { + const slow = { ...input, model: { ...input.model, launchWallMs: 8000 } }; + expect(projectOracleCost(slow).fits).toBe(false); + }); + + it('summarises timings with nearest-rank percentiles', () => { + expect(summarizeTimings([5, 1, 4, 2, 3, 10, 6, 7, 9, 8])).toEqual({ + count: 10, + meanMs: 5.5, + medianMs: 5, + p90Ms: 9, + minMs: 1, + maxMs: 10, + }); + }); +}); + +describe('benchmark launch sample', () => { + const inventory: LaunchTarget[] = ['t0', 't1', 't2', 't3', 't4', 't5'].map((name, index) => ({ + id: `${FILE}#S > ${name}`, + file: FILE, + fullName: `S > ${name}`, + location: { line: 10 + index, column: 3 }, + })); + const mutant = ( + localId: string, + oracleClass: CommandRunMutant['oracleClass'], + killers: string[] = [], + ): CommandRunMutant => ({ + localId, + globalId: `g${localId}`, + oracleClass, + killedBy: killers, + assertionKilledBy: killers, + wallClockMs: 1000, + }); + + it('picks evenly spaced entries in order', () => { + expect(evenlySpaced([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 3)).toEqual([1, 5, 8]); + }); + + it('plans killer, non-killer, survivor and baseline launches deterministically', () => { + const mutants = [ + mutant('3', 'killed-by-test:assertion', [`${FILE}#S > t2`]), + mutant('1', 'survived'), + mutant('2', 'timeout'), + ]; + const plan = planLaunches(mutants, inventory, { mutants: 1, testsPerMutant: 2, baselines: 1 }); + expect( + plan.map((launch) => [launch.role, launch.mutantId, launch.target.fullName, launch.expected]), + ).toEqual([ + ['killer', '3', 'S > t2', 'killed-by-test:assertion'], + ['non-killer', '3', 'S > t3', 'survived'], + ['survivor', '1', 'S > t1', 'survived'], + ['survivor', '1', 'S > t4', 'survived'], + ['baseline', null, 'S > t3', 'survived'], + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/mutant-identity.test.ts b/gitnexus/test/unit/mutation/mutant-identity.test.ts new file mode 100644 index 000000000..2e56d53da --- /dev/null +++ b/gitnexus/test/unit/mutation/mutant-identity.test.ts @@ -0,0 +1,268 @@ +/** + * Locks the global mutant identity (origin §2, KTD12): a mutant is named by its original repo + * path, the content hash of the original file, its 1-based end-exclusive span, its mutator and + * its replacement, so the same mutant gets the same id in Stryker runs whose local ids differ. + */ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + contentHash, + identifyMutants, + parseMutationReport, + type MutationReport, + type ReportMutant, +} from '../../../tools/mutation/prepare/mutant-identity.js'; +import { buildPathMap } from '../../../tools/mutation/prepare/path-map.js'; + +const PACKAGE_DIR = path.join(path.sep, 'prepared', 'gitnexus'); +const SHARED_KEY = 'relocated/gitnexus-shared/src/lbug/schema-constants.ts'; +const SHARED_REPO = 'gitnexus-shared/src/lbug/schema-constants.ts'; +const SRC_KEY = 'src/core/example.ts'; +const SRC_REPO = 'gitnexus/src/core/example.ts'; + +const ORIGINAL = "export const KINDS = ['a', 'b'] as const;\n"; +/** What the prepare step writes for {@link ORIGINAL}: ` as const` blanked to spaces. */ +const PREPARED = "export const KINDS = ['a', 'b'] ;\n"; + +const ORIGINALS: Readonly> = { + [SHARED_REPO]: ORIGINAL, + [SRC_REPO]: 'const a = 1;\n', +}; + +const missing = (repoPath: string): never => { + throw new Error(`no original for ${repoPath}`); +}; + +const readOriginal = (repoPath: string): string => ORIGINALS[repoPath] ?? missing(repoPath); + +/** StringLiteral on `'a'` of {@link ORIGINAL}: columns 23-26, 1-based and end exclusive. */ +const mutant = (overrides: Partial = {}): ReportMutant => ({ + id: '0', + mutatorName: 'StringLiteral', + replacement: '""', + status: 'Survived', + location: { start: { line: 1, column: 23 }, end: { line: 1, column: 26 } }, + ...overrides, +}); + +const report = (files: Record): MutationReport => ({ + schemaVersion: '1.0', + files: Object.fromEntries( + Object.entries(files).map(([file, mutants]) => [ + file, + { language: 'typescript', source: PREPARED, mutants: [...mutants] }, + ]), + ), +}); + +const identify = (files: Record) => + identifyMutants(report(files), { packageDir: PACKAGE_DIR, readOriginal }); + +describe('identifyMutants', () => { + it('gives two mutants on the same span with different replacements different identities', () => { + const run = identify({ + [SHARED_KEY]: [mutant({ id: '0' }), mutant({ id: '1', replacement: '"Stryker was here!"' })], + }); + const [first, second] = run.mutants; + expect(first.globalId).not.toBe(second.globalId); + expect({ ...first.identity, replacement: '' }).toEqual({ ...second.identity, replacement: '' }); + }); + + it('gives the same mutant the same global id in two runs with different local ids', () => { + const runA = identify({ [SHARED_KEY]: [mutant({ id: '3' })] }); + const runB = identify({ + [SRC_KEY]: [mutant({ id: '16', mutatorName: 'BooleanLiteral' })], + [SHARED_KEY]: [mutant({ id: '17' })], + }); + expect(runA.localToGlobal.get('3')).toBe(runB.localToGlobal.get('17')); + expect(runB.localToGlobal.get('16')).not.toBe(runB.localToGlobal.get('17')); + }); + + it('maps every local id of a run to its global id', () => { + const run = identify({ + [SHARED_KEY]: [mutant({ id: '0' }), mutant({ id: '9', replacement: 'x' })], + }); + expect([...run.localToGlobal]).toEqual(run.mutants.map((m) => [m.localId, m.globalId])); + }); + + it('names the mutant by its original repo path and the hash of the original file', () => { + const [identified] = identify({ [SHARED_KEY]: [mutant()] }).mutants; + expect(identified.identity).toEqual({ + file: SHARED_REPO, + fileHash: contentHash(ORIGINAL), + location: { start: { line: 1, column: 23 }, end: { line: 1, column: 26 } }, + mutatorName: 'StringLiteral', + replacement: '""', + }); + expect(identified.identity.fileHash).not.toBe(contentHash(PREPARED)); + expect(identified.reportFile).toBe(SHARED_KEY); + }); + + it('formats the global id as path@file-hash:span:mutator:replacement-hash', () => { + const [identified] = identify({ [SRC_KEY]: [mutant()] }).mutants; + expect(identified.globalId).toBe( + `${SRC_REPO}@${contentHash('const a = 1;\n').slice(0, 16)}:1:23-1:26:StringLiteral:` + + contentHash('""').slice(0, 16), + ); + }); + + it('changes the global id when the original file content changes', () => { + const before = identify({ [SRC_KEY]: [mutant()] }).mutants[0].globalId; + const after = identifyMutants(report({ [SRC_KEY]: [mutant()] }), { + packageDir: PACKAGE_DIR, + readOriginal: () => 'const a = 2;\n', + }).mutants[0].globalId; + expect(after).not.toBe(before); + }); + + it('maps an absolute report path under the package dir like a relative one', () => { + const absolute = identify({ [path.join(PACKAGE_DIR, SHARED_KEY)]: [mutant()] }); + const relative = identify({ [SHARED_KEY]: [mutant()] }); + expect(absolute.mutants[0].globalId).toBe(relative.mutants[0].globalId); + }); + + it.each([ + ['a test file', 'test/unit/schema.test.ts'], + ['a tooling file', 'tools/mutation/scope-file.ts'], + ['a file above the package dir', '../gitnexus-shared/src/index.ts'], + ])('throws for a mutant in %s, outside the mapped roots', (_label, file) => { + expect(() => identify({ [file]: [mutant()] })).toThrow( + /outside the mapped roots|not a relative path/, + ); + }); + + it('throws when two local mutants share one global identity', () => { + expect(() => identify({ [SHARED_KEY]: [mutant({ id: '0' }), mutant({ id: '1' })] })).toThrow( + /local ids 0 and 1 have the same global identity/, + ); + }); + + it('throws when a local id appears twice', () => { + expect(() => + identify({ [SHARED_KEY]: [mutant({ id: '4' }), mutant({ id: '4', replacement: 'x' })] }), + ).toThrow(/local id 4 appears twice/); + }); +}); + +describe('identifyMutants: extra roots', () => { + const FIXTURE_KEY = 'test/fixtures/mutation-conformance/src/runtime.ts'; + const FIXTURE_REPO = `gitnexus/${FIXTURE_KEY}`; + const fixtureReport = report({ [FIXTURE_KEY]: [mutant()] }); + const readFixture = (repoPath: string): string => + repoPath === FIXTURE_REPO ? ORIGINAL : missing(repoPath); + + it('names a fixture mutant by its repo path when the path map has the fixture root', () => { + const [identified] = identifyMutants(fixtureReport, { + packageDir: PACKAGE_DIR, + readOriginal: readFixture, + pathMap: buildPathMap(['gitnexus/test/fixtures/mutation-conformance']), + }).mutants; + expect(identified.globalId.startsWith(`${FIXTURE_REPO}@`)).toBe(true); + }); + + it('rejects the fixture mutant under the default path map', () => { + expect(() => + identifyMutants(fixtureReport, { packageDir: PACKAGE_DIR, readOriginal: readFixture }), + ).toThrow(/outside the mapped roots/); + }); +}); + +describe('contentHash', () => { + it('is the sha256 hex digest of the UTF-8 text', () => { + expect(contentHash('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + }); +}); + +/** A report in Stryker's JSON shape, as `JSON.parse` sees it. */ +const rawReport = (mutantOverrides: Record = {}): Record => ({ + schemaVersion: '1.0', + projectRoot: PACKAGE_DIR, + thresholds: { high: 80, low: 60, break: null }, + files: { + [SHARED_KEY]: { + language: 'typescript', + source: PREPARED, + mutants: [ + { + id: '7', + mutatorName: 'StringLiteral', + replacement: '""', + status: 'Killed', + static: true, + statusReason: 'expected', + testsCompleted: 3, + coveredBy: [], + killedBy: ['2', '5'], + location: { end: { column: 26, line: 1 }, start: { column: 23, line: 1 } }, + ...mutantOverrides, + }, + ], + }, + }, +}); + +describe('parseMutationReport', () => { + it('reads the fields the identity and the outcome comparison consume', () => { + const parsed = parseMutationReport(JSON.stringify(rawReport())); + expect(parsed.projectRoot).toBe(PACKAGE_DIR); + expect(parsed.files[SHARED_KEY]).toEqual({ + language: 'typescript', + source: PREPARED, + mutants: [ + { + id: '7', + mutatorName: 'StringLiteral', + replacement: '""', + status: 'Killed', + static: true, + statusReason: 'expected', + testsCompleted: 3, + coveredBy: [], + killedBy: ['2', '5'], + location: { start: { line: 1, column: 23 }, end: { line: 1, column: 26 } }, + }, + ], + }); + }); + + const position = (line: unknown, column: unknown) => ({ line, column }); + + it.each([ + ['a missing replacement', { replacement: undefined }, /replacement/], + ['a numeric id', { id: 7 }, /\.id/], + ['a mutator name with a colon', { mutatorName: 'String:Literal' }, /mutatorName/], + ['an unknown status', { status: 'Exploded' }, /status/], + ['a non-string killedBy entry', { killedBy: [2] }, /killedBy/], + [ + 'a zero column', + { location: { start: position(1, 0), end: position(1, 26) } }, + /location\.start/, + ], + [ + 'a fractional line', + { location: { start: position(1.5, 23), end: position(1, 26) } }, + /location\.start/, + ], + [ + 'an end before the start', + { location: { start: position(2, 1), end: position(1, 26) } }, + /ends before it starts/, + ], + ])('rejects a mutant with %s', (_label, overrides, message) => { + expect(() => parseMutationReport(JSON.stringify(rawReport(overrides)))).toThrow(message); + }); + + it.each([ + ['schema version 2', { ...rawReport(), schemaVersion: '2' }, /schemaVersion/], + ['files that are not an object', { ...rawReport(), files: [] }, /files/], + [ + 'a file without source', + { schemaVersion: '1.0', files: { a: { language: 'typescript', mutants: [] } } }, + /source/, + ], + ])('rejects a report with %s', (_label, raw, message) => { + expect(() => parseMutationReport(JSON.stringify(raw))).toThrow(message); + }); +}); diff --git a/gitnexus/test/unit/mutation/normalise-results.test.ts b/gitnexus/test/unit/mutation/normalise-results.test.ts new file mode 100644 index 000000000..d7359e9df --- /dev/null +++ b/gitnexus/test/unit/mutation/normalise-results.test.ts @@ -0,0 +1,634 @@ +/** + * Locks the scope driver's result normalisation (U10, KTD6, KTD16, KTD17): report test ids map to + * the runner's Stryker ids through the run manifest, sidecar records join their mutant through the + * local id map (latest attempt wins), and integrity problems are flagged instead of hidden. Every + * attempt's per-test rows are kept and a class change between attempts is marked (Phase 1 KTD8), + * and the dry run's hook marker is exposed per mutant (Phase 1 KTD2, KTD9). + */ +import { describe, expect, it } from 'vitest'; +import { + mapReportTestIds, + normaliseRun, + orderAttempts, + parseReportTestFiles, + type NormaliseInput, + type ReportTestFiles, + type SidecarInput, +} from '../../../tools/mutation/driver/normalise-results.js'; +import { + identifyMutants, + type MutationReport, + type ReportMutant, +} from '../../../tools/mutation/prepare/mutant-identity.js'; +import type { RunClassification } from '../../../tools/mutation/runner/classify-outcome.js'; +import type { RunManifest } from '../../../tools/mutation/runner/run-manifest.js'; +import { + CoverageRecorder, + hookAttributedTests, + hookCoverage, + rekeyCoverage, + type HitContext, +} from '../../../tools/mutation/runner/coverage-attribution.js'; +import { + SIDECAR_SCHEMA_VERSION, + type SidecarRecord, +} from '../../../tools/mutation/runner/sidecar.js'; + +const PACKAGE_DIR = '/prepared/gitnexus'; +const SOURCE = 'export const a = 1 + 2;\nexport const b = a > 0;\n'; +const FILE = 'src/x.ts'; +const T_ONE = 'test/unit/a.test.ts#A > one'; +const T_TWO = 'test/unit/a.test.ts#A > two'; + +const manifest: RunManifest = { + schemaVersion: 1, + project: 'mutation', + vitestVersion: '5.0.5', + files: [ + { + file: 'test/unit/a.test.ts', + tests: [ + { + id: T_ONE, + taskId: 'h_0', + fullName: 'A > one', + location: null, + notOracleValidated: false, + }, + { + id: T_TWO, + taskId: 'h_1', + fullName: 'A > two', + location: null, + notOracleValidated: false, + }, + ], + skipped: [], + }, + ], +}; + +const testFiles: ReportTestFiles = { + 'test/unit/a.test.ts': { + tests: [ + { id: '0', name: 'A > one' }, + { id: '1', name: 'A > two' }, + ], + }, +}; + +const mutant = (id: string, overrides: Partial): ReportMutant => ({ + id, + mutatorName: 'ArithmeticOperator', + replacement: `r${id}`, + location: { start: { line: 1, column: 18 }, end: { line: 1, column: 23 } }, + status: 'Survived', + ...overrides, +}); + +const identities = (mutants: ReportMutant[]) => { + const report: MutationReport = { + schemaVersion: '1.0', + projectRoot: PACKAGE_DIR, + files: { [FILE]: { language: 'typescript', source: SOURCE, mutants } }, + }; + return identifyMutants(report, { packageDir: PACKAGE_DIR, readOriginal: () => SOURCE }); +}; + +const classification = (overrides: Partial): RunClassification => ({ + status: 'survived', + outcome: 'survived', + reason: undefined, + killedBy: [], + failureMessage: undefined, + nrOfTests: 2, + hitCount: 1, + tests: [], + outsideTest: [], + ...overrides, +}); + +const killedByOne = classification({ + status: 'killed', + outcome: 'killed-by-test:assertion', + killedBy: [T_ONE], + failureMessage: 'expected 3 to be 4', + tests: [ + { id: T_ONE, outcome: 'killed-by-test:assertion', message: 'expected 3 to be 4' }, + { id: T_TWO, outcome: 'survived', message: undefined }, + ], +}); + +const sidecar = ( + mutantId: string | null, + result: RunClassification, + where: { + worker?: string; + attempt?: number; + at?: number; + hookAttributedTests?: Record; + } = {}, +): SidecarInput => { + const record: SidecarRecord = { + schemaVersion: SIDECAR_SCHEMA_VERSION, + kind: mutantId === null ? 'dry-run' : 'mutant', + mutantId, + worker: where.worker ?? '1', + attempt: where.attempt ?? 1, + pid: 100, + mutantActivation: mutantId === null ? null : 'runtime', + hitLimit: null, + requestedTestIds: [T_ONE, T_TWO], + hookAttributedTests: mutantId === null ? (where.hookAttributedTests ?? {}) : null, + result, + }; + return { + record, + file: `/out/sidecars/${mutantId ?? 'dry-run'}.json`, + writtenAtMs: where.at ?? 0, + }; +}; + +const dryRun = sidecar(null, classification({ nrOfTests: 2 })); + +const run = (mutants: ReportMutant[], sidecars: SidecarInput[]): NormaliseInput => ({ + identities: identities(mutants), + testFiles, + manifest, + sidecars: [dryRun, ...sidecars], +}); + +describe('normaliseRun: sidecar join through the local id map', () => { + const ids = identities([ + mutant('7', { status: 'Killed', killedBy: ['0'], coveredBy: ['0', '1'] }), + ]); + const result = normaliseRun({ + identities: ids, + testFiles, + manifest, + sidecars: [dryRun, sidecar('7', killedByOne, { worker: '2' })], + }); + const [only] = result.mutants; + + it('carries the global id of the local mutant', () => { + expect(only.globalId).toBe(ids.localToGlobal.get('7')); + expect(only.localId).toBe('7'); + expect(only.file).toBe(`gitnexus/${FILE}`); + }); + + it('takes the outcome class from the sidecar record of that local id', () => { + expect(only).toMatchObject({ + outcome: 'killed-by-test:assertion', + outcomeSource: 'sidecar', + sidecar: { worker: '2', attempt: 1, status: 'killed', killedBy: [T_ONE], nrOfTests: 2 }, + assertionKilledBy: [T_ONE], + }); + }); + + it('maps report killers and coverage to runner test ids', () => { + expect(only.killedBy).toEqual([T_ONE]); + expect(only.coveredBy).toEqual([T_ONE, T_TWO]); + }); + + it('builds per-test kill sets keyed by runner test id', () => { + expect(result.tests.find((test) => test.id === T_ONE)).toMatchObject({ + killed: [only.globalId], + assertionKilled: [only.globalId], + }); + expect(result.tests.find((test) => test.id === T_TWO)?.killed).toEqual([]); + }); + + it('raises no flag', () => { + expect(result.flags).toEqual([]); + }); +}); + +describe('normaliseRun: assertion kills come only from killed runs (KTD17)', () => { + const assertionRow = { id: T_ONE, outcome: 'killed-by-test:assertion', message: 'x' } as const; + // Found by the gate 2 conformance fixture (U4): a hit-limit timeout whose swallowed error made + // the test fail its assertion, and inconclusive runs of a scope with a `retry: 1` test. + const notKilled = [ + ['a hit-limit timeout', 'Timeout', classification({ status: 'timeout', outcome: 'timeout' })], + [ + 'an inconclusive run', + 'RuntimeError', + classification({ status: 'error', outcome: 'inconclusive' }), + ], + ['an invalid run', 'RuntimeError', classification({ status: 'error', outcome: 'invalid' })], + ] as const; + + it.each(notKilled)('lists no assertion killer for %s', (_label, status, result) => { + const normalised = normaliseRun( + run([mutant('5', { status })], [sidecar('5', { ...result, tests: [assertionRow] })]), + ); + expect(normalised.mutants[0].assertionKilledBy).toEqual([]); + expect(normalised.tests.find((test) => test.id === T_ONE)?.assertionKilled).toEqual([]); + }); + + it('still lists the assertion killers of a killed run', () => { + const normalised = normaliseRun( + run([mutant('6', { status: 'Killed', killedBy: ['0'] })], [sidecar('6', killedByOne)]), + ); + expect(normalised.mutants[0].assertionKilledBy).toEqual([T_ONE]); + }); +}); + +describe('normaliseRun: integrity flags', () => { + it('flags a killedBy entry with no sidecar record, and the Killed mutant without one', () => { + const result = normaliseRun(run([mutant('3', { status: 'Killed', killedBy: ['1'] })], [])); + expect(result.flags.map((flag) => [flag.kind, flag.localId])).toEqual([ + ['killed-by-without-sidecar', '3'], + ['killed-without-sidecar', '3'], + ]); + expect(result.flags[0].detail).toContain(T_TWO); + expect(result.mutants[0]).toMatchObject({ outcome: null, outcomeSource: 'none' }); + }); + + it('flags a killedBy entry the sidecar does not list', () => { + const result = normaliseRun( + run([mutant('3', { status: 'Killed', killedBy: ['0', '1'] })], [sidecar('3', killedByOne)]), + ); + expect(result.flags.map((flag) => flag.kind)).toEqual(['killed-by-not-in-sidecar']); + expect(result.flags[0].detail).toContain(T_TWO); + }); + + it('flags a mutant run that executed zero tests', () => { + const invalid = classification({ status: 'error', outcome: 'invalid', nrOfTests: 0 }); + const result = normaliseRun( + run([mutant('4', { status: 'RuntimeError' })], [sidecar('4', invalid)]), + ); + expect(result.flags.map((flag) => flag.kind)).toEqual(['zero-tests']); + expect(result.summary.zeroTestRuns).toBe(1); + }); + + it('does not flag zero tests when the file failed to collect', () => { + const collection = classification({ + status: 'killed', + outcome: 'killed-outside-test:collection', + nrOfTests: 0, + }); + const result = normaliseRun( + run([mutant('4', { status: 'Killed', static: true })], [sidecar('4', collection)]), + ); + expect(result.flags).toEqual([]); + }); + + // A loop turned endless at module load or in a beforeAll fixture hits the instrumenter's limit + // (or the hook timeout) before any requested test runs: a timeout, explained by its row. + it.each([ + [ + 'a hit-limit timeout while collecting the file', + undefined, + 'Stryker: Hit count limit reached', + ], + ['a beforeAll hook timeout', 'h_s0', 'Hook timed out in 2000ms'], + ])('does not flag zero tests explained by %s', (_label, suiteTaskId, message) => { + const timeout = classification({ + status: 'timeout', + outcome: 'timeout', + nrOfTests: 0, + outsideTest: [ + { file: 'test/unit/a.test.ts', suiteTaskId, outcome: 'timeout', attributedTo: [], message }, + ], + }); + const result = normaliseRun(run([mutant('4', { status: 'Timeout' })], [sidecar('4', timeout)])); + expect(result.flags).toEqual([]); + expect(result.summary.zeroTestRuns).toBe(0); + }); + + it.each([ + ['an invalid run', 'RuntimeError', classification({ status: 'error', outcome: 'invalid' })], + [ + 'a timeout with no outside-test row', + 'Timeout', + classification({ status: 'timeout', outcome: 'timeout' }), + ], + ] as const)('still flags zero tests for %s', (_label, status, result) => { + const normalised = normaliseRun( + run([mutant('4', { status })], [sidecar('4', { ...result, nrOfTests: 0 })]), + ); + expect(normalised.flags.map((flag) => flag.kind)).toEqual(['zero-tests']); + }); + + it('flags a sidecar status that disagrees with the Stryker status', () => { + const result = normaliseRun( + run([mutant('5', { status: 'Survived' })], [sidecar('5', killedByOne)]), + ); + expect(result.flags.map((flag) => flag.kind)).toEqual(['status-mismatch']); + }); + + it('flags a Survived mutant without a sidecar record', () => { + const result = normaliseRun(run([mutant('5', { status: 'Survived' })], [])); + expect(result.flags.map((flag) => flag.kind)).toEqual(['missing-sidecar']); + }); + + it('flags a sidecar record whose local id is not in the report', () => { + const result = normaliseRun(run([], [sidecar('99', killedByOne)])); + expect(result.flags).toEqual([ + expect.objectContaining({ kind: 'orphan-sidecar', localId: '99', globalId: null }), + ]); + }); + + it('flags a vitest5 run without a dry-run record', () => { + const result = normaliseRun({ ...run([], []), sidecars: [] }); + expect(result.flags.map((flag) => flag.kind)).toEqual(['dry-run-missing']); + expect(result.dryRun).toBeNull(); + }); + + it('flags a report test id that maps to no runner test', () => { + const result = normaliseRun({ + ...run( + [mutant('6', { status: 'Survived', coveredBy: ['42'] })], + [sidecar('6', classification({}))], + ), + }); + expect(result.flags.map((flag) => [flag.kind, flag.localId])).toEqual([ + ['unmapped-test-id', '6'], + ]); + expect(result.mutants[0].coveredBy).toEqual(['42']); + }); +}); + +describe('normaliseRun: outcomes without a sidecar record', () => { + it.each([ + ['NoCoverage', 'survived', 'no-coverage'], + ['Timeout', 'timeout', 'stryker-timeout'], + ['Ignored', null, 'none'], + ] as const)('%s -> %s (%s), no flag', (status, outcome, outcomeSource) => { + const result = normaliseRun(run([mutant('8', { status })], [])); + expect(result.mutants[0]).toMatchObject({ outcome, outcomeSource }); + expect(result.flags).toEqual([]); + }); +}); + +describe('normaliseRun: attempts', () => { + it('records every attempt and lets the latest one win', () => { + const timeout = classification({ status: 'timeout', outcome: 'timeout', nrOfTests: 1 }); + const result = normaliseRun( + run( + [mutant('9', { status: 'Killed', killedBy: ['0'] })], + [ + sidecar('9', killedByOne, { worker: '3', at: 20 }), + sidecar('9', timeout, { worker: '1', at: 10 }), + ], + ), + ); + expect(result.mutants[0].attempts.map((attempt) => [attempt.worker, attempt.outcome])).toEqual([ + ['1', 'timeout'], + ['3', 'killed-by-test:assertion'], + ]); + expect(result.mutants[0].sidecar?.worker).toBe('3'); + expect(result.flags).toEqual([]); + }); + + it('orders attempts by write time, then numeric worker, then attempt number', () => { + const records = [ + sidecar('1', killedByOne, { worker: '10', attempt: 1, at: 5 }), + sidecar('1', killedByOne, { worker: '9', attempt: 2, at: 5 }), + sidecar('1', killedByOne, { worker: '9', attempt: 1, at: 5 }), + sidecar('1', killedByOne, { worker: '2', attempt: 1, at: 1 }), + ]; + expect( + orderAttempts(records).map(({ record }) => `${record.worker}/${record.attempt}`), + ).toEqual(['2/1', '9/1', '9/2', '10/1']); + }); +}); + +describe('normaliseRun: per-test classes of every attempt (KTD8)', () => { + const runtimeByOne = classification({ + status: 'killed', + outcome: 'killed-by-test:runtime', + killedBy: [T_ONE], + tests: [ + { id: T_ONE, outcome: 'killed-by-test:runtime', message: 'x is undefined' }, + { id: T_TWO, outcome: 'survived', message: undefined }, + ], + }); + + it('keeps the rows of both attempts and marks a class change between them', () => { + const result = normaliseRun( + run( + [mutant('9', { status: 'Killed', killedBy: ['0'] })], + [ + sidecar('9', killedByOne, { worker: '1', at: 10 }), + sidecar('9', runtimeByOne, { worker: '2', at: 20 }), + ], + ), + ); + expect([result.mutants[0].perTest, result.mutants[0].attemptsDisagree]).toEqual([ + [ + { testId: T_ONE, outcome: 'killed-by-test:assertion', attempt: 0 }, + { testId: T_TWO, outcome: 'survived', attempt: 0 }, + { testId: T_ONE, outcome: 'killed-by-test:runtime', attempt: 1 }, + { testId: T_TWO, outcome: 'survived', attempt: 1 }, + ], + true, + ]); + }); + + it.each([ + ['one attempt', [sidecar('9', killedByOne)]], + [ + 'two attempts with the same classes', + [sidecar('9', killedByOne, { at: 1 }), sidecar('9', killedByOne, { worker: '2', at: 2 })], + ], + ])('does not mark %s as disagreeing', (_label, records) => { + const result = normaliseRun(run([mutant('9', { status: 'Killed', killedBy: ['0'] })], records)); + expect(result.mutants[0].attemptsDisagree).toBe(false); + }); + + it('keeps no rows and no disagreement for a mutant without a record', () => { + const result = normaliseRun(run([mutant('8', { status: 'NoCoverage' })], [])); + expect([result.mutants[0].perTest, result.mutants[0].attemptsDisagree]).toEqual([[], false]); + }); +}); + +describe('normaliseRun: hook marker from the dry-run record (KTD2, KTD9)', () => { + /** The dry run's hook bucket: mutant 7 is hit only in suite `h`'s beforeAll. */ + const dryRunMarker = (): Record => { + let context: HitContext = { phase: 'collection' }; + const recorder = new CoverageRecorder(() => context); + context = { phase: 'run', testId: undefined, runningSuiteIds: ['f', 'h'] }; + const coverage = recorder.instrumenterView; + coverage.static['7'] = (coverage.static['7'] ?? 0) + 1; + const byTask = hookCoverage(recorder.snapshot(), { f: ['h_0', 'h_1'], h: ['h_0', 'h_1'] }); + const byId = rekeyCoverage( + { static: {}, perTest: byTask }, + new Map([ + ['h_0', T_ONE], + ['h_1', T_TWO], + ]), + ); + return hookAttributedTests(byId.perTest); + }; + + it('lists every test of the suite for a mutant covered only through a beforeAll hit', () => { + const result = normaliseRun({ + identities: identities([ + mutant('7', { status: 'Killed', killedBy: ['0'], coveredBy: ['0', '1'] }), + mutant('8', { status: 'NoCoverage' }), + ]), + testFiles, + manifest, + sidecars: [ + sidecar(null, classification({}), { hookAttributedTests: dryRunMarker() }), + sidecar('7', killedByOne), + ], + }); + expect(result.mutants.map((entry) => [entry.localId, entry.hookAttributedTests])).toEqual([ + ['7', [T_ONE, T_TWO]], + ['8', []], + ]); + }); + + it('has no marker when no dry-run record names one', () => { + const result = normaliseRun({ + ...run([mutant('8', { status: 'NoCoverage' })], []), + sidecars: [], + }); + expect(result.mutants[0].hookAttributedTests).toBeNull(); + }); +}); + +describe('normaliseRun: summary and dry run', () => { + const result = normaliseRun( + run( + [ + mutant('1', { status: 'Killed', killedBy: ['0'] }), + mutant('2', { status: 'NoCoverage' }), + mutant('3', { status: 'Survived' }), + ], + [sidecar('1', killedByOne), sidecar('3', classification({}))], + ), + ); + + it('counts statuses and outcome classes', () => { + expect(result.summary).toMatchObject({ + mutants: 3, + byStatus: { Killed: 1, NoCoverage: 1, Survived: 1 }, + byOutcome: { 'killed-by-test:assertion': 1, survived: 2 }, + mutantsWithSidecar: 2, + mutantsWithOutcome: 3, + testsWithKills: 1, + testsWithAssertionKills: 1, + zeroTestRuns: 0, + flags: 0, + }); + }); + + it('summarises the dry-run record', () => { + expect(result.dryRun).toMatchObject({ outcome: 'survived', nrOfTests: 2, attempts: 1 }); + }); +}); + +describe('normaliseRun: command variant (no manifest, no sidecars)', () => { + const commandFiles: ReportTestFiles = { '': { tests: [{ id: '0', name: 'All tests' }] } }; + const result = normaliseRun({ + identities: identities([ + mutant('1', { status: 'Survived' }), + mutant('2', { status: 'Killed', killedBy: ['0'] }), + ]), + testFiles: commandFiles, + manifest: null, + sidecars: null, + }); + + it('maps report test ids to report test names and joins no sidecar', () => { + expect(result.mutants.map((m) => [m.status, m.killedBy, m.outcomeSource])).toEqual([ + ['Survived', [], 'none'], + ['Killed', ['All tests'], 'none'], + ]); + expect(result.tests).toEqual([ + expect.objectContaining({ + id: 'All tests', + taskId: null, + killed: [result.mutants[1].globalId], + }), + ]); + }); + + it('raises no sidecar flag', () => { + expect(result.flags).toEqual([]); + expect(result.dryRun).toBeNull(); + }); +}); + +describe('mapReportTestIds', () => { + const twin = (id: string, taskId: string, skipped = false) => ({ + id, + taskId, + fullName: 'T > row', + location: { line: 3, column: 5 }, + notOracleValidated: skipped, + }); + + it('pairs tests that share a full name by collection order, across ran and skipped tests', () => { + const twins: RunManifest = { + ...manifest, + files: [ + { + file: 'test/unit/t.test.ts', + tests: [ + twin('test/unit/t.test.ts#T > row:3:5:0', 'h_0'), + twin('test/unit/t.test.ts#T > row:3:5:2', 'h_2'), + ], + skipped: [{ ...twin('test/unit/t.test.ts#T > other', 'h_1'), fullName: 'T > other' }], + }, + ], + }; + const files: ReportTestFiles = { + 'test/unit/t.test.ts': { + tests: [ + { id: '0', name: 'T > row' }, + { id: '1', name: 'T > other' }, + { id: '2', name: 'T > row' }, + ], + }, + }; + const mapped = mapReportTestIds(files, twins); + expect([...mapped.ids]).toEqual([ + ['0', 'test/unit/t.test.ts#T > row:3:5:0'], + ['1', 'test/unit/t.test.ts#T > other'], + ['2', 'test/unit/t.test.ts#T > row:3:5:2'], + ]); + expect(mapped.problems).toEqual([]); + }); + + it('reports a report test that the manifest does not list, and a manifest test the report lacks', () => { + const files: ReportTestFiles = { + 'test/unit/a.test.ts': { + tests: [ + { id: '0', name: 'A > one' }, + { id: '1', name: 'A > ghost' }, + ], + }, + 'test/unit/z.test.ts': { tests: [{ id: '2', name: 'Z' }] }, + }; + const mapped = mapReportTestIds(files, manifest); + expect([...mapped.ids]).toEqual([['0', T_ONE]]); + expect(mapped.problems).toEqual([ + expect.stringContaining('"A > ghost"'), + expect.stringContaining(T_TWO), + expect.stringContaining('test/unit/z.test.ts'), + ]); + }); +}); + +describe('parseReportTestFiles', () => { + it('reads the testFiles section of a Stryker report', () => { + const text = JSON.stringify({ schemaVersion: '1.0', files: {}, testFiles }); + expect(parseReportTestFiles(text)).toEqual(testFiles); + }); + + it('returns no files when the report has no testFiles section', () => { + expect(parseReportTestFiles(JSON.stringify({ schemaVersion: '1.0', files: {} }))).toEqual({}); + }); + + it.each([ + ['a non-object section', { testFiles: [] }, /testFiles must be an object/], + ['a file without tests', { testFiles: { a: {} } }, /tests must be an array/], + ['a test without a name', { testFiles: { a: { tests: [{ id: '0' }] } } }, /id and name/], + ])('rejects %s', (_label, value, message) => { + expect(() => parseReportTestFiles(JSON.stringify(value))).toThrow(message); + }); +}); diff --git a/gitnexus/test/unit/mutation/oracle-boundary.test.ts b/gitnexus/test/unit/mutation/oracle-boundary.test.ts new file mode 100644 index 000000000..6e3b5e280 --- /dev/null +++ b/gitnexus/test/unit/mutation/oracle-boundary.test.ts @@ -0,0 +1,189 @@ +/** + * Enforces KTD15: code under tools/mutation/oracle/ never imports code under + * tools/mutation/runner/ (the vitest5 runner), so gate 3 compares two independent + * implementations. The scanner reads every direct import specifier of each oracle module with + * @babel/parser: `import` (type-only included), `export ... from`, `import x = require()`, + * `import()` and `require()`; a dynamic import or require with a non-literal argument fails, + * because it cannot be checked. Relative specifiers resolve against the module's directory. + * + * Allowed: the scope driver (tools/mutation/driver/) and the prepare step + * (tools/mutation/prepare/), which KTD18 lets runner and oracle share. command-run.ts (U13) + * imports driver/run-scope.ts, which imports runner/run-manifest.ts and runner/sidecar.ts; the + * oracle reaches those runner modules only transitively through the driver, and only this + * direct-import rule is enforced. + */ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +import { describe, expect, it } from 'vitest'; + +const PACKAGE_DIR = fileURLToPath(new URL('../../../', import.meta.url)); +const ORACLE_DIR = path.join(PACKAGE_DIR, 'tools/mutation/oracle'); +const RUNNER_DIR = path.join(PACKAGE_DIR, 'tools/mutation/runner'); + +interface ImportSite { + specifier: string | null; + line: number; +} + +interface Violation { + file: string; + line: number; + detail: string; +} + +interface AstNode { + type: string; + loc?: { start: { line: number } } | null; + [key: string]: unknown; +} + +const isNode = (value: unknown): value is AstNode => + typeof value === 'object' && value !== null && typeof Reflect.get(value, 'type') === 'string'; + +/** The cooked text of a template element (`{ value: { raw, cooked } }`). */ +const cooked = (quasi: unknown): string => { + const value: unknown = isNode(quasi) ? quasi.value : undefined; + const text: unknown = + typeof value === 'object' && value !== null ? Reflect.get(value, 'cooked') : undefined; + return typeof text === 'string' ? text : ''; +}; + +const literal = (node: unknown): string | null => + isNode(node) && node.type === 'StringLiteral' && typeof node.value === 'string' + ? node.value + : isNode(node) && + node.type === 'TemplateLiteral' && + Array.isArray(node.expressions) && + node.expressions.length === 0 && + Array.isArray(node.quasis) + ? node.quasis.map(cooked).join('') + : null; + +/** Every direct import site of a module's source, in source order. */ +function importSites(source: string): ImportSite[] { + const ast = parse(source, { sourceType: 'module', plugins: ['typescript'] }); + const sites: ImportSite[] = []; + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + if (!isNode(node)) return; + const line = node.loc?.start.line ?? 0; + const declares = + node.type === 'ImportDeclaration' || + node.type === 'ExportAllDeclaration' || + (node.type === 'ExportNamedDeclaration' && node.source !== null); + if (declares) sites.push({ specifier: literal(node.source), line }); + if (node.type === 'TSExternalModuleReference') { + sites.push({ specifier: literal(node.expression), line }); + } + if (node.type === 'ImportExpression') sites.push({ specifier: literal(node.source), line }); + const callee = node.type === 'CallExpression' && isNode(node.callee) ? node.callee : null; + const calls = + callee !== null && + (callee.type === 'Import' || (callee.type === 'Identifier' && callee.name === 'require')); + if (calls && Array.isArray(node.arguments)) { + sites.push({ specifier: literal(node.arguments[0]), line }); + } + for (const [key, value] of Object.entries(node)) { + if (key !== 'loc' && key !== 'start' && key !== 'end') visit(value); + } + }; + visit(ast.program); + return sites; +} + +const inside = (dir: string, file: string): boolean => { + const relative = path.relative(dir, file); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +}; + +/** Import sites of `source` (a module at `file`) that reach the runner or cannot be checked. */ +function boundaryViolations(file: string, source: string): Violation[] { + return importSites(source).flatMap((site): Violation[] => { + const at = { + file: path.relative(PACKAGE_DIR, file).split(path.sep).join('/'), + line: site.line, + }; + if (site.specifier === null) return [{ ...at, detail: 'non-literal dynamic import' }]; + const relative = site.specifier.startsWith('.') || path.isAbsolute(site.specifier); + if (!relative) return []; + const target = path.resolve(path.dirname(file), site.specifier); + return inside(RUNNER_DIR, target) ? [{ ...at, detail: `imports ${site.specifier}` }] : []; + }); +} + +const oracleModules = (): string[] => + readdirSync(ORACLE_DIR, { recursive: true, encoding: 'utf8' }) + .filter((name) => /\.[cm]?[jt]s$/.test(name)) + .map((name) => path.join(ORACLE_DIR, name)) + .sort(); + +const SYNTHETIC = path.join(ORACLE_DIR, 'synthetic.ts'); + +describe('oracle boundary (KTD15)', () => { + it('scans the oracle modules', () => { + const names = oracleModules().map((file) => path.basename(file)); + expect(names).toEqual( + expect.arrayContaining([ + 'command-run.ts', + 'command-wrapper.ts', + 'compare.ts', + 'freeze.ts', + 'launch-single.ts', + 'oracle-classify.ts', + 'oracle-reporter.ts', + 'oracle-setup.ts', + 'probe.ts', + ]), + ); + }); + + it('no module under oracle/ imports from runner/', () => { + const violations = oracleModules().flatMap((file) => + boundaryViolations(file, readFileSync(file, 'utf8')), + ); + expect(violations).toEqual([]); + }); + + it('command-run.ts reaches the runner only through the scope driver', () => { + const file = path.join(ORACLE_DIR, 'command-run.ts'); + const specifiers = importSites(readFileSync(file, 'utf8')).map((site) => site.specifier); + expect(specifiers).toEqual(expect.arrayContaining(['../driver/run-scope.js'])); + }); + + it.each([ + ['a static import', 1, "import { RUNNER_NAME } from '../runner/plugin.js';"], + ['a type-only import', 1, "import type { FileMeta } from '../runner/protocol.js';"], + ['a re-export', 1, "export { readSidecars } from '../runner/sidecar.js';"], + ['a star re-export', 1, "export * from '../runner/test-ids.js';"], + ['a dynamic import', 1, "const m = await import('../runner/setup.js');"], + ['a template-literal import', 1, 'const m = await import(`../runner/setup.js`);'], + ['a require', 1, "const m = require('../runner/version-guard.js');"], + ['an import-equals', 1, "import m = require('../runner/run-manifest.js');"], + ['a longer path into the runner', 1, "import '../../mutation/runner/plugin.js';"], + ['the runner directory itself', 1, "import '../runner';"], + ['a non-literal dynamic import', 1, 'const m = await import(name);'], + ['the scope driver (allowed)', 0, "import { runScope } from '../driver/run-scope.js';"], + ['the prepare step (allowed)', 0, "import { prepare } from '../prepare/prepare.js';"], + ['another oracle module (allowed)', 0, "import { classifyRun } from './oracle-classify.js';"], + ['a package (allowed)', 0, "import { startVitest } from 'vitest/node';"], + ['a sibling named like the runner (allowed)', 0, "import x from '../runner-notes/x.js';"], + ])('a synthetic oracle module with %s has %i violation(s)', (_label, count, source) => { + expect(boundaryViolations(SYNTHETIC, source)).toHaveLength(count); + }); + + it('reports the module and line of a violation', () => { + const source = "import path from 'node:path';\n\nimport { x } from '../runner/plugin.js';\n"; + expect(boundaryViolations(SYNTHETIC, source)).toEqual([ + { + file: 'tools/mutation/oracle/synthetic.ts', + line: 3, + detail: 'imports ../runner/plugin.js', + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/oracle-classify.test.ts b/gitnexus/test/unit/mutation/oracle-classify.test.ts new file mode 100644 index 000000000..db41cbe6b --- /dev/null +++ b/gitnexus/test/unit/mutation/oracle-classify.test.ts @@ -0,0 +1,520 @@ +/** + * Locks the oracle's own KTD17 classifier (U13): synthetic wrapper records (Vitest observations + * from the oracle reporter plus Vitest's JSON totals) and Stryker statuses read from a + * `mutation.json` text map to the outcome classes, per-test rows and kill sets. + */ +import { describe, expect, it } from 'vitest'; +import { + parseMutationReport, + type MutantStatus, +} from '../../../tools/mutation/prepare/mutant-identity.js'; +import { + ORACLE_CLASSES, + assignTestIds, + classifyMutant, + classifyRun, + isUnusableClass, + parseOracleRunRecord, + parseRecordFileName, + recordFileName, + strykerStatuses, + toOracleError, + type OracleError, + type OracleModuleRecord, + type OracleObservedRun, + type OracleRunRecord, + type OracleSuiteRecord, + type OracleTestRecord, + type VitestJsonTotals, +} from '../../../tools/mutation/oracle/oracle-classify.js'; + +const FILE = 'test/unit/a.test.ts'; +const T0 = `${FILE}#S > t0`; +const T1 = `${FILE}#S > t1`; +const EXPECTED: readonly string[] = [T0, T1]; + +const error = (name: string, message: string): OracleError => ({ name, message }); +const assertionError = toOracleError({ + message: 'expected 1 to be 2', + stack: 'AssertionError: expected 1 to be 2\n at test/unit/a.test.ts:12:15', +}); +const typeError = error('TypeError', "Cannot read properties of undefined (reading 'x')"); +const testTimeout = error( + 'Error', + 'Test timed out in 30000ms.\nIf this is a long-running test, pass a timeout value as the last ' + + 'argument or configure it globally with "testTimeout".', +); +const hookTimeout = error( + 'Error', + 'Hook timed out in 120000ms.\nIf this is a long-running hook, pass a timeout value as the last ' + + 'argument or configure it globally with "hookTimeout".', +); + +const test = (index: number, overrides: Partial = {}): OracleTestRecord => ({ + fullName: `S > t${index}`, + location: { line: 10 + index, column: 3 }, + state: 'passed', + errors: [], + failedHooks: [], + retry: 0, + retryCount: 0, + ...overrides, +}); + +const suite = (tests: readonly number[], overrides: Partial = {}) => ({ + fullName: 'S', + errors: [], + failedHooks: [], + tests: [...tests], + ...overrides, +}); + +const module = ( + tests: OracleTestRecord[], + overrides: Partial = {}, +): OracleModuleRecord => ({ + file: FILE, + state: 'passed', + errors: [], + failedHooks: [], + suites: [suite(tests.map((_, index) => index))], + tests, + ...overrides, +}); + +const observed = ( + modules: OracleModuleRecord[], + overrides: Partial = {}, +): OracleObservedRun => ({ reason: 'passed', unhandledErrors: [], modules, ...overrides }); + +/** What Vitest's own JSON reporter counts for the same run. */ +const totals = (run: OracleObservedRun): VitestJsonTotals => { + const all = run.modules.flatMap((entry) => entry.tests); + return { + numTotalTests: all.length, + numPassedTests: all.filter((entry) => entry.state === 'passed').length, + numFailedTests: all.filter((entry) => entry.state === 'failed').length, + }; +}; + +const record = ( + run: OracleObservedRun, + exitCode: number, + overrides: Partial = {}, +): OracleRunRecord => ({ + schemaVersion: 1, + kind: 'mutant', + mutantId: '7', + attempt: 1, + pid: 4242, + root: '/sandbox', + configFile: '/sandbox/tools/mutation/vitest.mutation.config.ts', + vitestVersion: '5.0.5', + exitCode, + wallClockMs: 1500, + wrapperError: null, + run, + vitestJson: totals(run), + vitestJsonFile: '/reports/mutant-7.attempt-1.vitest.json', + ...overrides, +}); + +const classify = (run: OracleObservedRun, exitCode: number) => + classifyMutant({ + strykerStatus: exitCode === 0 ? 'Survived' : 'Killed', + record: record(run, exitCode), + expected: EXPECTED, + }); + +/** A one-file Stryker report holding a mutant with local id 7 and the given status. */ +const mutationJson = (status: MutantStatus): string => + JSON.stringify({ + schemaVersion: '1.0', + files: { + 'src/x.ts': { + language: 'typescript', + source: 'export const x = 1;\n', + mutants: [ + { + id: '7', + mutatorName: 'BooleanLiteral', + replacement: 'false', + location: { start: { line: 1, column: 18 }, end: { line: 1, column: 19 } }, + status, + }, + ], + }, + }, + }); + +const statusOf = (status: MutantStatus): MutantStatus => + strykerStatuses(parseMutationReport(mutationJson(status))).get('7') ?? 'Pending'; + +describe('oracle classifier: KTD17 rows from a wrapper record', () => { + it('classifies one failing test with an AssertionError stack as killed-by-test, assertion', () => { + const result = classify( + observed([module([test(0, { state: 'failed', errors: [assertionError] }), test(1)])]), + 1, + ); + expect(assertionError.name).toBe('AssertionError'); + expect(result).toMatchObject({ + outcome: 'killed-by-test:assertion', + source: 'report', + killedBy: [T0], + assertionKilledBy: [T0], + executed: [T0, T1], + }); + expect(result.tests).toEqual([ + { + id: T0, + file: FILE, + fullName: 'S > t0', + outcome: 'killed-by-test:assertion', + message: 'expected 1 to be 2', + }, + { id: T1, file: FILE, fullName: 'S > t1', outcome: 'survived', message: null }, + ]); + }); + + it('classifies a file-level error with no tests as killed-outside-test, collection', () => { + const result = classify( + observed([ + module([], { + state: 'failed', + errors: [error('SyntaxError', 'Unexpected token')], + suites: [], + }), + ]), + 1, + ); + expect(result).toMatchObject({ + outcome: 'killed-outside-test:collection', + killedBy: [], + assertionKilledBy: [], + executed: [], + }); + expect(result.outsideTest).toEqual([ + { + file: FILE, + suite: null, + outcome: 'killed-outside-test:collection', + attributedTo: [], + message: 'SyntaxError: Unexpected token', + }, + ]); + }); + + it('classifies a missing report with a non-zero exit and no Timeout status as infrastructure', () => { + expect( + classifyMutant({ strykerStatus: 'Killed', record: null, expected: EXPECTED }), + ).toMatchObject({ + outcome: 'infrastructure', + source: 'missing-report', + killedBy: [], + }); + }); + + it('classifies a Stryker Timeout with no report as timeout, read from mutation.json', () => { + const strykerStatus = statusOf('Timeout'); + expect(strykerStatus).toBe('Timeout'); + expect(classifyMutant({ strykerStatus, record: null, expected: EXPECTED })).toMatchObject({ + outcome: 'timeout', + source: 'stryker-timeout', + killedBy: [], + }); + }); + + it('classifies a Stryker Timeout as timeout even when a report shows a kill', () => { + const run = observed([ + module([test(0, { state: 'failed', errors: [assertionError] }), test(1)]), + ]); + expect( + classifyMutant({ strykerStatus: 'Timeout', record: record(run, 1), expected: EXPECTED }), + ).toMatchObject({ outcome: 'timeout', source: 'stryker-timeout', assertionKilledBy: [] }); + }); + + it('classifies a report with zero executed tests as invalid', () => { + const skipped = { state: 'skipped' as const }; + const result = classify(observed([module([test(0, skipped), test(1, skipped)])]), 0); + expect(result.outcome).toBe('invalid'); + expect(result.reason).toContain('zero tests ran'); + }); + + it('classifies a TypeError as killed-by-test, runtime', () => { + const result = classify( + observed([module([test(0, { state: 'failed', errors: [typeError] }), test(1)])]), + 1, + ); + expect(result).toMatchObject({ + outcome: 'killed-by-test:runtime', + killedBy: [T0], + assertionKilledBy: [], + }); + }); + + it('classifies a beforeEach failure as killed-by-test, hook', () => { + const failed = test(0, { state: 'failed', errors: [typeError], failedHooks: ['beforeEach'] }); + const result = classify(observed([module([failed, test(1)])]), 1); + expect(result).toMatchObject({ + outcome: 'killed-by-test:hook', + killedBy: [T0], + assertionKilledBy: [], + }); + }); + + it('attributes a beforeAll failure to the skipped suite tests as killed-outside-test, hook', () => { + const skipped = { state: 'skipped' as const }; + const result = classify( + observed([ + module([test(0, skipped), test(1, skipped)], { + state: 'failed', + suites: [suite([0, 1], { failedHooks: ['beforeAll'], errors: [typeError] })], + }), + ]), + 1, + ); + expect(result).toMatchObject({ + outcome: 'killed-outside-test:hook', + killedBy: [T0, T1], + assertionKilledBy: [], + executed: [], + }); + expect(result.outsideTest).toEqual([ + { + file: FILE, + suite: 'S', + outcome: 'killed-outside-test:hook', + attributedTo: [T0, T1], + message: `TypeError: ${typeError.message}`, + }, + ]); + expect(result.tests.map((row) => row.outcome)).toEqual([ + 'killed-outside-test:hook', + 'killed-outside-test:hook', + ]); + }); + + it("classifies Vitest's own testTimeout error as timeout", () => { + const result = classify( + observed([module([test(0, { state: 'failed', errors: [testTimeout] }), test(1)])]), + 1, + ); + expect(result).toMatchObject({ outcome: 'timeout', killedBy: [] }); + }); + + it('classifies a beforeAll hookTimeout error as timeout without attribution', () => { + const skipped = { state: 'skipped' as const }; + const result = classify( + observed([ + module([test(0, skipped), test(1, skipped)], { + state: 'failed', + suites: [suite([0, 1], { failedHooks: ['beforeAll'], errors: [hookTimeout] })], + }), + ]), + 1, + ); + expect(result).toMatchObject({ outcome: 'timeout', killedBy: [] }); + expect(result.outsideTest[0]?.attributedTo).toEqual([]); + }); + + it('classifies an all-passing report as survived', () => { + expect(classify(observed([module([test(0), test(1)])]), 0)).toMatchObject({ + outcome: 'survived', + reason: null, + killedBy: [], + executed: [T0, T1], + }); + }); + + it('classifies NoCoverage as survived, read from mutation.json', () => { + expect( + classifyMutant({ strykerStatus: statusOf('NoCoverage'), record: null, expected: EXPECTED }), + ).toMatchObject({ outcome: 'survived', source: 'no-coverage' }); + }); +}); + +describe('oracle classifier: further KTD17 rows and integrity checks', () => { + it('classifies an unhandled error as infrastructure', () => { + const run = observed([module([test(0), test(1)])], { + reason: 'failed', + unhandledErrors: [error('Error', 'socket hang up')], + }); + expect(classify(run, 1)).toMatchObject({ outcome: 'infrastructure', killedBy: [] }); + }); + + it('classifies an executed test with retry above zero as inconclusive', () => { + const result = classify(observed([module([test(0, { retry: 2 }), test(1)])]), 0); + expect(result.outcome).toBe('inconclusive'); + expect(result.reason).toContain(`${T0} has retry 2`); + }); + + it('classifies a test set different from the baseline as invalid', () => { + const result = classify(observed([module([test(0), test(2)])]), 0); + expect(result.outcome).toBe('invalid'); + expect(result.reason).toContain(`baseline test ${T1} did not run`); + expect(result.reason).toContain(`test ${FILE}#S > t2 ran but is not in the baseline`); + }); + + it('classifies an afterAll failure with passing tests as killed-outside-test, hook, unattributed', () => { + const result = classify( + observed([ + module([test(0), test(1)], { + state: 'failed', + suites: [suite([0, 1], { failedHooks: ['afterAll'], errors: [typeError] })], + }), + ]), + 1, + ); + expect(result).toMatchObject({ + outcome: 'killed-outside-test:hook', + killedBy: [], + executed: [T0, T1], + }); + }); + + it.each([ + ['a passing body and a failing afterEach', [typeError], 'killed-by-test:hook'], + [ + 'a failing body and a failing afterEach', + [assertionError, typeError], + 'killed-by-test:assertion', + ], + ] as const)('classifies %s by the first failure', (_label, errors, expected) => { + const failed = test(0, { state: 'failed', errors: [...errors], failedHooks: ['afterEach'] }); + expect(classify(observed([module([failed, test(1)])]), 1).outcome).toBe(expected); + }); + + it('prefers an assertion kill over a runtime kill in the same run', () => { + const result = classify( + observed([ + module([ + test(0, { state: 'failed', errors: [typeError] }), + test(1, { state: 'failed', errors: [assertionError] }), + ]), + ]), + 1, + ); + expect(result).toMatchObject({ + outcome: 'killed-by-test:assertion', + killedBy: [T0, T1], + assertionKilledBy: [T1], + }); + }); + + it.each([ + ['exit 0 with a failed test', 0, test(0, { state: 'failed', errors: [assertionError] })], + ['a non-zero exit with every test passing', 1, test(0)], + ] as const)('classifies %s as infrastructure', (_label, exitCode, first) => { + const run = observed([module([first, test(1)])]); + const result = classifyRun(run, { exitCode, expected: EXPECTED, vitestJson: totals(run) }); + expect(result.outcome).toBe('infrastructure'); + expect(result.reason).toContain(`Vitest exited ${exitCode}`); + }); + + it('classifies a disagreement with the Vitest JSON totals as infrastructure', () => { + const run = observed([module([test(0), test(1)])]); + const result = classifyRun(run, { + exitCode: 0, + expected: EXPECTED, + vitestJson: { numTotalTests: 2, numPassedTests: 1, numFailedTests: 1 }, + }); + expect(result.outcome).toBe('infrastructure'); + expect(result.reason).toContain('Vitest JSON report'); + }); + + it.each([ + ['Killed', 0], + ['Survived', 1], + ] as const)( + 'classifies Stryker status %s with wrapper exit %i as infrastructure', + (strykerStatus, exitCode) => { + const run = observed([module([test(0), test(1)])]); + expect( + classifyMutant({ strykerStatus, record: record(run, exitCode), expected: EXPECTED }) + .outcome, + ).toBe('infrastructure'); + }, + ); + + it('classifies a record whose wrapper could not start Vitest as infrastructure', () => { + const run = observed([]); + const broken = record(run, 1, { + run: null, + vitestJson: null, + wrapperError: 'config failed to load', + }); + expect( + classifyMutant({ strykerStatus: 'Killed', record: broken, expected: EXPECTED }), + ).toMatchObject({ + outcome: 'infrastructure', + reason: 'config failed to load', + }); + }); + + it('classifies a mutant Stryker never ran as invalid', () => { + expect( + classifyMutant({ strykerStatus: 'Ignored', record: null, expected: EXPECTED }), + ).toMatchObject({ + outcome: 'invalid', + source: 'not-run', + }); + }); +}); + +describe('oracle test identity and record files', () => { + it('suffixes duplicate full names with the location, then the row at that location', () => { + const at = (line: number) => ({ line, column: 5 }); + expect( + assignTestIds([ + { file: FILE, fullName: 'S > unique', location: at(1) }, + { file: FILE, fullName: 'S > dup', location: at(2) }, + { file: FILE, fullName: 'S > dup', location: at(3) }, + { file: FILE, fullName: 'S > row', location: at(4) }, + { file: FILE, fullName: 'S > row', location: at(4) }, + { file: FILE, fullName: 'S > other', location: at(4) }, + { file: 'test/unit/b.test.ts', fullName: 'S > unique', location: at(1) }, + ]), + ).toEqual([ + `${FILE}#S > unique`, + `${FILE}#S > dup:2:5`, + `${FILE}#S > dup:3:5`, + `${FILE}#S > row:4:5:0`, + `${FILE}#S > row:4:5:1`, + `${FILE}#S > other`, + 'test/unit/b.test.ts#S > unique', + ]); + }); + + it.each([ + ['dry-run', null, 1, 'dry-run.attempt-1.json'], + ['mutant', '42', 3, 'mutant-42.attempt-3.json'], + ] as const)('names a %s record and parses the name back', (kind, mutantId, attempt, name) => { + expect(recordFileName(kind, mutantId, attempt)).toBe(name); + expect(parseRecordFileName(name)).toEqual({ kind, mutantId, attempt }); + }); + + it.each(['mutant-42.attempt-3.vitest.json', '.mutant-42.attempt-3.json.tmp', 'notes.txt'])( + 'ignores the file name %s', + (name) => { + expect(parseRecordFileName(name)).toBeNull(); + }, + ); + + it('round-trips a record through JSON and rejects a malformed one', () => { + const original = record(observed([module([test(0), test(1)])]), 0); + expect(parseOracleRunRecord(JSON.stringify(original))).toEqual(original); + expect(() => parseOracleRunRecord(JSON.stringify({ ...original, schemaVersion: 2 }))).toThrow( + /schemaVersion/, + ); + }); +}); + +describe('oracle classes without a verdict', () => { + it('counts exactly infrastructure, inconclusive and invalid as unusable', () => { + expect(ORACLE_CLASSES.filter(isUnusableClass)).toEqual([ + 'infrastructure', + 'inconclusive', + 'invalid', + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/oracle-compare.test.ts b/gitnexus/test/unit/mutation/oracle-compare.test.ts new file mode 100644 index 000000000..64ee465f9 --- /dev/null +++ b/gitnexus/test/unit/mutation/oracle-compare.test.ts @@ -0,0 +1,493 @@ +/** + * Locks gate 3's comparison rules (U7, KTD12, KTD13, KTD17, KTD18): freshness ("re-freeze + * required"), the inventory check that blocks before outcomes are compared, per-mutant classes + * and per-test outcomes under exactly the four KTD13 normalisations, and the gate's exit codes. + * Every input is synthetic: a frozen manifest and the parsed views of a runner run. + */ +import { describe, expect, it } from 'vitest'; +import { + EXIT_DIFFERENCES, + EXIT_PASS, + EXIT_REFREEZE, + EXIT_USAGE, + checkFreshness, + compareScope, + gateExitCode, + runnerPerTest, + runnerTestClass, + type CurrentState, + type Freshness, + type RunnerPerTest, + type ScopeInput, + type SidecarView, +} from '../../../tools/mutation/oracle/compare.js'; +import { + diffInventory, + normaliseTestId, + type CombinedStratum, + type OracleManifest, + type RunnerMutantView, + type RunnerRunView, +} from '../../../tools/mutation/oracle/freeze.js'; +import type { OracleClass } from '../../../tools/mutation/oracle/oracle-classify.js'; + +const FILE = 'test/unit/a.test.ts'; +const T0 = `${FILE}#S > one`; +const T1 = `${FILE}#S > two (#2415)`; +const M0 = 'gitnexus/src/a.ts@0123456789abcdef:3:1-3:9:StringLiteral:aaaaaaaaaaaaaaaa'; +const M1 = 'gitnexus/src/a.ts@0123456789abcdef:5:1-5:9:ConditionalExpression:bbbbbbbbbbbbbbbb'; +const M2 = 'gitnexus/src/a.ts@0123456789abcdef:7:1-7:9:BlockStatement:cccccccccccccccc'; + +const manifest = (overrides: Partial = {}): OracleManifest => ({ + schemaVersion: 1, + kind: 'gitnexus-mutation-oracle-manifest', + name: 'a', + description: 'synthetic', + refreeze: 'npm run test:mutation:oracle:freeze -- --from-manifest a.json --seed --work ', + scope: { testFiles: [FILE], mutate: ['gitnexus/src/a.ts'] }, + versions: { stryker: '10.0.0', vitest: '5.0.5', nodeMajor: 22 }, + operators: { mutator: null, names: ['BlockStatement', 'ConditionalExpression', 'StringLiteral'] }, + inputs: { + closure: { 'gitnexus/src/a.ts': 'h-src', [`gitnexus/${FILE}`]: 'h-test' }, + vitestConfig: { 'gitnexus/vitest.config.ts': 'h-config' }, + mutationConfig: { 'gitnexus/tools/mutation/vitest.mutation.config.ts': 'h-mutation' }, + prepareTool: { 'gitnexus/tools/mutation/prepare/prepare.ts': 'h-prepare' }, + }, + sample: { seed: 's', method: 'sha256', size: 100, topUpMin: 3, random: 2, topUps: [] }, + strata: [], + baseline: { tests: 2, passedAlone: 2 }, + tests: [T0, T1], + notOracleValidated: [], + mutants: [ + [M0, 'killed-by-test:assertion'], + [M1, 'survived'], + [M2, 'survived'], + ], + sampled: [ + { + id: M0, + strata: ['multi-killer'], + covering: 2, + outcomes: [[1, 'killed-by-test:assertion']], + invalid: [], + }, + { id: M1, strata: ['survived'], covering: 1, outcomes: [], invalid: [] }, + ], + launches: { total: 6, valid: 4, invalid: 0, wallClockLimitMs: 180000 }, + warnings: [], + problems: [], + ...overrides, +}); + +const runnerMutant = ( + globalId: string, + outcome: OracleClass | null, + status: string, + overrides: Partial = {}, +): RunnerMutantView => ({ + globalId, + localId: globalId.slice(-1), + status, + outcome, + outcomeSource: 'sidecar', + sidecarFile: `/sidecars/${globalId.slice(-1)}.json`, + ...overrides, +}); + +const runner = (mutants: RunnerMutantView[]): RunnerRunView => ({ + file: '/run/results.json', + scope: { testFiles: [FILE], mutate: ['gitnexus/src/a.ts'] }, + runDir: '/run/run', + preparedRoot: '/run/prepared', + configFile: '/run/stryker.config.json', + mutants, + flags: [], + spanMapFailures: 0, +}); + +const sidecar = (tests: [string, OracleClass][]): SidecarView => ({ + tests: tests.map(([id, outcome]) => ({ id, outcome })), + outsideTest: [], +}); + +const FRESH: Freshness = { fresh: true, mismatches: [], refreeze: 'refreeze' }; + +const AGREEING_RUNNER = runner([ + runnerMutant(M0, 'killed-by-test:assertion', 'Killed'), + runnerMutant(M1, 'survived', 'Survived'), + runnerMutant(M2, 'survived', 'Survived'), +]); + +const AGREEING_SIDECARS: Record = { + [M0]: sidecar([ + [T0, 'survived'], + [T1, 'killed-by-test:assertion'], + ]), + [M1]: sidecar([[T0, 'survived']]), +}; + +const scopeInput = (overrides: Partial = {}): ScopeInput => ({ + manifest: manifest(), + manifestFile: 'a.json', + freshness: FRESH, + staleness: [], + runner: AGREEING_RUNNER, + runnerTests: [T0, T1], + perTest: (mutant): RunnerPerTest => + runnerPerTest(mutant, (file) => { + const found = Object.entries(AGREEING_SIDECARS).find(([id]) => + file.endsWith(`${id.slice(-1)}.json`), + ); + return found?.[1] ?? sidecar([]); + }), + ...overrides, +}); + +describe('test id normalisation (KTD13: title escaping, path separators)', () => { + it.each([ + ['path separators', 'test\\unit\\a.test.ts#S > one', T0], + ['an escaped regex metacharacter', `${FILE}#S > two \\(#2415\\)`, T1], + ['an escaped quote', `${FILE}#it's \\"quoted\\"`, `${FILE}#it's "quoted"`], + ['NFD title', `${FILE}#café`, `${FILE}#café`], + ['an unchanged id', T0, T0], + ])('normalises %s', (_label, raw, expected) => { + expect(normaliseTestId(raw)).toBe(normaliseTestId(expected)); + }); + + it('keeps distinct titles distinct', () => { + expect(normaliseTestId(`${FILE}#a > b`)).not.toBe(normaliseTestId(`${FILE}#a > c`)); + }); + + it('reports ids one inventory lacks and ids that collapse after normalisation', () => { + expect( + diffInventory([T0, T1, `${FILE}#x(`, `${FILE}#x\\(`], [T0, `${FILE}#y`], normaliseTestId), + ).toEqual({ + onlyA: [T1, `${FILE}#x(`].sort(), + onlyB: [`${FILE}#y`], + ambiguousA: [`${FILE}#x\\(`], + ambiguousB: [], + }); + }); +}); + +describe('gate 3 comparison', () => { + it('passes a runner run that agrees with the manifest', () => { + const report = compareScope(scopeInput()); + expect({ + result: report.result, + classes: report.classes?.differences, + perTest: [report.perTest?.compared, report.perTest?.agree, report.perTest?.differences], + }).toEqual({ result: 'pass', classes: [], perTest: [4, 4, []] }); + }); + + it('blocks before outcomes when a test of the oracle inventory is missing from the run manifest', () => { + const report = compareScope(scopeInput({ runnerTests: [T0] })); + expect({ + result: report.result, + blocked: report.inventory?.blocked, + onlyOracle: report.inventory?.tests.onlyA, + classes: report.classes, + perTest: report.perTest, + }).toEqual({ + result: 'differences', + blocked: true, + onlyOracle: [T1], + classes: null, + perTest: null, + }); + }); + + it('blocks when a global mutant id of run A is missing from run B', () => { + const report = compareScope( + scopeInput({ runner: runner(AGREEING_RUNNER.mutants.filter((m) => m.globalId !== M2)) }), + ); + expect([report.result, report.inventory?.mutants.onlyA, report.classes]).toEqual([ + 'differences', + [M2], + null, + ]); + }); + + it('blocks when run B has a global mutant id that run A lacks', () => { + const extra = `${M2.slice(0, -1)}d`; + const report = compareScope( + scopeInput({ + runner: runner([...AGREEING_RUNNER.mutants, runnerMutant(extra, 'survived', 'Survived')]), + }), + ); + expect([report.inventory?.blocked, report.inventory?.mutants.onlyB]).toEqual([true, [extra]]); + }); + + it('accepts a runner Timeout against an oracle wall-clock kill as one class', () => { + const timedOut = manifest({ + mutants: [ + [M0, 'timeout'], + [M1, 'survived'], + [M2, 'survived'], + ], + sampled: [{ id: M0, strata: [], covering: 2, outcomes: [[1, 'timeout']], invalid: [] }], + }); + const hitLimit = runner([ + runnerMutant(M0, 'timeout', 'Timeout'), + ...AGREEING_RUNNER.mutants.slice(1), + ]); + const report = compareScope( + scopeInput({ + manifest: timedOut, + runner: hitLimit, + perTest: () => ({ + kind: 'record', + sidecar: sidecar([ + [T0, 'survived'], + [T1, 'timeout'], + ]), + }), + }), + ); + expect({ + result: report.result, + normalised: report.classes?.normalised.map((row) => [row.globalId, row.rule]), + perTest: report.perTest?.differences, + }).toEqual({ result: 'pass', normalised: [[M0, 'timeout']], perTest: [] }); + }); + + it("lists the per-test rows of a mutant Stryker's timer killed as unsupported", () => { + const timedOut = manifest({ + mutants: [ + [M0, 'timeout'], + [M1, 'survived'], + [M2, 'survived'], + ], + sampled: [{ id: M0, strata: [], covering: 2, outcomes: [[1, 'timeout']], invalid: [] }], + }); + const strykerTimer = runner([ + runnerMutant(M0, 'timeout', 'Timeout', { + outcomeSource: 'stryker-timeout', + sidecarFile: null, + }), + ...AGREEING_RUNNER.mutants.slice(1), + ]); + const report = compareScope(scopeInput({ manifest: timedOut, runner: strykerTimer })); + expect([report.result, report.perTest?.unsupported, report.perTest?.compared]).toEqual([ + 'pass', + [{ globalId: M0, reason: "Stryker's timer killed the run; no record", observations: 2 }], + 0, + ]); + }); + + it('accepts runner NoCoverage against oracle survived and keeps the raw status', () => { + const noCoverage = runner([ + AGREEING_RUNNER.mutants[0], + runnerMutant(M1, 'survived', 'NoCoverage', { + outcomeSource: 'no-coverage', + sidecarFile: null, + }), + AGREEING_RUNNER.mutants[2], + ]); + const report = compareScope(scopeInput({ runner: noCoverage })); + expect({ + result: report.result, + normalised: report.classes?.normalised, + notRunAsSurvived: report.perTest?.notRunAsSurvived, + }).toEqual({ + result: 'pass', + normalised: [ + { + globalId: M1, + oracle: 'survived', + runner: 'survived', + runnerStatus: 'NoCoverage', + runnerSource: 'no-coverage', + rule: 'no-coverage', + }, + ], + notRunAsSurvived: 2, + }); + }); + + it('blocks a runner assertion kill against an oracle runtime kill, even with a recorded cause', () => { + const runtime = manifest({ + mutants: [ + [M0, 'killed-by-test:runtime'], + [M1, 'survived'], + [M2, 'survived'], + ], + sampled: [ + { id: M0, strata: [], covering: 2, outcomes: [[1, 'killed-by-test:runtime']], invalid: [] }, + ], + warnings: ['known cause: the mutant throws a TypeError inside expect()'], + }); + const report = compareScope(scopeInput({ manifest: runtime })); + expect({ + result: report.result, + classes: report.classes?.differences.map((row) => [row.globalId, row.oracle, row.runner]), + perTest: report.perTest?.differences.map((row) => [row.testId, row.oracle, row.runner]), + exit: gateExitCode([report], []), + }).toEqual({ + result: 'differences', + classes: [[M0, 'killed-by-test:runtime', 'killed-by-test:assertion']], + perTest: [[T1, 'killed-by-test:runtime', 'killed-by-test:assertion']], + exit: EXIT_DIFFERENCES, + }); + }); + + it('blocks a runner run with flags', () => { + const flagged = { ...AGREEING_RUNNER, flags: ['status-mismatch: Killed vs survived'] }; + expect(compareScope(scopeInput({ runner: flagged })).result).toBe('differences'); + }); + + it('blocks a sampled mutant the runner left without a record', () => { + const missing = runner([ + runnerMutant(M0, 'killed-by-test:assertion', 'Killed', { sidecarFile: null }), + ...AGREEING_RUNNER.mutants.slice(1), + ]); + const report = compareScope(scopeInput({ runner: missing })); + expect(report.perTest?.differences.map((row) => [row.testId, row.runnerSource])).toEqual([ + [T0, 'missing'], + [T1, 'missing'], + ]); + }); + + it('skips per-test outcomes of tests that are not oracle-validated', () => { + const marked = manifest({ notOracleValidated: [T1] }); + const killedElsewhere: Record = { + [M0]: sidecar([[T1, 'killed-by-test:runtime']]), + }; + const report = compareScope( + scopeInput({ + manifest: marked, + perTest: (mutant) => ({ + kind: 'record', + sidecar: killedElsewhere[mutant.globalId] ?? sidecar([]), + }), + }), + ); + expect([report.result, report.perTest?.compared, report.perTest?.notOracleValidated]).toEqual([ + 'pass', + 2, + 2, + ]); + }); +}); + +describe("the runner's per-test class", () => { + const record: SidecarView = { + tests: [{ id: `${FILE}#ran`, outcome: 'killed-by-test:hook' }], + outsideTest: [ + { + file: FILE, + suiteTaskId: 'suite-1', + outcome: 'killed-outside-test:hook', + attributedTo: [`${FILE}#skipped by beforeAll`], + }, + { + file: 'test/unit/b.test.ts', + suiteTaskId: null, + outcome: 'killed-outside-test:collection', + attributedTo: [], + }, + ], + }; + + it.each([ + ['its row', `${FILE}#ran`, { outcome: 'killed-by-test:hook', source: 'row' }], + [ + 'an attributing beforeAll row', + `${FILE}#skipped by beforeAll`, + { outcome: 'killed-outside-test:hook', source: 'attributed' }, + ], + [ + 'a collection failure of its file', + 'test/unit/b.test.ts#anything', + { outcome: 'killed-outside-test:collection', source: 'collection' }, + ], + [ + 'a test the runner did not run', + `${FILE}#not run`, + { outcome: 'survived', source: 'not-run' }, + ], + ])('comes from %s', (_label, testId, expected) => { + expect(runnerTestClass(record, testId)).toEqual(expected); + }); +}); + +describe('freshness (KTD12)', () => { + const current = (overrides: Partial = {}): CurrentState => ({ + inputs: manifest().inputs, + versions: manifest().versions, + mutator: null, + ...overrides, + }); + + it('is fresh when hashes, versions and operators match', () => { + expect(checkFreshness(manifest(), current())).toEqual({ + fresh: true, + mismatches: [], + refreeze: manifest().refreeze, + }); + }); + + it('requires a re-freeze when a closure hash or a version changed', () => { + const changed = current({ + inputs: { + ...manifest().inputs, + closure: { ...manifest().inputs.closure, 'gitnexus/src/a.ts': 'h-new' }, + }, + versions: { ...manifest().versions, vitest: '5.0.6' }, + }); + const freshness = checkFreshness(manifest(), changed); + const report = compareScope(scopeInput({ freshness })); + expect({ + mismatches: freshness.mismatches.map((entry) => [entry.kind, entry.subject]), + result: report.result, + compared: [report.inventory, report.classes, report.perTest], + exit: gateExitCode([report], []), + }).toEqual({ + mismatches: [ + ['input', 'closure: gitnexus/src/a.ts'], + ['version', 'vitest'], + ], + result: 'refreeze-required', + compared: [null, null, null], + exit: EXIT_REFREEZE, + }); + }); + + it('requires a re-freeze when a closure file appears or the operator set changes', () => { + const changed = current({ + inputs: { + ...manifest().inputs, + closure: { ...manifest().inputs.closure, 'gitnexus/src/new.ts': 'h' }, + }, + mutator: { excludedMutations: ['StringLiteral'] }, + }); + expect(checkFreshness(manifest(), changed).mismatches.map((entry) => entry.kind)).toEqual([ + 'input', + 'operators', + ]); + }); +}); + +describe('gate exit codes', () => { + const pass = compareScope(scopeInput()); + const stale = compareScope(scopeInput({ staleness: ['the runner run used Vitest 5.0.4'] })); + const unmet: CombinedStratum = { + name: 'no-coverage', + population: 3, + sampled: 0, + status: 'unmet', + scopes: [], + }; + + it.each([ + ['every scope passes', [pass], [], EXIT_PASS], + ['a stale runner run', [pass, stale], [], EXIT_USAGE], + ['an unmet stratum across the scopes', [pass], [unmet], EXIT_DIFFERENCES], + ] as const)('%s', (_label, scopes, strata, expected) => { + expect(gateExitCode(scopes, strata)).toBe(expected); + }); + + it('compares nothing for a stale runner run', () => { + expect([stale.result, stale.inventory]).toEqual(['stale-runner-run', null]); + }); +}); diff --git a/gitnexus/test/unit/mutation/oracle-sample.test.ts b/gitnexus/test/unit/mutation/oracle-sample.test.ts new file mode 100644 index 000000000..bc1010a39 --- /dev/null +++ b/gitnexus/test/unit/mutation/oracle-sample.test.ts @@ -0,0 +1,584 @@ +/** + * Locks the oracle freeze's sampling and strata rules (U7, KTD11, KTD12): the seeded sample and + * its top-ups, the per-scope strata requirements, strata counted across scopes, the strata + * populations from baseline coverage, the resumable launch work dir and the manifest format. + * Launches are synthetic: the injected launcher only writes launch.json files into a temporary + * work dir; no process is spawned. + */ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + combineStrata, + computePopulations, + eachRowTests, + evaluateStrata, + formatJson, + launchKey, + ordinalTests, + parseManifest, + runLaunches, + sampleRank, + selectSample, + type OracleManifest, + type PlannedLaunch, + type Populations, + type StrataInput, + type StratumName, + type StratumRecord, +} from '../../../tools/mutation/oracle/freeze.js'; +import type { LaunchOptions, LaunchTarget } from '../../../tools/mutation/oracle/launch-single.js'; + +const FILE = 'test/unit/a.test.ts'; +const MUTANTS = Array.from( + { length: 40 }, + (_, i) => + `gitnexus/src/a.ts@0123456789abcdef:${i + 1}:1-${i + 1}:9:StringLiteral:fedcba9876543210`, +); +const byRank = + (seed: string) => + (a: string, b: string): number => + sampleRank(seed, a).localeCompare(sampleRank(seed, b)); + +const populations = (entries: Partial>): Populations => + new Map(Object.entries(entries).map(([name, ids]) => [name as StratumName, new Set(ids)])); + +const target = (name: string, line: number, column = 3, file = FILE): LaunchTarget => ({ + id: `${file}#${name}`, + file, + fullName: name, + location: { line, column }, +}); + +describe('seeded sample (U7 sampling)', () => { + const sample = (seed: string, mutants: readonly string[] = MUTANTS): string[] => + selectSample({ mutants, seed, sampleSize: 10, topUpMin: 0, populations: new Map() }).selected; + + it('gives the same sample for the same seed', () => { + expect(sample('seed-1')).toEqual(sample('seed-1')); + }); + + it('gives a different sample for a different seed', () => { + expect(sample('seed-2')).not.toEqual(sample('seed-1')); + }); + + it('does not depend on the input order', () => { + expect(sample('seed-1', [...MUTANTS].reverse())).toEqual(sample('seed-1')); + }); + + it('takes the lowest ranks of SHA-256(seed + newline + global id)', () => { + expect(sample('seed-1')).toEqual([...MUTANTS].sort(byRank('seed-1')).slice(0, 10)); + }); + + it('takes every mutant when the scope has fewer than the sample size', () => { + const few = MUTANTS.slice(0, 4); + expect(sample('seed-1', few).sort()).toEqual([...few].sort()); + }); + + it('tops a stratum up with its lowest-ranked members outside the random sample', () => { + const lastThree = [...MUTANTS].sort(byRank('s')).slice(-3); + const selection = selectSample({ + mutants: MUTANTS, + seed: 's', + sampleSize: 10, + topUpMin: 2, + populations: populations({ static: lastThree }), + }); + expect(selection.topUps).toEqual([ + { globalId: lastThree[0], stratum: 'static' }, + { globalId: lastThree[1], stratum: 'static' }, + ]); + expect(selection.selected).toHaveLength(12); + }); + + it('adds no top-ups with --top-up-min 0', () => { + const lastThree = [...MUTANTS].sort(byRank('s')).slice(-3); + const selection = selectSample({ + mutants: MUTANTS, + seed: 's', + sampleSize: 10, + topUpMin: 0, + populations: populations({ static: lastThree }), + }); + expect(selection.topUps).toEqual([]); + }); + + it('counts random members toward a stratum quota', () => { + const firstTwo = [...MUTANTS].sort(byRank('s')).slice(0, 2); + const selection = selectSample({ + mutants: MUTANTS, + seed: 's', + sampleSize: 10, + topUpMin: 2, + populations: populations({ survived: firstTwo }), + }); + expect(selection.topUps).toEqual([]); + }); +}); + +const strataInput = (overrides: Partial): StrataInput => ({ + populations: populations({}), + selected: [], + topUps: [], + constructs: { beforeAll: false, each: false }, + affectedTests: { beforeAll: [], each: [] }, + notOracleValidated: new Set(), + ...overrides, +}); + +const statusOf = (records: readonly StratumRecord[], name: StratumName): string | undefined => + records.find((record) => record.name === name)?.status; + +describe('per-scope strata (KTD11)', () => { + it('freezes a scope without hooks or it.each without a beforeAll or it.each stratum', () => { + const records = evaluateStrata( + strataInput({ + populations: populations({ static: [MUTANTS[0]], survived: [MUTANTS[1]] }), + selected: [MUTANTS[0]], + }), + ); + expect(records.map((record) => [record.name, record.status, record.required])).toEqual([ + ['static', 'combined', false], + ['hook-hit', 'construct-absent', false], + ['multi-killer', 'empty-population', false], + ['survived', 'combined', false], + ['no-coverage', 'empty-population', false], + ['each-row', 'construct-absent', false], + ]); + }); + + it('fails a scope with beforeAll and a hook-hit population whose sample has none', () => { + const records = evaluateStrata( + strataInput({ + populations: populations({ 'hook-hit': [MUTANTS[2]] }), + selected: [MUTANTS[0]], + constructs: { beforeAll: true, each: false }, + affectedTests: { beforeAll: [`${FILE}#a`, `${FILE}#b`], each: [] }, + }), + ); + expect(records.find((record) => record.name === 'hook-hit')).toMatchObject({ + required: true, + status: 'unmet', + population: 1, + sampled: 0, + affectedTests: 2, + }); + }); + + it('waives that stratum when every affected test is marked not oracle-validated', () => { + const records = evaluateStrata( + strataInput({ + populations: populations({ 'hook-hit': [MUTANTS[2]] }), + constructs: { beforeAll: true, each: false }, + affectedTests: { beforeAll: [`${FILE}#a`, `${FILE}#b`], each: [] }, + notOracleValidated: new Set([`${FILE}#a`, `${FILE}#b`]), + }), + ); + expect(statusOf(records, 'hook-hit')).toBe('waived'); + }); + + it('keeps it unmet when only some affected tests are marked', () => { + const records = evaluateStrata( + strataInput({ + populations: populations({ 'hook-hit': [MUTANTS[2]] }), + constructs: { beforeAll: true, each: false }, + affectedTests: { beforeAll: [`${FILE}#a`, `${FILE}#b`], each: [] }, + notOracleValidated: new Set([`${FILE}#a`]), + }), + ); + expect(statusOf(records, 'hook-hit')).toBe('unmet'); + }); + + it('records an empty population of a present construct without failing', () => { + const records = evaluateStrata( + strataInput({ + constructs: { beforeAll: true, each: true }, + affectedTests: { beforeAll: [`${FILE}#a`], each: [`${FILE}#r1`, `${FILE}#r2`] }, + }), + ); + expect( + records + .filter((record) => record.construct !== null) + .map((record) => [record.name, record.status, record.required, record.population]), + ).toEqual([ + ['hook-hit', 'empty-population', false, 0], + ['each-row', 'empty-population', false, 0], + ]); + }); + + it('meets an it.each stratum with one sampled row mutant and counts its top-ups', () => { + const records = evaluateStrata( + strataInput({ + populations: populations({ 'each-row': [MUTANTS[3], MUTANTS[4]] }), + selected: [MUTANTS[0], MUTANTS[3]], + topUps: [{ globalId: MUTANTS[3], stratum: 'each-row' }], + constructs: { beforeAll: false, each: true }, + affectedTests: { beforeAll: [], each: [`${FILE}#r1`, `${FILE}#r2`] }, + }), + ); + expect(records.find((record) => record.name === 'each-row')).toMatchObject({ + status: 'met', + required: true, + sampled: 1, + toppedUp: 1, + }); + }); +}); + +const record = ( + name: StratumName, + status: StratumRecord['status'], + population: number, + sampled: number, +): StratumRecord => ({ + name, + construct: null, + constructPresent: null, + population, + sampled, + toppedUp: 0, + required: status === 'met' || status === 'unmet' || status === 'waived', + status, + affectedTests: 0, +}); + +describe('strata across the scopes combined (KTD11)', () => { + const scopes = [ + { + name: 'shared', + strata: [ + record('static', 'combined', 245, 20), + record('hook-hit', 'construct-absent', 0, 0), + record('multi-killer', 'combined', 90, 0), + record('each-row', 'construct-absent', 0, 0), + ], + }, + { + name: 'schema-constants', + strata: [ + record('static', 'combined', 72, 72), + record('hook-hit', 'construct-absent', 0, 0), + record('multi-killer', 'combined', 3, 2), + record('each-row', 'empty-population', 0, 0), + ], + }, + { + name: 'middleware', + strata: [ + record('static', 'combined', 3, 3), + record('hook-hit', 'empty-population', 0, 0), + record('multi-killer', 'empty-population', 0, 0), + record('each-row', 'met', 12, 4), + ], + }, + ]; + const combined = combineStrata(scopes); + const byName = (name: StratumName) => combined.find((entry) => entry.name === name); + + it('sums populations and sampled members over the three scopes', () => { + expect(byName('static')).toMatchObject({ population: 320, sampled: 95, status: 'met' }); + expect(byName('multi-killer')).toMatchObject({ population: 93, sampled: 2, status: 'met' }); + }); + + it('records a stratum whose population is empty everywhere', () => { + expect(byName('hook-hit')).toMatchObject({ + population: 0, + sampled: 0, + status: 'empty-population', + }); + }); + + it('meets a construct stratum one scope samples', () => { + expect(byName('each-row')).toMatchObject({ population: 12, sampled: 4, status: 'met' }); + }); + + it('lists every scope of a stratum', () => { + expect(byName('static')?.scopes.map((entry) => entry.scope)).toEqual([ + 'shared', + 'schema-constants', + 'middleware', + ]); + }); + + it('fails a stratum with a population but no sampled member anywhere', () => { + const unmet = combineStrata([ + { name: 'a', strata: [record('no-coverage', 'combined', 5, 0)] }, + { name: 'b', strata: [record('no-coverage', 'empty-population', 0, 0)] }, + ]); + expect(unmet.find((entry) => entry.name === 'no-coverage')?.status).toBe('unmet'); + }); + + it('fails a stratum one scope leaves unmet even when another samples it', () => { + const unmet = combineStrata([ + { name: 'a', strata: [record('hook-hit', 'unmet', 2, 0)] }, + { name: 'b', strata: [record('hook-hit', 'met', 3, 1)] }, + ]); + expect(unmet.find((entry) => entry.name === 'hook-hit')?.status).toBe('unmet'); + }); +}); + +describe('strata populations from baseline coverage', () => { + const ids = ['m0', 'm1', 'm2', 'm3', 'm4', 'm5'].map((id) => `gitnexus/src/a.ts@x:${id}`); + const mutants = [ + { globalId: ids[0], localId: '0', oracleClass: 'survived' as const, killedBy: [] }, + { + globalId: ids[1], + localId: '1', + oracleClass: 'killed-by-test:assertion' as const, + killedBy: [`${FILE}#a`, `${FILE}#b`], + }, + { + globalId: ids[2], + localId: '2', + oracleClass: 'killed-by-test:runtime' as const, + killedBy: [`${FILE}#a`], + }, + { globalId: ids[3], localId: '3', oracleClass: 'survived' as const, killedBy: [] }, + { globalId: ids[4], localId: '4', oracleClass: 'survived' as const, killedBy: [] }, + { globalId: ids[5], localId: '5', oracleClass: 'timeout' as const, killedBy: [] }, + ]; + const baseline = [ + { testId: `${FILE}#a`, coverage: { loadAndCollection: ['0'], hook: [], test: ['1', '2'] } }, + { testId: `${FILE}#r1`, coverage: { loadAndCollection: ['0'], hook: ['3'], test: ['4'] } }, + ]; + const result = computePopulations(mutants, baseline, new Set([`${FILE}#r1`])); + const members = (name: StratumName): string[] => [...(result.get(name) ?? [])].sort(); + + it.each([ + ['static', [ids[0]]], + ['hook-hit', [ids[3]]], + ['multi-killer', [ids[1]]], + ['survived', [ids[0], ids[3], ids[4]]], + ['no-coverage', [ids[5]]], + ['each-row', [ids[4]]], + ] as const)('%s', (name, expected) => { + expect(members(name)).toEqual([...expected].sort()); + }); +}); + +describe('test inventory shapes (KTD3)', () => { + const inventory = [ + target('S > one', 10), + target('row 1', 20), + target('row 2', 20), + { ...target('dup', 30), id: `${FILE}#dup:30:3:0` }, + { ...target('dup', 30), id: `${FILE}#dup:30:3:1` }, + { ...target('title ends like 1:2:3', 40), id: `${FILE}#title ends like 1:2:3` }, + ]; + + it('finds it.each rows by their shared file and location', () => { + expect(eachRowTests(inventory)).toEqual([ + `${FILE}#row 1`, + `${FILE}#row 2`, + `${FILE}#dup:30:3:0`, + `${FILE}#dup:30:3:1`, + ]); + }); + + it('finds the tests whose id needed the row ordinal', () => { + expect(ordinalTests(inventory)).toEqual([`${FILE}#dup:30:3:0`, `${FILE}#dup:30:3:1`]); + }); +}); + +describe('resumable launches (U7 work dir)', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'oracle-freeze-test-')); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + const plans: PlannedLaunch[] = ['a', 'b', 'c', 'd'].map((name, i) => ({ + key: launchKey('7', `${FILE}#${name}`), + target: target(name, 10 + i), + mutantId: '7', + })); + + const fakeLaunch = + (calls: string[], validity: 'valid' | 'invalid' = 'valid') => + async (options: LaunchOptions): Promise => { + calls.push(path.relative(root, options.launchDir)); + mkdirSync(options.launchDir, { recursive: true }); + writeFileSync( + path.join(options.launchDir, 'launch.json'), + JSON.stringify({ + target: { id: options.target.id }, + mutantId: options.mutantId, + observation: { validity, outcome: 'survived', reason: null, covering: false }, + }), + ); + }; + + const common = { + sandboxDir: '/nowhere', + scopeFile: '/nowhere/scope.json', + wallClockMs: 1000, + concurrency: 1, + }; + + it('stops on abort, then skips the done launches and redoes an unfinished one', async () => { + const launchRoot = path.join(root, 'resume'); + const controller = new AbortController(); + const firstCalls: string[] = []; + const writeLaunch = fakeLaunch(firstCalls); + // The first launch finishes, then the freeze is interrupted. + const first = await runLaunches(plans, { + ...common, + launchRoot, + retryInvalid: false, + signal: controller.signal, + launch: async (options) => { + await writeLaunch(options); + controller.abort(); + }, + }); + // A launch killed mid-way leaves an attempt dir without launch.json. + mkdirSync(path.join(launchRoot, plans[2].key, 'attempt-1'), { recursive: true }); + writeFileSync(path.join(launchRoot, plans[2].key, 'attempt-1', 'request.json'), '{}'); + + const secondCalls: string[] = []; + const second = await runLaunches(plans, { + ...common, + launchRoot, + retryInvalid: false, + launch: fakeLaunch(secondCalls), + }); + expect({ + first: { aborted: first.aborted, launched: first.launched, calls: firstCalls }, + second: { aborted: second.aborted, launched: second.launched, reused: second.reused }, + secondCalls, + done: second.completed.size, + leftover: existsSync(path.join(launchRoot, plans[2].key, 'attempt-1', 'request.json')), + }).toEqual({ + first: { + aborted: true, + launched: 1, + calls: [path.join('resume', plans[0].key, 'attempt-1')], + }, + second: { aborted: false, launched: 3, reused: 1 }, + secondCalls: [1, 2, 3].map((i) => path.join('resume', plans[i].key, 'attempt-1')), + done: 4, + leftover: false, + }); + }); + + it('gives a done invalid launch a new attempt only with --retry-invalid', async () => { + const launchRoot = path.join(root, 'retry'); + const one = plans.slice(0, 1); + await runLaunches(one, { + ...common, + launchRoot, + retryInvalid: false, + launch: fakeLaunch([], 'invalid'), + }); + const skipped: string[] = []; + await runLaunches(one, { + ...common, + launchRoot, + retryInvalid: false, + launch: fakeLaunch(skipped), + }); + const retried: string[] = []; + const result = await runLaunches(one, { + ...common, + launchRoot, + retryInvalid: true, + launch: fakeLaunch(retried), + }); + expect({ + skipped, + retried, + attempts: readdirSync(path.join(launchRoot, one[0].key)).sort(), + validity: result.completed.get(one[0].key)?.validity, + }).toEqual({ + skipped: [], + retried: [path.join('retry', one[0].key, 'attempt-2')], + attempts: ['attempt-1', 'attempt-2'], + validity: 'valid', + }); + }); + + it('refuses a work dir whose launch holds another test', async () => { + const launchRoot = path.join(root, 'foreign'); + const dir = path.join(launchRoot, plans[0].key, 'attempt-1'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'launch.json'), + JSON.stringify({ + target: { id: `${FILE}#other` }, + mutantId: '7', + observation: { validity: 'valid', outcome: 'survived', reason: null, covering: null }, + }), + ); + await expect( + runLaunches(plans.slice(0, 1), { + ...common, + launchRoot, + retryInvalid: false, + launch: fakeLaunch([]), + }), + ).rejects.toThrow(/use a new work dir/); + }); +}); + +describe('manifest format (KTD12)', () => { + const manifest: OracleManifest = { + schemaVersion: 1, + kind: 'gitnexus-mutation-oracle-manifest', + name: 'tiny', + description: 'a tiny manifest', + refreeze: 'npm run test:mutation:oracle:freeze -- --from-manifest x --seed --work ', + scope: { testFiles: [FILE], mutate: ['gitnexus/src/a.ts'] }, + versions: { stryker: '10.0.0', vitest: '5.0.5', nodeMajor: 22 }, + operators: { mutator: null, names: ['StringLiteral'] }, + inputs: { + closure: { 'gitnexus/src/a.ts': 'aa', [`gitnexus/${FILE}`]: 'bb' }, + vitestConfig: { 'gitnexus/vitest.config.ts': 'cc' }, + mutationConfig: { 'gitnexus-shared/package.json': 'dd' }, + prepareTool: { 'gitnexus/tools/mutation/prepare/prepare.ts': 'ee' }, + }, + sample: { + seed: 's', + method: 'sha256', + size: 100, + topUpMin: 3, + random: 2, + topUps: [{ globalId: MUTANTS[2], stratum: 'static' }], + }, + strata: [record('static', 'combined', 3, 1)], + baseline: { tests: 2, passedAlone: 2 }, + tests: [`${FILE}#a`, `${FILE}#b`], + notOracleValidated: [], + mutants: [ + [MUTANTS[0], 'survived'], + [MUTANTS[1], 'killed-by-test:assertion'], + ], + sampled: [ + { + id: MUTANTS[1], + strata: ['multi-killer'], + covering: 2, + outcomes: [[1, 'killed-by-test:assertion']], + invalid: [], + }, + ], + launches: { total: 4, valid: 2, invalid: 0, wallClockLimitMs: 180000 }, + warnings: [], + problems: [], + }; + + it('round-trips through its JSON format', () => { + expect(parseManifest(formatJson(manifest))).toEqual(manifest); + }); + + it('keeps each mutant row on one line', () => { + expect(formatJson(manifest).split('\n')).toContain( + ` ${JSON.stringify([MUTANTS[0], 'survived'])},`, + ); + }); + + it('rejects an outcome row that names a test outside the inventory', () => { + const broken = { + ...manifest, + sampled: [{ ...manifest.sampled[0], outcomes: [[5, 'killed-by-test:assertion']] }], + }; + expect(() => parseManifest(JSON.stringify(broken))).toThrow(/no test 5/); + }); +}); diff --git a/gitnexus/test/unit/mutation/oracle-util.test.ts b/gitnexus/test/unit/mutation/oracle-util.test.ts new file mode 100644 index 000000000..10cf207c0 --- /dev/null +++ b/gitnexus/test/unit/mutation/oracle-util.test.ts @@ -0,0 +1,39 @@ +/** + * Locks the oracle's shared helpers (oracle/util.ts): the object guard of its JSON readers and the + * temp-file-and-rename write that freeze.ts, launch-single.ts, command-wrapper.ts and + * oracle-setup.ts use for manifests, launch files, wrapper records and probe files. + */ +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isObject, writeFileAtomicSync } from '../../../tools/mutation/oracle/util.js'; + +describe('isObject', () => { + it.each([ + { label: 'a plain object', value: {}, expected: true }, + { label: 'null', value: null, expected: false }, + { label: 'an array', value: [], expected: false }, + { label: 'a string', value: 'x', expected: false }, + ])('$label is an object: $expected', ({ value, expected }) => { + expect(isObject(value)).toBe(expected); + }); +}); + +describe('writeFileAtomicSync', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-oracle-util-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('replaces the file and leaves no temp file beside it', () => { + const file = path.join(dir, 'probe.json'); + writeFileSync(file, 'old\n'); + writeFileAtomicSync(file, '{"complete":true}\n'); + expect(readFileSync(file, 'utf8')).toBe('{"complete":true}\n'); + expect(readdirSync(dir)).toEqual(['probe.json']); + }); +}); diff --git a/gitnexus/test/unit/mutation/path-map.test.ts b/gitnexus/test/unit/mutation/path-map.test.ts new file mode 100644 index 000000000..487cdec9d --- /dev/null +++ b/gitnexus/test/unit/mutation/path-map.test.ts @@ -0,0 +1,210 @@ +/** + * Locks the prepared-copy path map and the `gitnexus-shared` aliases (KTD8): relocated + * shared sources translate back to `gitnexus-shared/src/...`, `gitnexus/src/...` is the + * identity, anything else is rejected, and one alias exists per package `exports` key. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + PATH_MAP, + buildPathMap, + deriveSharedAliases, + parseSharedPackageJson, + toPreparedPath, + toRepoPath, + toRepoPathFromPackageDir, +} from '../../../tools/mutation/prepare/path-map.js'; + +const RELOCATED_SCHEMA = 'gitnexus/relocated/gitnexus-shared/src/lbug/schema-constants.ts'; +const SHARED_DIR = path.join(path.sep, 'prepared', 'gitnexus', 'relocated', 'gitnexus-shared'); + +describe('toRepoPath', () => { + it('translates a relocated shared path back to gitnexus-shared/src', () => { + expect(toRepoPath(RELOCATED_SCHEMA)).toBe('gitnexus-shared/src/lbug/schema-constants.ts'); + }); + + it('leaves a gitnexus/src path unchanged', () => { + expect(toRepoPath('gitnexus/src/core/ingestion/type-extractors/shared.ts')).toBe( + 'gitnexus/src/core/ingestion/type-extractors/shared.ts', + ); + }); + + it('accepts Windows separators and a leading ./', () => { + expect(toRepoPath('.\\gitnexus\\relocated\\gitnexus-shared\\src\\index.ts')).toBe( + 'gitnexus-shared/src/index.ts', + ); + }); + + it.each([ + ['a test file', 'gitnexus/test/unit/schema.test.ts'], + ['an unrelocated shared path', 'gitnexus-shared/src/index.ts'], + ['a parent-relative path', '../gitnexus/src/index.ts'], + ['a path that climbs out of src', 'gitnexus/src/../../etc/passwd'], + ['an absolute path', '/abs/gitnexus/src/index.ts'], + ['the src root itself', 'gitnexus/src'], + ])('rejects %s', (_label, input) => { + expect(() => toRepoPath(input)).toThrow(/outside the mapped roots|not a relative path/); + }); +}); + +describe('toPreparedPath', () => { + it('maps gitnexus-shared/src into the relocated directory', () => { + expect(toPreparedPath('gitnexus-shared/src/lbug/schema-constants.ts')).toBe(RELOCATED_SCHEMA); + }); + + it('round-trips a gitnexus/src path', () => { + expect(toRepoPath(toPreparedPath('gitnexus/src/core/lbug/schema.ts'))).toBe( + 'gitnexus/src/core/lbug/schema.ts', + ); + }); + + it('rejects a path outside both roots', () => { + expect(() => toPreparedPath('gitnexus/test/unit/schema.test.ts')).toThrow( + /outside the mapped roots/, + ); + }); +}); + +describe('toRepoPathFromPackageDir', () => { + const sandbox = path.join(path.sep, 'tmp', 'sandbox-1'); + + it('maps an absolute relocated path under a sandbox back to gitnexus-shared/src', () => { + expect( + toRepoPathFromPackageDir( + sandbox, + path.join(sandbox, 'relocated', 'gitnexus-shared', 'src', 'lbug', 'schema-constants.ts'), + ), + ).toBe('gitnexus-shared/src/lbug/schema-constants.ts'); + }); + + it('rejects an absolute path outside the package directory', () => { + expect(() => + toRepoPathFromPackageDir(sandbox, path.join(path.sep, 'tmp', 'other', 'src', 'a.ts')), + ).toThrow(/outside the mapped roots|not a relative path/); + }); +}); + +describe('buildPathMap: extra roots for mutation fixture projects', () => { + const FIXTURE = 'gitnexus/test/fixtures/mutation-conformance'; + const FIXTURE_FILE = `${FIXTURE}/src/static-table.ts`; + const map = buildPathMap([FIXTURE]); + + it('is the default map when no extra root is given', () => { + expect(buildPathMap()).toEqual(PATH_MAP); + }); + + it('maps a file under an extra root to itself, in both directions', () => { + expect([toPreparedPath(FIXTURE_FILE, map), toRepoPath(FIXTURE_FILE, map)]).toEqual([ + FIXTURE_FILE, + FIXTURE_FILE, + ]); + }); + + it('maps an absolute sandbox path under an extra root back to its repo path', () => { + const sandbox = path.join(path.sep, 'tmp', 'sandbox-2'); + const absolute = path.join(sandbox, 'test', 'fixtures', 'mutation-conformance', 'src', 'a.ts'); + expect(toRepoPathFromPackageDir(sandbox, absolute, map)).toBe(`${FIXTURE}/src/a.ts`); + }); + + it('keeps the default roots in an extended map', () => { + expect(toPreparedPath('gitnexus-shared/src/lbug/schema-constants.ts', map)).toBe( + RELOCATED_SCHEMA, + ); + }); + + it('still rejects the fixture path under the default map', () => { + expect(() => toPreparedPath(FIXTURE_FILE)).toThrow(/outside the mapped roots/); + }); + + it('still rejects paths outside every root under an extended map', () => { + expect(() => toPreparedPath('gitnexus/test/unit/schema.test.ts', map)).toThrow( + /outside the mapped roots/, + ); + }); + + it('accepts a trailing slash, Windows separators and a repeated root once', () => { + expect(buildPathMap([`${FIXTURE}/`, 'gitnexus\\test\\fixtures\\mutation-conformance'])).toEqual( + map, + ); + }); + + it.each([ + ['another test directory', 'gitnexus/test/unit'], + ['a fixture that is not a mutation fixture', 'gitnexus/test/fixtures/lang-resolution'], + ['a file inside a mutation fixture', 'gitnexus/test/fixtures/mutation-x/src'], + ['the fixtures directory itself', 'gitnexus/test/fixtures'], + ['a production root', 'gitnexus/src'], + ['a parent-relative path', '../gitnexus/test/fixtures/mutation-x'], + ['an absolute path', '/gitnexus/test/fixtures/mutation-x'], + ])('rejects %s as an extra root', (_label, root) => { + expect(() => buildPathMap([root])).toThrow(/path map/); + }); +}); + +describe('deriveSharedAliases', () => { + it('creates exactly one alias per key of the gitnexus-shared exports map', () => { + const text = readFileSync( + new URL('../../../../gitnexus-shared/package.json', import.meta.url), + 'utf8', + ); + const { exports: exportsMap }: { exports: Record } = JSON.parse(text); + const aliases = deriveSharedAliases(parseSharedPackageJson(text), SHARED_DIR); + + expect(aliases.map((a) => a.specifier)).toEqual( + // '.' -> 'gitnexus-shared', './test-helpers' -> 'gitnexus-shared/test-helpers' + Object.keys(exportsMap).map((key) => `gitnexus-shared${key.slice(1)}`), + ); + }); + + it('points each alias at the matching source file under the relocated src', () => { + const aliases = deriveSharedAliases( + { + name: 'gitnexus-shared', + exports: { + '.': { types: './dist/index.d.ts', default: './dist/index.js' }, + './test-helpers': { + types: './dist/test-helpers.d.ts', + default: './dist/test-helpers.js', + }, + }, + }, + SHARED_DIR, + ); + + expect(aliases.map(({ specifier, replacement }) => ({ specifier, replacement }))).toEqual([ + { specifier: 'gitnexus-shared', replacement: path.join(SHARED_DIR, 'src', 'index.ts') }, + { + specifier: 'gitnexus-shared/test-helpers', + replacement: path.join(SHARED_DIR, 'src', 'test-helpers.ts'), + }, + ]); + }); + + it('anchors each alias so it matches only its own specifier', () => { + const [root] = deriveSharedAliases( + { name: 'gitnexus-shared', exports: { '.': './dist/index.js' } }, + SHARED_DIR, + ); + + expect( + [ + 'gitnexus-shared', + 'gitnexus-shared/test-helpers', + 'gitnexus-shared-x', + 'x/gitnexus-shared', + ].map((specifier) => root.find.test(specifier)), + ).toEqual([true, false, false, false]); + }); + + it.each([ + ['a wildcard export', { './*': './dist/*.js' }], + ['a target outside dist', { '.': './lib/index.js' }], + ['a target without a default condition', { '.': { types: './dist/index.d.ts' } }], + ['a non-subpath key', { import: './dist/index.js' }], + ])('rejects %s', (_label, exportsMap) => { + expect(() => + deriveSharedAliases({ name: 'gitnexus-shared', exports: exportsMap }, SHARED_DIR), + ).toThrow(/gitnexus-shared exports/); + }); +}); diff --git a/gitnexus/test/unit/mutation/prepare-mutate-paths.test.ts b/gitnexus/test/unit/mutation/prepare-mutate-paths.test.ts new file mode 100644 index 000000000..1c0afc740 --- /dev/null +++ b/gitnexus/test/unit/mutation/prepare-mutate-paths.test.ts @@ -0,0 +1,37 @@ +/** + * Locks how the prepare step resolves its `--mutate` files: canonical repo paths, each once, + * through the default path map, and through an extended map (a mutation fixture project's root, + * U4) only when the caller passes one. + */ +import { describe, expect, it } from 'vitest'; +import { canonicalMutatePaths } from '../../../tools/mutation/prepare/prepare.js'; +import { buildPathMap } from '../../../tools/mutation/prepare/path-map.js'; + +const FIXTURE_ROOT = 'gitnexus/test/fixtures/mutation-conformance'; +const FIXTURE_FILE = `${FIXTURE_ROOT}/src/runtime.ts`; + +describe('canonicalMutatePaths', () => { + it('canonicalises separators and a leading ./ and lists each file once', () => { + expect( + canonicalMutatePaths([ + './gitnexus-shared/src/lbug/schema-constants.ts', + 'gitnexus-shared\\src\\lbug\\schema-constants.ts', + 'gitnexus/src/core/lbug/schema.ts', + ]), + ).toEqual(['gitnexus-shared/src/lbug/schema-constants.ts', 'gitnexus/src/core/lbug/schema.ts']); + }); + + it('accepts a fixture file under the extended path map', () => { + expect(canonicalMutatePaths([`./${FIXTURE_FILE}`], buildPathMap([FIXTURE_ROOT]))).toEqual([ + FIXTURE_FILE, + ]); + }); + + it.each([ + ['a fixture file under the default map', FIXTURE_FILE, /outside the mapped roots/], + ['a declaration file', 'gitnexus/src/types.d.ts', /not a \.ts source file/], + ['a non-TypeScript file', 'gitnexus/src/index.js', /not a \.ts source file/], + ])('rejects %s', (_label, file, message) => { + expect(() => canonicalMutatePaths([file])).toThrow(message); + }); +}); diff --git a/gitnexus/test/unit/mutation/run-manifest.test.ts b/gitnexus/test/unit/mutation/run-manifest.test.ts new file mode 100644 index 000000000..3bbde1073 --- /dev/null +++ b/gitnexus/test/unit/mutation/run-manifest.test.ts @@ -0,0 +1,189 @@ +/** + * Locks the vitest5 runner's run manifest (KTD16): the dry-run process writes it atomically to the + * run directory, every other runner process loads it before its first mutant run, and test + * selection (KTD4) accepts only manifest test ids. + */ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + RUN_MANIFEST_FILE, + RunManifestMissingError, + TestFilterError, + parseRunManifest, + readRunManifest, + selectTests, + writeRunManifest, + type RunManifest, +} from '../../../tools/mutation/runner/run-manifest.js'; + +const MODULE_URL = new URL('../../../tools/mutation/runner/run-manifest.ts', import.meta.url).href; + +const manifest: RunManifest = { + schemaVersion: 1, + project: 'mutation', + vitestVersion: '5.0.5', + files: [ + { + file: 'test/unit/a.test.ts', + tests: [ + { + id: 'test/unit/a.test.ts#A > one (#2415)', + taskId: 'h1_0_0', + fullName: 'A > one (#2415)', + location: { line: 4, column: 5 }, + notOracleValidated: false, + }, + { + id: 'test/unit/a.test.ts#A > two', + taskId: 'h1_0_1', + fullName: 'A > two', + location: { line: 8, column: 5 }, + notOracleValidated: false, + }, + ], + skipped: [ + { + id: 'test/unit/a.test.ts#A > later', + taskId: 'h1_0_2', + fullName: 'A > later', + location: { line: 12, column: 5 }, + notOracleValidated: false, + }, + ], + }, + { + file: 'test/unit/b.test.ts', + tests: [ + { + id: 'test/unit/b.test.ts#row:3:1:0', + taskId: 'h2_0', + fullName: 'row', + location: { line: 3, column: 1 }, + notOracleValidated: true, + }, + ], + skipped: [], + }, + ], +}; + +let runDir: string; + +beforeEach(() => { + runDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-run-manifest-')); +}); + +afterEach(() => { + rmSync(runDir, { recursive: true, force: true }); +}); + +describe('writeRunManifest and readRunManifest', () => { + it('loads, in this process, a manifest written by another process', () => { + const script = [ + `const { writeRunManifest } = await import(${JSON.stringify(MODULE_URL)});`, + `writeRunManifest(process.argv[1], JSON.parse(process.argv[2]));`, + ].join('\n'); + execFileSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script, runDir, JSON.stringify(manifest)], + { stdio: 'pipe' }, + ); + + const loaded = readRunManifest(runDir); + const taskIdByStrykerId = new Map( + loaded.files.flatMap((file) => file.tests.map((test) => [test.id, test.taskId] as const)), + ); + + expect(loaded).toEqual(manifest); + expect(Object.fromEntries(taskIdByStrykerId)).toEqual({ + 'test/unit/a.test.ts#A > one (#2415)': 'h1_0_0', + 'test/unit/a.test.ts#A > two': 'h1_0_1', + 'test/unit/b.test.ts#row:3:1:0': 'h2_0', + }); + }); + + it('leaves only the final manifest file in the run directory', () => { + writeRunManifest(runDir, manifest); + writeRunManifest(runDir, manifest); + + expect(readdirSync(runDir)).toEqual([RUN_MANIFEST_FILE]); + }); + + it('refuses to load when the dry run left no manifest', () => { + expect(() => readRunManifest(runDir)).toThrow(RunManifestMissingError); + expect(() => readRunManifest(runDir)).toThrow(/no run manifest .*dry run/); + }); + + it('refuses a relative run directory', () => { + expect(() => writeRunManifest('relative/run', manifest)).toThrow(/absolute/); + }); + + it('refuses a corrupt manifest file', () => { + writeFileSync(path.join(runDir, RUN_MANIFEST_FILE), '{"schemaVersion":1'); + + expect(() => readRunManifest(runDir)).toThrow(/not valid JSON/); + }); +}); + +describe('parseRunManifest', () => { + const withFiles = (files: unknown): string => JSON.stringify({ ...manifest, files }); + + it.each([ + ['an unknown schema version', JSON.stringify({ ...manifest, schemaVersion: 2 }), /schema/], + ['a missing project', JSON.stringify({ ...manifest, project: '' }), /project/], + [ + 'a Stryker id listed twice', + withFiles([manifest.files[0], { ...manifest.files[0], file: 'test/unit/c.test.ts' }]), + /listed twice/, + ], + [ + 'an absolute test file', + withFiles([{ ...manifest.files[1], file: '/abs/b.test.ts' }]), + /package-relative/, + ], + [ + 'a test without a task id', + withFiles([{ ...manifest.files[1], tests: [{ ...manifest.files[1].tests[0], taskId: '' }] }]), + /taskId/, + ], + ])('rejects %s', (_label, text, message) => { + expect(() => parseRunManifest(text, 'run-manifest.json')).toThrow(message); + }); +}); + +describe('selectTests', () => { + it('selects the filtered tests per file by Vitest task id', () => { + expect( + selectTests(manifest, ['test/unit/b.test.ts#row:3:1:0', 'test/unit/a.test.ts#A > two']), + ).toEqual({ + files: [ + { file: 'test/unit/a.test.ts', tests: [manifest.files[0].tests[1]] }, + { file: 'test/unit/b.test.ts', tests: [manifest.files[1].tests[0]] }, + ], + }); + }); + + it("selects the manifest's full test set, without skipped tests, when there is no filter", () => { + expect(selectTests(manifest, undefined)).toEqual({ + files: [ + { file: 'test/unit/a.test.ts', tests: manifest.files[0].tests }, + { file: 'test/unit/b.test.ts', tests: manifest.files[1].tests }, + ], + }); + }); + + it('rejects a filter that holds a test file path instead of a test id', () => { + expect(() => selectTests(manifest, ['test/unit/a.test.ts'])).toThrow(TestFilterError); + expect(() => selectTests(manifest, ['/sandbox/test/unit/a.test.ts'])).toThrow(/testFiles/); + }); + + it.each([ + ['an unknown test id', 'test/unit/a.test.ts#A > three'], + ['a test that was skipped in the dry run', 'test/unit/a.test.ts#A > later'], + ])('rejects %s', (_label, id) => { + expect(() => selectTests(manifest, [id])).toThrow(/not a test id of the run manifest/); + }); +}); diff --git a/gitnexus/test/unit/mutation/run-scope.test.ts b/gitnexus/test/unit/mutation/run-scope.test.ts new file mode 100644 index 000000000..ffd059756 --- /dev/null +++ b/gitnexus/test/unit/mutation/run-scope.test.ts @@ -0,0 +1,133 @@ +/** + * Locks the scope driver's pure helpers (U10): CLI parsing, the Stryker command line, the child + * environment, which must name the scope file and must not inherit Stryker's own worker and + * active-mutant variables (KTD5, KTD10) unless a caller sets them explicitly, and the pid that + * signals reach the whole Stryker process tree through. + */ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { runLayout } from '../../../tools/mutation/driver/stryker-config.js'; +import { + findSandboxes, + parseCliArgs, + signalTarget, + strykerChildEnv, + strykerCommand, +} from '../../../tools/mutation/driver/run-scope.js'; + +const CWD = path.resolve('/work/gitnexus'); + +describe('parseCliArgs', () => { + it('applies the defaults and resolves paths against the working directory', () => { + expect(parseCliArgs(['--scope', 'scope.json', '--out', '/tmp/run'], CWD)).toEqual({ + scopeFile: path.join(CWD, 'scope.json'), + outDir: path.resolve('/tmp/run'), + variant: 'vitest5', + concurrency: 2, + maxTestRunnerReuse: 0, + timeoutMS: 10000, + command: undefined, + }); + }); + + it('reads the command variant and the scheduling profile', () => { + const args = [ + '--scope=/s.json', + '--out=/o', + '--variant=command', + '--command=true', + '--concurrency=4', + '--max-test-runner-reuse=10', + '--timeout-ms=20000', + ]; + expect(parseCliArgs(args, CWD)).toMatchObject({ + variant: 'command', + command: 'true', + concurrency: 4, + maxTestRunnerReuse: 10, + timeoutMS: 20000, + }); + }); + + it.each([ + ['no --scope', ['--out', '/o'], /--scope is required/], + ['no --out', ['--scope', '/s.json'], /--out is required/], + ['an unknown variant', ['--scope=/s', '--out=/o', '--variant=jest'], /unknown variant "jest"/], + ['a non-numeric concurrency', ['--scope=/s', '--out=/o', '--concurrency=two'], /integer/], + ['an unknown flag', ['--scope=/s', '--out=/o', '--testFiles=a'], /Unknown option/], + ])('rejects %s', (_label, args, message) => { + expect(() => parseCliArgs(args, CWD)).toThrow(message); + }); +}); + +describe('strykerChildEnv', () => { + const inherited = { + PATH: '/bin', + __STRYKER_ACTIVE_MUTANT__: '12', + STRYKER_MUTATOR_WORKER: '3', + GITNEXUS_MUTATION_SCOPE: 'stale.json', + }; + const env = strykerChildEnv(inherited, '/out/scope.json'); + + it('names the run scope file', () => { + expect(env.GITNEXUS_MUTATION_SCOPE).toBe('/out/scope.json'); + }); + + it('scrubs inherited Stryker worker and active-mutant variables', () => { + expect(Object.keys(env).sort()).toEqual(['GITNEXUS_MUTATION_SCOPE', 'PATH']); + }); + + // Gate 2's environment case hands Stryker an active mutant the runner itself must remove. + it('sets an explicit child environment after the scrub', () => { + expect( + strykerChildEnv(inherited, '/out/scope.json', { __STRYKER_ACTIVE_MUTANT__: '7' }), + ).toEqual({ + PATH: '/bin', + __STRYKER_ACTIVE_MUTANT__: '7', + GITNEXUS_MUTATION_SCOPE: '/out/scope.json', + }); + }); + + it('keeps the run scope file over a child environment that names another', () => { + const child = strykerChildEnv(inherited, '/out/scope.json', { + GITNEXUS_MUTATION_SCOPE: 'other.json', + }); + expect(child.GITNEXUS_MUTATION_SCOPE).toBe('/out/scope.json'); + }); +}); + +describe('signalTarget', () => { + it.each([ + ['linux', -4242], + ['darwin', -4242], + ['win32', 4242], + ] as const)('signals the Stryker process tree on %s through pid %i', (platform, target) => { + expect(signalTarget(4242, platform)).toBe(target); + }); +}); + +describe('strykerCommand', () => { + it('runs the prepared copy Stryker binary under tsx from the prepared package', () => { + const layout = runLayout(path.resolve('/out')); + expect(strykerCommand(layout, '/usr/bin/node')).toEqual({ + command: '/usr/bin/node', + args: [ + '--import', + 'tsx', + path.join(layout.packageDir, 'node_modules/@stryker-mutator/core/bin/stryker.js'), + 'run', + layout.configFile, + ], + cwd: layout.packageDir, + }); + }); +}); + +describe('findSandboxes', () => { + it('keeps sandbox directories only, sorted', () => { + expect(findSandboxes('/t', ['sandbox-b', 'other', 'sandbox-a', '.lock'])).toEqual([ + path.join('/t', 'sandbox-a'), + path.join('/t', 'sandbox-b'), + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/scope-file.test.ts b/gitnexus/test/unit/mutation/scope-file.test.ts new file mode 100644 index 000000000..ae390d28b --- /dev/null +++ b/gitnexus/test/unit/mutation/scope-file.test.ts @@ -0,0 +1,56 @@ +/** + * Locks the scope-file contract between the caller of a mutation run and + * tools/mutation/vitest.mutation.config.ts (KTD9): a JSON object whose `testFiles` lists + * package-relative test paths. + */ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + MUTATION_SCOPE_ENV, + parseMutationScope, + resolveMutationScopePath, +} from '../../../tools/mutation/scope-file.js'; + +describe('parseMutationScope', () => { + it('returns the listed test files in order', () => { + expect( + parseMutationScope( + JSON.stringify({ testFiles: ['test/unit/schema.test.ts', 'test/unit/a/b.test.ts'] }), + ), + ).toEqual({ testFiles: ['test/unit/schema.test.ts', 'test/unit/a/b.test.ts'] }); + }); + + it.each([ + ['invalid JSON', '{', /not valid JSON/], + ['a non-object', '[]', /must be a JSON object/], + ['a missing testFiles', '{}', /testFiles must be a non-empty array/], + ['an empty testFiles', '{"testFiles":[]}', /testFiles must be a non-empty array/], + ['a non-string entry', '{"testFiles":[1]}', /must be a string/], + ['an absolute path', '{"testFiles":["/abs/x.test.ts"]}', /package-relative/], + ['a parent segment', '{"testFiles":["test/../../x.test.ts"]}', /package-relative/], + ['a backslash path', '{"testFiles":["test\\\\unit\\\\x.test.ts"]}', /package-relative/], + ['a duplicate entry', '{"testFiles":["t/x.test.ts","t/x.test.ts"]}', /listed twice/], + ])('rejects %s', (_label, text, message) => { + expect(() => parseMutationScope(text)).toThrow(message); + }); +}); + +describe('resolveMutationScopePath', () => { + const packageDir = path.join(path.sep, 'prepared', 'gitnexus'); + + it('resolves a relative scope path against the package directory', () => { + expect(resolveMutationScopePath({ [MUTATION_SCOPE_ENV]: 'scope.json' }, packageDir)).toBe( + path.join(packageDir, 'scope.json'), + ); + }); + + it('keeps an absolute scope path', () => { + const absolute = path.join(path.sep, 'runs', 'scope.json'); + + expect(resolveMutationScopePath({ [MUTATION_SCOPE_ENV]: absolute }, packageDir)).toBe(absolute); + }); + + it('fails with the variable name when no scope file is set', () => { + expect(() => resolveMutationScopePath({}, packageDir)).toThrow(MUTATION_SCOPE_ENV); + }); +}); diff --git a/gitnexus/test/unit/mutation/sidecar.test.ts b/gitnexus/test/unit/mutation/sidecar.test.ts new file mode 100644 index 000000000..47783a52d --- /dev/null +++ b/gitnexus/test/unit/mutation/sidecar.test.ts @@ -0,0 +1,155 @@ +/** + * Locks the vitest5 runner's sidecar files (KTD6): one record per local mutant id, attempt and + * worker, written under a temporary name and published under its final name only when complete. + */ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { RunClassification } from '../../../tools/mutation/runner/classify-outcome.js'; +import { + SIDECAR_SCHEMA_VERSION, + readSidecars, + sidecarFileName, + writeSidecar, + type SidecarDraft, +} from '../../../tools/mutation/runner/sidecar.js'; + +const survived: RunClassification = { + status: 'survived', + outcome: 'survived', + reason: undefined, + killedBy: [], + failureMessage: undefined, + nrOfTests: 1, + hitCount: 0, + tests: [{ id: 'test/unit/a.test.ts#A > one', outcome: 'survived', message: undefined }], + outsideTest: [], +}; + +const draft = (worker: string): SidecarDraft => ({ + kind: 'mutant', + mutantId: '42', + worker, + mutantActivation: 'runtime', + hitLimit: 100, + requestedTestIds: ['test/unit/a.test.ts#A > one'], + hookAttributedTests: null, + result: survived, +}); + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sidecar-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('sidecarFileName', () => { + it.each([ + ['a mutant run', 'mutant', '42', '3', 1, 'mutant-42.worker-3.attempt-1.json'], + ['the dry run', 'dry-run', null, '1', 2, 'dry-run.worker-1.attempt-2.json'], + ['an unsafe worker id', 'mutant', '7', '../x', 1, 'mutant-7.worker-___x.attempt-1.json'], + ] as const)('names %s', (_label, kind, mutantId, worker, attempt, expected) => { + expect(sidecarFileName(kind, mutantId, worker, attempt)).toBe(expected); + }); +}); + +describe('writeSidecar', () => { + it('keeps the records of two workers for the same mutant and attempt apart', () => { + const first = writeSidecar(dir, draft('1')); + const second = writeSidecar(dir, draft('2')); + + expect([first.attempt, second.attempt]).toEqual([1, 1]); + expect(readdirSync(dir).sort()).toEqual([ + 'mutant-42.worker-1.attempt-1.json', + 'mutant-42.worker-2.attempt-1.json', + ]); + expect(readSidecars(dir).map((record) => [record.worker, record.attempt])).toEqual([ + ['1', 1], + ['2', 1], + ]); + }); + + it('gives a repeated run of the same mutant in the same worker the next attempt', () => { + writeSidecar(dir, draft('1')); + const retry = writeSidecar(dir, draft('1')); + + expect(retry.attempt).toBe(2); + expect(readdirSync(dir).sort()).toEqual([ + 'mutant-42.worker-1.attempt-1.json', + 'mutant-42.worker-1.attempt-2.json', + ]); + }); + + it('never overwrites a record already published under the final name', () => { + const taken = path.join(dir, 'mutant-42.worker-1.attempt-1.json'); + writeFileSync(taken, 'published by another process'); + + const written = writeSidecar(dir, draft('1')); + + expect(readFileSync(taken, 'utf8')).toBe('published by another process'); + expect(path.basename(written.file)).toBe('mutant-42.worker-1.attempt-2.json'); + }); + + it('never shows a half-written record under the final name', () => { + const finalName = path.join(dir, 'mutant-42.worker-1.attempt-1.json'); + const seenDuringWrite: boolean[] = []; + + expect(() => + writeSidecar(dir, draft('1'), { + writeFile: (file, data) => { + writeFileSync(file, data.slice(0, 10)); + seenDuringWrite.push(existsSync(finalName)); + throw new Error('disk full'); + }, + }), + ).toThrow(/disk full/); + expect(seenDuringWrite).toEqual([false]); + expect(readdirSync(dir)).toEqual([]); + }); + + it('writes the complete record, with pid and schema version, as JSON', () => { + const written = writeSidecar(dir, draft('5')); + + expect(JSON.parse(readFileSync(written.file, 'utf8'))).toEqual({ + schemaVersion: SIDECAR_SCHEMA_VERSION, + attempt: 1, + pid: process.pid, + ...draft('5'), + }); + }); + + it('keeps the dry-run hook marker: per mutant, the tests a suite hook attributed it to', () => { + const marker = { '3': ['test/unit/a.test.ts#A > one', 'test/unit/a.test.ts#A > two'] }; + const written = writeSidecar(dir, { + ...draft('1'), + kind: 'dry-run', + mutantId: null, + mutantActivation: null, + hitLimit: null, + hookAttributedTests: marker, + }); + + expect(readSidecars(dir)).toEqual([ + expect.objectContaining({ schemaVersion: 2, kind: 'dry-run', hookAttributedTests: marker }), + ]); + expect(path.basename(written.file)).toBe('dry-run.worker-1.attempt-1.json'); + }); + + it('refuses a relative sidecar directory', () => { + expect(() => writeSidecar('relative/sidecars', draft('1'))).toThrow(/absolute/); + }); +}); + +describe('readSidecars', () => { + it('ignores temporary files that a crashed writer left behind', () => { + writeSidecar(dir, draft('1')); + writeFileSync(path.join(dir, '.mutant-42.worker-9.attempt-1.json.123.tmp'), '{"half":'); + + expect(readSidecars(dir).map((record) => record.worker)).toEqual(['1']); + }); +}); diff --git a/gitnexus/test/unit/mutation/span-map-check.test.ts b/gitnexus/test/unit/mutation/span-map-check.test.ts new file mode 100644 index 000000000..8f52ace75 --- /dev/null +++ b/gitnexus/test/unit/mutation/span-map-check.test.ts @@ -0,0 +1,218 @@ +/** + * Locks the span-map check (KTD7): Stryker's locations refer to the prepared file, whose only + * difference from the original is `as` assertions blanked to spaces of equal length. A mutant + * passes when its span means the same text in both files outside the blanked ranges; any other + * difference fails with the mutant's global identity in the message. + */ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { blankAssertions } from '../../../tools/mutation/prepare/blank-assertions.js'; +import { + identifyMutants, + type MutationReport, + type ReportLocation, + type ReportMutant, +} from '../../../tools/mutation/prepare/mutant-identity.js'; +import { + assertSpanMap, + checkSpanMap, + indexSource, + locationToOffsets, +} from '../../../tools/mutation/prepare/span-map-check.js'; + +const PACKAGE_DIR = path.join(path.sep, 'prepared', 'gitnexus'); +const SHARED_KEY = 'relocated/gitnexus-shared/src/lbug/schema-constants.ts'; + +const TABLE = "export const KINDS = ['a', 'b'] as const;\n"; +const TABLE_PREPARED = "export const KINDS = ['a', 'b'] ;\n"; + +const CALL = 'export function f(x: unknown) {\n return g(x as string);\n}\n'; +const CALL_PREPARED = 'export function f(x: unknown) {\n return g(x );\n}\n'; + +const loc = ( + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, +): ReportLocation => ({ + start: { line: startLine, column: startColumn }, + end: { line: endLine, column: endColumn }, +}); + +const mutant = (id: string, location: ReportLocation, replacement = '""'): ReportMutant => ({ + id, + mutatorName: 'StringLiteral', + replacement, + status: 'Survived', + location, +}); + +/** Runs the check on one file: `original` in the repo, `prepared` as Stryker read it. */ +const check = (original: string, prepared: string, mutants: readonly ReportMutant[]) => { + const report: MutationReport = { + schemaVersion: '1.0', + files: { [SHARED_KEY]: { language: 'typescript', source: prepared, mutants: [...mutants] } }, + }; + const run = identifyMutants(report, { packageDir: PACKAGE_DIR, readOriginal: () => original }); + return { run, result: checkSpanMap(report, run) }; +}; + +describe('fixtures', () => { + it.each([ + ['an as-const table', TABLE, TABLE_PREPARED], + ['an assertion inside a call', CALL, CALL_PREPARED], + ])('match what the prepare step writes for %s', (_label, original, prepared) => { + expect(blankAssertions(original)).toEqual({ ok: true, assertions: 1, text: prepared }); + }); +}); + +describe('locationToOffsets', () => { + it.each([ + ['a span at the start of a line', 'ab\ncd\n', loc(2, 1, 2, 2), 'c'], + ['a span that ends at the end of a line', 'ab\ncd\n', loc(2, 2, 2, 3), 'd'], + ['the last line without a terminator', 'ab\ncd', loc(2, 1, 2, 3), 'cd'], + ['an empty span at the end of a line', 'ab\n', loc(1, 3, 1, 3), ''], + ['a multi-line span', 'ab\ncd\nef', loc(1, 2, 3, 2), 'b\ncd\ne'], + ['a span on a CRLF line', 'ab\r\ncd\r\n', loc(2, 1, 2, 3), 'cd'], + ['a multi-line span over CRLF', 'ab\r\ncd', loc(1, 2, 2, 2), 'b\r\nc'], + ['a span after a lone CR', 'ab\rcd', loc(2, 1, 2, 3), 'cd'], + ['a span after a line separator', 'ab
cd', loc(2, 2, 2, 3), 'd'], + ])('converts %s', (_label, text, location, expected) => { + const { start, end } = locationToOffsets(indexSource(text), location); + expect(text.slice(start, end)).toBe(expected); + }); + + it('returns 0-based offsets with an exclusive end', () => { + expect(locationToOffsets(indexSource('ab\r\ncd\r\n'), loc(2, 1, 2, 3))).toEqual({ + start: 4, + end: 6, + }); + }); + + it.each([ + ['line 0', 'ab\n', loc(0, 1, 1, 2)], + ['a line past the end', 'ab\n', loc(3, 1, 3, 1)], + ['column 0', 'ab\n', loc(1, 0, 1, 2)], + ['a column past the end of the line', 'ab\ncd', loc(1, 1, 1, 4)], + ['a column inside a CRLF terminator', 'ab\r\ncd', loc(1, 1, 1, 4)], + ])('rejects %s', (_label, text, location) => { + expect(() => locationToOffsets(indexSource(text), location)).toThrow(RangeError); + }); +}); + +describe('checkSpanMap', () => { + it('maps a mutant inside a blanked-assertion line to the same line and column', () => { + const { run, result } = check(TABLE, TABLE_PREPARED, [mutant('5', loc(1, 28, 1, 31))]); + expect(result).toEqual({ + checked: 1, + failures: [], + mapped: [ + { + localId: '5', + globalId: run.mutants[0].globalId, + start: 27, + end: 30, + originalText: "'b'", + preparedText: "'b'", + }, + ], + }); + }); + + it('passes a mutant whose span contains a whole blanked assertion', () => { + const { result } = check(CALL, CALL_PREPARED, [mutant('0', loc(1, 31, 3, 2), '{}')]); + expect(result.failures).toEqual([]); + expect(result.mapped.map((m) => [m.originalText, m.preparedText])).toEqual([ + ['{\n return g(x as string);\n}', '{\n return g(x );\n}'], + ]); + }); + + it('fails a mutant whose span text differs outside blanked ranges, naming its global id', () => { + const { run, result } = check('const a = 1;\n', 'const a = 2;\n', [ + mutant('3', loc(1, 11, 1, 12), '0'), + ]); + expect(result.mapped).toEqual([]); + expect(result.failures).toEqual([ + { + localId: '3', + globalId: run.mutants[0].globalId, + message: + `span map: mutant ${run.mutants[0].globalId} (local id 3): the original span text ` + + 'differs from the prepared span text at 1:11 outside blanked assertions ' + + '(original "1", prepared "2")', + }, + ]); + }); + + it('quotes only a window around the difference of a long span', () => { + const literal = (digit: string) => `const s = '${'a'.repeat(40)}${digit}${'b'.repeat(40)}';\n`; + const { result } = check(literal('1'), literal('2'), [mutant('6', loc(1, 11, 1, 94))]); + const window = (digit: string) => `…"${'a'.repeat(24)}${digit}${'b'.repeat(23)}"…`; + expect(result.failures.map((f) => f.message.replace(/^.*?\): /, ''))).toEqual([ + 'the original span text differs from the prepared span text at 1:52 outside blanked ' + + `assertions (original ${window('1')}, prepared ${window('2')})`, + ]); + }); + + it('fails a mutant when the files differ outside its span', () => { + const { run, result } = check('const a = 1;\nconst b = 2;\n', 'const a = 1;\nconst b = 3;\n', [ + mutant('8', loc(1, 11, 1, 12), '0'), + ]); + expect(result.failures.map((f) => f.message)).toEqual([ + `span map: mutant ${run.mutants[0].globalId} (local id 8): the original file differs ` + + 'from the prepared file at 2:11 outside blanked assertions', + ]); + }); + + it('fails every mutant when the prepared file has a different length', () => { + const { run, result } = check(TABLE, `// @ts-nocheck\n${TABLE_PREPARED}`, [ + mutant('0', loc(2, 23, 2, 26)), + mutant('1', loc(2, 28, 2, 31)), + ]); + expect(result.failures.map((f) => [f.globalId, f.message])).toEqual( + run.mutants.map((m) => [ + m.globalId, + `span map: mutant ${m.globalId} (local id ${m.localId}): the prepared file has 57 ` + + 'characters and the original 42, so columns do not map', + ]), + ); + }); + + it('fails a mutant whose span boundary cuts a blanked assertion', () => { + const { run, result } = check(TABLE, TABLE_PREPARED, [mutant('2', loc(1, 34, 1, 41), '')]); + expect(result.failures.map((f) => f.message)).toEqual([ + `span map: mutant ${run.mutants[0].globalId} (local id 2): the span start cuts a ` + + 'blanked assertion at 1:34', + ]); + }); + + it('fails a mutant whose location lies outside the file', () => { + const { run, result } = check(TABLE, TABLE_PREPARED, [mutant('4', loc(1, 23, 9, 1))]); + expect(result.failures.map((f) => f.message)).toEqual([ + `span map: mutant ${run.mutants[0].globalId} (local id 4): line 9 is outside the ` + + 'prepared file (2 lines)', + ]); + }); +}); + +describe('assertSpanMap', () => { + it('accepts a result without failures', () => { + const { result } = check(TABLE, TABLE_PREPARED, [mutant('0', loc(1, 23, 1, 26))]); + expect(() => assertSpanMap(result)).not.toThrow(); + }); + + it('throws with the count and every failing global id', () => { + const { run, result } = check('const a = 1;\n', 'const a = 2;\n', [ + mutant('0', loc(1, 11, 1, 12), '0'), + mutant('1', loc(1, 1, 1, 6), ''), + ]); + const ids = run.mutants.map((m) => m.globalId); + expect(() => assertSpanMap(result)).toThrow( + new RegExp(`2 of 2 mutants failed[\\s\\S]*${ids.map(escape).join('[\\s\\S]*')}`), + ); + }); +}); + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&'); +} diff --git a/gitnexus/test/unit/mutation/stryker-config.test.ts b/gitnexus/test/unit/mutation/stryker-config.test.ts new file mode 100644 index 000000000..e26582780 --- /dev/null +++ b/gitnexus/test/unit/mutation/stryker-config.test.ts @@ -0,0 +1,469 @@ +/** + * Locks the scope driver's generated Stryker config (U10, KTD4, KTD6, KTD10, KTD16, KTD18): mutate + * patterns point inside the prepared tree (relocated gitnexus-shared included), the vitest5 variant + * carries absolute run and sidecar directories, the command variant keeps its sandbox, and no + * generated config ever sets `testFiles`. A scope's `testIds` (Phase 1 KTD2) are validated against + * its test files, reach the runner as `vitest5.testIds`, and select exactly those tests in the dry + * run; an empty selection is rejected everywhere. + */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_IGNORED_DIRS, + assertTestFilesInSandbox, + buildStrykerConfig, + formatMutateEntry, + parseDriverScope, + parseMutateEntry, + parseVariant, + runLayout, + strykerMutatePattern, + type StrykerConfigInput, +} from '../../../tools/mutation/driver/stryker-config.js'; +import { buildPathMap } from '../../../tools/mutation/prepare/path-map.js'; +import { parseRunnerOptions } from '../../../tools/mutation/runner/plugin.js'; +import { + assignTestIds, + selectRequestedTests, + type CollectedTest, +} from '../../../tools/mutation/runner/test-ids.js'; + +const OUT = path.resolve('/tmp/gitnexus-u10-config-test/out'); +const SHARED = 'gitnexus-shared/src/lbug/schema-constants.ts'; +const LOCAL = 'gitnexus/src/core/ingestion/type-extractors/shared.ts'; + +const input = (overrides: Partial = {}): StrykerConfigInput => ({ + variant: 'vitest5', + outDir: OUT, + mutate: [parseMutateEntry(LOCAL), parseMutateEntry(SHARED)], + concurrency: 3, + maxTestRunnerReuse: 25, + timeoutMS: 10000, + ...overrides, +}); + +const commandInput = (overrides: Partial = {}): StrykerConfigInput => + input({ variant: 'command', command: 'node wrapper.mjs', ...overrides }); + +describe('runLayout', () => { + it('places every run artefact under the absolute output directory', () => { + const layout = runLayout(OUT); + expect(layout).toMatchObject({ + outDir: OUT, + preparedRoot: path.join(OUT, 'prepared'), + packageDir: path.join(OUT, 'prepared', 'gitnexus'), + pluginFile: path.join(OUT, 'prepared', 'gitnexus', 'tools/mutation/runner/plugin.ts'), + scopeFile: path.join(OUT, 'scope.json'), + configFile: path.join(OUT, 'stryker.config.json'), + reportFile: path.join(OUT, 'mutation.json'), + runDir: path.join(OUT, 'run'), + sidecarDir: path.join(OUT, 'sidecars'), + resultsFile: path.join(OUT, 'results.json'), + }); + }); + + it('rejects a relative output directory', () => { + expect(() => runLayout('tmp/out')).toThrow(/must be an absolute path/); + }); +}); + +describe('parseMutateEntry and strykerMutatePattern', () => { + it('maps a relocated gitnexus-shared path to a pattern inside the prepared tree', () => { + const layout = runLayout(OUT); + const pattern = strykerMutatePattern(parseMutateEntry(SHARED)); + expect(pattern).toBe('relocated/gitnexus-shared/src/lbug/schema-constants.ts'); + expect(path.resolve(layout.packageDir, pattern)).toBe( + path.join( + layout.preparedRoot, + 'gitnexus/relocated/gitnexus-shared/src/lbug/schema-constants.ts', + ), + ); + }); + + it.each([ + [`${LOCAL}`, 'src/core/ingestion/type-extractors/shared.ts'], + [`${LOCAL}:545-660`, 'src/core/ingestion/type-extractors/shared.ts:545-660'], + [`${SHARED}:3:5-9:1`, 'relocated/gitnexus-shared/src/lbug/schema-constants.ts:3:5-9:1'], + ])('keeps a Stryker line range: %s', (entry, pattern) => { + expect(strykerMutatePattern(parseMutateEntry(entry))).toBe(pattern); + }); + + it('splits the file from its range', () => { + expect(parseMutateEntry(`${LOCAL}:545-660`)).toEqual({ file: LOCAL, range: '545-660' }); + }); + + it.each([LOCAL, `${LOCAL}:545-660`, `${SHARED}:3:5-9:1`])( + 'formats the parsed entry %s back to its text', + (entry) => { + expect(formatMutateEntry(parseMutateEntry(entry))).toBe(entry); + }, + ); + + it.each([ + ['README.md', /\.ts source file/], + ['gitnexus/src/types.d.ts', /\.ts source file/], + ['gitnexus/test/unit/schema.test.ts', /outside the mapped roots/], + ['/abs/gitnexus/src/a.ts', /not a relative path inside the root/], + [`${LOCAL}:0-4`, /line range/], + [`${LOCAL}:9-4`, /line range/], + [`${LOCAL}:4-`, /\.ts source file/], + ])('rejects %s', (entry, message) => { + expect(() => parseMutateEntry(entry)).toThrow(message); + }); +}); + +describe('mutate entries under an extra root (fixture projects)', () => { + const FIXTURE_ROOT = 'gitnexus/test/fixtures/mutation-conformance'; + const FIXTURE = `${FIXTURE_ROOT}/src/static-table.ts`; + const map = buildPathMap([FIXTURE_ROOT]); + + it('accepts a fixture file and maps it to its package-relative pattern', () => { + expect(strykerMutatePattern(parseMutateEntry(`${FIXTURE}:3-9`, map), map)).toBe( + 'test/fixtures/mutation-conformance/src/static-table.ts:3-9', + ); + }); + + it('rejects the fixture file under the default map', () => { + expect(() => parseMutateEntry(FIXTURE)).toThrow(/outside the mapped roots/); + }); + + it('parses a driver scope with fixture files under the extended map only', () => { + const text = JSON.stringify({ + testFiles: ['test/fixtures/mutation-conformance/a.case.ts'], + mutate: [FIXTURE], + }); + expect(parseDriverScope(text, map).mutate).toEqual([{ file: FIXTURE, range: null }]); + expect(() => parseDriverScope(text)).toThrow(/outside the mapped roots/); + }); +}); + +describe('parseDriverScope', () => { + it('parses package-relative test files and repo-relative mutate entries', () => { + const scope = parseDriverScope( + JSON.stringify({ testFiles: ['test/unit/schema.test.ts'], mutate: [`${SHARED}:1-40`] }), + ); + expect(scope).toEqual({ + testFiles: ['test/unit/schema.test.ts'], + mutate: [{ file: SHARED, range: '1-40' }], + }); + }); + + it.each([ + ['missing testFiles', { mutate: [LOCAL] }, /testFiles must be a non-empty array/], + ['missing mutate', { testFiles: ['test/unit/a.test.ts'] }, /mutate must be a non-empty array/], + ['empty mutate', { testFiles: ['test/unit/a.test.ts'], mutate: [] }, /mutate must be/], + [ + 'a non-string mutate entry', + { testFiles: ['test/unit/a.test.ts'], mutate: [3] }, + /mutate entries must be strings/, + ], + [ + 'a repeated mutate entry', + { testFiles: ['test/unit/a.test.ts'], mutate: [LOCAL, LOCAL] }, + /listed twice/, + ], + [ + 'an unknown key', + { testFiles: ['test/unit/a.test.ts'], mutate: [LOCAL], testFile: [] }, + /unknown key "testFile"/, + ], + ['a non-object', [LOCAL], /must be a JSON object/], + ])('rejects %s', (_label, value, message) => { + expect(() => parseDriverScope(JSON.stringify(value))).toThrow(message); + }); + + it('rejects text that is not JSON', () => { + expect(() => parseDriverScope('{')).toThrow(/not valid JSON/); + }); +}); + +describe('parseDriverScope: testIds (single-test selection, KTD2)', () => { + const FILE = 'test/unit/a.test.ts'; + const OTHER = 'test/unit/b.test.ts'; + /** Five collected tests of one file, in collection order. */ + const FIVE: readonly CollectedTest[] = ['one', 'two', 'three', 'four', 'five'].map( + (name, index) => ({ + taskId: `h_${index}`, + fullName: `A > ${name}`, + location: { line: index + 3, column: 3 }, + }), + ); + const PICKED = [`${FILE}#A > two`, `${FILE}#A > four`]; + const scopeText = (testIds: unknown): string => + JSON.stringify({ testFiles: [FILE, OTHER], mutate: [LOCAL], testIds }); + + it('keeps the requested ids of two of five tests in one file', () => { + expect(parseDriverScope(scopeText(PICKED)).testIds).toEqual(PICKED); + }); + + it('leaves testIds out when the scope file has none', () => { + const scope = parseDriverScope(JSON.stringify({ testFiles: [FILE], mutate: [LOCAL] })); + expect('testIds' in scope).toBe(false); + }); + + it('carries the ids into the generated config, whose dry-run selection holds exactly those two tests', () => { + const scope = parseDriverScope(scopeText(PICKED)); + const config = buildStrykerConfig(input({ testIds: scope.testIds })); + const options = parseRunnerOptions(config.vitest5); + const selection = selectRequestedTests( + new Map([[FILE, assignTestIds(FILE, FIVE)]]), + options.testIds ?? [], + ); + expect([config.vitest5?.testIds, selection]).toEqual([ + PICKED, + { files: [{ file: FILE, taskIds: ['h_1', 'h_3'] }], unknown: [] }, + ]); + }); + + it.each([ + ['an empty array', [], /testIds must be a non-empty array/], + ['a non-array', `${FILE}#A > two`, /testIds must be a non-empty array/], + ['a non-string entry', [3], /testIds entries must be strings/], + ['a repeated id', [PICKED[0], PICKED[0]], /listed twice/], + ['an id without a file part', ['A > two'], /"A > two" is not a test id/], + ])('rejects %s', (_label, testIds, message) => { + expect(() => parseDriverScope(scopeText(testIds))).toThrow(message); + }); + + it('rejects an id of a file outside the scope, naming the id', () => { + const stray = 'test/unit/c.test.ts#C > three'; + expect(() => parseDriverScope(scopeText([PICKED[0], stray]))).toThrow( + `test id "${stray}" names no test file of the scope`, + ); + }); +}); + +describe('buildStrykerConfig: testIds', () => { + const ids = ['test/unit/a.test.ts#A > two']; + + it('passes no testIds to the runner when the scope selects none', () => { + expect(buildStrykerConfig(input()).vitest5).not.toHaveProperty('testIds'); + }); + + it.each([ + ['an empty selection', input({ testIds: [] }), /testIds must not be empty/], + ['a selection for the command variant', commandInput({ testIds: ids }), /only for the vitest5/], + ])('rejects %s', (_label, configInput, message) => { + expect(() => buildStrykerConfig(configInput)).toThrow(message); + }); +}); + +describe('parseRunnerOptions: testIds', () => { + const base = { runDir: '/run', sidecarDir: '/sidecars' }; + + it('reads no selection as null', () => { + expect(parseRunnerOptions(base).testIds).toBeNull(); + }); + + it.each([ + ['an empty array', []], + ['a non-array', 'test/unit/a.test.ts#A > two'], + ['a non-string entry', [7]], + ['an id without a file part', ['A > two']], + ['a repeated id', ['test/unit/a.test.ts#A', 'test/unit/a.test.ts#A']], + ])('rejects %s', (_label, testIds) => { + expect(() => parseRunnerOptions({ ...base, testIds })).toThrow( + /vitest5\.testIds must be a non-empty array of distinct test ids/, + ); + }); +}); + +describe('parseVariant', () => { + it.each(['vitest5', 'command'] as const)('accepts %s', (variant) => { + expect(parseVariant(variant)).toBe(variant); + }); + + it('rejects an unknown variant', () => { + expect(() => parseVariant('jest')).toThrow(/unknown variant "jest"/); + }); +}); + +describe('buildStrykerConfig: vitest5 variant', () => { + const layout = runLayout(OUT); + const config = buildStrykerConfig(input()); + + it('loads the runner plugin from the prepared copy and runs it under tsx', () => { + expect(config).toMatchObject({ + testRunner: 'vitest5', + appendPlugins: [layout.pluginFile], + testRunnerNodeArgs: ['--import', 'tsx'], + checkerNodeArgs: ['--import', 'tsx'], + coverageAnalysis: 'perTest', + disableBail: true, + disableTypeChecks: false, + }); + }); + + it('sets the run and sidecar directories as absolute paths', () => { + expect(config.vitest5).toEqual({ runDir: layout.runDir, sidecarDir: layout.sidecarDir }); + expect([config.vitest5?.runDir, config.vitest5?.sidecarDir].map(path.isAbsolute)).toEqual([ + true, + true, + ]); + }); + + it('points tsconfigFile at a file that does not exist', () => { + expect(path.isAbsolute(config.tsconfigFile)).toBe(true); + expect(existsSync(config.tsconfigFile)).toBe(false); + }); + + it('passes the scheduling profile through', () => { + expect(config).toMatchObject({ concurrency: 3, maxTestRunnerReuse: 25, timeoutMS: 10000 }); + }); + + it('writes the JSON report to the layout report file', () => { + expect(config.reporters).toContain('json'); + expect(config.jsonReporter).toEqual({ fileName: layout.reportFile }); + }); + + it('maps every mutate entry inside the prepared tree', () => { + expect(config.mutate).toEqual([ + 'src/core/ingestion/type-extractors/shared.ts', + 'relocated/gitnexus-shared/src/lbug/schema-constants.ts', + ]); + }); + + it('keeps vendor, native and build output out of the sandbox, but not the relocated tree', () => { + expect(config.ignorePatterns).toEqual( + expect.arrayContaining(['/vendor', '/prebuilds', '/dist']), + ); + expect(config.ignorePatterns).toEqual(DEFAULT_IGNORED_DIRS.map((dir) => `/${dir}`)); + expect(config.ignorePatterns.some((pattern) => pattern.includes('relocated'))).toBe(false); + }); + + it('cleans the sandbox after a successful run', () => { + expect(config.cleanTempDir).toBe(true); + }); +}); + +describe('buildStrykerConfig: command variant', () => { + const config = buildStrykerConfig(commandInput()); + + it('keeps the sandbox: cleanTempDir is false', () => { + expect(config.cleanTempDir).toBe(false); + }); + + it('runs the given command with coverage analysis off and without the runner plugin', () => { + expect(config).toMatchObject({ + testRunner: 'command', + commandRunner: { command: 'node wrapper.mjs' }, + coverageAnalysis: 'off', + }); + expect(config.appendPlugins).toBeUndefined(); + expect(config.vitest5).toBeUndefined(); + }); + + it('sets no test-runner node args, which the command runner ignores, but keeps the checker args', () => { + expect(config.testRunnerNodeArgs).toBeUndefined(); + expect(config.checkerNodeArgs).toEqual(['--import', 'tsx']); + }); +}); + +describe('buildStrykerConfig: never sets testFiles', () => { + it.each([ + ['vitest5', input()], + ['command', commandInput()], + ])('%s', (_variant, configInput) => { + const config = buildStrykerConfig(configInput); + expect(Object.keys(config)).not.toContain('testFiles'); + expect(JSON.stringify(config)).not.toContain('testFiles'); + }); +}); + +describe('buildStrykerConfig: input errors', () => { + it.each([ + ['a relative output directory', input({ outDir: 'relative/out' }), /absolute path/], + ['no mutate entry', input({ mutate: [] }), /at least one mutate entry/], + ['concurrency 0', input({ concurrency: 0 }), /concurrency must be a positive integer/], + ['a fractional concurrency', input({ concurrency: 1.5 }), /concurrency must be/], + [ + 'a negative maxTestRunnerReuse', + input({ maxTestRunnerReuse: -1 }), + /maxTestRunnerReuse must be a non-negative integer/, + ], + ['timeoutMS 0', input({ timeoutMS: 0 }), /timeoutMS must be a positive integer/], + ['a command for vitest5', input({ command: 'true' }), /only for the command variant/], + ['no command for the command variant', commandInput({ command: undefined }), /needs a command/], + ['an empty command', commandInput({ command: '' }), /needs a command/], + ])('rejects %s', (_label, configInput, message) => { + expect(() => buildStrykerConfig(configInput)).toThrow(message); + }); + + it('rejects an unknown variant that bypassed the type', () => { + const variant = parseVariantUnchecked('karma'); + expect(() => buildStrykerConfig({ ...input(), variant })).toThrow(/unknown variant "karma"/); + }); +}); + +describe('buildStrykerConfig: fixture projects', () => { + const FIXTURE_ROOT = 'gitnexus/test/fixtures/mutation-conformance'; + const map = buildPathMap([FIXTURE_ROOT]); + const fixtureMutate = [parseMutateEntry(`${FIXTURE_ROOT}/src/runtime.ts`, map)]; + const fixtureIgnored = DEFAULT_IGNORED_DIRS.filter((dir) => dir !== 'test/fixtures'); + const vitest = { + configFile: 'test/fixtures/mutation-conformance/vitest.config.ts', + project: 'conformance', + }; + + it('passes the Vitest config file and project to the vitest5 runner', () => { + const config = buildStrykerConfig(input({ vitest })); + expect(config.vitest5).toEqual({ + runDir: runLayout(OUT).runDir, + sidecarDir: runLayout(OUT).sidecarDir, + ...vitest, + }); + }); + + it('maps fixture mutate entries through the given path map', () => { + const config = buildStrykerConfig( + input({ mutate: fixtureMutate, pathMap: map, ignoredDirs: fixtureIgnored }), + ); + expect(config.mutate).toEqual(['test/fixtures/mutation-conformance/src/runtime.ts']); + }); + + it('rejects a mutate file that an ignored directory keeps out of the sandbox', () => { + expect(() => buildStrykerConfig(input({ mutate: fixtureMutate, pathMap: map }))).toThrow( + /mutate file .* lies under the ignored directory test\/fixtures\//, + ); + }); + + it.each([ + ['a Vitest config for the command variant', commandInput({ vitest }), /only for the vitest5/], + [ + 'an absolute Vitest config path', + input({ vitest: { ...vitest, configFile: '/abs/vitest.config.ts' } }), + /package-relative POSIX path/, + ], + [ + 'a Vitest config path that climbs out of the package', + input({ vitest: { ...vitest, configFile: '../vitest.config.ts' } }), + /package-relative POSIX path/, + ], + ['an empty project name', input({ vitest: { ...vitest, project: '' } }), /project name/], + ])('rejects %s', (_label, configInput, message) => { + expect(() => buildStrykerConfig(configInput)).toThrow(message); + }); +}); + +describe('assertTestFilesInSandbox', () => { + it('accepts test files outside the ignored directories', () => { + expect(() => + assertTestFilesInSandbox(['test/unit/schema.test.ts'], DEFAULT_IGNORED_DIRS), + ).not.toThrow(); + }); + + it.each(['test/fixtures/mutation-x/a.case.ts', 'test/integration/b.test.ts', 'bench/c.test.ts'])( + 'rejects %s, which the sandbox would not contain', + (file) => { + expect(() => assertTestFilesInSandbox([file], DEFAULT_IGNORED_DIRS)).toThrow( + /lies under the ignored directory/, + ); + }, + ); +}); + +/** Simulates an unvalidated CLI value reaching the generator. */ +function parseVariantUnchecked(value: string): StrykerConfigInput['variant'] { + return value as StrykerConfigInput['variant']; +} diff --git a/gitnexus/test/unit/mutation/test-ids.test.ts b/gitnexus/test/unit/mutation/test-ids.test.ts new file mode 100644 index 000000000..57def31c1 --- /dev/null +++ b/gitnexus/test/unit/mutation/test-ids.test.ts @@ -0,0 +1,241 @@ +/** + * Locks the Stryker test identity of the vitest5 runner (KTD3): `#`, + * parsed on the first `#`, with a `::` suffix for duplicate full names and a + * `:::` suffix for rows of one `it.each`/`test.for` call that still collide. + * The dry run's `testIds` selection maps requested ids to task ids and checks the executed set. + */ +import { describe, expect, it } from 'vitest'; +import { + assignTestIds, + assignPortableTestIds, + formatTestId, + parseTestId, + selectRequestedTests, + selectionProblems, + type CollectedTest, +} from '../../../tools/mutation/runner/test-ids.js'; + +const FILE = 'test/unit/shared-type-extractors.test.ts'; + +const collected = ( + taskId: string, + fullName: string, + line: number, + column: number, +): CollectedTest => ({ taskId, fullName, location: { line, column } }); + +describe('portable test identity', () => { + it('matches coverage, trace and Stryker copies while retaining the runtime title', () => { + const roots = [ + '/work/coverage/prepared/gitnexus', + '/work/trace/prepared/gitnexus', + '/work/.stryker-tmp/sandbox-1', + ]; + const runs = roots.map((root) => { + const fullName = `hooks > reads ${root}/hooks/registry.cjs`; + const [test] = assignPortableTestIds(FILE, [collected('task', fullName, 20, 3)], root); + expect(test.fullName).toBe(fullName); + return test.id; + }); + expect(new Set(runs).size).toBe(1); + }); + + it('keeps sibling plugin paths and package paths distinct across prepared copies', () => { + const runs = ['/coverage/prepared', '/trace/prepared'].map((root) => + assignPortableTestIds( + FILE, + ['gitnexus', 'gitnexus-claude-plugin'].map((dir, i) => + collected(`task-${i}`, `reads ${root}/${dir}/hooks/registry.cjs`, 20, 3), + ), + `${root}/gitnexus`, + ).map((test) => test.id), + ); + expect(runs[0]).toEqual(runs[1]); + expect(new Set(runs[0]).size).toBe(2); + }); + + it('does not rewrite other absolute paths or a root prefix in a sibling directory', () => { + const tests = [collected('a', 'reads /outside/a and /copycat/gitnexus/a', 1, 1)]; + expect(assignPortableTestIds(FILE, tests, '/copy/gitnexus')).toEqual( + assignTestIds(FILE, tests), + ); + }); + + it('retains collision suffixes when normalization makes titles equal', () => { + const tests = [ + collected('a', 'reads /copy/gitnexus/a', 1, 1), + collected('b', 'reads /a', 2, 1), + ]; + const ids = assignPortableTestIds(FILE, tests, '/copy/gitnexus'); + expect(ids.map((test) => test.id)).toEqual([ + `${FILE}#reads /a:1:1`, + `${FILE}#reads /a:2:1`, + ]); + }); +}); + +describe('formatTestId and parseTestId', () => { + it('joins the file and the full name with the first #', () => { + expect(formatTestId(FILE, 'describe A > it b (#2415)')).toBe( + `${FILE}#describe A > it b (#2415)`, + ); + }); + + it.each([ + ['a title with an issue reference', 'describe A > it b (#2415)'], + ['a title that contains the separator twice', 'a # b > c#d'], + ['a title containing " > "', 'outer > inner > maps a > b to c'], + ['a title that ends with a location-like suffix', 'parses x:12:3'], + ])('round-trips %s', (_label, name) => { + const id = formatTestId(FILE, name); + + expect(parseTestId(id)).toEqual({ file: FILE, name }); + }); + + it('splits on the first # only', () => { + expect(parseTestId('test/a.test.ts#A > b (#1) and #2')).toEqual({ + file: 'test/a.test.ts', + name: 'A > b (#1) and #2', + }); + }); + + it('rejects an id without a separator', () => { + expect(() => parseTestId('test/a.test.ts')).toThrow(/no "#"/); + }); + + it('rejects a file path that contains the separator', () => { + expect(() => formatTestId('test/a#b.test.ts', 'x')).toThrow(/contains "#"/); + }); +}); + +describe('assignTestIds', () => { + it('uses the plain full name when it is unique in the file', () => { + expect( + assignTestIds(FILE, [collected('f_0', 'A > one', 3, 5), collected('f_1', 'A > two', 4, 5)]), + ).toEqual([ + { + taskId: 'f_0', + fullName: 'A > one', + location: { line: 3, column: 5 }, + id: `${FILE}#A > one`, + notOracleValidated: false, + }, + { + taskId: 'f_1', + fullName: 'A > two', + location: { line: 4, column: 5 }, + id: `${FILE}#A > two`, + notOracleValidated: false, + }, + ]); + }); + + it('suffixes duplicate full names with their location', () => { + const ids = assignTestIds(FILE, [ + collected('f_0', 'A > same', 3, 5), + collected('f_1', 'A > same', 9, 7), + collected('f_2', 'A > other', 12, 5), + ]); + + expect(ids.map((test) => [test.id, test.notOracleValidated])).toEqual([ + [`${FILE}#A > same:3:5`, false], + [`${FILE}#A > same:9:7`, false], + [`${FILE}#A > other`, false], + ]); + }); + + it('adds the row ordinal of the call when name and location collide, and flags those rows', () => { + const ids = assignTestIds(FILE, [ + collected('f_0', 'each > truncated row title', 20, 3), + collected('f_1', 'each > distinct row', 20, 3), + collected('f_2', 'each > truncated row title', 20, 3), + ]); + + expect(ids.map((test) => [test.id, test.notOracleValidated])).toEqual([ + [`${FILE}#each > truncated row title:20:3:0`, true], + [`${FILE}#each > distinct row`, false], + [`${FILE}#each > truncated row title:20:3:2`, true], + ]); + }); + + it('keeps every assigned id parseable back to its file and suffixed name', () => { + const [first] = assignTestIds(FILE, [ + collected('f_0', 'A > b (#2415)', 3, 5), + collected('f_1', 'A > b (#2415)', 4, 5), + ]); + + expect(parseTestId(first.id)).toEqual({ file: FILE, name: 'A > b (#2415):3:5' }); + }); + + it('refuses a duplicate full name without a location', () => { + expect(() => + assignTestIds(FILE, [ + { taskId: 'f_0', fullName: 'A > same', location: undefined }, + { taskId: 'f_1', fullName: 'A > same', location: undefined }, + ]), + ).toThrow(/includeTaskLocation/); + }); + + it('refuses ids that still collide after suffixing', () => { + expect(() => + assignTestIds(FILE, [ + collected('f_0', 'A > x:3:5', 1, 1), + collected('f_1', 'A > x', 3, 5), + collected('f_2', 'A > x', 4, 5), + ]), + ).toThrow(/not unique/); + }); + + it('refuses a task id listed twice', () => { + expect(() => + assignTestIds(FILE, [collected('f_0', 'A > one', 3, 5), collected('f_0', 'A > two', 4, 5)]), + ).toThrow(/task id f_0/); + }); +}); + +describe('selectRequestedTests (dry-run selection by testIds)', () => { + const OTHER = 'test/unit/other.test.ts'; + const identified = new Map([ + [ + FILE, + assignTestIds(FILE, [ + collected('f_0', 'A > one', 3, 5), + collected('f_1', 'A > two', 4, 5), + collected('f_2', 'A > three', 5, 5), + ]), + ], + [OTHER, assignTestIds(OTHER, [collected('g_0', 'B > one', 3, 5)])], + ]); + + it('maps the requested ids to task ids per file, in collection order, and leaves out files with none', () => { + expect(selectRequestedTests(identified, [`${FILE}#A > three`, `${FILE}#A > one`])).toEqual({ + files: [{ file: FILE, taskIds: ['f_0', 'f_2'] }], + unknown: [], + }); + }); + + it('reports every requested id that no collected test has', () => { + expect( + selectRequestedTests(identified, [`${OTHER}#B > one`, `${FILE}#A > four`, 'x.test.ts#y']), + ).toEqual({ + files: [{ file: OTHER, taskIds: ['g_0'] }], + unknown: [`${FILE}#A > four`, 'x.test.ts#y'], + }); + }); +}); + +describe('selectionProblems (the executed set must equal the request)', () => { + const ONE = `${FILE}#A > one`; + const TWO = `${FILE}#A > two`; + + it('accepts a run that executed exactly the requested tests, in any order', () => { + expect(selectionProblems([ONE, TWO], [TWO, ONE])).toEqual([]); + }); + + it('names a requested test that did not run and a test that ran unrequested', () => { + expect(selectionProblems([ONE], [TWO])).toEqual([ + `requested test ${JSON.stringify(ONE)} did not run (vitest5.testIds)`, + `test ${JSON.stringify(TWO)} ran but vitest5.testIds does not request it`, + ]); + }); +}); diff --git a/gitnexus/test/unit/mutation/version-guard.test.ts b/gitnexus/test/unit/mutation/version-guard.test.ts new file mode 100644 index 000000000..52d33ed91 --- /dev/null +++ b/gitnexus/test/unit/mutation/version-guard.test.ts @@ -0,0 +1,105 @@ +/** + * Locks the vitest5 runner's version guard (KTD2): the Vitest resolved from the project root must + * be 5.0.x, and the plugin location must resolve the same Vitest copy. + */ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + assertSupportedVitest, + checkVitestInstalls, + resolveVitestInstall, + type VitestInstall, +} from '../../../tools/mutation/runner/version-guard.js'; + +const install = (version: string, packageJsonPath = '/repo/node_modules/vitest/package.json') => + ({ version, packageJsonPath }) satisfies VitestInstall; + +describe('checkVitestInstalls', () => { + it('accepts Vitest 5.0.5 resolved to one copy from both locations', () => { + expect(checkVitestInstalls(install('5.0.5'), install('5.0.5'))).toEqual(install('5.0.5')); + }); + + it.each(['5.1.0', '4.1.12', '5.0.5-beta.1', '6.0.0'])('rejects Vitest %s', (version) => { + expect(() => checkVitestInstalls(install(version), install(version))).toThrow(/5\.0\.x/); + }); + + it('rejects a second Vitest copy resolved from the plugin location', () => { + expect(() => + checkVitestInstalls( + install('5.0.5'), + install('5.0.5', '/plugin/node_modules/vitest/package.json'), + ), + ).toThrow(/second Vitest copy/); + }); + + it('rejects a project root that resolves no Vitest', () => { + expect(() => checkVitestInstalls(undefined, install('5.0.5'))).toThrow(/project root/); + }); + + it('rejects a plugin location that resolves no Vitest', () => { + expect(() => checkVitestInstalls(install('5.0.5'), undefined)).toThrow(/plugin location/); + }); +}); + +describe('resolveVitestInstall and assertSupportedVitest', () => { + let root: string; + + const fakeVitest = (dir: string, version: string): void => { + const pkgDir = path.join(dir, 'node_modules', 'vitest'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'vitest', version })); + }; + + beforeEach(() => { + root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'gitnexus-version-guard-'))); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('reads the version and real path of the Vitest a directory resolves', () => { + const project = path.join(root, 'project'); + fakeVitest(project, '5.0.5'); + + expect(resolveVitestInstall(path.join(project, 'test'))).toEqual({ + version: '5.0.5', + packageJsonPath: path.join(project, 'node_modules', 'vitest', 'package.json'), + }); + }); + + it('returns undefined when no Vitest resolves', () => { + const empty = path.join(root, 'empty'); + mkdirSync(empty); + + expect(resolveVitestInstall(empty)).toBeUndefined(); + }); + + it('accepts a project whose node_modules is a symlink to the plugin copy', () => { + const checkout = path.join(root, 'checkout'); + const prepared = path.join(root, 'prepared'); + fakeVitest(checkout, '5.0.5'); + mkdirSync(prepared); + symlinkSync(path.join(checkout, 'node_modules'), path.join(prepared, 'node_modules'), 'dir'); + + expect(assertSupportedVitest(prepared, path.join(checkout, 'tools')).version).toBe('5.0.5'); + }); + + it('rejects a plugin that resolves its own Vitest copy', () => { + const project = path.join(root, 'project'); + const plugin = path.join(root, 'plugin'); + fakeVitest(project, '5.0.5'); + fakeVitest(plugin, '5.0.5'); + + expect(() => assertSupportedVitest(project, plugin)).toThrow(/second Vitest copy/); + }); + + it('rejects a project that resolves Vitest 5.1.0', () => { + const project = path.join(root, 'project'); + fakeVitest(project, '5.1.0'); + + expect(() => assertSupportedVitest(project, project)).toThrow(/5\.1\.0/); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/baseline.test.ts b/gitnexus/test/unit/scenario-map/baseline.test.ts new file mode 100644 index 000000000..7e11f5205 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/baseline.test.ts @@ -0,0 +1,177 @@ +/** + * Locks the week-over-week comparison of the scenario map report (U13, R12, KTD13, design §5.5): + * new, changed, unchanged and resolved items keyed by continuity anchors, the unique-only body + * fallback for moved tests, the "first run, no baseline" and "incompatible baseline" banners (a + * baseline that could not be read included), and + * a report compared with itself and with a copy whose one evidence hash differs. + */ +import { describe, expect, it } from 'vitest'; +import { + BANNERS, + UnreadableBaseline, + compareWithBaseline, + itemKey, + type ContinuityItem, + type ContinuitySection, +} from '../../../tools/scenario-map/report/baseline.js'; +import { buildReport } from '../../../tools/scenario-map/report/report.js'; +import { SCENARIO_MAP_SCHEMA_VERSION, type TestAnchor } from '../../../tools/scenario-map/types.js'; +import { reportInput } from './report-fixtures.js'; + +const anchorOf = (key: string, body: string): TestAnchor => ({ + key, + file: 'test/unit/p.test.ts', + describePath: ['p'], + title: key, + rowFingerprint: 'row-0', + bodyFingerprint: body, +}); +const X = anchorOf('x', 'body-x'); +const Y = anchorOf('y', 'body-y'); + +const pairItem = (members: readonly string[], evidenceHash: string): ContinuityItem => ({ + kind: 'pair', + key: itemKey('pair', members), + members: [...members].sort(), + evidenceHash, + label: members.join(' + '), +}); +const P = (evidenceHash: string): ContinuityItem => pairItem(['x', 'y'], evidenceHash); +const section = (items: ContinuityItem[], anchors: TestAnchor[] = [X, Y]): ContinuitySection => ({ + anchors, + items, +}); +const previousReport = ( + continuity: ContinuitySection, + schemaVersion: number = SCENARIO_MAP_SCHEMA_VERSION, +): unknown => ({ schemaVersion, run: { id: '1000' }, continuity }); + +describe('compareWithBaseline', () => { + it('keeps a pair with the same evidence hash unchanged, out of "new or changed"', () => { + const result = compareWithBaseline(section([P('h1')]), previousReport(section([P('h1')]))); + expect(result.changes).toEqual([ + { kind: 'pair', key: P('h1').key, status: 'unchanged', previous: P('h1').key }, + ]); + expect(result.changes.filter((change) => change.status !== 'unchanged')).toEqual([]); + expect(result.counts).toEqual({ new: 0, changed: 0, unchanged: 1, resolved: 0 }); + }); + + it('marks a pair whose evidence hash differs as changed', () => { + const result = compareWithBaseline(section([P('h2')]), previousReport(section([P('h1')]))); + expect(result.changes.map((change) => change.status)).toEqual(['changed']); + expect(result.counts).toEqual({ new: 0, changed: 1, unchanged: 0, resolved: 0 }); + }); + + it('counts a pair found only in the previous report as resolved', () => { + const result = compareWithBaseline(section([]), previousReport(section([P('h1')]))); + expect(result.resolved).toEqual([ + { kind: 'pair', key: P('h1').key, label: 'x + y', evidenceHash: 'h1' }, + ]); + expect(result.counts).toEqual({ new: 0, changed: 0, unchanged: 0, resolved: 1 }); + }); + + it('marks every item new under the "first run, no baseline" banner without a baseline', () => { + const gap: ContinuityItem = { + kind: 'gap', + key: itemKey('gap', [], 'gitnexus/src/a.ts'), + members: [], + evidenceHash: 'h3', + label: 'gitnexus/src/a.ts', + }; + const result = compareWithBaseline(section([P('h1'), gap]), null); + expect(result).toMatchObject({ + state: 'first-run', + banner: 'first run, no baseline', + counts: { new: 2, changed: 0, unchanged: 0, resolved: 0 }, + }); + expect(result.changes.map((change) => change.status)).toEqual(['new', 'new']); + }); + + it('shows the "incompatible baseline" banner and no delta for an older schema version', () => { + const older = previousReport(section([P('h1')]), SCENARIO_MAP_SCHEMA_VERSION - 1); + expect(compareWithBaseline(section([P('h2')]), older)).toMatchObject({ + state: 'incompatible', + banner: BANNERS.incompatible, + counts: null, + changes: [], + resolved: [], + }); + }); + + it('treats a baseline without a readable continuity section as incompatible', () => { + const broken = { schemaVersion: SCENARIO_MAP_SCHEMA_VERSION, continuity: { anchors: 'x' } }; + expect(compareWithBaseline(section([P('h1')]), broken)).toMatchObject({ + state: 'incompatible', + reason: 'baseline has no readable continuity', + counts: null, + }); + }); + + it('treats a baseline that could not be read as incompatible, with the read error', () => { + const unreadable = new UnreadableBaseline('Unexpected end of JSON input'); + expect(compareWithBaseline(section([P('h1')]), unreadable)).toMatchObject({ + state: 'incompatible', + banner: BANNERS.incompatible, + reason: 'unreadable baseline: Unexpected end of JSON input', + baseline: { runId: null, schemaVersion: null }, + counts: null, + changes: [], + }); + }); + + it('follows a moved test through its unique body fingerprint', () => { + const moved = anchorOf('x-moved', 'body-x'); + const current = section([pairItem(['x-moved', 'y'], 'h1')], [moved, Y]); + const result = compareWithBaseline(current, previousReport(section([P('h1')]))); + expect(result.changes).toEqual([ + { + kind: 'pair', + key: itemKey('pair', ['x-moved', 'y']), + status: 'unchanged', + previous: P('h1').key, + }, + ]); + expect(result.counts?.resolved).toBe(0); + }); + + it('never follows a body that two current tests share', () => { + const twinA = anchorOf('x-a', 'body-x'); + const twinB = anchorOf('x-b', 'body-x'); + const current = section( + [pairItem(['x-a', 'y'], 'h1'), pairItem(['x-b', 'y'], 'h1')], + [twinA, twinB, Y], + ); + const result = compareWithBaseline(current, previousReport(section([P('h1')]))); + expect(result.counts).toEqual({ new: 2, changed: 0, unchanged: 0, resolved: 1 }); + }); +}); + +describe('a built report against a baseline', () => { + it('finds every item unchanged against itself', () => { + const first = buildReport(reportInput()); + const again = buildReport(reportInput({ overrides: { baseline: first } })); + expect(again.changes.counts).toEqual({ + new: 0, + changed: 0, + unchanged: first.continuity.items.length, + resolved: 0, + }); + expect(again.pairs.map((pair) => pair.change)).toEqual(['unchanged', 'unchanged']); + }); + + it('finds exactly the item whose evidence hash was altered changed', () => { + const first = buildReport(reportInput()); + const altered = structuredClone(first); + altered.continuity.items[0] = { ...altered.continuity.items[0], evidenceHash: 'e-altered' }; + const again = buildReport(reportInput({ overrides: { baseline: altered } })); + expect(again.changes.counts).toEqual({ + new: 0, + changed: 1, + unchanged: first.continuity.items.length - 1, + resolved: 0, + }); + expect(again.changes.changes.filter((change) => change.status === 'changed')).toEqual([ + expect.objectContaining({ key: first.continuity.items[0].key }), + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/bind.test.ts b/gitnexus/test/unit/scenario-map/bind.test.ts new file mode 100644 index 000000000..7f600a757 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/bind.test.ts @@ -0,0 +1,241 @@ +/** + * Locks the test side of the value-flow check (U10, R9, KTD11 "Test side (Babel)", design §5.3a + * step 1): each differing discarded value of a pair (real U6 facets) is bound to where it enters + * production code, a call argument (callee and argument index), a field of a fixture object or + * array, or a table key (an `it.each` row value), following `const` bindings. A differing expected + * value follows the input that produced it; when only expected values differ the pair is unknown + * (`expected-only`). A value transformed by test code, used nowhere, or bound differently by the + * two tests is unknown. + */ +import { describe, expect, it } from 'vitest'; +import { + computeFacet, + createFacetContext, + type FacetSources, + type FacetSubject, +} from '../../../tools/scenario-map/facet/input-facet.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import { + bindPair, + bindSideOf, + type BindResult, + type BindSide, +} from '../../../tools/scenario-map/value-flow/bind.js'; + +const TEST_FILE = 'gitnexus/test/unit/a.test.ts'; +const PACKAGE_FILE = 'test/unit/a.test.ts'; +const M = 'gitnexus/src/m.ts'; +const PRODUCTION = [ + 'export function roundScore(raw: number): number { return Math.round(raw); }', + 'export function formatTags(tags: readonly string[]): string { return tags.join(); }', + 'export function configure(options: { ratio: number }): number { return options.ratio; }', + 'export class Box { constructor(readonly size: number) {} }', + 'export function pick(first: number, second: number): number { return first + second; }', +].join('\n'); +const IMPORTS = `import { Box, configure, formatTags, pick, roundScore } from '../../src/m.js';`; + +const memorySources = (files: Record): FacetSources => ({ + read: (file) => files[file] ?? null, + list: (dir) => + Object.keys(files) + .filter((file) => file.startsWith(`${dir}/`)) + .sort(), +}); + +/** The two bind sides of the instances written in `body`, with real U6 facets. */ +async function sidesOf(body: string): Promise<[BindSide, BindSide]> { + const source = [IMPORTS, `describe('d', () => {`, body, `});`].join('\n'); + const sites = expandCases([{ path: TEST_FILE, source }]).sites; + const ctx = createFacetContext({ + sources: memorySources({ [M]: PRODUCTION, [TEST_FILE]: source }), + productionFiles: [M], + closures: new Map([[PACKAGE_FILE, [TEST_FILE, M]]]), + builtinFiles: [], + }); + const subjects: FacetSubject[] = sites.flatMap((site) => + (site.instances ?? []).map((instance) => ({ + id: `${PACKAGE_FILE}#d > ${site.record.title}#${instance.row}`, + file: PACKAGE_FILE, + record: site.record, + instance, + executedFiles: [M], + })), + ); + expect(subjects).toHaveLength(2); + const sides = await Promise.all( + subjects.map(async (subject) => + bindSideOf(subject.record, (await computeFacet(subject, ctx)).discarded, source), + ), + ); + return [sides[0], sides[1]]; +} + +/** The result without AST-dependent detail, for comparison. */ +function summary(result: BindResult): unknown { + return { + status: result.status, + reasons: result.reasons, + entries: result.entries.map( + (entry) => + `${entry.kind} ${entry.isNew ? 'new ' : ''}${entry.callee}#${entry.argIndex}${entry.path.length > 0 ? ` ${entry.path.join('.')}` : ''} @${entry.position}`, + ), + roles: result.positions.map((position) => `${position.position}:${position.role}`), + }; +} + +const twoTests = (first: string, second: string): string => + `it('a', () => { ${first} });\nit('b', () => { ${second} });`; + +describe('bindPair: entries', () => { + it.each([ + { + title: 'a call argument, with the expected value following its input', + body: twoTests('expect(roundScore(2.4)).toBe(2);', 'expect(roundScore(2.5)).toBe(3);'), + want: { + status: 'bound', + reasons: [], + entries: ['call-argument roundScore#0 @0'], + roles: ['0:input', '1:expected'], + }, + }, + { + title: 'a call argument through a const binding', + body: twoTests( + 'const raw = 2.4; expect(roundScore(raw)).toBe(2);', + 'const raw = 2.5; expect(roundScore(raw)).toBe(3);', + ), + want: { + status: 'bound', + reasons: [], + entries: ['call-argument roundScore#0 @0'], + roles: ['0:input', '1:expected'], + }, + }, + { + title: 'a fixture field', + body: twoTests( + 'expect(configure({ ratio: 2.4 })).toBeGreaterThan(2);', + 'expect(configure({ ratio: 2.5 })).toBeGreaterThan(2);', + ), + want: { + status: 'bound', + reasons: [], + entries: ['fixture-field configure#0 ratio @0'], + roles: ['0:input'], + }, + }, + { + title: 'a constructor argument', + body: twoTests( + 'expect(new Box(2.4).size).toBe(2.4);', + 'expect(new Box(2.5).size).toBe(2.5);', + ), + want: { + status: 'bound', + reasons: [], + entries: ['call-argument new Box#0 @0'], + roles: ['0:input', '1:expected'], + }, + }, + { + title: 'a table key: an it.each row value read by the body', + body: "it.each([[2.4, 2], [2.5, 3]])('rounds %s', (raw, want) => { expect(roundScore(raw)).toBe(want); });", + want: { + status: 'bound', + reasons: [], + entries: ['table-key roundScore#0 @0'], + roles: ['0:input', '1:expected'], + }, + }, + { + title: 'a table key read through a shorthand property of a fixture', + body: "it.each([{ ratio: 2.4 }, { ratio: 2.5 }])('ratio $ratio', ({ ratio }) => { expect(configure({ ratio })).toBeGreaterThan(2); });", + want: { + status: 'bound', + reasons: [], + entries: ['table-key configure#0 ratio @0'], + roles: ['0:input'], + }, + }, + ])('$title', async ({ body, want }) => { + const [a, b] = await sidesOf(body); + expect(summary(bindPair(a, b))).toEqual(want); + }); +}); + +describe('bindPair: unknown and not required', () => { + it.each([ + { + title: 'only the expected values differ (expected-only)', + body: [ + "const tags = ['x', 'y'];", + twoTests( + "expect(formatTags(tags)).toContain('alpha');", + "expect(formatTags(tags)).toContain('beta');", + ), + ].join('\n'), + want: { status: 'unknown', reasons: ['expected-only'], entries: [], roles: ['0:expected'] }, + }, + { + title: 'a value transformed by test code', + body: twoTests( + 'expect(roundScore(2.4 * 2)).toBeGreaterThan(2);', + 'expect(roundScore(2.5 * 2)).toBeGreaterThan(2);', + ), + want: { + status: 'unknown', + reasons: ['position 0: derived in the test (BinaryExpression)'], + entries: [], + roles: ['0:unbound'], + }, + }, + { + title: 'a value that reaches no call', + body: twoTests( + 'const unused = 2.4; expect(roundScore(3)).toBe(3);', + 'const unused = 2.5; expect(roundScore(3)).toBe(3);', + ), + want: { + status: 'unknown', + reasons: ['position 0: unbound (never read)'], + entries: [], + roles: ['0:unbound'], + }, + }, + { + title: 'equal discarded values need no check', + body: twoTests('expect(roundScore(2.4)).toBe(2);', 'expect(roundScore(2.4)).toBe(2);'), + want: { status: 'not-required', reasons: [], entries: [], roles: [] }, + }, + ])('$title', async ({ body, want }) => { + const [a, b] = await sidesOf(body); + expect(summary(bindPair(a, b))).toEqual(want); + }); + + it('is unknown when the two tests bind a position to different arguments', async () => { + const [a, b] = await sidesOf( + twoTests( + 'const x = 2.4; expect(pick(x, 3)).toBe(1);', + 'const x = 2.5; expect(pick(3, x)).toBe(1);', + ), + ); + expect(summary(bindPair(a, b))).toEqual({ + status: 'unknown', + reasons: ['position 0: the two tests bind it differently'], + entries: [], + roles: ['0:input'], + }); + }); + + it('is unknown when the discarded positions do not align', async () => { + const [a, b] = await sidesOf( + twoTests('expect(roundScore(2.4)).toBe(2);', 'expect(roundScore(2.5)).toBe(3);'), + ); + expect(summary(bindPair(a, { ...b, discarded: b.discarded.slice(0, 1) }))).toEqual({ + status: 'unknown', + reasons: ['discarded values do not align'], + entries: [], + roles: [], + }); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/candidates.test.ts b/gitnexus/test/unit/scenario-map/candidates.test.ts new file mode 100644 index 000000000..66f247341 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/candidates.test.ts @@ -0,0 +1,237 @@ +/** + * Locks Layer 1 candidate retrieval (U4, R3, KTD5, KTD17): exact Jaccard >= 0.99 over block sets + * (the size-ratio and rare-block prefix filters never drop a qualifying pair), pairs sharing a + * checks-and-context key whatever their blocks, connected components with deterministic ids, the + * identical-blocks flag (KTD5 caps a non-identical pair at weak), the path-unattributable flag of + * concurrent tests (design §5.2), the largest group's size for shard planning, and the focus of a + * diff-scoped run (pairs without a focused test dropped before the groups form). + */ +import { describe, expect, it } from 'vitest'; +import { + findCandidates, + jaccard, + type CandidateTest, +} from '../../../tools/scenario-map/path-coverage/candidates.js'; + +const range = (from: number, to: number): number[] => + Array.from({ length: to - from }, (_, i) => from + i); + +const test = ( + id: string, + blocks: readonly number[], + extra: Partial> = {}, +): CandidateTest => ({ + id, + blocks: [...blocks].sort((a, b) => a - b), + concurrent: false, + checksContext: null, + ...extra, +}); + +const pairsOf = (tests: readonly CandidateTest[]): [string, string][] => + findCandidates(tests).pairs.map((pair) => [pair.a, pair.b]); + +/** mulberry32: a fixed-seed PRNG, so the corpus is the same on every run. */ +function prng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** 12 families of 6 near-copies: each copy drops ~0.3% of its family's blocks and adds 0-3 (about 60 pairs qualify, about 120 miss narrowly). */ +function nearDuplicateCorpus(seed: number): CandidateTest[] { + const random = prng(seed); + return range(0, 12).flatMap((base) => { + const core = range(base * 1000, base * 1000 + 150 + base * 10); + return range(0, 6).map((copy) => { + const kept = core.filter(() => random() > 0.003); + const extra = range(0, 3) + .filter(() => random() > 0.8) + .map((i) => 900_000 + base * 100 + copy * 10 + i); + return test(`t${base}-${copy}`, [...kept, ...extra]); + }); + }); +} + +/** Every pair at Jaccard >= 0.99 by exhaustive comparison, ids ordered, sorted. */ +function bruteForcePairs(tests: readonly CandidateTest[]): [string, string][] { + const ids = (x: CandidateTest, y: CandidateTest): [string, string] => + x.id < y.id ? [x.id, y.id] : [y.id, x.id]; + return tests + .flatMap((x, i) => tests.slice(i + 1).map((y) => ({ x, y }))) + .filter(({ x, y }) => jaccard(x.blocks, y.blocks) >= 0.99) + .map(({ x, y }) => ids(x, y)) + .sort((p, q) => (p.join('\0') < q.join('\0') ? -1 : 1)); +} + +describe('jaccard', () => { + it.each([ + { name: 'identical', a: [1, 2, 3], b: [1, 2, 3], expected: 1 }, + { name: 'half overlap', a: [1, 2, 3], b: [2, 3, 4], expected: 0.5 }, + { name: 'disjoint', a: [1], b: [2], expected: 0 }, + ])('computes $name sets', ({ a, b, expected }) => { + expect(jaccard(a, b)).toBe(expected); + }); +}); + +describe('findCandidates: Jaccard source', () => { + it.each([ + { name: 'one block removed (199/200)', b: range(0, 199) }, + { name: 'one block replaced (199/201)', b: [...range(0, 199), 500] }, + ])('makes a pair that differs by 1 in 200 a candidate: $name', ({ b }) => { + const result = findCandidates([test('a', range(0, 200)), test('b', b)]); + expect(result.pairs).toEqual([ + expect.objectContaining({ + a: 'a', + b: 'b', + sources: ['jaccard'], + identicalBlocks: false, + pathUnattributable: false, + }), + ]); + expect(result.pairs[0].jaccard).toBeGreaterThanOrEqual(0.99); + }); + + it.each([ + { name: 'three blocks removed (197/200)', b: range(0, 197) }, + { name: 'three blocks replaced (197/203)', b: [...range(0, 197), 500, 501, 502] }, + ])('does not pair sets that differ by 3 in 200: $name', ({ b }) => { + expect(pairsOf([test('a', range(0, 200)), test('b', b)])).toEqual([]); + }); + + it('accepts a pair exactly at the threshold (99/100)', () => { + expect(pairsOf([test('a', range(0, 100)), test('b', range(0, 99))])).toEqual([['a', 'b']]); + }); + + it('marks identical block sets and never pairs empty ones through Jaccard', () => { + const result = findCandidates([ + test('a', [3, 4, 5]), + test('b', [3, 4, 5]), + test('c', []), + test('d', []), + ]); + expect(result.pairs).toEqual([ + expect.objectContaining({ a: 'a', b: 'b', jaccard: 1, identicalBlocks: true }), + ]); + }); + + it('finds exactly the brute-force pairs on a seeded near-duplicate corpus', () => { + const corpus = nearDuplicateCorpus(20261024); + const brute = bruteForcePairs(corpus); + expect(brute.length).toBeGreaterThan(40); + expect(pairsOf(corpus)).toEqual(brute); + }); +}); + +describe('findCandidates: checks-and-context source', () => { + it('pairs tests with Jaccard 0.5 that share a checks-and-context key', () => { + const result = findCandidates([ + test('a', [1, 2, 3], { checksContext: 'k1' }), + test('b', [2, 3, 4], { checksContext: 'k1' }), + test('c', [2, 3, 4], { checksContext: 'k2' }), + ]); + expect(result.pairs).toEqual([ + expect.objectContaining({ a: 'a', b: 'b', sources: ['checks-context'], jaccard: 0.5 }), + expect.objectContaining({ a: 'b', b: 'c', sources: ['jaccard'], jaccard: 1 }), + ]); + }); + + it('records both sources on a pair found twice', () => { + const result = findCandidates([ + test('a', [1, 2], { checksContext: 'k' }), + test('b', [1, 2], { checksContext: 'k' }), + ]); + expect(result.pairs.map((pair) => pair.sources)).toEqual([['jaccard', 'checks-context']]); + expect(result.stats.bySource).toEqual({ jaccard: 0, checksContext: 0, both: 1 }); + }); +}); + +describe('findCandidates: groups', () => { + const chain = [ + test('a', range(0, 200)), + test('b', range(1, 201)), + test('c', range(2, 202)), + test('lone', range(5000, 5010)), + ]; + + it('makes A-B and B-C one group of three with exactly two pairs when A-C misses', () => { + const result = findCandidates(chain); + expect(jaccard(chain[0].blocks, chain[2].blocks)).toBeLessThan(0.99); + expect(pairsOf(chain)).toEqual([ + ['a', 'b'], + ['b', 'c'], + ]); + expect(result.groups).toEqual([ + { id: result.pairs[0].group, tests: ['a', 'b', 'c'], pairs: 2 }, + ]); + expect(result.pairs.map((pair) => pair.group)).toEqual([ + result.groups[0].id, + result.groups[0].id, + ]); + expect(result.stats).toMatchObject({ + tests: 4, + pairs: 2, + groups: 1, + largestGroup: { id: result.groups[0].id, tests: 3, pairs: 2 }, + }); + }); + + it('gives the same pairs and group ids whatever the input order', () => { + expect(findCandidates([...chain].reverse())).toEqual(findCandidates(chain)); + }); + + it('picks the largest group by test count, then pair count', () => { + const result = findCandidates([ + ...chain, + test('x', [1], { checksContext: 'k' }), + test('y', [2], { checksContext: 'k' }), + ]); + expect(result.groups.map((group) => group.tests.length).sort()).toEqual([2, 3]); + expect(result.stats.largestGroup).toMatchObject({ tests: 3, pairs: 2 }); + }); +}); + +describe('findCandidates: concurrent tests', () => { + it('flags every pair of a concurrent test as path-unattributable', () => { + const result = findCandidates([ + test('a', [1, 2], { checksContext: 'k' }), + test('b', [1, 2], { checksContext: 'k', concurrent: true }), + test('c', [7, 8], { checksContext: 'k' }), + ]); + expect(result.pairs.map((pair) => [pair.a, pair.b, pair.pathUnattributable])).toEqual([ + ['a', 'b', true], + ['a', 'c', false], + ['b', 'c', true], + ]); + }); +}); + +describe('findCandidates: focus', () => { + it('keeps only pairs with a focused test, and groups only those', () => { + const result = findCandidates( + [ + test('a', [1, 2], { checksContext: 'k' }), + test('b', [1, 2], { checksContext: 'k' }), + test('c', [1, 2], { checksContext: 'k' }), + test('d', [7, 8], { checksContext: 'j' }), + test('e', [7, 8], { checksContext: 'j' }), + ], + (id) => id === 'a', + ); + expect([ + result.pairs.map((pair) => [pair.a, pair.b]), + result.groups.map((group) => group.tests), + ]).toEqual([ + [ + ['a', 'b'], + ['a', 'c'], + ], + [['a', 'b', 'c']], + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/cases.test.ts b/gitnexus/test/unit/scenario-map/cases.test.ts new file mode 100644 index 000000000..7e0221c4b --- /dev/null +++ b/gitnexus/test/unit/scenario-map/cases.test.ts @@ -0,0 +1,228 @@ +/** + * Locks the static expansion of test calls into instances (U3, design §5.3): `it.each` / + * `test.each` / `test.for` tables, tagged-template tables, `describe.each` and `for…of` loops over + * literal arrays give one instance per row with its bound values; anything whose number of + * instances is not static (a computed table, another loop, a test inside a helper or callback) + * fails closed as unresolved. Also pins the predicted Vitest location and static full name. + */ +import { describe, expect, it } from 'vitest'; +import { expandCases, renderRowValue } from '../../../tools/scenario-map/inventory/cases.js'; +import type { StaticCallSite } from '../../../tools/scenario-map/types.js'; +import type { SourceFile } from '../../../tools/test-dupes/test-dupes.js'; + +const FILE = 'gitnexus/test/unit/a.test.ts'; +const file = (...lines: string[]): SourceFile => ({ path: FILE, source: lines.join('\n') }); +const sitesOf = (...lines: string[]): StaticCallSite[] => expandCases([file(...lines)]).sites; +const onlySite = (...lines: string[]): StaticCallSite => { + const sites = sitesOf(...lines); + expect(sites).toHaveLength(1); + return sites[0]; +}; + +/** Rows of a site as rendered callback arguments; null when the count is not static. */ +const argsOf = (site: StaticCallSite): (string[] | null)[] | null => + site.instances?.map((instance) => instance.args?.map(renderRowValue) ?? null) ?? null; + +describe('expandCases: tables', () => { + it('expands it.each([[1, 2], [3, 4]]) into two instances with spread bound values', () => { + const site = onlySite(`it.each([[1, 2], [3, 4]])('adds %i', (a, b) => { add(a, b); });`); + expect(site.instances?.map((instance) => instance.row)).toEqual([0, 1]); + expect(argsOf(site)).toEqual([ + ['1', '2'], + ['3', '4'], + ]); + expect(site.instances?.map((instance) => instance.unresolved)).toEqual([[], []]); + }); + + it.each([ + [ + 'scalar rows, passed whole', + '', + `it.each(['a', 'b'])('t %s', (s) => {});`, + [['"a"'], ['"b"']], + ], + [ + 'mixed array and scalar rows, passed whole', + '', + `it.each([[1], 2])('t %s', (x) => {});`, + [['[1]'], ['2']], + ], + [ + 'object rows', + '', + `it.each([{ a: 1 }, { a: -2 }])('t $a', ({ a }) => {});`, + [['{a: 1}'], ['{a: -2}']], + ], + [ + 'test.for rows, never spread', + '', + `test.for([[1, 2], [3, 4]])('t %i', ([a, b]) => {});`, + [['[1, 2]'], ['[3, 4]']], + ], + [ + 'a tagged-template table', + '', + 'it.each`\n a | b\n ${1} | ${"x"}\n ${2} | ${"y"}\n`(\'t $a\', ({ a, b }) => {});', + [['{a: 1, b: "x"}'], ['{a: 2, b: "y"}']], + ], + [ + 'a const table', + `const ROWS = [[1], [2]] as const;`, + `it.each(ROWS)('t %i', (n) => {});`, + [['1'], ['2']], + ], + [ + 'a sliced const table', + `const ROWS = ['a', 'b', 'c'];`, + `it.each(ROWS.slice(1))('t %s', (s) => {});`, + [['"b"'], ['"c"']], + ], + [ + 'a spread of const tables', + `const A = [1]; const B = [2];`, + `it.each([...A, ...B])('t %i', (n) => {});`, + [['1'], ['2']], + ], + [ + 'templates, arithmetic and Number constants', + `const P = 'pre-';`, + "it.each([[`${P}x`, Number.MAX_SAFE_INTEGER - 1, -0]])('t', (s, n, z) => {});", + [['"pre-x"', '9007199254740990', '-0']], + ], + ])('binds %s', (_label, prelude, call, expected) => { + expect(argsOf(onlySite(prelude, call))).toEqual(expected); + }); + + it.each([ + ['a computed table', `it.each(buildRows())('t %s', (r) => {});`, /^each-table: /], + ['a let table', `let rows = [1]; it.each(rows)('t %s', (r) => {});`, /not a const/], + [ + 'an imported table', + `import { rows } from './rows'; it.each(rows)('t', (r) => {});`, + /import/, + ], + [ + 'a const table mutated elsewhere', + `const rows = [1]; rows.push(2); it.each(rows)('t %s', (r) => {});`, + /may be mutated/, + ], + [ + 'a const table passed to a function', + `const rows = [1]; shuffle(rows); it.each(rows)('t %s', (r) => {});`, + /may be mutated/, + ], + [ + 'a const table whose rows a callback method reaches', + `const rows = [[1]]; expect(rows.find((r) => r.pop())); it.each(rows)('t', (r) => {});`, + /may be mutated/, + ], + ['a spread of an unknown array', `it.each([...more()])('t %s', (r) => {});`, /^each-table: /], + ])('marks %s unresolved with no instance count', (_label, source, reason) => { + const site = onlySite(source); + expect(site.instances).toBeNull(); + expect(site.unresolved.join('\n')).toMatch(reason); + }); + + it('keeps the row count of a literal table whose values are not static, marking the rows', () => { + const site = onlySite(`it.each([[make(), 1], [make(), 2]])('t', (x, n) => {});`); + expect(argsOf(site)).toEqual([ + ['?(make())', '1'], + ['?(make())', '2'], + ]); + expect(site.instances?.map((instance) => instance.unresolved.length > 0)).toEqual([true, true]); + }); + + it('cannot tell whether Vitest spreads rows of unknown shape: args unknown, rows unresolved', () => { + const site = onlySite(`import { a, b } from './x';`, `it.each([a, b])('t', (x) => {});`); + expect(argsOf(site)).toEqual([null, null]); + expect(site.instances?.map((instance) => instance.unresolved)).toEqual([ + expect.arrayContaining(['each-row-shape']), + expect.arrayContaining(['each-row-shape']), + ]); + }); +}); + +describe('expandCases: enclosing constructs', () => { + it('expands a for…of loop over a literal array into one instance per element', () => { + const site = onlySite(`for (const lang of ['go', 'rust']) {`, ` it('parses', () => {});`, `}`); + expect( + site.instances?.map((instance) => + instance.dimensions.map((d) => [d.kind, d.index, renderRowValue(d.row)]), + ), + ).toEqual([[['for-of', 0, '"go"']], [['for-of', 1, '"rust"']]]); + expect(argsOf(site)).toEqual([[], []]); + }); + + it('expands describe.each times the inner it.each, outer rows varying slowest', () => { + const site = onlySite( + `describe.each(['x', 'y'])('suite %s', (s) => {`, + ` it.each([1, 2])('t %i', (n) => {});`, + `});`, + ); + expect( + site.instances?.map((instance) => + instance.dimensions.map((d) => `${d.kind}:${renderRowValue(d.row)}`).join(' '), + ), + ).toEqual([ + 'describe-each:"x" each:1', + 'describe-each:"x" each:2', + 'describe-each:"y" each:1', + 'describe-each:"y" each:2', + ]); + }); + + it.each([ + ['a counting loop', `for (let i = 0; i < 2; i++) { it('t', () => {}); }`, 'loop: ForStatement'], + ['a while loop', `while (more()) { it('t', () => {}); }`, 'loop: WhileStatement'], + ['a for…of over a computed list', `for (const x of list()) { it('t', () => {}); }`, 'for-of: '], + ['a forEach callback', `cases.forEach((c) => { it('t', () => {}); });`, 'nested-function'], + ['a helper function', `function suite() { it('t', () => {}); }`, 'nested-function'], + ])('fails closed on a test inside %s', (_label, source, reason) => { + const site = onlySite(source); + expect(site.instances).toBeNull(); + expect(site.unresolved.join('\n')).toContain(reason); + }); + + it('leaves a test under an if as one instance: the runtime join catches a skipped branch', () => { + expect(onlySite(`if (process.env.X) { it('t', () => {}); }`).instances).toHaveLength(1); + }); +}); + +describe('expandCases: location, name and files', () => { + it.each([ + ['a plain call, at the callee', ['describe("d", () => {', ' it("t", () => {});', '});'], 2, 3], + ['a member call, at the property', ['test.skip("t", () => {});'], 1, 6], + ['an each call, at the outer call paren', ['it.each([', ' [1],', '])("t", (n) => {});'], 3, 3], + ])('predicts the Vitest location of %s', (_label, lines, line, column) => { + expect(sitesOf(...lines).at(-1)?.location).toEqual({ line, column }); + }); + + it.each([ + ['plain titles', ['describe("a", () => {', ' it("b", () => {});', '});'], 'a > b'], + ['an each test', ['it.each([1])("b %i", (n) => {});'], null], + [ + 'a describe.each suite', + ['describe.each([1])("a %i", () => {', ' it("b", () => {});', '});'], + null, + ], + ['a computed title', ['it(NAME, () => {});'], null], + ])('derives the static full name for %s', (_label, lines, fullName) => { + expect(sitesOf(...lines).at(-1)?.fullName).toBe(fullName); + }); + + it('reports the package path, and reads a fixture *.case.ts file when it is a test of the run', () => { + const fixture: SourceFile = { + path: 'gitnexus/test/fixtures/mutation-scenario-map/rows.case.ts', + source: `it('t', () => {});`, + }; + expect(expandCases([fixture]).sites.map((site) => site.file)).toEqual([ + 'test/fixtures/mutation-scenario-map/rows.case.ts', + ]); + }); + + it('gives equal row fingerprints to equal rows and distinct ones to distinct rows', () => { + const site = onlySite(`it.each([[1], [2], [1]])('t %i', (n) => {});`); + const [a, b, c] = site.instances?.map((instance) => instance.rowFingerprint) ?? []; + expect([a === c, a === b]).toEqual([true, false]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/checks.test.ts b/gitnexus/test/unit/scenario-map/checks.test.ts new file mode 100644 index 000000000..9ff54bd0f --- /dev/null +++ b/gitnexus/test/unit/scenario-map/checks.test.ts @@ -0,0 +1,573 @@ +/** + * Locks the Layer 2 checks of a test instance (U5, R5, KTD6, design §2 "Checks"): the shape of + * each `expect(subject)[.not|.resolves|.rejects].matcher(args)` with literals abstracted and + * selector literals in the subject kept exact; snapshot matchers carry the snapshot text's hash; + * any other assertion form is unsupported with the form named; `it.each` rows and `for…of` + * elements give per-instance checks; `checksContextKey` joins the checks with the context. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + checksContextKey, + computeChecks, + fsModuleReader, + fsSnapshotReader, + SUPPORTED_MATCHERS, + type CheckSubject, + type InstanceChecks, + type ModuleReader, + type SnapshotReader, +} from '../../../tools/scenario-map/facet/checks.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import type { InventoryInstance, StaticCallSite } from '../../../tools/scenario-map/types.js'; + +const FILE = 'gitnexus/test/unit/a.test.ts'; +const IMPORTS = [`import { build, fn, getNodesByLabel } from '../../src/m.js';`]; + +const sitesOf = (...lines: string[]): StaticCallSite[] => + expandCases([{ path: FILE, source: [...IMPORTS, ...lines].join('\n') }]).sites; + +/** One subject per static instance, sites in walk order. */ +const subjectsOf = (...lines: string[]): CheckSubject[] => + sitesOf(...lines).flatMap((site) => + (site.instances ?? []).map( + (instance): CheckSubject => ({ record: site.record, instance, fullName: site.fullName }), + ), + ); + +const checksOf = (...lines: string[]): InstanceChecks[] => + subjectsOf(...lines).map((subject) => computeChecks(subject)); + +/** The checks of two tests written inside one describe. */ +function pairChecks(first: string, second: string): [InstanceChecks, InstanceChecks] { + const checks = checksOf(`describe('d', () => {`, first, second, `});`); + expect(checks).toHaveLength(2); + return [checks[0], checks[1]]; +} + +const shapes = (checks: InstanceChecks): string[] => checks.checks.map((check) => check.shape); + +describe('computeChecks: shapes', () => { + it.each([ + [ + 'the input and expected literals', + `it('a', () => { expect(fn('a')).toBe(1); });`, + `it('b', () => { expect(fn('b')).toBe(2); });`, + ], + [ + 'renamed locals', + `it('a', () => { const graph = build(); expect(graph.size).toBe(1); });`, + `it('b', () => { const g = build(); expect(g.size).toBe(2); });`, + ], + [ + 'the quote style of a selector literal', + `it('a', () => { const r = build(); expect(getNodesByLabel(r, 'Function')).toHaveLength(1); });`, + `it('b', () => { const r = build(); expect(getNodesByLabel(r, "Function")).toHaveLength(1); });`, + ], + [ + 'an expected value inside an asymmetric matcher', + `it('a', () => { expect(fn('x')).toEqual(expect.objectContaining({ id: 1 })); });`, + `it('b', () => { expect(fn('y')).toEqual(expect.objectContaining({ id: 2 })); });`, + ], + [ + 'a thrown message', + `it('a', () => { expect(() => fn('x')).toThrow('bad x'); });`, + `it('b', () => { expect(() => fn('y')).toThrow('bad y'); });`, + ], + ])('gives the same checks to tests that differ only in %s', (_label, first, second) => { + const [a, b] = pairChecks(first, second); + expect(a.fingerprint).toBe(b.fingerprint); + expect(a.unsupported).toEqual([]); + }); + + it.each([ + [ + 'a selector literal of the subject call', + `it('a', () => { const r = build(); expect(getNodesByLabel(r, 'Function')).toHaveLength(1); });`, + `it('b', () => { const r = build(); expect(getNodesByLabel(r, 'Class')).toHaveLength(1); });`, + ], + [ + 'the not modifier', + `it('a', () => { const x = fn('a'); expect(x).not.toBe(1); });`, + `it('b', () => { const x = fn('a'); expect(x).toBe(1); });`, + ], + [ + 'resolves against rejects', + `it('a', async () => { await expect(fn('a')).resolves.toBe(1); });`, + `it('b', async () => { await expect(fn('a')).rejects.toBe(1); });`, + ], + [ + 'expect.soft against expect', + `it('a', () => { expect.soft(fn('a')).toBe(1); });`, + `it('b', () => { expect(fn('a')).toBe(1); });`, + ], + [ + 'the matcher', + `it('a', () => { expect(fn('a')).toBe(1); });`, + `it('b', () => { expect(fn('a')).toEqual(1); });`, + ], + [ + 'the called function', + `it('a', () => { expect(fn('a')).toBe(1); });`, + `it('b', () => { expect(build('a')).toBe(1); });`, + ], + [ + 'a boolean expected value', + `it('a', () => { expect(fn('a')).toBe(true); });`, + `it('b', () => { expect(fn('a')).toBe(false); });`, + ], + [ + 'a literal index into a local', + `it('a', () => { const r = build(); expect(r[0]).toBe(1); });`, + `it('b', () => { const r = build(); expect(r[1]).toBe(1); });`, + ], + [ + 'a method selector on a local receiver', + `it('a', () => { const r = build(); expect(r.get('a')).toBe(1); });`, + `it('b', () => { const r = build(); expect(r.get('b')).toBe(1); });`, + ], + ])('gives different checks to tests that differ only in %s', (_label, first, second) => { + const [a, b] = pairChecks(first, second); + expect(a.fingerprint).not.toBe(b.fingerprint); + }); + + it('renders literals as kinds, selectors exact and locals by first use', () => { + const [checks] = checksOf( + `it('t', async () => {`, + ` const r = build();`, + ` expect(getNodesByLabel(r, 'Function')).toHaveLength(1);`, + ` expect(fn('a', 2)).not.toBe(null);`, + ` await expect(fn(/x/)).rejects.toThrow(\`boom\`);`, + ` expect.assertions(3);`, + `});`, + ); + expect(shapes(checks)).toEqual([ + 'expect(getNodesByLabel(,"Function")).toHaveLength()', + 'expect(fn(,)).not.toBe(null)', + 'expect(fn()).rejects.toThrow()', + 'expect.assertions(3)', + ]); + expect(checks.checks.map((check) => [check.line, check.form, check.matcher])).toEqual([ + [4, 'expect', 'toHaveLength'], + [5, 'expect', 'toBe'], + [6, 'expect', 'toThrow'], + [7, 'expect.assertions', ''], + ]); + }); + + it('documents a supported matcher list with the snapshot matchers and without toSatisfy', () => { + expect([...SUPPORTED_MATCHERS]).toEqual( + expect.arrayContaining(['toBe', 'toEqual', 'toMatchInlineSnapshot', 'toMatchSnapshot']), + ); + expect(SUPPORTED_MATCHERS.has('toSatisfy')).toBe(false); + }); +}); + +describe('computeChecks: unsupported forms', () => { + it.each([ + ['assert.equal', `assert.equal(fn('a'), 1);`, 'assert.equal', 'not an expect assertion'], + [ + 'a node:assert function', + `strictEqual(fn('a'), 1);`, + 'strictEqual', + 'not an expect assertion', + ], + ['a custom matcher', `expect(fn('a')).toBeValidThing();`, 'expect(...).toBeValidThing', ''], + ['toSatisfy', `expect(fn('a')).toSatisfy((v) => v > 1);`, 'expect(...).toSatisfy', ''], + ['a chai chain', `expect(fn('a')).to.equal(1);`, 'expect(...).to.equal', ''], + ['expect.poll', `await expect.poll(() => fn('a')).toBe(1);`, 'expect.poll(...)', ''], + ['expect without a matcher', `expect(fn('a'));`, 'expect(...)', 'no matcher'], + [ + 'expect in a loop', + `for (const v of ['a', 'b']) expect(fn(v)).toBe(1);`, + 'expect(...).toBe', + 'loop', + ], + [ + 'expect in a conditional', + `if (fn('a')) expect(fn('b')).toBe(1);`, + 'expect(...).toBe', + 'conditional', + ], + [ + 'expect in a callback', + `['a'].forEach((v) => expect(fn(v)).toBe(1));`, + 'expect(...).toBe', + 'nested function', + ], + [ + 'expect in a catch clause', + `try { fn('a'); } catch (e) { expect(e).toBe(1); }`, + 'expect(...).toBe', + 'catch', + ], + ])('marks %s unsupported, naming the form', (_label, statement, form, reason) => { + const [checks] = checksOf( + `import { strictEqual } from 'node:assert';`, + `it('t', async () => {`, + ` ${statement}`, + `});`, + ); + expect(checks.unsupported).toEqual([ + expect.objectContaining({ form, reason: expect.stringContaining(reason), line: 4 }), + ]); + }); + + it('marks expect inside a file-level helper the body calls', () => { + const [checks] = checksOf( + `function check(v: string) { expect(fn(v)).toBe(1); }`, + `it('t', () => { check('a'); });`, + ); + expect(checks.checks).toEqual([]); + expect(checks.unsupported).toEqual([ + { line: 3, form: 'check', reason: 'assertion in helper check (expect)' }, + ]); + }); + + const HELPER = 'gitnexus/test/helpers/assertions'; + const reader = (source: string): ModuleReader => ({ + read: (key) => (key === HELPER ? { path: `${HELPER}.ts`, source } : null), + }); + const helperTest = [ + `import { verify } from '../helpers/assertions.js';`, + `it('t', () => { verify(fn('a')); expect(fn('a')).toBe(1); });`, + ]; + + it.each([ + [ + 'an imported helper that asserts', + reader( + `export function verify(v: unknown) { inner(v); }\nfunction inner(v: unknown) { expect(v).toBeDefined(); }`, + ), + [{ line: 3, form: 'verify', reason: 'assertion in helper verify (expect)' }], + ], + [ + 'an imported helper that cannot be read', + reader(''), + [{ line: 3, form: 'verify', reason: `helper verify (${HELPER}) not inspected` }], + ], + [ + 'an imported helper that does not assert', + reader(`export const verify = (v: unknown) => v;`), + [], + ], + ])('inspects %s', (_label, modules, unsupported) => { + const [subject] = subjectsOf(...helperTest); + expect(computeChecks(subject, { modules }).unsupported).toEqual(unsupported); + }); + + it('reports an imported test helper as not inspected without a module reader', () => { + const [checks] = checksOf(...helperTest); + expect(checks.unsupported.map((entry) => entry.reason)).toEqual([ + `helper verify (${HELPER}) not inspected`, + ]); + }); + + it('never inspects production imports', () => { + const [checks] = checksOf(`it('t', () => { build(); expect(fn('a')).toBe(1); });`); + expect(checks.unsupported).toEqual([]); + }); + + it('keeps unsupported forms in the fingerprint', () => { + const [a, b] = pairChecks( + `it('a', () => { assert.equal(fn('a'), 1); });`, + `it('b', () => { assert.ok(fn('a')); });`, + ); + expect(a.fingerprint).not.toBe(b.fingerprint); + }); +}); + +describe('computeChecks: snapshots', () => { + const inline = (text: string): string => + `it('t', () => { expect(fn('a')).toMatchInlineSnapshot(${text}); });`; + + it('hashes inline snapshot text: different text gives different checks', () => { + const [a, b] = pairChecks(inline('`"x"`'), inline('`"y"`')); + expect(a.checks[0].snapshot).not.toBeNull(); + expect(a.fingerprint).not.toBe(b.fingerprint); + }); + + it('ignores the indentation of inline snapshot text', () => { + const [a, b] = pairChecks(inline('`\n "x"\n `'), inline('`\n "x"\n`')); + expect(a.fingerprint).toBe(b.fingerprint); + }); + + it('marks an inline snapshot without text unsupported', () => { + const [checks] = checksOf(inline('')); + expect(checks.unsupported.map((entry) => entry.reason)).toEqual([ + 'inline snapshot not written', + ]); + }); + + /** A snapshot reader over in-memory entries that records the keys it was asked for. */ + function memoryReader(entries: Record, files: Record = {}) { + const asked: string[] = []; + const snapshots: SnapshotReader = { + entry: (testFile, key) => { + asked.push(`${testFile}|${key}`); + return entries[key] ?? null; + }, + file: (testFile, snapshotPath) => files[`${testFile}|${snapshotPath}`] ?? null, + }; + return { snapshots, asked }; + } + + it('reads .snap entries by full name, hint and per-name counter', () => { + const { snapshots, asked } = memoryReader({ + 'd > t 1': 'one', + 'd > t 2': 'two', + 'd > t > hint 1': 'three', + }); + const [subject] = subjectsOf( + `describe('d', () => {`, + ` it('t', () => {`, + ` expect(fn('a')).toMatchInlineSnapshot('"x"');`, + ` expect(fn('b')).toMatchSnapshot();`, + ` expect(fn('c')).toMatchSnapshot('hint');`, + ` });`, + `});`, + ); + const checks = computeChecks(subject, { snapshots }); + expect(asked).toEqual([`${FILE}|d > t 2`, `${FILE}|d > t > hint 1`]); + expect(checks.checks.map((check) => check.snapshot === null)).toEqual([false, false, false]); + expect(checks.unsupported).toEqual([]); + }); + + it.each([ + ['equal entries', 'same', 'same', true], + ['different entries', 'one', 'two', false], + ])('compares file snapshots with %s', (_label, first, second, equal) => { + const { snapshots } = memoryReader({ 'd > a 1': first, 'd > b 1': second }); + const subjects = subjectsOf( + `describe('d', () => {`, + ` it('a', () => { expect(fn('a')).toMatchSnapshot(); });`, + ` it('b', () => { expect(fn('a')).toMatchSnapshot(); });`, + `});`, + ); + const [a, b] = subjects.map((subject) => computeChecks(subject, { snapshots })); + expect(a.fingerprint === b.fingerprint).toBe(equal); + }); + + it.each([ + ['a missing .snap entry', { snapshots: memoryReader({}).snapshots }, '.snap entry missing'], + ['no snapshot reader', {}, 'snapshot file not read'], + ])('marks a file snapshot unsupported on %s', (_label, options, reason) => { + const [subject] = subjectsOf(`it('t', () => { expect(fn('a')).toMatchSnapshot(); });`); + expect(computeChecks(subject, options).unsupported.map((entry) => entry.reason)).toEqual([ + reason, + ]); + }); + + it.each([ + [ + 'a property-matcher object', + `expect(fn('a')).toMatchSnapshot({ id: expect.any(String) });`, + [`${FILE}|t 1`], + [], + ], + [ + 'a hint that is not static', + `const hint = fn('h'); expect(fn('a')).toMatchSnapshot(hint);`, + [], + ['snapshot hint not static'], + ], + ])('reads the .snap key of %s', (_label, statement, keys, reasons) => { + const { snapshots, asked } = memoryReader({ 't 1': 'x' }); + const [subject] = subjectsOf(`it('t', () => { ${statement} });`); + const checks = computeChecks(subject, { snapshots }); + expect(asked).toEqual(keys); + expect(checks.unsupported.map((entry) => entry.reason)).toEqual(reasons); + }); + + it('marks a file snapshot unsupported when the test name is not known', () => { + const [site] = sitesOf(`it.each([1])('t %i', (n) => { expect(fn(n)).toMatchSnapshot(); });`); + const subject: CheckSubject = { + record: site.record, + instance: site.instances?.[0] ?? null, + fullName: site.fullName, + }; + const { snapshots } = memoryReader({}); + expect(computeChecks(subject, { snapshots }).unsupported.map((e) => e.reason)).toEqual([ + 'test name unknown', + ]); + }); + + it('hashes toMatchFileSnapshot from the file relative to the test', () => { + const { snapshots } = memoryReader({}, { [`${FILE}|./out/a.txt`]: 'content' }); + const [subject] = subjectsOf( + `it('t', async () => { await expect(fn('a')).toMatchFileSnapshot('./out/a.txt'); });`, + ); + const checks = computeChecks(subject, { snapshots }); + expect(checks.unsupported).toEqual([]); + expect(checks.checks[0].snapshot).not.toBeNull(); + }); + + describe('fsSnapshotReader', () => { + const root = mkdtempSync(path.join(tmpdir(), 'scenario-map-checks-')); + afterAll(() => rmSync(root, { recursive: true, force: true })); + const dir = path.join(root, 'gitnexus/test/unit'); + mkdirSync(path.join(dir, '__snapshots__'), { recursive: true }); + writeFileSync( + path.join(dir, '__snapshots__/a.test.ts.snap'), + [ + '// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html', + '', + 'exports[`d > t 1`] = `', + '{', + ' "a": 1,', + '}', + '`;', + '', + ].join('\n'), + ); + writeFileSync(path.join(dir, 'out.txt'), 'file content'); + mkdirSync(path.join(root, 'gitnexus/test/helpers/lib'), { recursive: true }); + writeFileSync(path.join(root, 'gitnexus/test/helpers/h.ts'), 'export const h = 1;'); + writeFileSync(path.join(root, 'gitnexus/test/helpers/lib/index.ts'), 'export const l = 1;'); + const reader = fsSnapshotReader(root); + const modules = fsModuleReader(root); + + it.each([ + ['an entry', () => reader.entry(FILE, 'd > t 1'), '\n{\n "a": 1,\n}\n'], + ['a missing entry', () => reader.entry(FILE, 'd > t 2'), null], + ['a missing .snap file', () => reader.entry('gitnexus/test/unit/b.test.ts', 'x 1'), null], + ['a file snapshot', () => reader.file(FILE, './out.txt'), 'file content'], + ['a file outside the root', () => reader.file(FILE, '../../../../x.txt'), null], + ])('reads %s', (_label, read, expected) => { + expect(read()).toBe(expected); + }); + + it.each([ + [ + 'a .ts module', + 'gitnexus/test/helpers/h', + { path: 'gitnexus/test/helpers/h.ts', source: 'export const h = 1;' }, + ], + [ + 'an index module', + 'gitnexus/test/helpers/lib', + { path: 'gitnexus/test/helpers/lib/index.ts', source: 'export const l = 1;' }, + ], + ['a missing module', 'gitnexus/test/helpers/none', null], + ['a module outside the root', '../outside', null], + ])('fsModuleReader reads %s', (_label, key, expected) => { + expect(modules.read(key)).toEqual(expected); + }); + }); +}); + +describe('computeChecks: instances', () => { + it('keeps a selector that comes from an it.each row exact per instance', () => { + const checks = checksOf( + `it.each([['Function', 1], ['Class', 2]])('finds %s', (label, n) => {`, + ` const r = build();`, + ` expect(getNodesByLabel(r, label)).toHaveLength(n);`, + `});`, + ); + expect(checks.map(shapes)).toEqual([ + ['expect(getNodesByLabel(,"Function")).toHaveLength()'], + ['expect(getNodesByLabel(,"Class")).toHaveLength()'], + ]); + }); + + it.each([ + [ + 'spread array rows', + `it.each([['a', 1], ['b', 2]])('maps %s', (s, n) => { expect(fn(s)).toBe(n); });`, + ], + [ + 'destructured object rows', + `it.each([{ s: 'a', n: 1 }, { s: 'b', n: 2 }])('maps $s', ({ s, n }) => { expect(fn(s)).toBe(n); });`, + ], + [ + 'row members', + `it.each([{ s: 'a', n: 1 }, { s: 'b', n: 2 }])('maps $s', (row) => { expect(fn(row.s)).toBe(row.n); });`, + ], + [ + 'test.for rows', + `test.for([['a', 1], ['b', 2]])('maps %s', ([s, n]) => { expect(fn(s)).toBe(n); });`, + ], + ])('abstracts expected values from %s like literals', (_label, eachTest) => { + const checks = checksOf(eachTest, `it('plain', () => { expect(fn('a')).toBe(1); });`); + expect(checks).toHaveLength(3); + expect(new Set(checks.map((check) => check.fingerprint)).size).toBe(1); + expect(checks.map((check) => check.unresolved)).toEqual([[], [], []]); + }); + + it('binds a for…of element per instance', () => { + const checks = checksOf( + `for (const label of ['Function', 'Class']) {`, + ` it(\`finds \${label}\`, () => { const r = build(); expect(getNodesByLabel(r, label)).toHaveLength(1); });`, + `}`, + ); + expect(checks.map(shapes)).toEqual([ + ['expect(getNodesByLabel(,"Function")).toHaveLength()'], + ['expect(getNodesByLabel(,"Class")).toHaveLength()'], + ]); + }); + + it('marks a selector unresolved when the instance is not known', () => { + const [site] = sitesOf( + `it.each([['Function']])('finds %s', (label) => { const r = build(); expect(getNodesByLabel(r, label)).toHaveLength(1); });`, + ); + const checks = computeChecks({ record: site.record, instance: null, fullName: null }); + expect(shapes(checks)).toEqual([ + 'expect(getNodesByLabel(,)).toHaveLength()', + ]); + expect(checks.unresolved).toEqual(['selector label: instance value unknown']); + }); +}); + +describe('checksContextKey', () => { + const inventory = (site: StaticCallSite | null): InventoryInstance => ({ + id: `test/unit/a.test.ts#t`, + file: 'test/unit/a.test.ts', + taskId: 'task-0', + fullName: 't', + location: null, + row: null, + notOracleValidated: false, + site, + instance: site?.instances?.[0] ?? null, + anchor: null, + unsupported: [], + unresolved: [], + dupeOk: null, + }); + const keysOf = (...lines: string[]): (string | null)[] => + sitesOf(...lines).map((site) => checksContextKey(inventory(site))); + + it('gives tests with equal checks and context the same key', () => { + const [a, b] = keysOf( + `describe('d', () => {`, + ` it('a', () => { expect(fn('a')).toBe(1); });`, + ` it('b', () => { expect(fn('b')).toBe(2); });`, + `});`, + ); + expect(a).not.toBeNull(); + expect(a).toBe(b); + }); + + it('splits equal checks under different afterEach bodies', () => { + const [a, b] = keysOf( + `describe('a', () => {`, + ` afterEach(() => { fn('reset'); });`, + ` it('t', () => { expect(fn('a')).toBe(1); });`, + `});`, + `describe('b', () => {`, + ` afterEach(() => { fn('clear'); });`, + ` it('t', () => { expect(fn('a')).toBe(1); });`, + `});`, + ); + expect(a).not.toBe(b); + }); + + it.each([ + ['a test without checks', () => keysOf(`it('t', () => { fn('a'); });`)[0]], + ['an instance without a call site', () => checksContextKey(inventory(null))], + ])('gives no key to %s', (_label, key) => { + expect(key()).toBeNull(); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/cli.test.ts b/gitnexus/test/unit/scenario-map/cli.test.ts new file mode 100644 index 000000000..727f9904d --- /dev/null +++ b/gitnexus/test/unit/scenario-map/cli.test.ts @@ -0,0 +1,1890 @@ +/** + * Locks the scenario-map CLI (U14, R14, R16, KTD1, KTD17) without spawning Vitest or Stryker: + * argument parsing and usage errors (exit 2), the stages that run tests refusing to start without + * a built `dist/`, the merge stage over a small temp work directory whose second shard never reported + * (the run is partial and that shard's pairs are Inconclusive) and whose group with an empty + * mutate scope is never run (its pair is Out of scope), the final shard plan value-flow re-plans + * and its fallback to plan.json's provisional plan (merge and the `shards` count follow it), and + * the pure helpers behind the plan, shard and controls stages (the D9/D10 cost terms at a target + * concurrency and speed factor, the shards needed, the value-flow view of the isolation + * selection, the seeded group sample, the shard completeness check, the expected.json + * comparison, the batch split of observations and run universes, the slim batch record), and a + * merge that reports a group result of an older schema as incomplete. Also: a merge whose + * --baseline does not read, is not JSON or has another schema still writes its report (the + * incompatible-baseline banner, a warning); a truncated isolation run file leaves the run partial; + * an invalid trace run leaves it partial; the controls oracle fails on an empty expected.json, an + * unlisted repeat verdict and a case file without a pair; the PDG analyze budget stays below the + * CI value-flow step timeout. + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CandidatePair } from '../../../tools/scenario-map/path-coverage/candidates.js'; +import type { + CoverageTest, + CoverageWorkFile, +} from '../../../tools/scenario-map/path-coverage/run-coverage.js'; +import { pairKey } from '../../../tools/scenario-map/mutation/isolation.js'; +import type { GroupScope, PlannedGroup } from '../../../tools/scenario-map/mutation/plan-shards.js'; +import type { + GroupObservations, + StrykerSummary, +} from '../../../tools/scenario-map/mutation/run-group.js'; +import type { RowDeletionPlan } from '../../../tools/scenario-map/mutation/row-deletion.js'; +import type { ScenarioMapReport } from '../../../tools/scenario-map/report/report.js'; +import type { + Observation, + RunUniverse, +} from '../../../tools/scenario-map/mutation/observations.js'; +import type { PairValueFlow } from '../../../tools/scenario-map/value-flow/pdg-walk.js'; +import type { PairTrace } from '../../../tools/scenario-map/value-flow/trace.js'; +import type { + FingerprintPair, + FingerprintRule, + FingerprintVerdict, +} from '../../../tools/scenario-map/verdict/fingerprint.js'; +import type { PairVerdict, RuleId, Verdict } from '../../../tools/scenario-map/verdict/verdict.js'; +import { + ANALYZE_TIMEOUT_MS, + SCENARIO_COST_MODEL, + WORK_SCHEMA_VERSION, + mutantRunScale, + scenarioCostModel, + valueFlowPasses, + valueFlowView, + batchGroups, + batchRecordOf, + batchScope, + compareControls, + controlsCaseFiles, + effectiveShardPlan, + estimateScenarioGroup, + isolationOrder, + changedTestFiles, + layer1FilesOf, + mutationTarget, + passingTracePairs, + shardRowPlans, + parseCliArgs, + parseExpectedControls, + planShards, + replanShards, + runCli, + sampleGroups, + shardIssues, + splitBatchResult, + UsageError, + workLayout, + type CliDeps, + type Layer2File, + type Layer2Test, + type PlanFile, + type ScenarioCostModel, + type ShardReplan, + type ShardStageReport, + type ValueFlowFile, +} from '../../../tools/scenario-map/scenario-map.js'; + +const temps: string[] = []; + +describe('trace selection', () => { + it.each(['none', 'possible-duplicates', 'all'] as const)( + 'excludes configured retries from %s traces even when coverage passed', + (mutation) => { + const pairs = [ + { a: 'a', b: 'b' }, + { a: 'a', b: 'retry' }, + { a: 'a', b: 'failed' }, + { a: 'a', b: 'failed-retry' }, + ]; + expect( + passingTracePairs( + [ + { id: 'a', state: 'passed', retry: 0 }, + { id: 'b', state: 'passed' }, + { id: 'retry', state: 'passed', retry: 1 }, + { id: 'failed', state: 'failed' }, + { id: 'failed-retry', state: 'failed', retry: 2 }, + ], + pairs, + mutation, + ), + ).toEqual(mutation === 'all' ? [pairs[0], pairs[2]] : [pairs[0]]); + }, + ); + + it('keeps non-passing coverage tests out of the shared trace without losing valid pairs', () => { + const pairs = [ + { a: 'a', b: 'b' }, + { a: 'a', b: 'failed' }, + { a: 'skipped', b: 'b' }, + { a: 'pending', b: 'b' }, + { a: 'missing', b: 'b' }, + ]; + expect( + passingTracePairs( + [ + { id: 'a', state: 'passed' }, + { id: 'b', state: 'passed' }, + { id: 'failed', state: 'failed' }, + { id: 'skipped', state: 'skipped' }, + { id: 'pending', state: 'pending' }, + ], + pairs, + ), + ).toEqual([pairs[0]]); + }); +}); +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-u14-')); + temps.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** A checkout without `dist/` and a work directory outside it. */ +function setup(): { deps: CliDeps; lines: string[]; root: string; work: string } { + const base = tempDir(); + const root = path.join(base, 'repo'); + mkdirSync(path.join(root, 'gitnexus'), { recursive: true }); + const lines: string[] = []; + return { + root, + work: path.join(base, 'work'), + lines, + deps: { + repoRoot: root, + packageDir: path.join(root, 'gitnexus'), + cwd: base, + log: (line) => lines.push(line), + now: () => 0, + sourceRevision: () => null, + }, + }; +} + +describe('parseCliArgs', () => { + it('gives the shard stage its index, count and work directory', () => { + const options = parseCliArgs(['shard', '--index', '2', '--of', '8', '--work', 'dir'], '/base'); + expect([options.stage, options.index, options.of, options.workDir, options.sample]).toEqual([ + 'shard', + 2, + 8, + path.resolve('/base', 'dir'), + null, + ]); + }); + + it('takes the concurrency and speed factor of the estimate, and leaves the shard count open', () => { + const plan = parseCliArgs(['plan', '--concurrency', '4', '--speed-factor', '1.5'], '/base'); + const shard = parseCliArgs( + ['shard', '--index', '0', '--of', '1', '--concurrency', '3'], + '/base', + ); + expect([plan.concurrency, plan.speedFactor, plan.shards, shard.concurrency]).toEqual([ + 4, + 1.5, + null, + 3, + ]); + }); + + it.each([ + { argv: ['plan'], scope: 'all' }, + { argv: ['plan', '--mutation', 'none'], scope: 'none' }, + { argv: ['all', '--mutation', 'possible-duplicates'], scope: 'possible-duplicates' }, + ])('takes the mutation scope $scope', ({ argv, scope }) => { + expect(parseCliArgs(argv, '/base').mutation).toBe(scope); + }); + + it('rejects an unknown mutation scope, and the flag outside plan and all', () => { + expect(() => parseCliArgs(['plan', '--mutation', 'some'], '/base')).toThrow( + /--mutation must be one of none, possible-duplicates, all/, + ); + expect(() => parseCliArgs(['controls', '--mutation', 'none'], '/base')).toThrow( + /does not apply to the controls stage/, + ); + }); + + it('takes the worker bound of the value-flow trace run', () => { + const valueFlow = parseCliArgs(['value-flow', '--concurrency', '3'], '/base'); + expect([valueFlow.stage, valueFlow.concurrency]).toEqual(['value-flow', 3]); + }); + + it('takes the debug group selection of the shard stage', () => { + const options = parseCliArgs( + ['shard', '--index', '0', '--of', '1', '--sample', '12', '--groups', 'ga, gb'], + '/base', + ); + expect([options.sample, options.groups]).toEqual([12, ['ga', 'gb']]); + }); + + it('runs controls in controls mode over the U17 fixture with the report header flags', () => { + const options = parseCliArgs( + ['controls', '--work', 'w', '--run-id', '42', '--run-attempt', '2', '--default-branch'], + '/base', + ); + expect([options.mode, options.run]).toEqual([ + { kind: 'controls', fixtureRoot: 'gitnexus/test/fixtures/mutation-scenario-map' }, + { id: '42', attempt: 2, url: null, ref: null, defaultBranch: true }, + ]); + }); + + it.each([ + ['an index not below the shard count', ['shard', '--index', '9', '--of', '8'], /--index 9/], + ['a shard without its index', ['shard', '--of', '8'], /--index and --of /], + ['a negative index', ['shard', '--index', '-1', '--of', '8'], /index/], + ['no stage', [], /no stage/], + ['an unknown stage', ['frobnicate'], /unknown stage/], + ['an unknown mode', ['plan', '--mode', 'weird'], /--mode/], + ['a flag of another stage', ['plan', '--index', '1'], /does not apply to the plan stage/], + ['an unknown flag', ['merge', '--bogus'], /bogus/], + ['zero shards', ['plan', '--shards', '0'], /--shards/], + ['a non-numeric attempt', ['merge', '--run-attempt', 'x'], /--run-attempt/], + ['contradicting modes', ['all', '--controls', '--mode', 'normal'], /contradicts/], + [ + 'a repeated debug group', + ['shard', '--index', '0', '--of', '1', '--groups', 'g1,g1'], + /--groups/, + ], + ['a zero speed factor', ['plan', '--speed-factor', '0'], /--speed-factor/], + ['a speed factor that is no number', ['plan', '--speed-factor', 'fast'], /--speed-factor/], + [ + 'a zero concurrency', + ['shard', '--index', '0', '--of', '1', '--concurrency', '0'], + /--concurrency/, + ], + [ + 'a concurrency on merge', + ['merge', '--concurrency', '4'], + /does not apply to the merge stage/, + ], + ['a coverage slice without its index', ['coverage', '--of', '4'], /coverage needs --index/], + ['a diff base that reads as a flag', ['plan', '--changed-since=-x'], /git revision/], + [ + 'a diff scope with slice files', + ['plan', '--changed-since', 'main', '--coverage', 'dir'], + /takes the diff scope from --coverage/, + ], + [ + 'a diff scope of the controls fixture', + ['all', '--controls', '--changed-since', 'main'], + /not the controls fixture/, + ], + + [ + 'slice files on the coverage stage', + ['coverage', '--index', '0', '--of', '4', '--coverage', 'dir'], + /does not apply to the coverage stage/, + ], + ])('rejects %s', (_title, argv, message) => { + expect(() => parseCliArgs(argv, '/base')).toThrow(UsageError); + expect(() => parseCliArgs(argv, '/base')).toThrow(message); + }); +}); + +describe('runCli usage', () => { + it('exits 2 with the usage text for --index 9 --of 8', async () => { + const { deps, lines, work } = setup(); + const code = await runCli(['shard', '--index', '9', '--of', '8', '--work', work], deps); + expect([code, lines[0], lines.some((line) => line.startsWith('usage:'))]).toEqual([ + 2, + 'scenario-map: --index 9 is not below --of 8', + true, + ]); + }); + + it('gives the coverage stage its slice and scope, and the plan its slice directory', () => { + const coverage = parseCliArgs( + ['coverage', '--index', '3', '--of', '12', '--mutation', 'none'], + '/base', + ); + const plan = parseCliArgs(['plan', '--coverage', 'slices'], '/base'); + expect([ + coverage.stage, + coverage.index, + coverage.of, + coverage.mutation, + coverage.coverage, + plan.coverage, + ]).toEqual(['coverage', 3, 12, 'none', null, path.resolve('/base', 'slices')]); + }); + + it('names the changed test files of a diff in the fingerprint scope, as package paths', () => { + expect( + changedTestFiles( + [ + 'gitnexus/test/unit/a.test.ts', + 'gitnexus/test/unit/__snapshots__/c.test.ts.snap', + 'gitnexus/src/a.ts', + 'gitnexus/test/helpers/h.ts', + ], + ['test/unit/a.test.ts', 'test/unit/b.test.ts', 'test/unit/c.test.ts'], + ), + ).toEqual(['test/unit/a.test.ts', 'test/unit/c.test.ts']); + }); + + it('parses a diff base on coverage, plan and all', () => { + expect( + [['coverage', '--index', '0', '--of', '2'], ['plan'], ['all']].map( + (argv) => parseCliArgs([...argv, '--changed-since', 'HEAD^1'], '/base').changedSince, + ), + ).toEqual(['HEAD^1', 'HEAD^1', 'HEAD^1']); + }); + + it.each([ + { mutation: 'all' as const, kind: 'normal', mode: { kind: 'normal' as const } }, + { + mutation: 'none' as const, + kind: 'controls', + mode: { kind: 'controls' as const, fixtureRoot: 'gitnexus/test/fixtures/x' }, + }, + ])('runs every file in Layer 1 for mutation $mutation in $kind mode', ({ mutation, mode }) => { + const files = ['test/unit/a.test.ts', 'test/unit/b.test.ts']; + expect(layer1FilesOf('/no-repo', files, mutation, mode)).toEqual(files); + }); + + it('exits 2 for a work directory inside the checkout', async () => { + const { deps, lines, root } = setup(); + const code = await runCli(['plan', '--work', path.join(root, 'work')], deps); + expect([code, lines[0]]).toEqual([2, expect.stringMatching(/must lie outside the checkout/)]); + }); + + it.each([ + { argv: ['controls'] }, + { argv: ['coverage', '--index', '0', '--of', '2'] }, + { argv: ['plan'] }, + { argv: ['value-flow'] }, + { argv: ['all', '--mutation', 'none'] }, + { argv: ['all', '--mutation', 'all'] }, + ])( + 'exits 2 for $argv without a built dist/, naming the build step, before any stage runs', + async ({ argv }) => { + const { deps, lines, work } = setup(); + const code = await runCli([...argv, '--work', work], deps); + expect([code, lines[0], existsSync(work)]).toEqual([ + 2, + expect.stringContaining('node scripts/build.js'), + false, + ]); + }, + ); + + it.each([{ argv: ['shards'] }, { argv: ['merge'] }, { argv: ['plan', '--coverage', 'slices'] }])( + 'lets $argv start without dist/: it runs no test', + async ({ argv }) => { + const { deps, lines, work } = setup(); + await runCli([...argv, '--work', work], deps); + expect(lines.some((line) => line.includes('node scripts/build.js'))).toBe(false); + }, + ); +}); + +// ── Merge over a temp work directory ─────────────────────────────────────── + +const A1 = 'test/unit/a.test.ts#A > one'; +const A2 = 'test/unit/a.test.ts#A > two'; +const B1 = 'test/unit/b.test.ts#B > one'; +const B2 = 'test/unit/b.test.ts#B > two'; +const C1 = 'test/unit/c.test.ts#C > one'; +const C2 = 'test/unit/c.test.ts#C > two'; +const DIGEST = 'd'.repeat(64); + +const coverageTest = (id: string, line: number): CoverageTest => ({ + id, + taskId: `task-${line}`, + file: id.split('#')[0], + fullName: id.split('#')[1], + location: { line, column: 3 }, + state: 'passed', + concurrent: false, + attempts: 2, + blocks: [0], + suiteBlocks: [], + provenance: { + known: true, + executedFiles: ['gitnexus/src/a.ts'], + nativeEntries: [], + workerSpawns: [], + excludedCandidates: [], + }, +}); + +const layer2Test = (id: string): Layer2Test => ({ + id, + file: id.split('#')[0], + anchor: null, + notOracleValidated: false, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + checksContext: 'c1', + unsupportedForms: [], + unresolvedSelectors: [], + facet: null, +}); + +const plannedGroup = (id: string, tests: string[]): PlannedGroup => ({ + id, + tests, + testFiles: [tests[0].split('#')[0]], + pairs: 1, + mutate: ['gitnexus/src/a.ts'], + excluded: [], + closureIncomplete: [], + provenanceUnknown: [], + mutatedBytes: 100, + estimateMs: 1000, +}); + +const candidatePair = (a: string, b: string, group: string): CandidatePair => ({ + a, + b, + sources: ['jaccard'], + jaccard: 1, + identicalBlocks: true, + pathUnattributable: false, + group, +}); + +const GROUPS: PlannedGroup[] = [ + plannedGroup('ga', [A1, A2]), + plannedGroup('gb', [B1, B2]), + { ...plannedGroup('gc', [C1, C2]), mutate: [], mutatedBytes: 0, estimateMs: 0 }, +]; + +/** Two shards (ga, gb) provisionally; gc executes no production file (in no shard). */ +function basePlan(): PlanFile { + const groups = GROUPS; + return { + schemaVersion: WORK_SCHEMA_VERSION, + mode: { kind: 'normal' }, + seed: 's1', + mutation: 'all', + eligibility: { + mode: 'normal', + files: ['test/unit/a.test.ts', 'test/unit/b.test.ts', 'test/unit/c.test.ts'], + fingerprintFiles: ['test/unit/a.test.ts', 'test/unit/b.test.ts', 'test/unit/c.test.ts'], + delta: { notValidated: [], rejected: [] }, + extraRoots: [], + }, + changedFiles: null, + manifestDigest: DIGEST, + costModel: { ...SCENARIO_COST_MODEL }, + shardPlan: { + schemaVersion: 1, + budgetMs: 300 * 60_000, + costModel: { ...SCENARIO_COST_MODEL }, + groups, + shards: [ + { index: 0, groups: ['ga'], estimateMs: 1000 }, + { index: 1, groups: ['gb'], estimateMs: 1000 }, + ], + overBudget: [], + }, + shardsRequested: null, + shardsNeeded: 2, + shardsCapped: false, + estimates: [], + isolationCandidates: [], + pairs: [candidatePair(A1, A2, 'ga'), candidatePair(B1, B2, 'gb'), candidatePair(C1, C2, 'gc')], + rowDeletion: [], + stats: { tests: 6, groups: 3, pairs: 3, facetEqualPairs: 0, wallClockMs: {} }, + }; +} + +/** A value-flow re-plan that packs ga and gb into one shard. */ +const ONE_SHARD_REPLAN: ShardReplan = { + shardPlan: { + schemaVersion: 1, + budgetMs: 300 * 60_000, + costModel: { ...SCENARIO_COST_MODEL }, + groups: GROUPS, + shards: [{ index: 0, groups: ['ga', 'gb'], estimateMs: 2000 }], + overBudget: [], + }, + estimates: [], + shardsNeeded: 1, + shardsCapped: false, + isolationCandidates: [], +}; + +/** + * Writes plan, Layer 1/2, value flow (with `replan`) and shard 0's results; shard 1 never + * reported, and group gc executes no production file (empty mutate scope: in no shard, never run). + */ +function writeTwoShardRun(work: string, replan: ShardReplan | null = null): void { + const layout = workLayout(work); + const tests = [A1, A2, B1, B2, C1, C2]; + const groups = GROUPS; + const write = (file: string, value: unknown): void => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); + }; + const plan = basePlan(); + const coverage: CoverageWorkFile = { + schemaVersion: 1, + mode: 'normal', + extraRoots: [], + repeats: 1, + blocks: ['gitnexus/src/a.ts#f@0'], + files: [], + tests: tests.map((id, i) => coverageTest(id, i + 1)), + unhandledErrors: [], + }; + const layer2: Layer2File = { schemaVersion: WORK_SCHEMA_VERSION, tests: tests.map(layer2Test) }; + const shard0: ShardStageReport = { + schemaVersion: WORK_SCHEMA_VERSION, + shard: 0, + of: 2, + manifestDigest: DIGEST, + manifestCheck: { ok: true, mismatches: [] }, + deadlineMs: 300 * 60_000, + concurrency: 2, + valueFlow: 'used', + batches: [], + sample: null, + groups: { + ran: [ + { + group: 'ga', + batch: null, + status: 'inconclusive', + reason: 'baseline-failed', + wallClockMs: 10, + mainEstimateMs: 1000, + estimateMs: 1000, + mutants: null, + evaluated: null, + }, + ], + notStarted: [], + sampledOut: [], + }, + rowDeletion: { status: 'none', pairs: 0, planned: 0, runs: 0, estimateMs: 0, wallClockMs: 0 }, + isolation: { candidates: 0, selected: 0, runs: [], skipped: [], deferred: [] }, + finished: true, + wallClockMs: 20, + }; + const groupResult: GroupObservations = { + schemaVersion: 2, + group: 'ga', + shard: 0, + manifestDigest: DIGEST, + status: 'inconclusive', + reason: 'baseline-failed', + detail: 'baseline 1: A > two failed', + tests: [A1, A2], + scope: groups[0], + baselines: [], + stryker: null, + runUniverse: null, + observations: [], + wallClockMs: 10, + }; + const valueFlow: ValueFlowFile = { + schemaVersion: WORK_SCHEMA_VERSION, + manifestDigest: DIGEST, + pairs: { facetEqual: 0, valueDiffering: 0 }, + pdg: { status: 'skipped', detail: null, indexPrefix: '', wallClockMs: 0 }, + staticValueFlow: { + degraded: [], + checker: { version: null, tsconfig: null, error: null }, + pdgLayer: 'unknown', + pairs: [], + }, + trace: { + status: 'not-run', + detail: 'no pairs', + issues: [], + wrap: { added: [], skipped: [], unavailable: [], errors: [] }, + baseline: null, + traced: null, + overhead: null, + tests: [], + pairs: [], + wallClockMs: 0, + }, + replan, + wallClockMs: {}, + }; + write(layout.plan, plan); + write(layout.coverage, coverage); + write(layout.layer2, layer2); + write(layout.shardReport(0), shard0); + write(path.join(work, 'observations', 'ga.json'), groupResult); + write(layout.valueFlow, valueFlow); +} + +describe('merge stage', () => { + it('reports the fingerprint of every pair and no mutation verdict for a fingerprint-only run', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work); + const plan: PlanFile = { + ...basePlan(), + mutation: 'none', + shardPlan: { ...basePlan().shardPlan, groups: [], shards: [] }, + shardsNeeded: 0, + }; + writeFileSync(workLayout(work).plan, JSON.stringify(plan)); + const code = await runCli(['merge', '--work', work, '--run-id', '7'], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect({ + code, + complete: report.completeness.complete, + mutation: report.mutation, + mutationPairs: report.pairs.length, + tests: report.tests.length, + fingerprint: report.fingerprint.pairs.map((pair) => [pair.a, pair.verdict, pair.rule]), + }).toEqual({ + code: 0, + complete: true, + mutation: 'none', + mutationPairs: 0, + tests: 0, + fingerprint: [ + [A1, 'possible-duplicate', 'facet-unknown'], + [B1, 'possible-duplicate', 'facet-unknown'], + [C1, 'possible-duplicate', 'facet-unknown'], + ], + }); + }); + + it('marks a run with a missing shard partial, its pairs Inconclusive, and a not-run empty scope Out of scope', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work); + const code = await runCli(['merge', '--work', work, '--run-id', '7'], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect({ + code, + partial: report.completeness.partial, + shards: report.completeness.shards, + pairs: report.pairs.map((pair) => [pair.a, pair.verdict, pair.rule]), + written: [workLayout(work).reportMarkdown, workLayout(work).issueBody].map(existsSync), + }).toEqual({ + code: 1, + partial: true, + shards: { planned: 2, complete: 1, incomplete: [{ shard: '1', reason: 'no shard result' }] }, + pairs: [ + [A1, 'inconclusive', 'baseline-failed'], + [B1, 'inconclusive', 'not-scheduled'], + [C1, 'out-of-scope', 'covers-no-evaluated-mutant'], + ], + written: [true, true], + }); + }); + + it('reports a group result of the pre-compact schema as incomplete, its pairs not scheduled', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work); + const file = path.join(work, 'observations', 'ga.json'); + const written = JSON.parse(readFileSync(file, 'utf8')) as Record; + writeFileSync(file, JSON.stringify({ ...written, schemaVersion: 1 })); + const code = await runCli(['merge', '--work', work, '--run-id', '7'], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect({ + code, + incomplete: report.completeness.shards.incomplete, + pair: report.pairs.map((pair) => [pair.a, pair.rule])[0], + }).toEqual({ + code: 1, + incomplete: [ + { shard: '1', reason: 'no shard result' }, + { + shard: 'group ga', + reason: expect.stringMatching(/has schema 1, not 2: .*re-run its shard$/), + }, + ], + pair: [A1, 'not-scheduled'], + }); + }); + + it.each([ + [ + 'a truncated report (not JSON)', + (file: string): void => writeFileSync(file, '{"schemaVersion": 1, "contin'), + /^unreadable baseline: /, + ], + [ + 'a report of another schema', + (file: string): void => writeFileSync(file, JSON.stringify({ schemaVersion: 999 })), + /^baseline schema version 999, this report \d+$/, + ], + ['a missing file', (): void => undefined, /^unreadable baseline: ENOENT/], + ])( + 'still writes the report with %s as --baseline, under the incompatible-baseline banner', + async (_title, write, reason) => { + const { deps, lines, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const baseline = path.join(path.dirname(work), 'baseline.json'); + write(baseline); + const code = await runCli(['merge', '--work', work, '--baseline', baseline], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect({ + code, + state: report.changes.state, + banner: report.changes.banner, + reason: report.changes.reason, + warned: lines.some((line) => line.startsWith(`merge: warning: --baseline ${baseline}`)), + }).toEqual({ + code: 0, + state: 'incompatible', + banner: 'incompatible baseline', + reason: expect.stringMatching(reason), + warned: true, + }); + }, + ); + + it('leaves a truncated isolation run out and the run partial', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const file = path.join(work, 'isolation', 'ga', 'ga-together-1.json'); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, '{"schemaVersion": 2, "group": "ga", "observ'); + const code = await runCli(['merge', '--work', work], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect([code, report.completeness.partial, report.completeness.shards.incomplete]).toEqual([ + 1, + true, + [{ shard: 'isolation ga/ga-together-1.json', reason: expect.stringMatching(/JSON/) }], + ]); + }); + + it('marks the run partial when the trace run is invalid', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const layout = workLayout(work); + const valueFlow = JSON.parse(readFileSync(layout.valueFlow, 'utf8')) as ValueFlowFile; + const invalid: ValueFlowFile = { + ...valueFlow, + pairs: { facetEqual: 1, valueDiffering: 0 }, + trace: { + ...valueFlow.trace, + status: 'ran', + detail: null, + issues: ['traced run error/infrastructure'], + }, + }; + writeFileSync(layout.valueFlow, JSON.stringify(invalid)); + const code = await runCli(['merge', '--work', work], deps); + const report = JSON.parse(readFileSync(layout.reportJson, 'utf8')) as ScenarioMapReport; + expect([code, report.completeness.shards.incomplete]).toEqual([ + 1, + [ + { + shard: 'value-flow', + reason: 'trace run invalid: traced run error/infrastructure', + }, + ], + ]); + }); + + it('follows the value-flow re-plan instead of the provisional plan', async () => { + const { deps, work } = setup(); + writeTwoShardRun(work, ONE_SHARD_REPLAN); + const code = await runCli(['merge', '--work', work], deps); + const report = JSON.parse( + readFileSync(workLayout(work).reportJson, 'utf8'), + ) as ScenarioMapReport; + expect([code, report.completeness.shards]).toEqual([ + 0, + { planned: 1, complete: 1, incomplete: [] }, + ]); + }); +}); + +describe('shards stage', () => { + it.each([ + ['the provisional plan without a re-plan', null, '2'], + ['the value-flow re-plan', ONE_SHARD_REPLAN, '1'], + ])('prints the shard count of %s', async (_title, replan, count) => { + const { deps, lines, work } = setup(); + writeTwoShardRun(work, replan); + const code = await runCli(['shards', '--work', work], deps); + expect([code, lines]).toEqual([0, [count]]); + }); +}); + +// ── Pure helpers ─────────────────────────────────────────────────────────── + +describe('estimateScenarioGroup', () => { + it('adds the D10 row-deletion runs and the D9 fixed isolation runs to the main run', () => { + const model = { + ...SCENARIO_COST_MODEL, + perRunMs: 1000, + perByteMs: 1, + mutantsPerByte: 0.5, + freshProcessMs: 10, + isolationRunMs: 100, + rowDeletionRunMs: 7, + }; + const estimate = estimateScenarioGroup( + { + group: 'g', + tests: 3, + mutatedBytes: 100, + isolationPairs: 1, + isolationRuns: 3, + isolationBytes: 40, + rowDeletionPairs: 1, + rowDeletionRuns: 3, + }, + model, + ); + // main 1000 + 100 bytes * 1; row deletion 3 * 7; isolation 3 * 100 + 40 * 0.5 * 3 runs * 10. + expect([ + estimate.mainMs, + estimate.rowDeletionMs, + estimate.isolationMs, + estimate.estimateMs, + ]).toEqual([1100, 21, 900, 2021]); + }); + + it.each([[2], [36]])( + 'estimates the main run of %i tests over the same bytes the same (no test factor)', + (tests) => { + const work = { + group: 'g', + tests, + mutatedBytes: 100_000, + isolationPairs: 0, + isolationRuns: 0, + isolationBytes: 0, + rowDeletionPairs: 0, + rowDeletionRuns: 0, + }; + // The U14 slice: 88 s + 100,000 bytes * 0.78 ms at concurrency 2 on the calibration machine. + expect(estimateScenarioGroup(work, SCENARIO_COST_MODEL).mainMs).toBe(166_000); + }, + ); +}); + +describe('cost model at a target concurrency and speed factor', () => { + it.each([ + [2, 1], + [4, 1 / 1.8], + [1, 2], + ])('scales the mutant runs at concurrency %i by %f', (concurrency, scale) => { + const model = { + ...scenarioCostModel({ concurrency, speedFactor: 1 }), + parallelEfficiency: 0.8, + }; + expect(mutantRunScale(model)).toBeCloseTo(scale, 10); + }); + + it('multiplies every term by the speed factor and only the mutant runs by the scale', () => { + const model = { + ...scenarioCostModel({ concurrency: 4, speedFactor: 2 }), + parallelEfficiency: 1, + perRunMs: 1000, + perByteMs: 1, + mutantsPerByte: 0.5, + freshProcessMs: 10, + isolationRunMs: 100, + rowDeletionRunMs: 7, + }; + const estimate = estimateScenarioGroup( + { + group: 'g', + tests: 3, + mutatedBytes: 100, + isolationPairs: 1, + isolationRuns: 3, + isolationBytes: 40, + rowDeletionPairs: 1, + rowDeletionRuns: 3, + }, + model, + ); + // Concurrency 4 halves the mutant runs (efficiency 1): main 2 * (1000 + 100 / 2), row + // deletion 2 * 21, isolation 2 * (300 + 600 / 2). + expect([estimate.mainMs, estimate.rowDeletionMs, estimate.isolationMs]).toEqual([ + 2100, 42, 1200, + ]); + }); +}); + +describe('mutationTarget', () => { + const A3 = 'test/unit/a.test.ts#A > three'; + const groups: GroupScope[] = [ + { + ...plannedGroup('ga', [A1, A2, A3]), + mutate: ['gitnexus/src/a.ts', 'gitnexus/src/b.ts'], + provenanceUnknown: [A3], + }, + plannedGroup('gb', [B1, B2]), + ]; + const pairs = [ + candidatePair(A1, A2, 'ga'), + candidatePair(A1, A3, 'ga'), + candidatePair(B1, B2, 'gb'), + ]; + const verdicts = new Map([ + [pairKey(A1, A2), 'possible-duplicate'], + [pairKey(A1, A3), 'table-row'], + [pairKey(B1, B2), 'duplicate'], + ]); + const executed = (test: string): string[] | null => (test === A3 ? null : ['gitnexus/src/a.ts']); + + it.each([ + { + scope: 'all', + expected: [ + ['ga', 3], + ['gb', 2], + ], + pairs: 3, + }, + { scope: 'none', expected: [], pairs: 0 }, + ] as const)('keeps every group for $scope, or none', ({ scope, expected, pairs: count }) => { + const target = mutationTarget(scope, groups, pairs, verdicts, executed, () => true); + expect([ + target.scopes.map((group) => [group.id, group.tests.length]), + target.pairs.length, + ]).toEqual([expected, count]); + }); + + it('keeps the possible duplicates, trimmed to their tests and the files those tests executed', () => { + const target = mutationTarget( + 'possible-duplicates', + groups, + pairs, + verdicts, + executed, + () => true, + ); + expect(target).toEqual({ + scopes: [ + { + id: 'ga', + tests: [A1, A2], + testFiles: ['test/unit/a.test.ts'], + pairs: 1, + mutate: ['gitnexus/src/a.ts'], + excluded: [], + closureIncomplete: [], + provenanceUnknown: [], + }, + ], + pairs: [pairs[0]], + }); + }); + + it('mutates no pair with a test outside the mutation-eligible files, whatever the scope', () => { + const onlyA = (test: string): boolean => test.startsWith('test/unit/a.test.ts'); + const all = mutationTarget('all', groups, pairs, verdicts, executed, onlyA); + const possible = mutationTarget( + 'possible-duplicates', + groups, + pairs, + verdicts, + executed, + onlyA, + ); + expect([all.pairs, all.scopes.map((group) => group.id), possible.pairs]).toEqual([ + [pairs[0], pairs[1]], + ['ga'], + [pairs[0]], + ]); + }); + + it('keeps every mutate file when a kept test has an unknown provenance', () => { + const unknown = new Map(verdicts).set(pairKey(A1, A3), 'possible-duplicate'); + const target = mutationTarget( + 'possible-duplicates', + groups, + pairs, + unknown, + executed, + () => true, + ); + expect(target.scopes.map((group) => [group.tests, group.mutate])).toEqual([ + [ + [A1, A2, A3], + ['gitnexus/src/a.ts', 'gitnexus/src/b.ts'], + ], + ]); + }); +}); + +describe('planShards', () => { + it('plans no shard when no group has anything to mutate', () => { + const planned = planShards({ + scopes: [], + pairs: [], + potentialRepeat: () => false, + executedFiles: () => null, + rowDeletion: [], + sizeOf: () => 0, + shards: null, + budgetMs: 300 * 60_000, + model: SCENARIO_COST_MODEL, + }); + expect([planned.shardPlan.shards, planned.shardsNeeded, planned.shardsCapped]).toEqual([ + [], + 0, + false, + ]); + }); + + const rowPlan = (a: string, b: string, deletions: number): RowDeletionPlan => ({ + a, + b, + status: deletions > 0 ? 'planned' : 'not-applicable', + reasons: [], + positions: [], + deletions: Array.from({ length: deletions }, (_, i) => ({ + globalId: `gitnexus/src/a.ts@0123456789abcdef:${i + 1}:1-${i + 1}:5:RowDeletion:aaaaaaaaaaaaaaaa`, + identity: { + file: 'gitnexus/src/a.ts', + fileHash: '0123456789abcdef', + location: { start: { line: i + 1, column: 1 }, end: { line: i + 1, column: 5 } }, + mutatorName: 'RowDeletion', + replacement: '', + }, + key: `s:k${i}`, + table: { file: 'gitnexus/src/a.ts', kind: 'object', binding: 'T', line: 1 }, + splice: { start: 0, end: 1 }, + })), + }); + + it('counts isolation only for potential repeat pairs and row deletion only for planned ones', () => { + const scope = plannedGroup('ga', [A1, A2, B1]); + const { shardPlan, estimates } = planShards({ + scopes: [{ ...scope, mutate: ['gitnexus/src/a.ts', 'gitnexus/src/b.ts'] }], + pairs: [candidatePair(A1, A2, 'ga'), candidatePair(A1, B1, 'ga')], + potentialRepeat: (pair) => pair.b === A2, + executedFiles: () => ['gitnexus/src/a.ts', 'node_modules/x/index.js'], + rowDeletion: [rowPlan(A1, A2, 2), rowPlan(A1, B1, 0)], + sizeOf: (file) => (file.endsWith('a.ts') ? 10 : 30), + shards: 1, + budgetMs: 300 * 60_000, + model: SCENARIO_COST_MODEL, + }); + expect({ + work: estimates.map((entry) => [ + entry.mutatedBytes, + entry.isolationPairs, + entry.isolationRuns, + entry.isolationBytes, + entry.rowDeletionPairs, + entry.rowDeletionRuns, + ]), + planned: shardPlan.groups.map((group) => group.estimateMs), + shards: shardPlan.shards.map((shard) => shard.groups), + }).toEqual({ + work: [[40, 1, 3, 10, 1, 3]], + planned: [estimates[0].estimateMs], + shards: [['ga']], + }); + }); + + it('packs into the shards needed, never packs an empty mutate scope, and caps the count', () => { + const big = (id: string): PlannedGroup => ({ + ...plannedGroup(id, [`${id}#x`, `${id}#y`]), + pairs: 0, + }); + const input = { + scopes: [big('g1'), big('g2'), big('g3'), { ...big('g4'), mutate: [] }], + pairs: [], + potentialRepeat: () => false, + executedFiles: () => null, + rowDeletion: [], + // 100 s + 50,000 bytes * 2 ms = 200 s per group; the reserve is 60 + 48 s. + sizeOf: () => 50_000, + budgetMs: 350_000, + model: { + ...scenarioCostModel({ concurrency: 2, speedFactor: 1 }), + perRunMs: 100_000, + perByteMs: 2, + }, + }; + const needed = planShards({ ...input, shards: null }); + const capped = planShards({ ...input, shards: null, maxShards: 2 }); + const forced = planShards({ ...input, shards: 1 }); + expect({ + needed: [ + needed.shardsNeeded, + needed.shardsCapped, + needed.shardPlan.shards.map((s) => s.groups), + ], + empty: needed.estimates.find((entry) => entry.group === 'g4')?.estimateMs, + capped: [capped.shardsNeeded, capped.shardsCapped], + forced: forced.shardPlan.shards.map((shard) => shard.groups), + }).toEqual({ + needed: [3, false, [['g1'], ['g2'], ['g3']]], + empty: 0, + capped: [2, true], + forced: [['g1', 'g2', 'g3']], + }); + }); + + it('runs a group whose isolation is over budget, with that isolation deferred, and drops only a main run over budget', () => { + const MIN = 60_000; + // ga: a 2 min main run and 3 isolation runs of 200 min; gb: a 2 + 400 min main run. + const sizes = new Map([['gitnexus/src/b.ts', 400 * MIN]]); + const { shardPlan, estimates } = planShards({ + scopes: [ + plannedGroup('ga', [A1, A2]), + { ...plannedGroup('gb', [B1, B2]), mutate: ['gitnexus/src/b.ts'] }, + ], + pairs: [candidatePair(A1, A2, 'ga'), candidatePair(B1, B2, 'gb')], + potentialRepeat: (pair) => pair.group === 'ga', + executedFiles: () => ['gitnexus/src/a.ts'], + rowDeletion: [], + sizeOf: (file) => sizes.get(file) ?? 0, + shards: null, + budgetMs: 300 * MIN, + model: { + ...scenarioCostModel({ concurrency: 2, speedFactor: 1 }), + perRunMs: 2 * MIN, + perByteMs: 1, + isolationRunMs: 200 * MIN, + }, + }); + expect({ + overBudget: shardPlan.overBudget, + shards: shardPlan.shards.map((shard) => shard.groups), + ga: estimates + .filter((entry) => entry.group === 'ga') + .map((entry) => [entry.isolationPlanned, entry.estimateMs / MIN, entry.isolationMs / MIN]), + }).toEqual({ + overBudget: ['gb'], + shards: [['ga']], + ga: [[false, 2, 600]], + }); + }); +}); + +describe('isolationOrder', () => { + it('puts the pairs of groups with planned isolation before those whose isolation was deferred', () => { + const order = isolationOrder( + [{ group: 'g1' }, { group: 'g2' }, { group: 'g3' }, { group: 'g2' }], + (group) => group === 'g2', + ); + expect(order.map((entry) => [entry.decision.group, entry.planned])).toEqual([ + ['g2', true], + ['g2', true], + ['g1', false], + ['g3', false], + ]); + }); +}); + +describe('value flow before isolation', () => { + const KEY = JSON.stringify([A1, A2]); + const flow = (status: PairValueFlow['status']): PairValueFlow => ({ + a: A1, + b: A2, + status, + reasons: [], + entries: [], + sinks: [], + opaqueSinks: [], + degraded: [], + }); + const trace = (status: PairTrace['status']): PairTrace => ({ + a: A1, + b: A2, + status, + reasons: [], + sharedSites: 0, + differences: [], + }); + const file = ( + staticPairs: PairValueFlow[], + tracePairs: PairTrace[], + overrides: Partial<{ + digest: string; + degraded: boolean; + traced: boolean; + replan: ShardReplan; + }> = {}, + ): ValueFlowFile => ({ + schemaVersion: WORK_SCHEMA_VERSION, + manifestDigest: overrides.digest ?? DIGEST, + pairs: { facetEqual: 1, valueDiffering: staticPairs.length }, + pdg: { status: 'indexed', detail: null, indexPrefix: '', wallClockMs: 0 }, + staticValueFlow: { + degraded: overrides.degraded === true ? ['no-pdg-layer'] : [], + checker: { version: '7.0.2', tsconfig: null, error: null }, + pdgLayer: 'ready', + pairs: staticPairs, + }, + trace: { + status: overrides.traced === false ? 'not-run' : 'ran', + detail: null, + issues: [], + wrap: { added: [], skipped: [], unavailable: [], errors: [] }, + baseline: null, + traced: null, + overhead: null, + tests: [], + pairs: tracePairs, + wallClockMs: 0, + }, + replan: overrides.replan ?? null, + wallClockMs: {}, + }); + + it.each([ + ['no value-flow result', 'missing', null], + ['another run manifest', 'stale', file([], [], { digest: 'e'.repeat(64) })], + ['a degraded static run', 'degraded', file([], [], { degraded: true })], + ['a trace that did not run', 'trace-not-run', file([], [], { traced: false })], + ['a usable result', 'used', file([], [])], + ])('reads %s as %s', (_title, use, result) => { + expect(valueFlowView(result, DIGEST).use).toBe(use); + }); + + it.each([ + ['a clean trace without a static check (equal discarded values)', true, [], [trace('clean')]], + ['a clean trace and a clean static check', true, [flow('clean')], [trace('clean')]], + ['a trace veto', false, [flow('clean')], [trace('veto')]], + ['an inconclusive trace', false, [], [trace('inconclusive')]], + ['a static veto', false, [flow('veto')], [trace('clean')]], + ['an unknown static check', false, [flow('unknown')], [trace('clean')]], + ['no trace for the pair', false, [flow('clean')], []], + ])('lets %s reach isolation: %s', (_title, passes, staticPairs, tracePairs) => { + expect(valueFlowPasses(valueFlowView(file(staticPairs, tracePairs), DIGEST), KEY)).toBe(passes); + }); + + it('isolates as without value flow when the result cannot be used', () => { + expect(valueFlowPasses(valueFlowView(null, DIGEST), KEY)).toBe(true); + }); + + it.each([ + ['no value-flow result', 'provisional', 2, null], + [ + 'another run manifest', + 'provisional', + 2, + file([], [], { digest: 'e'.repeat(64), replan: ONE_SHARD_REPLAN }), + ], + [ + 'a degraded static run', + 'provisional', + 2, + file([], [], { degraded: true, replan: ONE_SHARD_REPLAN }), + ], + [ + 'a trace that did not run', + 'provisional', + 2, + file([], [], { traced: false, replan: ONE_SHARD_REPLAN }), + ], + ['a usable result without a re-plan', 'provisional', 2, file([], [])], + ['a usable result with a re-plan', 'value-flow', 1, file([], [], { replan: ONE_SHARD_REPLAN })], + ])('given %s, follows the %s shard plan of %i shards', (_title, source, shards, result) => { + const effective = effectiveShardPlan(basePlan(), result); + expect([effective.source, effective.shardPlan.shards.length]).toEqual([source, shards]); + }); + + it('follows the re-plan of a narrower mutation scope even when the trace did not run', () => { + const scoped: PlanFile = { ...basePlan(), mutation: 'possible-duplicates' }; + const effective = effectiveShardPlan( + scoped, + file([], [], { traced: false, replan: ONE_SHARD_REPLAN }), + ); + expect([effective.source, effective.shardPlan.shards.length]).toEqual(['value-flow', 1]); + }); + + // A candidate whose three isolation runs alone take 600 min has its isolation deferred (the + // group still runs) until value flow vetoes it; every group's main run is about 90 s. + const isolationHeavy = (): PlanFile => ({ + ...basePlan(), + costModel: { + ...scenarioCostModel({ concurrency: 2, speedFactor: 1 }), + isolationRunMs: 200 * 60_000, + }, + isolationCandidates: [KEY], + }); + + const replanCases: [string, PairTrace['status'], string[], boolean][] = [ + ['a trace veto', 'veto', [], true], + ['a clean trace', 'clean', [KEY], false], + ]; + it.each(replanCases)('replans after %s', (_title, status, candidates, planned) => { + const replan = replanShards( + isolationHeavy(), + valueFlowView(file([], [trace(status)]), DIGEST), + { executedFiles: () => ['gitnexus/src/a.ts'], sizeOf: () => 100 }, + ); + expect([ + replan.shardPlan.overBudget, + replan.isolationCandidates, + replan.estimates.find((entry) => entry.group === 'ga')?.isolationPlanned, + replan.shardsNeeded, + replan.shardPlan.shards.map((shard) => shard.groups), + ]).toEqual([[], candidates, planned, 1, [['ga', 'gb']]]); + }); +}); + +describe('batchGroups', () => { + const MIN = 60_000; + + const model = (batchFactor: number): ScenarioCostModel => ({ + ...scenarioCostModel({ concurrency: 2, speedFactor: 1 }), + perRunMs: MIN, + batchFactor, + }); + + it('shares one run while the fixed cost once plus the members\u2019 extra stays in budget', () => { + const batches = batchGroups( + [ + { id: 'g1', mainMs: 2 * MIN, mutate: 1 }, + { id: 'g2', mainMs: 12 * MIN, mutate: 3 }, + { id: 'g3', mainMs: 2 * MIN, mutate: 0 }, + { id: 'g4', mainMs: 8 * MIN, mutate: 2 }, + { id: 'g5', mainMs: 40 * MIN, mutate: 9 }, + { id: 'g6', mainMs: 3 * MIN, mutate: 1 }, + ], + model(1), + 20 * MIN, + ); + // g1, g2, g4: the 1 min fixed cost once plus 1 + 11 + 7 = 20; g3 has no mutate scope; g5 is over. + expect(batches.map((batch) => [batch.groups, batch.mainMs / MIN])).toEqual([ + [['g1', 'g2', 'g4'], 20], + [['g3'], 2], + [['g5'], 40], + [['g6'], 3], + ]); + }); + + it('names a batch of several groups apart from every group id, and a single group by its id', () => { + const [batch, single] = batchGroups( + [ + { id: 'g1', mainMs: 2 * MIN, mutate: 1 }, + { id: 'g2', mainMs: 2 * MIN, mutate: 1 }, + { id: 'g9', mainMs: 99 * MIN, mutate: 1 }, + ], + model(1), + 20 * MIN, + ); + expect([batch.id, single.id]).toEqual([expect.stringMatching(/^b[0-9a-f]{16}$/), 'g9']); + }); + + it('prices a batch at the batch factor over one fixed cost plus the members\u2019 extra', () => { + const batches = batchGroups( + [ + { id: 'g1', mainMs: 2 * MIN, mutate: 1 }, + { id: 'g2', mainMs: 12 * MIN, mutate: 3 }, + { id: 'g4', mainMs: 8 * MIN, mutate: 2 }, + ], + model(1.5), + 20 * MIN, + ); + // g1 + g2: 1.5 * (1 + 1 + 11) = 19.5; adding g4 makes 1.5 * 20 = 30. + expect(batches.map((batch) => [batch.groups, batch.mainMs / MIN])).toEqual([ + [['g1', 'g2'], 19.5], + [['g4'], 8], + ]); + }); +}); + +describe('splitBatchResult', () => { + const observation = (test: string, file: string): Observation => ({ + group: 'bbatch', + kind: 'main', + lineage: 'main', + shard: 0, + attempt: 0, + test, + mutant: `${file}@0123456789abcdef:1:1-1:2:BooleanLiteral:aaaaaaaaaaaaaaaa`, + file, + covered: true, + hookAttributed: false, + static: false, + outcome: 'killed-by-test:assertion', + source: 'row', + valid: true, + reasons: [], + }); + + it('keeps each member\u2019s own tests on its own mutate files, relabelled with its group', () => { + const ga = { ...plannedGroup('ga', [A1, A2]), mutate: ['gitnexus/src/a.ts'] }; + const gb = { ...plannedGroup('gb', [B1, B2]), mutate: ['gitnexus/src/b.ts'] }; + const scope = batchScope('bbatch', [ga, gb]); + const universe: RunUniverse = { + group: 'bbatch', + kind: 'main', + lineage: 'main', + shard: 0, + tests: scope.tests, + mutants: ['gitnexus/src/a.ts', 'gitnexus/src/b.ts'].map((file) => ({ + id: observation(A1, file).mutant, + file, + static: false, + status: 'Killed' as const, + reasons: [], + hookMarker: true, + })), + valid: true, + reasons: [], + }; + const result: GroupObservations = { + schemaVersion: 2, + group: 'bbatch', + shard: 0, + manifestDigest: DIGEST, + status: 'complete', + reason: null, + detail: null, + tests: scope.tests, + scope, + baselines: [], + stryker: null, + runUniverse: universe, + observations: [ + observation(A1, 'gitnexus/src/a.ts'), + observation(A1, 'gitnexus/src/b.ts'), + observation(B2, 'gitnexus/src/b.ts'), + observation(B2, 'gitnexus/src/a.ts'), + ], + wallClockMs: 5, + }; + const split = splitBatchResult(result, [ga, gb]); + expect({ + scope: [scope.tests, scope.mutate, scope.pairs], + members: split.map((member) => [ + member.group, + member.tests, + member.scope.mutate, + member.observations.map((entry) => [entry.group, entry.test, entry.file]), + [ + member.runUniverse?.group, + member.runUniverse?.tests, + member.runUniverse?.mutants.map((entry) => entry.file), + ], + member.batch, + ]), + }).toEqual({ + scope: [[A1, A2, B1, B2], ['gitnexus/src/a.ts', 'gitnexus/src/b.ts'], 2], + members: [ + [ + 'ga', + [A1, A2], + ['gitnexus/src/a.ts'], + [['ga', A1, 'gitnexus/src/a.ts']], + ['ga', [A1, A2], ['gitnexus/src/a.ts']], + { id: 'bbatch', groups: ['ga', 'gb'] }, + ], + [ + 'gb', + [B1, B2], + ['gitnexus/src/b.ts'], + [['gb', B2, 'gitnexus/src/b.ts']], + ['gb', [B1, B2], ['gitnexus/src/b.ts']], + { id: 'bbatch', groups: ['ga', 'gb'] }, + ], + ], + }); + }); +}); + +describe('batchRecordOf', () => { + it('keeps a batch outcome and its members, never its run universe or observations', () => { + const scope = plannedGroup('bbatch', [A1, B1]); + const stryker: StrykerSummary = { + exitCode: 0, + wallClockMs: 1, + resultsFile: '/work/groups/bbatch/stryker/results.json', + mutants: 2, + evaluated: 2, + flags: 0, + }; + const record = batchRecordOf( + { + schemaVersion: 2, + group: 'bbatch', + shard: 0, + manifestDigest: DIGEST, + status: 'complete', + reason: null, + detail: null, + tests: scope.tests, + scope, + baselines: [], + stryker, + runUniverse: { + group: 'bbatch', + kind: 'main', + lineage: 'main', + shard: 0, + tests: scope.tests, + mutants: [], + valid: true, + reasons: [], + }, + observations: [], + wallClockMs: 5, + }, + ['ga', 'gb'], + ); + expect([ + 'runUniverse' in record, + 'observations' in record, + record.groups, + record.stryker?.resultsFile, + ]).toEqual([false, false, ['ga', 'gb'], '/work/groups/bbatch/stryker/results.json']); + }); +}); + +describe('sampleGroups', () => { + const ids = ['g1', 'g2', 'g3', 'g4', 'g5', 'g6']; + + it('picks the same groups for the same seed, in plan order', () => { + const picked = sampleGroups(ids, 3, 'seed-a'); + expect([picked.length, picked, [...picked].sort()]).toEqual([ + 3, + sampleGroups([...ids].reverse(), 3, 'seed-a').reverse(), + picked, + ]); + }); + + it('takes every group when the sample is larger than the shard', () => { + expect(sampleGroups(ids, 10, 'seed-a')).toEqual(ids); + }); +}); + +describe('shardIssues', () => { + const finished: ShardStageReport = { + schemaVersion: WORK_SCHEMA_VERSION, + shard: 0, + of: 1, + manifestDigest: DIGEST, + manifestCheck: { ok: true, mismatches: [] }, + deadlineMs: 1, + concurrency: 2, + valueFlow: 'used', + batches: [], + sample: null, + groups: { ran: [], notStarted: [], sampledOut: [] }, + rowDeletion: { status: 'none', pairs: 0, planned: 0, runs: 0, estimateMs: 0, wallClockMs: 0 }, + isolation: { candidates: 0, selected: 0, runs: [], skipped: [], deferred: [] }, + finished: true, + wallClockMs: 1, + }; + + it.each([ + ['a finished shard', finished, []], + ['no report', null, ['no shard result']], + [ + 'a manifest mismatch', + { ...finished, manifestCheck: { ok: false, mismatches: ['changed: gitnexus/x.ts'] } }, + ['its prepared copy differs from the run manifest'], + ], + ['an unfinished shard', { ...finished, finished: false }, ['the shard did not finish']], + [ + 'a group not started', + { + ...finished, + groups: { + ...finished.groups, + notStarted: [{ group: 'g', estimateMs: 1, elapsedMs: 1, owedMs: 0 }], + }, + }, + ['1 groups not started before the deadline'], + ], + [ + 'a skipped isolation pair', + { + ...finished, + isolation: { + ...finished.isolation, + skipped: [{ a: A1, b: A2, estimateMs: 1, elapsedMs: 1 }], + }, + }, + ['1 pairs not isolated before the deadline'], + ], + [ + 'a deferred isolation pair that did not fit (isolation not-run by plan)', + { + ...finished, + isolation: { + ...finished.isolation, + deferred: [{ a: A1, b: A2, estimateMs: 1, elapsedMs: 1 }], + }, + }, + [], + ], + ])('reports %s', (_title, report, issues) => { + expect(shardIssues(report)).toEqual(issues); + }); +}); + +describe('parseExpectedControls', () => { + const PAIR = { + id: 'p', + tests: ['p.case.ts#p > a', 'p.case.ts#p > b'], + verdict: 'review', + rule: 'value-flow-veto', + fingerprint: { verdict: 'table-row', rule: 'input-values-differ' }, + }; + + it('reads each pair of the fixture file', () => { + expect(parseExpectedControls({ schemaVersion: 1, pairs: [{ ...PAIR, stages: {} }] })).toEqual([ + PAIR, + ]); + }); + + it.each([ + ['pairs that are no array', { pairs: {} }, /pairs must be an array/], + ['no pairs at all', { schemaVersion: 1 }, /pairs must be an array/], + ['an empty pair list', { pairs: [] }, /pairs is empty/], + ['a pair with one test', { pairs: [{ ...PAIR, tests: ['p.case.ts#p > a'] }] }, /malformed/], + [ + 'a pair without a rule', + { pairs: [{ id: 'p', tests: PAIR.tests, verdict: 'row' }] }, + /malformed/, + ], + [ + 'a pair without a fingerprint', + { pairs: [{ id: 'p', tests: PAIR.tests, verdict: 'row', rule: 'kills-differ' }] }, + /malformed/, + ], + ])('rejects %s', (_title, data, message) => { + expect(() => parseExpectedControls(data)).toThrow(message); + }); +}); + +describe('controls oracle failures', () => { + const prefix = 'test/fixtures/mutation-scenario-map/'; + const expectedPair = { + id: 'p', + tests: ['p.case.ts#p > a', 'p.case.ts#p > b'] as [string, string], + verdict: 'review', + rule: 'value-flow-veto', + fingerprint: { verdict: 'table-row', rule: 'input-values-differ' }, + }; + const verdictOf = ( + a: string, + b: string, + verdict: Verdict, + rule: RuleId, + ): Pick => ({ + a: `${prefix}${a}`, + b: `${prefix}${b}`, + verdict, + rule, + }); + const listed = verdictOf('p.case.ts#p > a', 'p.case.ts#p > b', 'review', 'value-flow-veto'); + const fingerprintOf = ( + a: string, + b: string, + verdict: FingerprintVerdict, + rule: FingerprintRule, + ): Pick => ({ + a: `${prefix}${a}`, + b: `${prefix}${b}`, + verdict, + rule, + }); + const listedFingerprint = fingerprintOf( + 'p.case.ts#p > a', + 'p.case.ts#p > b', + 'table-row', + 'input-values-differ', + ); + + it.each([ + ['an empty expected list', [], [], [], [], ['no expected pair']], + [ + 'a strong repeat between two case files that expected.json does not list', + [expectedPair], + [ + listed, + verdictOf('p.case.ts#p > a', 'q.case.ts#q > a', 'repeat-strong', 'all-strong-conditions'), + ], + [listedFingerprint], + ['p.case.ts'], + ['unlisted repeat-strong: p.case.ts#p > a + q.case.ts#q > a'], + ], + [ + 'a weak repeat that expected.json does not list', + [expectedPair], + [listed, verdictOf('p.case.ts#p > b', 'q.case.ts#q > b', 'repeat-weak', 'weak-disqualifier')], + [listedFingerprint], + ['p.case.ts'], + ['unlisted repeat-weak: p.case.ts#p > b + q.case.ts#q > b'], + ], + [ + 'a fingerprint duplicate that expected.json does not list', + [expectedPair], + [listed], + [ + listedFingerprint, + fingerprintOf( + 'p.case.ts#p > a', + 'q.case.ts#q > a', + 'duplicate', + 'same-path-input-expectation', + ), + ], + ['p.case.ts'], + ['unlisted duplicate: p.case.ts#p > a + q.case.ts#q > a'], + ], + [ + 'a fingerprint verdict that differs', + [expectedPair], + [listed], + [ + fingerprintOf( + 'p.case.ts#p > a', + 'p.case.ts#p > b', + 'duplicate', + 'same-path-input-expectation', + ), + ], + ['p.case.ts'], + ['p fingerprint'], + ], + [ + 'a case file no expected pair exercises', + [expectedPair], + [listed], + [listedFingerprint], + ['p.case.ts', 'q.case.ts'], + ['case file without a pair: q.case.ts'], + ], + [ + 'an unlisted pair that is not a repeat (nothing to report)', + [expectedPair], + [listed, verdictOf('p.case.ts#p > a', 'q.case.ts#q > a', 'distinct', 'path-differs')], + [ + listedFingerprint, + fingerprintOf('p.case.ts#p > a', 'q.case.ts#q > a', 'distinct', 'path-differs'), + ], + ['p.case.ts'], + [], + ], + ] as const)('reports %s', (_title, expected, verdicts, fingerprints, caseFiles, differences) => { + expect( + compareControls(expected, verdicts, fingerprints, prefix, caseFiles).differences, + ).toEqual(differences); + }); + + it('finds the case files of a fixture, nested ones included, relative to its root', () => { + const dir = tempDir(); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + mkdirSync(path.join(dir, 'more'), { recursive: true }); + for (const file of ['b.case.ts', 'a.case.ts', 'more/c.case.ts', 'src/a.ts', 'expected.json']) { + writeFileSync(path.join(dir, file), ''); + } + expect(controlsCaseFiles(dir)).toEqual(['a.case.ts', 'b.case.ts', 'more/c.case.ts']); + }); +}); + +describe('PDG analyze budget', () => { + it('stays below the CI value-flow step timeout of 50 minutes, so the degraded path is reachable', () => { + expect(ANALYZE_TIMEOUT_MS).toBeLessThan(50 * 60_000); + }); +}); + +describe('compareControls', () => { + it('names every expected pair whose verdict or rule differs or has no verdict', () => { + const prefix = 'test/fixtures/mutation-scenario-map/'; + const comparison = compareControls( + [ + { + id: 'same', + tests: ['s.case.ts#s > b', 's.case.ts#s > a'], + verdict: 'row', + rule: 'row-deletion-asymmetric', + fingerprint: { verdict: 'table-row', rule: 'input-values-differ' }, + }, + { + id: 'other', + tests: ['o.case.ts#o > a', 'o.case.ts#o > b'], + verdict: 'review', + rule: 'facet-differs-same-kills', + fingerprint: { verdict: 'distinct', rule: 'input-shape-differs' }, + }, + { + id: 'gone', + tests: ['g.case.ts#g > a', 'g.case.ts#g > b'], + verdict: 'distinct', + rule: 'distinct', + fingerprint: { verdict: 'distinct', rule: 'path-differs' }, + }, + ], + [ + { + a: `${prefix}s.case.ts#s > a`, + b: `${prefix}s.case.ts#s > b`, + verdict: 'row', + rule: 'row-deletion-asymmetric', + }, + { + a: `${prefix}o.case.ts#o > a`, + b: `${prefix}o.case.ts#o > b`, + verdict: 'repeat-strong', + rule: 'facet-differs-same-kills', + }, + ], + [ + { + a: `${prefix}s.case.ts#s > a`, + b: `${prefix}s.case.ts#s > b`, + verdict: 'table-row', + rule: 'input-values-differ', + }, + { + a: `${prefix}o.case.ts#o > a`, + b: `${prefix}o.case.ts#o > b`, + verdict: 'distinct', + rule: 'input-shape-differs', + }, + ], + prefix, + ['s.case.ts', 'o.case.ts', 'g.case.ts'], + ); + expect(comparison.differences).toEqual(['other', 'gone', 'gone fingerprint']); + }); +}); + +describe('shardRowPlans', () => { + const rowPlan = (a: string, b: string): RowDeletionPlan => ({ + a, + b, + status: 'planned', + reasons: [], + positions: [], + deletions: [], + }); + const pairs = [ + candidatePair(A1, A2, 'ga'), + candidatePair(B1, B2, 'gb'), + candidatePair(C1, C2, 'gc'), + ]; + const groups = new Map([ + ['ga', { tests: [A1, A2] }], + ['gb', { tests: [B1] }], + ['gc', { tests: [C1, C2] }], + ]); + + it("runs a complete group's pairs whose two tests the trimmed group still holds", () => { + expect( + shardRowPlans( + [rowPlan(A1, A2), rowPlan(B1, B2), rowPlan(C1, C2)], + pairs, + groups, + new Set(['ga', 'gb']), + ).map((entry) => [entry.a, entry.b]), + ).toEqual([[A1, A2]]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/compact-observations.test.ts b/gitnexus/test/unit/scenario-map/compact-observations.test.ts new file mode 100644 index 000000000..a4d0b29a7 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/compact-observations.test.ts @@ -0,0 +1,529 @@ +/** + * Locks the compact observation model (U7, U12, R11) against the V8 string-limit overflow of the + * first full run: a run stores its universe and O(covered entries + per-test rows) explicit + * observations, never one per test and mutant, and skipped tests are no tests of the run. The + * small case pins comparePair, the per-test results and the gaps to the values the full + * materialisation (every not-covered survival stored) gave, and checks that the verdict report over + * the compact input equals the one over that materialisation. + */ +import { describe, expect, it } from 'vitest'; +import type { + NormalisedMutant, + NormalisedTest, + PerTestRow, + RunResults, +} from '../../../tools/mutation/driver/normalise-results.js'; +import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; +import { + comparePair, + encodeObservations, + extractObservations, + indexObservations, + mergeObservations, + type Observation, + type RunObservations, + type RunRef, +} from '../../../tools/scenario-map/mutation/observations.js'; +import { + computeVerdicts, + type VerdictInput, + type VerdictPairInput, + type VerdictTestInput, +} from '../../../tools/scenario-map/verdict/verdict.js'; + +const KILL: OutcomeClass = 'killed-by-test:assertion'; +const MAIN: RunRef = { group: 'g1', kind: 'main', lineage: 'main', shard: 0 }; + +const testRecord = (id: string, skipped: boolean): NormalisedTest => ({ + id, + file: id.split('#')[0], + fullName: id.split('#')[1], + taskId: null, + skipped, + notOracleValidated: false, + killed: [], + assertionKilled: [], +}); + +const row = (testId: string, outcome: OutcomeClass): PerTestRow => ({ + testId, + outcome, + attempt: 0, +}); + +const mutant = (globalId: string, overrides: Partial = {}): NormalisedMutant => ({ + globalId, + localId: globalId.slice(-4), + file: globalId.split('@')[0], + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 5 } }, + mutatorName: 'ArithmeticOperator', + replacement: '-', + static: false, + status: 'Survived', + statusReason: null, + testsCompleted: 1, + outcome: 'survived', + outcomeSource: 'sidecar', + killedBy: [], + coveredBy: [], + assertionKilledBy: [], + sidecar: null, + attempts: [ + { + file: '/s/m.json', + worker: '0', + attempt: 0, + pid: 1, + writtenAtMs: 1, + status: 'survived', + outcome: 'survived', + nrOfTests: 1, + }, + ], + perTest: [], + attemptsDisagree: false, + hookAttributedTests: [], + flags: [], + ...overrides, +}); + +function results( + mutants: NormalisedMutant[], + selected: string[], + skipped: readonly string[], +): RunResults { + return { + schemaVersion: 2, + variant: 'vitest5', + scope: { testFiles: ['test/unit/a.test.ts'], mutate: [], testIds: selected }, + paths: { + outDir: '/out', + preparedRoot: '/out/prepared', + packageDir: '/out/prepared/gitnexus', + configFile: '/out/stryker.config.json', + reportFile: '/out/mutation.json', + logFile: '/out/stryker.log', + runDir: '/out/run', + sidecarDir: '/out/sidecars', + sandboxDirs: [], + }, + stryker: { exitCode: 0, wallClockMs: 1 }, + spanMap: { checked: mutants.length, failures: [] }, + manifest: null, + dryRun: { + file: '/s/dry.json', + status: 'survived', + outcome: 'survived', + reason: null, + nrOfTests: selected.length, + attempts: 1, + }, + mutants, + tests: [ + ...selected.map((id) => testRecord(id, false)), + ...skipped.map((id) => testRecord(id, true)), + ], + flags: [], + summary: { + mutants: mutants.length, + byStatus: { + Killed: 0, + Survived: 0, + NoCoverage: 0, + CompileError: 0, + RuntimeError: 0, + Timeout: 0, + Ignored: 0, + Pending: 0, + }, + byOutcome: {}, + mutantsWithSidecar: 0, + mutantsWithOutcome: 0, + testsWithKills: 0, + testsWithAssertionKills: 0, + zeroTestRuns: 0, + flags: 0, + }, + }; +} + +/** + * The full materialisation the compact model replaced: every selected test without an explicit + * observation of a non-static universe mutant stored as a not-covered survival. + */ +function materialise(run: RunObservations): Observation[] { + const explicit = new Set(run.observations.map((entry) => `${entry.test}\n${entry.mutant}`)); + const reasons = (own: readonly string[]): string[] => + [...new Set([...run.universe.reasons, ...own])].sort(); + const implied = run.universe.mutants.flatMap((entry) => + entry.static + ? [] + : run.universe.tests + .filter((test) => !explicit.has(`${test}\n${entry.id}`)) + .map( + (test): Observation => ({ + ...MAIN, + attempt: null, + test, + mutant: entry.id, + file: entry.file, + covered: false, + hookAttributed: entry.hookMarker ? false : null, + static: false, + outcome: 'survived', + source: 'not-covered', + valid: reasons(entry.reasons).length === 0, + reasons: reasons(entry.reasons), + }), + ), + ); + return [...run.observations, ...implied]; +} + +// ── Regression: 2,000 uncovered mutants × 50 selected tests + 30 skipped ──── + +describe('a large run in the compact model', () => { + const pad = (index: number): string => String(index).padStart(4, '0'); + const selected = Array.from( + { length: 50 }, + (_, index) => `test/unit/a.test.ts#A > t${pad(index)}`, + ); + const skipped = Array.from( + { length: 30 }, + (_, index) => `test/unit/a.test.ts#A > s${pad(index)}`, + ); + const id = (index: number, mutator: string): string => + `gitnexus/src/x.ts@0123456789abcdef:${index + 1}:1-${index + 1}:5:${mutator}:` + + `${pad(index)}aaaaaaaaaaaa`; + // 2,000 non-static mutants no selected test covers; every 10th one covered by a skipped test. + const uncovered = Array.from({ length: 2000 }, (_, index) => + mutant(id(index, 'BlockStatement'), { + status: 'NoCoverage', + outcomeSource: 'no-coverage', + attempts: [], + coveredBy: index % 10 === 0 ? [skipped[index % 30]] : [], + }), + ); + // 10 mutants covered by two selected tests each (one kill and one survival row: 20 rows). + const covered = Array.from({ length: 10 }, (_, index) => + mutant(id(2000 + index, 'ArithmeticOperator'), { + status: 'Killed', + outcome: KILL, + coveredBy: [selected[index], selected[index + 1]], + perTest: [row(selected[index], KILL), row(selected[index + 1], 'survived')], + }), + ); + // 2 static mutants: every selected test runs against them (100 rows). + const statik = Array.from({ length: 2 }, (_, index) => + mutant(id(2010 + index, 'StringLiteral'), { + static: true, + perTest: selected.map((test) => row(test, 'survived')), + }), + ); + const run = extractObservations( + results([...uncovered, ...covered, ...statik], selected, skipped), + MAIN, + ); + + it('stores O(covered + rows) explicit observations, not one per test and mutant', () => { + expect([ + run.universe.tests.length, + run.universe.mutants.length, + run.observations.length, + ]).toEqual([50, 2012, 20 + 100]); + }); + + it('gives the skipped tests no observation and no place in the universe', () => { + const skippedIds = new Set(skipped); + expect([ + run.universe.tests.some((test) => skippedIds.has(test)), + run.observations.some((entry) => skippedIds.has(entry.test)), + ]).toEqual([false, false]); + }); + + it('writes the run as rows of a few bytes each', () => { + const rows = encodeObservations(run.universe, run.observations); + expect(JSON.stringify(rows).length).toBeLessThan(run.observations.length * 64); + }); + + it('still compares two selected tests over every evaluated mutant', () => { + const index = indexObservations(mergeObservations(run.observations, [run.universe])); + const comparison = comparePair(index, MAIN, selected[0], selected[1]); + expect([ + comparison.universe.length, + comparison.unknown.length, + comparison.killsA.length, + comparison.killsB.length, + comparison.sameKills, + ]).toEqual([2012, 0, 1, 1, false]); + }); +}); + +// ── Equivalence with the full materialisation on a small case ────────────── + +const T1 = 'test/unit/a.test.ts#A > one'; +const T2 = 'test/unit/a.test.ts#A > two'; +const T3 = 'test/unit/b.test.ts#B > three'; +const TS = 'test/unit/b.test.ts#B > skipped'; +const X = 'gitnexus/src/x.ts'; +const Y = 'gitnexus/src/y.ts'; +const gid = (file: string, line: number, mutator: string): string => + `${file}@0123456789abcdef:${line}:1-${line}:5:${mutator}:${line.toString(16).padStart(16, 'a')}`; +const M = { + killedByOne: gid(X, 1, 'ArithmeticOperator'), + statik: gid(X, 2, 'StringLiteral'), + noCoverage: gid(X, 3, 'BlockStatement'), + survivedByTwo: gid(X, 4, 'EqualityOperator'), + missingRow: gid(X, 5, 'ConditionalExpression'), + flagged: gid(X, 6, 'ArithmeticOperator'), + noMarker: gid(X, 7, 'EqualityOperator'), + strykerTimeout: gid(X, 8, 'BlockStatement'), + skippedOnly: gid(X, 9, 'StringLiteral'), + killedByOneAndThree: gid(Y, 10, 'ArithmeticOperator'), + ignored: gid(Y, 11, 'StringLiteral'), + hookedUncovered: gid(Y, 12, 'EqualityOperator'), +} as const; +const nameOf = new Map(Object.entries(M).map(([name, globalId]) => [globalId, name])); +const names = (ids: readonly string[]): string[] => ids.map((entry) => nameOf.get(entry) ?? entry); +const killed = { status: 'Killed', outcome: KILL } as const; + +const smallRun = (): RunObservations => + extractObservations( + results( + [ + mutant(M.killedByOne, { + ...killed, + coveredBy: [T1, T2], + perTest: [row(T1, KILL), row(T2, 'survived')], + }), + mutant(M.statik, { + static: true, + perTest: [row(T1, 'survived'), row(T2, 'survived'), row(T3, 'survived')], + }), + mutant(M.noCoverage, { status: 'NoCoverage', outcomeSource: 'no-coverage', attempts: [] }), + mutant(M.survivedByTwo, { coveredBy: [T2], perTest: [row(T2, 'survived')] }), + mutant(M.missingRow, { coveredBy: [T3] }), + mutant(M.flagged, { + ...killed, + coveredBy: [T1], + perTest: [row(T1, KILL)], + flags: ['status-mismatch'], + }), + mutant(M.noMarker, { + ...killed, + coveredBy: [T2], + perTest: [row(T2, KILL)], + hookAttributedTests: null, + }), + mutant(M.strykerTimeout, { + status: 'Timeout', + outcome: 'timeout', + outcomeSource: 'stryker-timeout', + coveredBy: [T1], + attempts: [], + }), + mutant(M.skippedOnly, { coveredBy: [TS] }), + mutant(M.killedByOneAndThree, { + ...killed, + coveredBy: [T1, T3], + perTest: [row(T1, KILL), row(T3, KILL)], + }), + mutant(M.ignored, { status: 'Ignored', attempts: [] }), + mutant(M.hookedUncovered, { + coveredBy: [T1], + perTest: [row(T1, 'survived')], + hookAttributedTests: [T3], + }), + ], + [T1, T2, T3], + [TS], + ), + MAIN, + ); + +const testInput = (id: string): VerdictTestInput => ({ + id, + eligible: true, + fingerprintEligible: true, + passed: true, + notOracleValidated: false, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + provenance: { known: true, nativeEntries: [], workerSpawns: [] }, + concurrent: false, + excludedFiles: [], + checksContext: 'k', + unsupportedForms: [], + unresolvedSelectors: [], + facet: null, +}); + +const pairInput = (a: string, b: string): VerdictPairInput => ({ + a, + b, + group: 'g1', + identicalBlocks: true, + pathUnattributable: false, + schedule: { status: 'evaluated', reason: null, detail: null }, + isolation: null, + rowDeletion: null, + staticValueFlow: null, + trace: null, +}); + +const verdictInput = (observations: VerdictInput['observations']): VerdictInput => ({ + tests: [T1, T2, T3].map(testInput), + pairs: [pairInput(T1, T2), pairInput(T1, T3), pairInput(T2, T3)], + observations, + valueFlowDegraded: [], +}); + +describe('the compact model against the full materialisation', () => { + const run = smallRun(); + const compact = mergeObservations(run.observations, [run.universe]); + const full = mergeObservations(materialise(run), []); + + it('stores 14 of the 33 observations the materialisation held', () => { + expect([run.observations.length, materialise(run).length]).toEqual([14, 33]); + }); + + // Expected values: the pre-compact implementation on the same results. + it.each([ + [ + 'one and two', + T1, + T2, + 'u33495bf29f08f907', + [ + 'killedByOne', + 'statik', + 'noCoverage', + 'survivedByTwo', + 'missingRow', + 'noMarker', + 'skippedOnly', + 'killedByOneAndThree', + 'hookedUncovered', + ], + ['flagged', 'strykerTimeout'], + ['killedByOne', 'killedByOneAndThree'], + ['noMarker'], + ], + [ + 'one and three', + T1, + T3, + 'uf232dd5e4781a286', + [ + 'killedByOne', + 'statik', + 'noCoverage', + 'survivedByTwo', + 'noMarker', + 'skippedOnly', + 'killedByOneAndThree', + 'hookedUncovered', + ], + ['missingRow', 'flagged', 'strykerTimeout'], + ['killedByOne', 'killedByOneAndThree'], + ['killedByOneAndThree'], + ], + [ + 'two and three', + T2, + T3, + 'ud73e410af4b52027', + [ + 'killedByOne', + 'statik', + 'noCoverage', + 'survivedByTwo', + 'noMarker', + 'strykerTimeout', + 'skippedOnly', + 'killedByOneAndThree', + 'hookedUncovered', + ], + ['missingRow', 'flagged'], + ['noMarker'], + ['killedByOneAndThree'], + ], + ])( + 'compares tests %s as the materialisation did', + (_title, a, b, universeId, universe, unknown, killsA, killsB) => { + const comparison = comparePair(indexObservations(compact), MAIN, a, b); + expect({ + universeId: comparison.universeId, + universe: names(comparison.universe), + unknown: names(comparison.unknown), + killsA: names(comparison.killsA), + killsB: names(comparison.killsB), + otherClasses: comparison.otherClasses, + sameKills: comparison.sameKills, + }).toEqual({ + universeId, + universe, + unknown, + killsA, + killsB, + otherClasses: [], + sameKills: null, + }); + }, + ); + + it('gives the per-test results the materialisation gave', () => { + const report = computeVerdicts(verdictInput(compact)); + expect( + report.tests.map((test) => [test.status, test.covered, test.kills, test.subsumption]), + ).toEqual([ + [ + 'killing', + 5, + 2, + { status: 'not-subsumed', by: [], universeId: 'u33495bf29f08f907', universe: 11 }, + ], + [ + 'killing', + 3, + 1, + { status: 'not-subsumed', by: [], universeId: 'u13a8131490f974fb', universe: 11 }, + ], + [ + 'killing', + 2, + 1, + { status: 'unknown', by: [T1], universeId: 'ud73e410af4b52027', universe: 11 }, + ], + ]); + }); + + it('gives the gaps the materialisation gave', () => { + const report = computeVerdicts(verdictInput(compact)); + expect( + report.gaps.map((gap) => [ + gap.file, + gap.mutants, + gap.killed, + names(gap.survived), + names(gap.uncovered), + names(gap.inconclusive), + ]), + ).toEqual([ + [ + X, + 9, + 2, + ['statik', 'survivedByTwo'], + ['noCoverage', 'skippedOnly'], + ['missingRow', 'flagged', 'strykerTimeout'], + ], + [Y, 2, 1, ['hookedUncovered'], [], []], + ]); + }); + + it('computes a verdict report identical to the one over the materialisation', () => { + expect(computeVerdicts(verdictInput(compact))).toEqual(computeVerdicts(verdictInput(full))); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/context.test.ts b/gitnexus/test/unit/scenario-map/context.test.ts new file mode 100644 index 000000000..6726fddbe --- /dev/null +++ b/gitnexus/test/unit/scenario-map/context.test.ts @@ -0,0 +1,128 @@ +/** + * Locks the Layer 2 context of a test (U5, R5, design §2 "Context"): Layer 0's context hash is + * reused unchanged (enclosing hooks, describe-level statements, wrapper-call arguments, reached + * declarations), and only what Layer 0 keeps out of it is added: mock, spy and env setup inside + * the body, and the test call's own options. Row values are inputs, so the rows of an `it.each` + * call share one context. + */ +import { describe, expect, it } from 'vitest'; +import { contextOf } from '../../../tools/scenario-map/facet/context.js'; +import { extractCases, type CaseRecord } from '../../../tools/test-dupes/test-dupes.js'; + +const FILE = 'gitnexus/test/unit/a.test.ts'; +const recordsOf = (...lines: string[]): CaseRecord[] => + extractCases({ path: FILE, source: lines.join('\n') }); +const IMPORTS = [`import * as m from '../../src/m.js';`]; + +/** The two records of a file holding the two given tests inside one describe. */ +function pair(first: string, second: string): [CaseRecord, CaseRecord] { + const records = recordsOf(...IMPORTS, `describe('d', () => {`, first, second, `});`); + expect(records).toHaveLength(2); + return [records[0], records[1]]; +} + +describe('contextOf: Layer 0 parts', () => { + it('splits two tests that differ only in an afterEach body', () => { + const records = recordsOf( + ...IMPORTS, + `describe('a', () => {`, + ` afterEach(() => { m.reset(); });`, + ` it('t', () => { expect(m.f()).toBe(1); });`, + `});`, + `describe('b', () => {`, + ` afterEach(() => { m.clear(); });`, + ` it('t', () => { expect(m.f()).toBe(1); });`, + `});`, + ); + const [a, b] = records.map(contextOf); + expect(a.layer0).toBe(records[0].contextHash); + expect(a.hash).not.toBe(b.hash); + }); + + it('shares the context of two tests under the same hooks whatever their bodies', () => { + const records = recordsOf( + ...IMPORTS, + `describe('a', () => {`, + ` afterEach(() => { m.reset(); });`, + ` it('one', () => { expect(m.f()).toBe(1); });`, + ` it('two', () => { expect(m.f()).toBe(2); });`, + `});`, + ); + expect(contextOf(records[0]).hash).toBe(contextOf(records[1]).hash); + }); + + it('shares the context of an it.each call and a plain test: row values are inputs', () => { + const [each, plain] = pair( + `it.each([[1], [2]])('row %i', (n) => { expect(m.f(n)).toBe(n); });`, + `it('plain', () => { expect(m.f(1)).toBe(1); });`, + ); + expect(each.each).not.toBeNull(); + expect(contextOf(each).hash).toBe(contextOf(plain).hash); + }); +}); + +describe('contextOf: parts Layer 0 keeps out of its context hash', () => { + it.each([ + [ + 'a vi.spyOn in the body', + `it('a', () => { vi.spyOn(m, 'g'); expect(m.f()).toBe(1); });`, + `it('b', () => { expect(m.f()).toBe(1); });`, + ], + [ + 'a vi.stubEnv value in the body', + `it('a', () => { vi.stubEnv('MODE', 'x'); expect(m.f()).toBe(1); });`, + `it('b', () => { vi.stubEnv('MODE', 'y'); expect(m.f()).toBe(1); });`, + ], + [ + 'an env write in the body', + `it('a', () => { process.env.MODE = 'x'; expect(m.f()).toBe(1); });`, + `it('b', () => { expect(m.f()).toBe(1); });`, + ], + [ + 'the fails modifier', + `it.fails('a', () => { expect(m.f()).toBe(1); });`, + `it('b', () => { expect(m.f()).toBe(1); });`, + ], + [ + 'a skipIf condition', + `it.skipIf(m.flag)('a', () => { expect(m.f()).toBe(1); });`, + `it('b', () => { expect(m.f()).toBe(1); });`, + ], + [ + 'an options argument', + `it('a', { retry: 2 }, () => { expect(m.f()).toBe(1); });`, + `it('b', () => { expect(m.f()).toBe(1); });`, + ], + ])('splits two tests that differ only in %s', (_label, first, second) => { + const [a, b] = pair(first, second); + expect(a.contextHash).toBe(b.contextHash); + expect(contextOf(a).hash).not.toBe(contextOf(b).hash); + }); + + it('records the body setup and the call options as exact code', () => { + const [record] = recordsOf( + ...IMPORTS, + `it.concurrent.skipIf(m.flag)('t', { timeout: 5 }, () => {`, + ` const spy = vi.spyOn(m, 'g');`, + ` m.run();`, + ` expect(spy).toHaveBeenCalled();`, + `});`, + ); + const context = contextOf(record); + expect(context.bodySetup).toEqual([`const spy=vi.spyOn(m,'g');`]); + expect(context.callOptions).toEqual([ + '.concurrent', + '.skipIf', + 'mod:m.flag', + 'arg:{timeout:5}', + ]); + }); + + it('keeps the each table out of the call options', () => { + const [record] = recordsOf( + ...IMPORTS, + `it.concurrent.each([[1], [2]])('t %i', (n) => { expect(m.f(n)).toBe(1); });`, + ); + expect(contextOf(record).callOptions).toEqual(['.concurrent']); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/coverage-records.test.ts b/gitnexus/test/unit/scenario-map/coverage-records.test.ts new file mode 100644 index 000000000..40d6a01ee --- /dev/null +++ b/gitnexus/test/unit/scenario-map/coverage-records.test.ts @@ -0,0 +1,457 @@ +/** + * Locks Layer 1's record processing (U4, R3, R4, KTD4, KTD7, design §5.2) without running Vitest: + * the worker-side pieces (records.ts: script paths, block keys, the attribution state machine, + * the spawn wrappers) feed synthetic V8 windows into raw records, and run-coverage.ts + * `buildWorkFile` turns them into the work file: module-load blocks are discarded, a suite's + * `beforeAll` blocks reach only its own tests while its provenance reaches every test of the + * outermost suite that started with it, the warm attempt gives the blocks and every attempt the + * provenance, blocks are keyed by repo path and kept for production files only, native entries, + * excluded candidates and spawns are flagged, and a concurrent or unrun test has unknown + * provenance. + */ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { + CoverageAttributor, + CoverageInterner, + callSite, + scriptPath, + spawnTarget, + wrapCall, + type Callable, + type RawCoverageRecord, + type RuntimeEvents, + type SpawnEvent, + type V8Function, + type V8Script, +} from '../../../tools/scenario-map/path-coverage/records.js'; +import { + buildWorkFile, + candidateTestsOf, + isNativeEntry, + type CoverageTest, + type CoverageWorkFile, + type RuntimeModule, + type RuntimeTest, +} from '../../../tools/scenario-map/path-coverage/run-coverage.js'; + +const PREPARED = '/prep'; +const PKG = `${PREPARED}/gitnexus`; +const MODULES = '/repo/gitnexus/node_modules'; +const TEST_FILE = 'test/unit/a.test.ts'; +const BLOCK_ROOTS = [`${PKG}/src/`, `${PKG}/relocated/gitnexus-shared/src/`]; +const SELF = new Set([ + `${PKG}/tools/scenario-map/path-coverage/coverage.setup.ts`, + `${PKG}/tools/scenario-map/path-coverage/records.ts`, +]); +const NO_EVENTS: RuntimeEvents = { natives: [], spawns: [] }; + +const fn = (functionName: string, ...ranges: [number, number, number][]): V8Function => ({ + functionName, + ranges: ranges.map(([startOffset, endOffset, count]) => ({ startOffset, endOffset, count })), +}); +const script = (url: string, ...functions: V8Function[]): V8Script => ({ url, functions }); +/** A script whose single function ran: listed as executed (and blocked under a block root). */ +const ran = (url: string, name = 'f', start = 0): V8Script => + script(url, fn(name, [start, start + 9, 1])); + +const rt = (taskId: string, fullName: string, line: number, extra: Partial = {}) => ({ + taskId, + fullName, + location: { line, column: 3 }, + state: 'passed' as const, + concurrent: false, + ...extra, +}); + +/** Drives one file's windows through the worker-side pieces, as coverage.setup.ts does. */ +class FileRun { + readonly interner = new CoverageInterner(BLOCK_ROOTS, SELF); + readonly attributor = new CoverageAttributor(); + + #delta(scripts: readonly V8Script[], events: RuntimeEvents) { + return this.interner.summarize(scripts, events); + } + + load(...scripts: V8Script[]): this { + this.attributor.load(this.#delta(scripts, NO_EVENTS)); + return this; + } + + start(taskId: string, chain: string[], scripts: V8Script[] = [], concurrent = false): this { + this.attributor.testStart(taskId, chain, concurrent, this.#delta(scripts, NO_EVENTS)); + return this; + } + + end(taskId: string, scripts: V8Script[] = [], events: RuntimeEvents = NO_EVENTS): this { + this.attributor.testEnd(taskId, this.#delta(scripts, events)); + return this; + } + + finish(...scripts: V8Script[]): RawCoverageRecord { + this.attributor.finish(this.#delta(scripts, NO_EVENTS)); + return this.attributor.record(`${PKG}/${TEST_FILE}`, this.interner); + } +} + +const work = (raws: RawCoverageRecord[], tests: RuntimeTest[]): CoverageWorkFile => + buildWorkFile({ + mode: { kind: 'normal' }, + preparedRoot: PREPARED, + repeats: 1, + modules: [{ file: TEST_FILE, errors: [], tests } satisfies RuntimeModule], + raws, + unhandledErrors: [], + }); + +const blocksOf = (file: CoverageWorkFile, test: CoverageTest, which: 'blocks' | 'suiteBlocks') => + test[which].map((index) => file.blocks[index]); + +const byName = (file: CoverageWorkFile, fullName: string): CoverageTest => { + const found = file.tests.find((test) => test.fullName === fullName); + if (found === undefined) throw new Error(`no test ${fullName}`); + return found; +}; + +describe('scriptPath', () => { + const absolute = path.resolve('/prep/x.ts'); + it.each([ + { + name: 'a plain path, query dropped', + url: `${PKG}/src/a.ts?v=12`, + expected: `${PKG}/src/a.ts`, + }, + { + name: 'a file URL', + url: pathToFileURL(absolute).href, + expected: absolute.split(path.sep).join('/'), + }, + { name: 'a Windows path', url: 'C:\\prep\\a.ts', expected: 'C:/prep/a.ts' }, + { name: 'a node: builtin', url: 'node:internal/fs/utils', expected: null }, + { name: 'evaluated code', url: 'evalmachine.', expected: null }, + { name: 'an empty URL', url: '', expected: null }, + ])('maps $name', ({ url, expected }) => { + expect(scriptPath(url)).toBe(expected); + }); +}); + +describe('CoverageInterner', () => { + it('keys executed functions and their nested ranges with a covered flag, blocks under block roots only', () => { + const interner = new CoverageInterner(BLOCK_ROOTS, SELF); + const delta = interner.summarize( + [ + script( + `${PKG}/src/a.ts`, + fn('', [0, 100, 1]), + fn('add', [10, 50, 2], [20, 30, 0], [30, 40, 1]), + fn('unused', [60, 80, 0]), + ), + script(`${PKG}/src/b.ts`, fn('idle', [0, 10, 0])), + ran(`${PKG}/${TEST_FILE}`), + ran(`${PKG}/tools/scenario-map/path-coverage/records.ts`), + ran('node:internal/process/task_queues'), + ran(`${MODULES}/vitest/dist/index.js`), + ], + NO_EVENTS, + ); + expect([...delta.blocks].map((index) => interner.blocks[index])).toEqual([ + '0#@0', + '0#add@10', + '0#add@10|20-30:0', + '0#add@10|30-40:1', + ]); + expect([...delta.scripts].map((index) => interner.scripts[index])).toEqual([ + `${PKG}/src/a.ts`, + `${PKG}/${TEST_FILE}`, + `${MODULES}/vitest/dist/index.js`, + ]); + }); + + it('carries the runtime events of the window', () => { + const spawn: SpawnEvent = { api: 'child_process.spawn', target: 'git', site: null }; + const delta = new CoverageInterner(BLOCK_ROOTS, SELF).summarize([], { + natives: ['/x/a.node'], + spawns: [spawn], + }); + expect([[...delta.natives], delta.spawns]).toEqual([['/x/a.node'], [spawn]]); + }); +}); + +describe('attribution (design §5.2)', () => { + const S = 'suite-s'; + const F = 'file-f'; + const record = new FileRun() + .load(ran(`${PKG}/src/module-load.ts`, 'atLoad')) + // `describe('s')` with a beforeAll that calls production code, then t1 and t2; t3 is top-level. + .start('t1', [F, S], [ran(`${PKG}/src/setup.ts`, 'buildGraph'), ran(`${PKG}/tools/x.ts`)]) + .end('t1', [ + ran(`${PKG}/src/a.ts`, 'first'), + ran(`${PKG}/src/lazy.ts`, 'coldInit'), + ran(`${PKG}/tools/cold.ts`), + ]) + .start('t1', [F, S]) + .end('t1', [ran(`${PKG}/src/a.ts`, 'first')]) + .start('t2', [F, S]) + .end('t2', [ran(`${PKG}/src/a.ts`, 'second', 20)]) + .start('t3', [F], [ran(`${PKG}/src/teardown.ts`, 'afterAllOfS')]) + .end('t3', [ran(`${PKG}/src/b.ts`, 'third')]) + .finish(ran(`${PKG}/src/tail.ts`, 'afterAllOfFile')); + const file = work([record], [rt('t1', 's > t1', 3), rt('t2', 's > t2', 4), rt('t3', 't3', 6)]); + + it.each([ + { name: 's > t1', blocks: ['gitnexus/src/a.ts#first@0', 'gitnexus/src/setup.ts#buildGraph@0'] }, + { + name: 's > t2', + blocks: ['gitnexus/src/a.ts#second@20', 'gitnexus/src/setup.ts#buildGraph@0'], + }, + { name: 't3', blocks: ['gitnexus/src/b.ts#third@0'] }, + ])('gives $name its own and its suites’ blocks, never module-load blocks', ({ name, blocks }) => { + expect(blocksOf(file, byName(file, name), 'blocks')).toEqual(blocks); + }); + + it('shows the beforeAll code in the suite blocks of the suite’s tests', () => { + expect(blocksOf(file, byName(file, 's > t2'), 'suiteBlocks')).toEqual([ + 'gitnexus/src/setup.ts#buildGraph@0', + ]); + }); + + it('keeps the warm attempt’s blocks but the provenance of every attempt', () => { + const t1 = byName(file, 's > t1'); + expect(t1.attempts).toBe(2); + expect(blocksOf(file, t1, 'blocks')).not.toContain('gitnexus/src/lazy.ts#coldInit@0'); + expect(t1.provenance.executedFiles).toContain('gitnexus/src/lazy.ts'); + expect(t1.provenance.excludedCandidates).toEqual([ + 'gitnexus/tools/cold.ts', + 'gitnexus/tools/x.ts', + ]); + }); + + it('gives the provenance of a window that started the file and a suite to every test of the file', () => { + expect(byName(file, 't3').provenance.excludedCandidates).toEqual(['gitnexus/tools/x.ts']); + }); + + it('keeps load, afterAll and tail provenance in the file’s unattributed bucket', () => { + expect(file.files[0].unattributed.executedFiles).toEqual([ + 'gitnexus/src/module-load.ts', + 'gitnexus/src/tail.ts', + 'gitnexus/src/teardown.ts', + ]); + }); + + it('leaves blocks no test owns out of the block table', () => { + expect(file.blocks).not.toContain('gitnexus/src/module-load.ts#atLoad@0'); + expect(file.blocks).not.toContain('gitnexus/src/tail.ts#afterAllOfFile@0'); + }); +}); + +describe('buildWorkFile: block ids', () => { + const record = new FileRun() + .load() + .start('t1', ['f']) + .end('t1', [ + ran(`${PKG}/relocated/gitnexus-shared/src/lang.ts`, 'langOf', 4), + ran(`${PKG}/src/core/x.ts`, 'run', 7), + ran(`${PKG}/src/generated.js`, 'gen'), + ]) + .finish(); + const file = work([record], [rt('t1', 't1', 3)]); + + it('keys blocks by repo path, keeps production files only and sorts the table', () => { + expect(file.blocks).toEqual([ + 'gitnexus-shared/src/lang.ts#langOf@4', + 'gitnexus/src/core/x.ts#run@7', + ]); + expect(file.tests[0].blocks).toEqual([0, 1]); + }); + + it('still lists the non-production file as executed', () => { + expect(file.tests[0].provenance.executedFiles).toContain('gitnexus/src/generated.js'); + }); + + it('gives Phase 0 ids, task ids and locations', () => { + expect(file.tests[0]).toMatchObject({ + id: `${TEST_FILE}#t1`, + taskId: 't1', + file: TEST_FILE, + location: { line: 3, column: 3 }, + }); + }); +}); + +describe('buildWorkFile: provenance', () => { + const record = new FileRun() + .load() + .start('t1', ['f']) + .end( + 't1', + [ + ran(`${PKG}/${TEST_FILE}`), + ran(`${MODULES}/tree-sitter/index.js`), + ran(`${MODULES}/vitest/dist/chunks/run.js`), + ran(`${PKG}/tools/mutation/prepare/path-map.ts`), + ran(`${PKG}/test/helpers/fixtures.ts`), + ], + { + natives: [`${MODULES}/@ladybugdb/core/lbug.node`], + spawns: [{ api: 'child_process.spawn', target: 'git', site: `${PKG}/src/git.ts:3:7` }], + }, + ) + .finish(); + const file = work([record], [rt('t1', 't1', 3)]); + + it('lists executed files without harness packages', () => { + expect(file.tests[0].provenance.executedFiles).toEqual([ + 'gitnexus/test/helpers/fixtures.ts', + 'gitnexus/test/unit/a.test.ts', + 'gitnexus/tools/mutation/prepare/path-map.ts', + 'node_modules/tree-sitter/index.js', + ]); + }); + + it('flags native-package entries, executed or loaded through dlopen', () => { + expect(file.tests[0].provenance.nativeEntries).toEqual([ + 'node_modules/@ladybugdb/core/lbug.node', + 'node_modules/tree-sitter/index.js', + ]); + }); + + it('names executed package files that are neither tests nor production as excluded candidates', () => { + expect(file.tests[0].provenance.excludedCandidates).toEqual([ + 'gitnexus/tools/mutation/prepare/path-map.ts', + ]); + }); + + it('records worker spawns with the call site as a repo path', () => { + expect(file.tests[0].provenance.workerSpawns).toEqual([ + { api: 'child_process.spawn', target: 'git', site: 'gitnexus/src/git.ts:3:7' }, + ]); + }); + + it('marks the provenance of a test that ran alone as known', () => { + expect(file.tests[0].provenance.known).toBe(true); + }); +}); + +describe('isNativeEntry', () => { + it.each([ + { key: 'node_modules/tree-sitter/index.js', native: true }, + { key: 'node_modules/tree-sitter-python/bindings/node/index.js', native: true }, + { key: 'node_modules/@ladybugdb/core/index.js', native: true }, + { key: 'node_modules/onnxruntime-node/dist/index.js', native: true }, + { key: 'node_modules/a/node_modules/tree-sitter/x.js', native: true }, + { key: 'gitnexus/build/addon.node', native: true }, + { key: 'node_modules/lodash/index.js', native: false }, + { key: 'node_modules/@ladybugdb/other/index.js', native: false }, + { key: 'gitnexus/src/core/tree-sitter/parser.ts', native: false }, + ])('classifies $key', ({ key, native }) => { + expect(isNativeEntry(key)).toBe(native); + }); +}); + +describe('buildWorkFile: concurrent and unrun tests', () => { + it.each([ + { retry: undefined, expected: 0 }, + { retry: 0, expected: 0 }, + { retry: 1, expected: 1 }, + { retry: { count: 2 }, expected: 2 }, + { retry: {}, expected: 0 }, + ])( + 'records configured retries ($retry) independently of observed attempts', + ({ retry, expected }) => { + const file = work([], [rt('t', 't', 3, { retry })]); + expect(file.tests[0]).toMatchObject({ state: 'passed', retry: expected, attempts: 0 }); + }, + ); + + const record = new FileRun() + .load() + .start('c1', ['f'], [], true) + .end('c1', [ran(`${PKG}/src/a.ts`)]) + .start('c2', ['f']) + .end('c2', [ran(`${PKG}/src/a.ts`)]) + .finish(); + + it.each([ + { name: 'concurrent in the worker', taskId: 'c1', runtime: false }, + { name: 'concurrent in the collected options', taskId: 'c2', runtime: true }, + ])( + 'marks a test $name as concurrent, with no path and unknown provenance', + ({ taskId, runtime }) => { + const file = work([record], [rt(taskId, 't', 3, { concurrent: runtime })]); + const [test] = file.tests; + expect([test.concurrent, test.blocks, test.provenance.known]).toEqual([true, [], false]); + }, + ); + + it('keeps a concurrent test that ran without a window of its own as a candidate input', () => { + const file = work([record], [rt('c3', 'c3', 5, { concurrent: true })]); + expect(candidateTestsOf(file, new Map([[`${TEST_FILE}#c3`, 'key']]))).toEqual([ + { id: `${TEST_FILE}#c3`, blocks: [], concurrent: true, checksContext: 'key' }, + ]); + }); + + it('leaves a test that did not run out of the candidate inputs', () => { + const file = work([record], [rt('skipped', 'skipped', 9, { state: 'skipped' })]); + expect(candidateTestsOf(file, new Map())).toEqual([]); + }); + + it('gives a test without a window no blocks and unknown provenance', () => { + const file = work([record], [rt('skipped', 'skipped', 9, { state: 'skipped' })]); + expect(file.tests[0]).toMatchObject({ attempts: 0, blocks: [], state: 'skipped' }); + expect(file.tests[0].provenance.known).toBe(false); + }); + + it('marks a file without a raw record as not recorded', () => { + const file = work([], [rt('t', 't', 3)]); + expect([file.files[0].recorded, file.tests[0].provenance.known]).toEqual([false, false]); + }); +}); + +describe('wrapCall and callSite', () => { + it('passes calls through and reports them', () => { + const notes: [string, readonly unknown[]][] = []; + const original: Callable = function (this: unknown, ...args: unknown[]) { + return [this, ...args]; + }; + const wrapped = wrapCall(original, 'child_process.spawn', (api, args) => + notes.push([api, args]), + ); + const receiver = { tag: 'r' }; + expect(wrapped.call(receiver, 'git', ['status'])).toEqual([receiver, 'git', ['status']]); + expect(notes).toEqual([['child_process.spawn', ['git', ['status']]]]); + }); + + it('wraps util.promisify.custom so promisified calls are seen and keep their shape', async () => { + const notes: string[] = []; + const original: Callable = () => undefined; + Reflect.defineProperty(original, promisify.custom, { + value: async (command: string) => ({ stdout: command, stderr: '' }), + }); + const wrapped = wrapCall(original, 'child_process.exec', (api) => notes.push(api)); + const run = promisify(wrapped) as unknown as (command: string) => Promise; + await expect(run('ls')).resolves.toEqual({ stdout: 'ls', stderr: '' }); + expect(notes).toEqual(['child_process.exec']); + }); + + it('takes the first stack frame outside the hook', () => { + const stack = [ + 'Error', + ` at wrapped (${PKG}/tools/scenario-map/path-coverage/records.ts:10:5)`, + ' at node:internal/child_process:1:1', + ` at runGit (${PKG}/src/git.ts:3:7)`, + ` at ${PKG}/${TEST_FILE}:9:2`, + ].join('\n'); + expect(callSite(stack, (file) => SELF.has(file))).toBe(`${PKG}/src/git.ts:3:7`); + }); + + it('returns no call site when every frame is the hook', () => { + const stack = `Error\n at x (${PKG}/tools/scenario-map/path-coverage/records.ts:1:1)`; + expect(callSite(stack, (file) => SELF.has(file))).toBeNull(); + }); + + it('cuts a long spawn target', () => { + expect(spawnTarget(['x'.repeat(500)])).toHaveLength(201); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/eligible.test.ts b/gitnexus/test/unit/scenario-map/eligible.test.ts new file mode 100644 index 000000000..6a2d266cf --- /dev/null +++ b/gitnexus/test/unit/scenario-map/eligible.test.ts @@ -0,0 +1,151 @@ +/** + * Locks the eligible set of the scenario map (R1): the gate 1 envelope's accepted files that the + * classifier still accepts, with the delta in both directions, and the `controls` mode that + * enrols a fixture project's `*.case.ts` files without consulting the envelope. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { + computeEligibility, + ENVELOPE_FILE, + parseEnvelopeFiles, + resolveEligibility, + type ClassifierVerdicts, +} from '../../../tools/scenario-map/inventory/eligible.js'; + +const A = 'test/unit/a.test.ts'; +const B = 'test/unit/b.test.ts'; +const C = 'test/unit/c.test.ts'; +const D = 'test/unit/d.test.ts'; +const FIXTURE = 'gitnexus/test/fixtures/mutation-scenario-map'; + +const verdicts: ClassifierVerdicts = { + accepted: [A, C, D], + rejected: [{ file: B, reasons: ['native package: tree-sitter'] }], +}; + +const roots: string[] = []; +afterAll(() => { + for (const root of roots) rmSync(root, { recursive: true, force: true }); +}); +/** A temp repo root with the given repo-relative files. */ +const tempRepo = (files: Record): string => { + const root = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-eligible-')); + roots.push(root); + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } + return root; +}; +const envelope = (files: string[]): string => JSON.stringify({ accepted: { files } }); + +describe('computeEligibility', () => { + it('keeps the envelope files the classifier still accepts and reports the delta', () => { + expect(computeEligibility([A, B, C], verdicts)).toEqual({ + mode: 'normal', + files: [A, C], + fingerprintFiles: [A, C], + delta: { + notValidated: [D], + rejected: [{ file: B, reasons: ['native package: tree-sitter'] }], + }, + extraRoots: [], + }); + }); + + it('gives the fingerprint every listed test file and every mutation-eligible one', () => { + const integration = 'test/integration/e.test.ts'; + expect(computeEligibility([A, B, C], verdicts, [integration, B]).fingerprintFiles).toEqual([ + integration, + A, + B, + C, + ]); + }); + + it('reports an envelope file the classifier no longer sees as rejected, not found', () => { + expect(computeEligibility([A, 'test/unit/gone.test.ts'], verdicts).delta?.rejected).toEqual([ + { file: 'test/unit/gone.test.ts', reasons: ['not found'] }, + ]); + }); +}); + +describe('parseEnvelopeFiles', () => { + it('returns accepted.files', () => { + expect(parseEnvelopeFiles({ accepted: { files: [B, A] } })).toEqual([B, A]); + }); + + it.each([ + ['no accepted section', {}], + ['accepted.files that is not an array', { accepted: { files: 'a' } }], + ['a non-string file', { accepted: { files: [1] } }], + ['a duplicated file', { accepted: { files: [A, A] } }], + ])('throws on %s', (_label, data) => { + expect(() => parseEnvelopeFiles(data)).toThrow(/envelope/); + }); +}); + +describe('resolveEligibility', () => { + it('normal mode reads the envelope and intersects it with the classifier', () => { + const repoRoot = tempRepo({ [ENVELOPE_FILE]: envelope([A, B, C]) }); + const classifyTests = vi.fn((_root: string) => verdicts); + expect(resolveEligibility({ repoRoot, mode: { kind: 'normal' }, classifyTests }).files).toEqual( + [A, C], + ); + expect(classifyTests).toHaveBeenCalledWith(repoRoot); + }); + + it('normal mode gives the fingerprint every unit and integration test file, nothing else', () => { + const repoRoot = tempRepo({ + [ENVELOPE_FILE]: envelope([A, C]), + 'gitnexus/test/unit/nested/x.test.ts': '', + 'gitnexus/test/integration/y.test.ts': '', + 'gitnexus/test/unit/helper.ts': '', + 'gitnexus/test/fixtures/z.test.ts': '', + }); + const classifyTests = vi.fn((_root: string) => verdicts); + expect( + resolveEligibility({ repoRoot, mode: { kind: 'normal' }, classifyTests }).fingerprintFiles, + ).toEqual(['test/integration/y.test.ts', A, C, 'test/unit/nested/x.test.ts']); + }); + + it('controls mode enrols the fixture case files and never consults the envelope', () => { + const repoRoot = tempRepo({ + [`${FIXTURE}/rows.case.ts`]: '', + [`${FIXTURE}/nested/pairs.case.ts`]: '', + [`${FIXTURE}/src/rows.ts`]: '', + [`${FIXTURE}/vitest.config.ts`]: '', + }); + const classifyTests = vi.fn((_root: string) => verdicts); + expect( + resolveEligibility({ + repoRoot, + mode: { kind: 'controls', fixtureRoot: FIXTURE }, + classifyTests, + }), + ).toEqual({ + mode: 'controls', + files: [ + 'test/fixtures/mutation-scenario-map/nested/pairs.case.ts', + 'test/fixtures/mutation-scenario-map/rows.case.ts', + ], + fingerprintFiles: [ + 'test/fixtures/mutation-scenario-map/nested/pairs.case.ts', + 'test/fixtures/mutation-scenario-map/rows.case.ts', + ], + delta: null, + extraRoots: [FIXTURE], + }); + expect(classifyTests).not.toHaveBeenCalled(); + }); + + it('controls mode rejects a fixture root outside the mutation fixtures', () => { + const repoRoot = tempRepo({}); + expect(() => + resolveEligibility({ repoRoot, mode: { kind: 'controls', fixtureRoot: 'gitnexus/tools' } }), + ).toThrow(/not a mutation fixture directory/); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/fingerprint.test.ts b/gitnexus/test/unit/scenario-map/fingerprint.test.ts new file mode 100644 index 000000000..7a021bf80 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/fingerprint.test.ts @@ -0,0 +1,218 @@ +/** + * Locks the scenario fingerprint verdicts (tools/scenario-map/verdict/fingerprint.ts): the base + * world is two tests that run the same path on the same input and check the same expectation, a + * duplicate. Each case edits one input and checks the deciding rule, one case per rule, plus the + * dupe-ok keep list, the orientation and determinism. + */ +import { describe, expect, it } from 'vitest'; +import type { InputFacet } from '../../../tools/scenario-map/facet/input-facet.js'; +import { + FINGERPRINT_ENGINE_VERSION, + computeFingerprints, + type FingerprintPairInput, + type FingerprintRule, + type FingerprintTestInput, + type FingerprintVerdict, +} from '../../../tools/scenario-map/verdict/fingerprint.js'; + +const A = 'test/unit/ext.test.ts#extract > User[] → User'; +const B = 'test/unit/shared.test.ts#extract > strips the array suffix: User[] → User'; + +const facet = (values: readonly string[], overrides: Partial = {}): InputFacet => ({ + fingerprint: 'facet-extract', + parts: { + language: '-', + source: [], + fixtures: [], + data: [], + calls: ['extractElementTypeFromString'], + order: ['', ''], + }, + discarded: values.map((value, position) => ({ + position, + kind: 'string', + value, + location: { file: 'gitnexus/test/unit/ext.test.ts', line: 3, column: 10 + position * 3 }, + origin: 'literal', + })), + unresolved: [], + skeleton: ['body.0\tcall extractElementTypeFromString', 'body.0.args.0\t'], + ...overrides, +}); + +const testInput = ( + id: string, + overrides: Partial = {}, +): FingerprintTestInput => ({ + id, + fingerprintEligible: true, + passed: true, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + provenance: { known: true, nativeEntries: [], workerSpawns: [] }, + concurrent: false, + checksContext: 'checks-toBe', + unsupportedForms: [], + unresolvedSelectors: [], + facet: facet(['User[]', 'User']), + ...overrides, +}); + +const pairInput = (overrides: Partial = {}): FingerprintPairInput => ({ + a: A, + b: B, + group: 'g1', + identicalBlocks: true, + pathUnattributable: false, + trace: { status: 'clean', reasons: [] }, + ...overrides, +}); + +interface WorldEdit { + a?: Partial; + b?: Partial; + pair?: Partial; +} + +const verdictOf = (edit: WorldEdit = {}) => + computeFingerprints({ + tests: [testInput(A, edit.a), testInput(B, edit.b)], + pairs: [pairInput(edit.pair)], + }).pairs[0]; + +const CASES: { rule: FingerprintRule; verdict: FingerprintVerdict; edit: WorldEdit }[] = [ + { rule: 'not-eligible', verdict: 'out-of-scope', edit: { b: { fingerprintEligible: false } } }, + { rule: 'not-passing', verdict: 'inconclusive', edit: { a: { passed: false } } }, + { rule: 'path-unattributable', verdict: 'inconclusive', edit: { a: { concurrent: true } } }, + { rule: 'path-differs', verdict: 'distinct', edit: { pair: { identicalBlocks: false } } }, + { + rule: 'same-input-different-checks', + verdict: 'different-checks', + edit: { b: { checksContext: 'checks-toEqual' } }, + }, + { + rule: 'checks-differ', + verdict: 'distinct', + edit: { b: { checksContext: 'checks-toEqual', facet: facet(['string[]', 'string']) } }, + }, + { + rule: 'input-shape-differs', + verdict: 'distinct', + edit: { b: { facet: facet(['User[]', 'User'], { fingerprint: 'facet-other' }) } }, + }, + { + rule: 'input-values-differ', + verdict: 'table-row', + edit: { b: { facet: facet(['string[]', 'string']) } }, + }, + { + rule: 'runtime-values-differ', + verdict: 'table-row', + edit: { pair: { trace: { status: 'veto', reasons: ['args-differ'] } } }, + }, + { rule: 'checks-unknown', verdict: 'possible-duplicate', edit: { a: { checksContext: null } } }, + { rule: 'facet-unknown', verdict: 'possible-duplicate', edit: { b: { facet: null } } }, + { + rule: 'input-unresolved', + verdict: 'possible-duplicate', + edit: { b: { inventoryUnresolved: ['each-table: familySuffixes may be mutated'] } }, + }, + { + rule: 'trace-unknown', + verdict: 'possible-duplicate', + edit: { pair: { trace: { status: 'inconclusive', reasons: ['trace-missing'] } } }, + }, + { rule: 'same-path-input-expectation', verdict: 'duplicate', edit: {} }, +]; + +describe('fingerprint rules', () => { + it.each(CASES)('rule $rule decides $verdict', ({ rule, verdict, edit }) => { + const decided = verdictOf(edit); + expect([decided.verdict, decided.rule]).toEqual([verdict, rule]); + }); + + it.each([ + { + label: 'an unresolved facet input', + edit: { a: { facet: facet(['User[]', 'User'], { unresolved: ['helper()'] }) } }, + }, + { label: 'an unresolved selector', edit: { a: { unresolvedSelectors: ['key'] } } }, + { label: 'a join problem', edit: { a: { joinProblems: ['join-count-unknown'] } } }, + { + label: 'an unsupported assertion', + edit: { a: { unsupportedForms: [{ line: 4, form: 'toSatisfy', reason: 'predicate' }] } }, + }, + { label: 'an unknown provenance', edit: { a: { provenance: null } } }, + { + label: 'a worker spawn', + edit: { + a: { + provenance: { + known: true, + nativeEntries: [], + workerSpawns: [{ api: 'new Worker', target: 'w.js', site: null }], + }, + }, + }, + }, + { label: 'a missing trace', edit: { pair: { trace: null } } }, + ])('never calls a pair with $label a duplicate', ({ edit }) => { + expect(verdictOf(edit).verdict).toBe('possible-duplicate'); + }); + + it('keeps a residual body difference out of the duplicates (D1)', () => { + const residual = facet(['User[]', 'User'], { + skeleton: ['body.0\tcall extractElementTypeFromString', 'body.1\tCallable'], + }); + expect([verdictOf({ b: { facet: residual } }).rule]).toEqual(['input-unresolved']); + }); + + it('records the evidence of a duplicate', () => { + expect(verdictOf().evidence).toEqual({ + path: 'same', + checks: 'same', + facet: 'equal', + values: 'equal', + trace: 'clean', + unresolved: [], + }); + }); +}); + +describe('computeFingerprints', () => { + it('orients a pair with the smaller id first and keeps a dupe-ok pair apart', () => { + const report = computeFingerprints({ + tests: [testInput(A, { dupeOk: 'kept on purpose' }), testInput(B)], + pairs: [pairInput({ a: B, b: A })], + }); + expect([ + report.engineVersion, + report.pairs, + report.kept.map((p) => [p.a, p.b, p.keep]), + ]).toEqual([ + FINGERPRINT_ENGINE_VERSION, + [], + [[A, B, [{ test: A, reason: 'kept on purpose' }]]], + ]); + }); + + it('is deterministic whatever the input order', () => { + const C = 'test/unit/ext.test.ts#extract > string[] → string'; + const tests = [ + testInput(A), + testInput(B), + testInput(C, { facet: facet(['string[]', 'string']) }), + ]; + const pairs = [pairInput(), pairInput({ a: A, b: C, group: 'g2' })]; + expect( + computeFingerprints({ tests: [...tests].reverse(), pairs: [...pairs].reverse() }), + ).toEqual(computeFingerprints({ tests, pairs })); + }); + + it('throws for a pair whose test has no input', () => { + expect(() => computeFingerprints({ tests: [testInput(A)], pairs: [pairInput()] })).toThrow( + /no test input/, + ); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/identity.test.ts b/gitnexus/test/unit/scenario-map/identity.test.ts new file mode 100644 index 000000000..64fac1e87 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/identity.test.ts @@ -0,0 +1,184 @@ +/** + * Locks the static-to-runtime join and the continuity anchors (U3, R2, KTD3): runtime records + * (as Layer 1 will feed them) get Phase 0 ids and join static instances by file, location and row + * order; any join that is not one-to-one is unsupported with a named problem; anchors carry no + * line, stay distinct by file, and follow a moved test only through a unique body fingerprint. + */ +import { describe, expect, it } from 'vitest'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import { joinRuntime, matchAnchors } from '../../../tools/scenario-map/inventory/identity.js'; +import type { + InventoryJoin, + RuntimeTestRecord, + StaticInventory, + TestAnchor, +} from '../../../tools/scenario-map/types.js'; +import type { SourceFile } from '../../../tools/test-dupes/test-dupes.js'; + +const A = 'test/unit/a.test.ts'; +const B = 'test/unit/b.test.ts'; +const source = (file: string, ...lines: string[]): SourceFile => ({ + path: `gitnexus/${file}`, + source: lines.join('\n'), +}); +const statics = (...files: SourceFile[]): StaticInventory => expandCases(files); + +let nextTask = 0; +/** A runtime record; task ids only need to be unique within a file. */ +const rt = (file: string, fullName: string, line: number, column: number): RuntimeTestRecord => ({ + file, + taskId: `task-${nextTask++}`, + fullName, + location: { line, column }, +}); + +const outcome = (join: InventoryJoin): [string, number | null, string[]][] => + join.instances.map((instance) => [instance.id, instance.row, instance.unsupported]); + +describe('joinRuntime', () => { + const eachFile = source(A, `it.each([[1, 2], [3, 4]])('adds %i', (a, b) => {});`); + + it('joins it.each rows one-to-one by location and row order, with Phase 0 ids', () => { + const join = joinRuntime(statics(eachFile), [rt(A, 'adds 1', 1, 26), rt(A, 'adds 3', 1, 26)]); + expect(outcome(join)).toEqual([ + [`${A}#adds 1`, 0, []], + [`${A}#adds 3`, 1, []], + ]); + expect(join.instances.map((instance) => instance.instance?.row)).toEqual([0, 1]); + expect(join.summary).toMatchObject({ runtimeTests: 2, oneToOne: 2, joinMismatchSites: 0 }); + }); + + it('makes every test at a location unsupported when the counts differ', () => { + const join = joinRuntime(statics(eachFile), [ + rt(A, 'adds 1', 1, 26), + rt(A, 'adds 3', 1, 26), + rt(A, 'adds 5', 1, 26), + ]); + expect(join.instances.map((instance) => instance.unsupported)).toEqual([ + ['join-mismatch'], + ['join-mismatch'], + ['join-mismatch'], + ]); + expect(join.instances.map((instance) => instance.instance)).toEqual([null, null, null]); + expect(join.summary).toMatchObject({ oneToOne: 0, joinMismatchSites: 1 }); + }); + + it('keeps the runtime rows of an unresolved table, unsupported with the static reason', () => { + const file = source(A, `it.each(buildRows())('row %s', (r) => {});`); + const join = joinRuntime(statics(file), [rt(A, 'row a', 1, 21), rt(A, 'row b', 1, 21)]); + expect(join.instances.map((instance) => instance.unsupported)).toEqual([ + ['join-count-unknown'], + ['join-count-unknown'], + ]); + expect(join.instances[0].unresolved).toEqual(['each-table: not static: buildRows()']); + }); + + it('flags a runtime full name that differs from the static one', () => { + const file = source(A, `describe('d', () => {`, ` it('t', () => {});`, `});`); + expect(outcome(joinRuntime(statics(file), [rt(A, 'd > u', 2, 3)]))).toEqual([ + [`${A}#d > u`, 0, ['join-name-mismatch']], + ]); + }); + + it.each([ + ['a record without a location', undefined, null, 'no-location'], + ['a record at a line with no test call', { line: 9, column: 3 }, 0, 'no-static-case'], + ])('reports %s', (_label, location, row, problem) => { + const record: RuntimeTestRecord = { file: A, taskId: 'task', fullName: 't', location }; + expect(outcome(joinRuntime(statics(source(A, `it('t', () => {});`)), [record]))).toEqual([ + [`${A}#t`, row, [problem]], + ]); + }); + + it('separates call sites sharing a line by column, and fails closed when none matches', () => { + const file = source(A, `it('a', () => {}); it('b', () => {});`); + const join = joinRuntime(statics(file), [ + rt(A, 'a', 1, 1), + rt(A, 'b', 1, 20), + rt(A, 'c', 1, 7), + ]); + expect(outcome(join)).toEqual([ + [`${A}#a`, 0, []], + [`${A}#b`, 0, []], + [`${A}#c`, 0, ['ambiguous-location']], + ]); + }); + + it('lists static call sites that no runtime record reaches as not run', () => { + const file = source(A, `it('a', () => {});`, `it.skip('b', () => {});`); + const join = joinRuntime(statics(file), [rt(A, 'a', 1, 1)]); + expect(join.notRun.map((site) => site.record.title)).toEqual(['b']); + expect(join.summary.notRunSites).toBe(1); + }); + + it('carries a `// dupe-ok:` reason as the human keep (R15)', () => { + const file = source(A, `// dupe-ok: covers the legacy path`, `it('a', () => {});`); + expect(joinRuntime(statics(file), [rt(A, 'a', 2, 1)]).instances[0].dupeOk).toBe( + 'covers the legacy path', + ); + }); +}); + +describe('anchors', () => { + const body = `it('parses', () => { expect(parse('x')).toBe(1); });`; + + it('gives identical bodies in different files equal body fingerprints and distinct anchors', () => { + const join = joinRuntime(statics(source(A, body), source(B, body)), [ + rt(A, 'parses', 1, 1), + rt(B, 'parses', 1, 1), + ]); + const [a, b] = join.instances.map((instance) => instance.anchor); + expect([a?.bodyFingerprint === b?.bodyFingerprint, a?.key === b?.key]).toEqual([true, false]); + }); + + it('keeps the anchor key when only the line moves', () => { + const before = joinRuntime(statics(source(A, body)), [rt(A, 'parses', 1, 1)]); + const after = joinRuntime(statics(source(A, '', '', body)), [rt(A, 'parses', 3, 1)]); + expect(after.instances[0].anchor?.key).toBe(before.instances[0].anchor?.key); + }); + + it('suffixes exact twins in declaration order and keys rows by their values', () => { + const file = source(A, `it.each([1, 2, 1])('n %i', (n) => {});`); + const join = joinRuntime(statics(file), [ + rt(A, 'n 1', 1, 19), + rt(A, 'n 2', 1, 19), + rt(A, 'n 1', 1, 19), + ]); + const [first, second, twin] = join.instances.map((instance) => instance.anchor?.key ?? ''); + expect([twin === `${first}~1`, first === second]).toEqual([true, false]); + }); +}); + +describe('matchAnchors', () => { + const anchor = (key: string, bodyFingerprint: string, rowFingerprint: string | null = 'r') => + ({ + key, + file: A, + describePath: [], + title: key, + rowFingerprint, + bodyFingerprint, + }) satisfies TestAnchor; + + it('matches by key first, then a moved test by its unique body, else reports it new', () => { + const previous = [anchor('same', 'b1'), anchor('old-name', 'b2'), anchor('dup-1', 'b3')]; + const current = [ + anchor('same', 'b1'), + anchor('new-name', 'b2'), + anchor('dup-2', 'b3'), + anchor('dup-3', 'b3'), + ]; + expect(matchAnchors(previous, current)).toEqual([ + { current: 'same', previous: 'same', via: 'key' }, + { current: 'new-name', previous: 'old-name', via: 'body' }, + { current: 'dup-2', previous: null, via: null }, + { current: 'dup-3', previous: null, via: null }, + ]); + }); + + it('never matches a moved row whose values are unknown', () => { + expect(matchAnchors([anchor('old', 'b', null)], [anchor('new', 'b', null)])).toEqual([ + { current: 'new', previous: null, via: null }, + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/input-facet.test.ts b/gitnexus/test/unit/scenario-map/input-facet.test.ts new file mode 100644 index 000000000..51ae0f545 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/input-facet.test.ts @@ -0,0 +1,572 @@ +/** + * Locks the input facet of a test instance (U6, R5, R9, KTD6, design §2 "Input facet", §5.3 + * rules 1–8, D1, D2): table members collapse with their discarded values kept per position, + * inline source keeps its anonymised shape, boundary numbers and numbers within ±1 of a + * production constant stay exact (constants-only modules and folded initialisers included), + * selectors stay exact, `it.each` rows are evaluated per instance, unresolvable inputs are + * marked, and the residual-difference guard catches what the facet does not carry. + */ +import { describe, expect, it } from 'vitest'; +import { + applyResidualGuard, + computeFacet, + createFacetContext, + residualDifference, + sameDiscardedValues, + type FacetSources, + type InputFacet, +} from '../../../tools/scenario-map/facet/input-facet.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; + +const TEST_FILE = 'gitnexus/test/unit/a.test.ts'; +const PACKAGE_FILE = 'test/unit/a.test.ts'; +const IMPORTS = [ + `import { SupportedLanguages } from 'gitnexus-shared';`, + `import { add, build, clamp, cobol, edge, getNodesByLabel, isCallable, isSuccess, join, load, make, matches, parse, parseSource, run, typeEdge } from '../../src/m.js';`, + `import { MiniGraph, NodeLabel } from '../../src/labels.js';`, +]; + +interface World { + /** Production and fixture sources by repo path. */ + files: Record; + /** Executed production files; default every `gitnexus/src/` file. */ + executed?: string[] | null; + /** Static import closure; default the test file and every `gitnexus/src/` file. */ + closure?: string[]; + /** Lines inside the describe, before the tests. */ + prelude?: string[]; +} + +const KINDS = 'gitnexus/src/kinds.ts'; +const M = 'gitnexus/src/m.ts'; +const NONE: World = { files: { [M]: `export const noop = (): void => {};` } }; +const TABLE: World = { + files: { + [M]: `export const isCallable = (k: string) => k.length > 0;`, + [KINDS]: `export const KINDS = ['Function', 'Class', 'Method'];`, + }, +}; +const LIMIT: World = { + files: { + [M]: `export const LIMIT = 100;\nexport const parse = (n: number) => Math.min(n, LIMIT);`, + }, +}; +const FOLDED: World = { + files: { + [M]: `export const LIMIT = 2 * 50;\nexport const parse = (n: number) => Math.min(n, LIMIT);`, + }, +}; +const CONSTANTS_ONLY: World = { + files: { + [M]: `import { LIMIT } from './limits.js';\nexport const parse = (n: number) => (n > LIMIT ? LIMIT : n);`, + 'gitnexus/src/limits.ts': `export const LIMIT = 100;`, + }, + executed: [M], +}; +const NEAR_TABLE: World = { + files: { + [M]: `import { KINDS } from './kinds.js';\nexport const isCallable = (k: string) => KINDS.includes(k);`, + [KINDS]: `export const KINDS = ['Function', 'Class', 'Method'];`, + }, + executed: [], +}; +const STATUS_SET: World = { + files: { + [M]: `const OK = new Set([200, 204]);\nexport const isSuccess = (s: number) => OK.has(s);`, + }, +}; + +const memorySources = (files: Record): FacetSources => ({ + read: (file) => files[file] ?? null, + list: (dir) => + Object.keys(files) + .filter((file) => file.startsWith(`${dir}/`)) + .sort(), +}); + +/** The facets of every instance of the tests, written inside one describe. */ +async function facetsOf(world: World, ...tests: string[]): Promise { + const source = [ + ...IMPORTS, + `describe('d', () => {`, + ...(world.prelude ?? []), + ...tests, + `});`, + ].join('\n'); + const sites = expandCases([{ path: TEST_FILE, source }]).sites; + const production = Object.keys(world.files).filter((file) => file.startsWith('gitnexus/src/')); + const ctx = createFacetContext({ + sources: memorySources({ ...world.files, [TEST_FILE]: source }), + productionFiles: production, + closures: new Map([[PACKAGE_FILE, world.closure ?? [TEST_FILE, ...production]]]), + builtinFiles: [], + }); + const executed = world.executed === undefined ? production : world.executed; + const subjects = sites.flatMap((site) => + (site.instances ?? []).map((instance) => ({ + id: `${site.record.title}#${instance.row}`, + file: PACKAGE_FILE, + record: site.record, + instance, + executedFiles: executed, + })), + ); + const facets: InputFacet[] = []; + for (const subject of subjects) facets.push(await computeFacet(subject, ctx)); + return facets; +} + +async function pairOf( + world: World, + first: string, + second: string, +): Promise<[InputFacet, InputFacet]> { + const facets = await facetsOf(world, first, second); + expect(facets).toHaveLength(2); + return [facets[0], facets[1]]; +} + +const positions = (facet: InputFacet): [number, string, string][] => + facet.discarded.map((entry) => [entry.position, entry.value, entry.origin]); + +describe('computeFacet: equal and different facets', () => { + it.each([ + [ + 'two members of one production string table', + true, + TABLE, + `it('a', () => { expect(isCallable('Function')).toBe(true); });`, + `it('b', () => { expect(isCallable('Class')).toBe(true); });`, + ], + [ + 'two members of a table two import hops away', + true, + NEAR_TABLE, + `it('a', () => { expect(isCallable('Function')).toBe(true); });`, + `it('b', () => { expect(isCallable('Class')).toBe(true); });`, + ], + [ + 'inline Rust against inline Go', + false, + NONE, + `it('a', () => { expect(parseSource('fn main() { let x = 1; }', SupportedLanguages.Rust)).toBe(true); });`, + `it('b', () => { expect(parseSource('package main\\nfunc main() { x := 1 }', SupportedLanguages.Go)).toBe(true); });`, + ], + [ + 'inline TypeScript differing only in a local variable name', + true, + NONE, + `it('a', () => { expect(parseSource('const alpha = compute(1);', SupportedLanguages.TypeScript)).toBe(true); });`, + `it('b', () => { expect(parseSource('const beta = compute(1);', SupportedLanguages.TypeScript)).toBe(true); });`, + ], + [ + 'COBOL using different reserved words', + false, + NONE, + `it('a', () => { expect(cobol('PERFORM X THRU Y')).toBe(true); });`, + `it('b', () => { expect(cobol('MOVE A TO B')).toBe(true); });`, + ], + [ + '101 against 150 under a production LIMIT = 100', + false, + LIMIT, + `it('a', () => { expect(parse(101)).toBe(100); });`, + `it('b', () => { expect(parse(150)).toBe(100); });`, + ], + [ + '150 against 170 under a production LIMIT = 100', + true, + LIMIT, + `it('a', () => { expect(parse(150)).toBe(100); });`, + `it('b', () => { expect(parse(170)).toBe(100); });`, + ], + [ + '101 against 150 with LIMIT in a constants-only module the test never executes', + false, + CONSTANTS_ONLY, + `it('a', () => { expect(parse(101)).toBe(100); });`, + `it('b', () => { expect(parse(150)).toBe(100); });`, + ], + [ + '101 against 150 under LIMIT = 2 * 50', + false, + FOLDED, + `it('a', () => { expect(parse(101)).toBe(100); });`, + `it('b', () => { expect(parse(150)).toBe(100); });`, + ], + [ + 'a literal 0 against a literal 5', + false, + NONE, + `it('a', () => { expect(clamp(0)).toBe(true); });`, + `it('b', () => { expect(clamp(5)).toBe(true); });`, + ], + [ + 'add(1, 2) against add(2, 3): 1 stays exact (D2)', + false, + NONE, + `it('a', () => { expect(add(1, 2)).toBe(3); });`, + `it('b', () => { expect(add(2, 3)).toBe(5); });`, + ], + [ + 'add(2, 3) against add(5, 5) (D2)', + true, + NONE, + `it('a', () => { expect(add(2, 3)).toBe(5); });`, + `it('b', () => { expect(add(5, 5)).toBe(10); });`, + ], + [ + 'a negative number against another negative number', + false, + NONE, + `it('a', () => { expect(clamp(-5)).toBe(true); });`, + `it('b', () => { expect(clamp(-7)).toBe(true); });`, + ], + [ + 'two entries of a production numeric Set (D3)', + true, + STATUS_SET, + `it('a', () => { expect(isSuccess(200)).toBe(true); });`, + `it('b', () => { expect(isSuccess(204)).toBe(true); });`, + ], + [ + 'literals [a, b] against [b, a] of different shapes', + false, + NONE, + `it('a', () => { expect(join('abc', 'Abc')).toBe(true); });`, + `it('b', () => { expect(join('Abc', 'abc')).toBe(true); });`, + ], + [ + 'two selector literals of an expect subject', + false, + NONE, + `it('a', () => { const r = build(); expect(getNodesByLabel(r, 'Function')).toHaveLength(2); });`, + `it('b', () => { const r = build(); expect(getNodesByLabel(r, 'Class')).toHaveLength(2); });`, + ], + [ + 'different object-literal keys', + false, + NONE, + `it('a', () => { expect(make({ alpha: 2 })).toBe(true); });`, + `it('b', () => { expect(make({ beta: 2 })).toBe(true); });`, + ], + [ + 'different builder calls', + false, + NONE, + `it('a', () => { expect(typeEdge('x')).toBe(true); });`, + `it('b', () => { expect(edge('x')).toBe(true); });`, + ], + [ + 'an empty string against a non-empty one', + false, + NONE, + `it('a', () => { expect(run('')).toBe(true); });`, + `it('b', () => { expect(run('x')).toBe(true); });`, + ], + ])('%s: equal facets is %s', async (_label, equal, world, first, second) => { + const [a, b] = await pairOf(world, first, second); + expect(a.fingerprint === b.fingerprint).toBe(equal); + }); + + it('keeps the data part equal when only the source order of the literals differs', async () => { + const [a, b] = await pairOf( + NONE, + `it('a', () => { expect(join('abc', 'Abc')).toBe(true); });`, + `it('b', () => { expect(join('Abc', 'abc')).toBe(true); });`, + ); + expect(a.parts.data).toEqual(b.parts.data); + expect([a.parts.order, b.parts.order]).toEqual([ + ['a', 'Aa'], + ['Aa', 'a'], + ]); + }); +}); + +describe('computeFacet: discarded values', () => { + it('keeps each discarded table member at its position', async () => { + const [a, b] = await pairOf( + TABLE, + `it('a', () => { expect(isCallable('Function')).toBe(true); });`, + `it('b', () => { expect(isCallable('Class')).toBe(true); });`, + ); + expect([positions(a), positions(b)]).toEqual([ + [[0, 'Function', 'literal']], + [[0, 'Class', 'literal']], + ]); + expect(a.parts.data).toEqual([`T:${KINDS}#0`]); + }); + + it('records where a discarded literal is written', async () => { + const [facet] = await facetsOf( + TABLE, + `it('a', () => { expect(isCallable('Function')).toBe(true); });`, + ); + expect(facet.discarded[0].location).toEqual({ file: TEST_FILE, line: 5, column: 34 }); + }); + + it.each([ + ['a boundary number', `it('a', () => { expect(add(1, -1)).toBe(0); });`, LIMIT], + ['a number within ±1 of a constant', `it('a', () => { expect(parse(99)).toBe(100); });`, LIMIT], + [ + 'a selector', + `it('a', () => { const r = build(); expect(getNodesByLabel(r, 'Function')).toHaveLength(1); });`, + NONE, + ], + ])('discards nothing for %s', async (_label, test, world) => { + const [facet] = await facetsOf(world, test); + expect(facet.discarded).toEqual([]); + }); + + it.each([ + [ + 'equal', + `it('a', () => { expect(parse(150)).toBe(100); });`, + `it('b', () => { expect(parse(150)).toBe(100); });`, + true, + ], + [ + 'different', + `it('a', () => { expect(parse(150)).toBe(100); });`, + `it('b', () => { expect(parse(170)).toBe(100); });`, + false, + ], + ])('compares %s discarded values', async (_label, first, second, same) => { + const [a, b] = await pairOf(LIMIT, first, second); + expect(sameDiscardedValues(a, b)).toBe(same); + }); +}); + +describe('computeFacet: per-instance rows', () => { + it.each([ + ['conditional alias', `const input = flag ? 'pool' : undefined;`], + ['transitive alias', `const selected = flag ? 'pool' : undefined; const input = selected;`], + ['captured helper', `function input() { return flag ? 'pool' : undefined; }`], + ])('does not resolve a captured loop %s from identical body text', async (_name, setup) => { + const facets = await facetsOf( + NONE, + `for (const flag of [false, true]) {`, + setup, + `it(String(flag), () => { expect(run(input, 'disabled')).toBe(false); });`, + `}`, + ); + expect(facets).toHaveLength(2); + for (const facet of facets) { + expect(facet.unresolved).toContain('captured loop binding input: instance value unknown'); + } + }); + + it('marks an outer loop alias captured inside an inner loop unresolved', async () => { + const facets = await facetsOf( + NONE, + `for (const flag of [false, true]) {`, + `const expected = flag ? 'pool' : undefined;`, + `for (const count of [1, 2]) {`, + `it(String(count), () => { expect(run(count)).toBe(expected); });`, + `}}`, + ); + expect(facets).toHaveLength(4); + for (const facet of facets) { + expect(facet.unresolved).toContain('captured loop binding expected: instance value unknown'); + } + }); + + it('leaves unused loop aliases and locally shadowed names out of input uncertainty', async () => { + const facets = await facetsOf( + NONE, + `for (const flag of [false, true]) {`, + `const input = flag ? 'pool' : undefined;`, + `it(String(flag), () => { const input = 'same'; expect(run(input)).toBe(false); });`, + `}`, + ); + expect(facets).toHaveLength(2); + expect(facets.map((facet) => facet.unresolved)).toEqual([[], []]); + }); + + it('does not let a nested callback parameter hide a captured loop input', async () => { + const facets = await facetsOf( + NONE, + `for (const flag of [false, true]) {`, + `const input = flag ? 'pool' : undefined;`, + `it(String(flag), () => { [1].map(input => input); expect(run(input)).toBe(false); });`, + `}`, + ); + expect(facets).toHaveLength(2); + for (const facet of facets) { + expect(facet.unresolved).toContain('captured loop binding input: instance value unknown'); + } + }); + + it('evaluates each it.each row, keeping its discarded value', async () => { + const facets = await facetsOf( + TABLE, + `it.each([['Function'], ['Class']])('k %s', (kind) => { expect(isCallable(kind)).toBe(true); });`, + ); + expect(facets.map((facet) => facet.fingerprint === facets[0].fingerprint)).toEqual([ + true, + true, + ]); + expect(facets.map(positions)).toEqual([[[0, 'Function', 'row']], [[0, 'Class', 'row']]]); + }); + + it('keeps a row number within ±1 of a constant exact', async () => { + const facets = await facetsOf( + LIMIT, + `it.each([[101], [150]])('p %s', (n) => { expect(parse(n)).toBe(100); });`, + ); + expect(facets[0].fingerprint === facets[1].fingerprint).toBe(false); + }); + + it('marks a row value that is not static unresolved', async () => { + const [facet] = await facetsOf( + NONE, + `it.each([[build()]])('u %s', (value) => { expect(run(value)).toBe(true); });`, + ); + expect(facet.unresolved).toEqual( + expect.arrayContaining([expect.stringMatching(/^row value value:/)]), + ); + }); + + it('reads a method call on a static row value as code over that value', async () => { + const facets = await facetsOf( + NONE, + `it.each([['Alpha'], ['Beta']])('m %s', (name) => { expect(run(name.toLowerCase())).toBe(true); });`, + ); + expect(facets.map((facet) => [facet.unresolved, positions(facet)])).toEqual([ + [[], [[0, 'Alpha', 'row']]], + [[], [[0, 'Beta', 'row']]], + ]); + }); + + it('flags rows that differ only in a boolean through the residual guard', async () => { + const facets = await facetsOf( + NONE, + `it.each([[true], [false]])('f %s', (flag) => { expect(run(flag)).toBe(true); });`, + ); + expect(residualDifference(facets[0], facets[1])).toEqual([ + { + path: 'body.body[0].expression.callee.object.arguments[0].arguments[0]', + a: '', + b: '', + }, + ]); + }); +}); + +describe('residual-difference guard', () => { + it('marks two tests that differ only in an enum member unresolved', async () => { + const [a, b] = await pairOf( + NONE, + `it('a', () => { const graph = new MiniGraph(); graph.addNode(NodeLabel.Function); expect(graph.hasCallableNode()).toBe(true); });`, + `it('b', () => { const graph = new MiniGraph(); graph.addNode(NodeLabel.Method); expect(graph.hasCallableNode()).toBe(true); });`, + ); + const guarded = applyResidualGuard(a, b); + expect(a.fingerprint).toBe(b.fingerprint); + expect(guarded.residual).toEqual([ + { + path: 'body.body[1].expression.arguments[0].property', + a: 'Name:Function', + b: 'Name:Method', + }, + ]); + expect([guarded.a.unresolved, guarded.b.unresolved]).toEqual([ + ['residual difference: body.body[1].expression.arguments[0].property'], + ['residual difference: body.body[1].expression.arguments[0].property'], + ]); + }); + + it('marks two tests that differ only in a regex literal unresolved', async () => { + const [a, b] = await pairOf( + NONE, + `it('a', () => { expect(matches(/^a/, 'x')).toBe(true); });`, + `it('b', () => { expect(matches(/^b/, 'x')).toBe(true); });`, + ); + const guarded = applyResidualGuard(a, b); + expect(a.fingerprint).toBe(b.fingerprint); + expect(guarded.residual.map((entry) => entry.path)).toEqual([ + 'body.body[0].expression.callee.object.arguments[0].arguments[0]', + ]); + expect(guarded.a.unresolved).toHaveLength(1); + }); + + it('finds no residual difference between textually identical bodies', async () => { + const [a, b] = await pairOf( + LIMIT, + `it('a', () => { const n = parse(150); expect(n).toBe(100); });`, + `it('b', () => { const n = parse(150); expect(n).toBe(100); });`, + ); + const guarded = applyResidualGuard(a, b); + expect([guarded.residual, guarded.a.unresolved, guarded.b.unresolved]).toEqual([[], [], []]); + }); + + it('ignores renamed locals and discarded values', async () => { + const [a, b] = await pairOf( + LIMIT, + `it('a', () => { const first = parse(150); expect(first).toBe(100); });`, + `it('b', () => { const second = parse(170); expect(second).toBe(100); });`, + ); + expect(residualDifference(a, b)).toEqual([]); + }); + + it('does not apply to facet-unequal pairs', async () => { + const [a, b] = await pairOf( + LIMIT, + `it('a', () => { expect(parse(101)).toBe(100); });`, + `it('b', () => { expect(parse(150)).toBe(100); });`, + ); + expect(residualDifference(a, b)).toEqual([]); + }); +}); + +describe('computeFacet: fixtures and unresolved inputs', () => { + it('marks a fixture file that cannot be shaped unresolved', async () => { + const world: World = { + files: { ...NONE.files, 'gitnexus/test/fixtures/big/huge.dat': 'x'.repeat(200_001) }, + }; + const [facet] = await facetsOf( + world, + `it('a', () => { expect(load('huge.dat')).toBe(true); });`, + ); + expect(facet.unresolved).toEqual(['fixture file huge.dat: cannot be shaped']); + }); + + it('gives a literal naming one fixture file that file shape', async () => { + const world: World = { + files: { + ...NONE.files, + 'gitnexus/test/fixtures/proj/main.go': 'package main\nfunc Handle() {}\n', + }, + }; + const [facet] = await facetsOf( + world, + `it('a', () => { expect(load('proj/main.go')).toBe(true); });`, + ); + expect([facet.parts.fixtures, facet.parts.data]).toEqual([ + [expect.stringMatching(/^fixfile:/)], + [], + ]); + }); + + it('resolves a literal against the fixture a describe hook names', async () => { + const world: World = { + files: { + ...NONE.files, + 'gitnexus/test/fixtures/sample-proj/main.go': + 'package main\nfunc Handle(n int) int { return n }\n', + }, + prelude: [`beforeAll(() => { load('sample-proj'); });`], + }; + const [facet] = await facetsOf(world, `it('a', () => { expect(run('Handle')).toBe(true); });`); + expect([facet.parts.fixtures, facet.parts.data, facet.parts.language]).toEqual([ + [expect.stringMatching(/^def:/)], + [], + '-', + ]); + }); + + it('marks unknown executed files unresolved', async () => { + const world: World = { ...LIMIT, executed: null }; + const [facet] = await facetsOf(world, `it('a', () => { expect(parse(150)).toBe(100); });`); + expect(facet.unresolved).toEqual(['executed files not known']); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/isolation.test.ts b/gitnexus/test/unit/scenario-map/isolation.test.ts new file mode 100644 index 000000000..5bcf889a4 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/isolation.test.ts @@ -0,0 +1,892 @@ +/** + * Locks the full isolation pass (U8, R7, KTD9, design §5.4) without spawning Vitest or Stryker: + * observations are in memory and every runner is a fake. Only pairs with the same path, checks, + * context, facet (the D1 residual guard included) and non-empty kill set reach the repeat stage; + * each selected pair gets one together run, and each test one alone run over the union of its + * pairs' universes; mutate is the universe as Stryker ranges; every run reuses no test-runner + * process; per test and mutant, coverage and outcome class (and the unmutated baseline) must agree + * across main, together and alone (a not-covered survival a run universe implies included), a + * missing result is unknown, and a hook-attributed mutant caps the pair at weak. Result files hold + * the compact model (schema 2); a schema 1 file is rejected. + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { + NormalisedMutant, + RunResults, +} from '../../../tools/mutation/driver/normalise-results.js'; +import type { RunScopeOptions } from '../../../tools/mutation/driver/run-scope.js'; +import { + buildStrykerConfig, + formatMutateEntry, + parseMutateEntry, + type StrykerConfigInput, +} from '../../../tools/mutation/driver/stryker-config.js'; +import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; +import type { RunManifest } from '../../../tools/mutation/runner/run-manifest.js'; +import type { InputFacet } from '../../../tools/scenario-map/facet/input-facet.js'; +import { + ISOLATION_TEST_RUNNER_REUSE, + evaluateIsolation, + isolationRunId, + mutateRangesOf, + pairKey, + planIsolation, + readIsolationRun, + readIsolationRuns, + runIsolationRun, + selectRepeatStagePairs, + type IsolationRunResult, + type IsolationRunSpec, + type RepeatStageDecision, + type RepeatStageFacts, +} from '../../../tools/scenario-map/mutation/isolation.js'; +import { + mergeObservations, + type Observation, + type RunKind, + type RunUniverse, +} from '../../../tools/scenario-map/mutation/observations.js'; +import type { PairRef } from '../../../tools/scenario-map/mutation/plan-shards.js'; +import { + buildRunManifest, + type BaselineSummary, + type GroupRunDeps, + type ScenarioRunManifest, +} from '../../../tools/scenario-map/mutation/run-group.js'; +import type { + VitestRunOptions, + VitestRunResult, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; + +const A = 'test/unit/a.test.ts#A > one'; +const B = 'test/unit/a.test.ts#A > two'; +const C = 'test/unit/c.test.ts#C > three'; +const id = (span: string, mutator: string): string => + `gitnexus/src/x.ts@0123456789abcdef:${span}:${mutator}:aaaaaaaaaaaaaaaa`; +/** Killed by both tests. */ +const M1 = id('2:10-2:22', 'ArithmeticOperator'); +/** Covered by both tests, survived by both. */ +const M2 = id('3:7-3:16', 'ConditionalExpression'); +/** Covered by neither test. */ +const M3 = id('6:18-8:4', 'BlockStatement'); +/** Killed by both tests in another pair's universe. */ +const M4 = id('9:1-9:9', 'StringLiteral'); +const KILL: OutcomeClass = 'killed-by-test:assertion'; +const PAIR: PairRef = { group: 'g1', a: A, b: B }; + +const temps: string[] = []; +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-u8-')); + temps.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +// ── In-memory observations ───────────────────────────────────────────────── + +const observation = ( + test: string, + mutant: string, + overrides: Partial = {}, +): Observation => ({ + group: 'g1', + kind: 'main', + lineage: 'main', + shard: 0, + attempt: 0, + test, + mutant, + file: 'gitnexus/src/x.ts', + covered: true, + hookAttributed: false, + static: false, + outcome: KILL, + source: 'row', + valid: true, + reasons: [], + ...overrides, +}); + +const uncovered: Partial = { + covered: false, + outcome: 'survived', + source: 'not-covered', + attempt: null, +}; + +/** One run's observations of A and B on M1 (killed), M2 (survived) and M3 (not covered). */ +const pairWorld = (overrides: Partial = {}): Observation[] => + [A, B].flatMap((test) => [ + observation(test, M1, overrides), + observation(test, M2, { outcome: 'survived', ...overrides }), + observation(test, M3, { ...uncovered, ...overrides }), + ]); + +const replace = ( + entries: readonly Observation[], + test: string, + mutant: string, + change: Partial, +): Observation[] => + entries.map((entry) => + entry.test === test && entry.mutant === mutant ? { ...entry, ...change } : entry, + ); + +const facet = (fingerprint = 'f1', skeleton: string[] = ['body\tcall']): InputFacet => ({ + fingerprint, + parts: { language: '-', source: [], fixtures: [], data: [], calls: [], order: [] }, + discarded: [], + unresolved: [], + skeleton, +}); + +const facts = ( + main: readonly Observation[] = pairWorld(), + overrides: Partial> = {}, +): RepeatStageFacts => ({ + merged: mergeObservations(main, []), + checksContext: new Map([ + [A, 'k1'], + [B, 'k1'], + [C, 'k1'], + ]), + facets: new Map([ + [A, facet()], + [B, facet()], + [C, facet()], + ]), + ...overrides, +}); + +const decisionOf = (main: readonly Observation[] = pairWorld()): RepeatStageDecision => + selectRepeatStagePairs([PAIR], facts(main))[0]; + +// ── Isolation run results ────────────────────────────────────────────────── + +const passed = (tests: readonly string[]): BaselineSummary => ({ + wallClockMs: 1, + outcome: 'survived', + identities: tests.map((test, index) => `${test}@${index + 1}:3`), + failing: [], + problems: [], +}); + +/** A complete run result of a planned run, with its observations retagged to the run. */ +function runResult( + spec: IsolationRunSpec, + observations: readonly Observation[], + overrides: Partial = {}, +): IsolationRunResult { + const tests = new Set(spec.tests); + return { + schemaVersion: 2, + id: spec.id, + group: spec.group, + kind: spec.kind, + lineage: spec.lineage, + tests: spec.tests, + testFiles: spec.testFiles, + universe: spec.universe, + mutate: spec.mutate, + shard: 0, + manifestDigest: 'd', + status: 'complete', + reason: null, + detail: null, + settings: { concurrency: 2, maxTestRunnerReuse: 1, timeoutMS: 10_000 }, + baselines: [passed(spec.tests), passed(spec.tests)], + stryker: null, + runUniverse: null, + observations: observations + .filter((entry) => tests.has(entry.test) && spec.universe.includes(entry.mutant)) + .map((entry) => ({ ...entry, kind: spec.kind, lineage: spec.lineage })), + missing: [], + outsideUniverse: 0, + wallClockMs: 1, + ...overrides, + }; +} + +interface RerunWorld { + main?: Observation[]; + together?: Observation[]; + alone?: Partial>; + aloneOverrides?: Partial>>; +} + +/** Plans the pair, fakes each run from the given observations and evaluates the pass. */ +function evaluate(world: RerunWorld = {}) { + const main = world.main ?? pairWorld(); + const decision = decisionOf(main); + const runs = planIsolation([decision]).runs.map((spec) => + spec.kind === 'together' + ? runResult(spec, world.together ?? pairWorld()) + : runResult( + spec, + world.alone?.[spec.lineage] ?? pairWorld(), + world.aloneOverrides?.[spec.lineage], + ), + ); + const [result] = evaluateIsolation([decision], { + merged: mergeObservations(main, []), + runs, + manifestDigest: 'd', + }); + return { result, runs }; +} + +/** A run universe of g1 over non-static mutants of x.ts. */ +const universeOf = ( + kind: RunKind, + lineage: string, + tests: readonly string[], + mutants: readonly string[], +): RunUniverse => ({ + group: 'g1', + kind, + lineage, + shard: 0, + tests: [...tests], + mutants: mutants.map((mutant) => ({ + id: mutant, + file: 'gitnexus/src/x.ts', + static: false, + status: 'Survived', + reasons: [], + hookMarker: true, + })), + valid: true, + reasons: [], +}); + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('selectRepeatStagePairs', () => { + it('selects a pair with the same path, checks, context, facet and non-empty kills', () => { + const decision = decisionOf(); + expect([decision.selected, decision.misses, decision.key]).toEqual([ + true, + [], + JSON.stringify([A, B]), + ]); + }); + + it('takes the mutants either test was exposed to as the isolation universe', () => { + const main = [ + ...pairWorld(), + observation(A, M4, { covered: false, static: true }), + observation(B, M4, { covered: false, static: true }), + ]; + expect(decisionOf(main).universe).toEqual([M1, M2, M4].sort()); + }); + + it.each([ + [ + 'different kill sets', + replace(pairWorld(), B, M1, { outcome: 'survived' }), + {}, + ['kills-differ'], + ], + [ + 'an unknown kill set', + replace(pairWorld(), B, M2, { valid: false, reasons: ['missing-result'] }), + {}, + ['kills-unknown'], + ], + ['a group run without observations', [], {}, ['kills-unknown']], + [ + 'two empty kill sets', + pairWorld().map((entry) => ({ ...entry, outcome: 'survived' as const })), + {}, + ['kills-empty'], + ], + [ + 'a timeout on a universe mutant', + replace(replace(pairWorld(), A, M2, { outcome: 'timeout' }), B, M2, { outcome: 'timeout' }), + {}, + ['other-classes'], + ], + ['another canonical path', replace(pairWorld(), B, M2, uncovered), {}, ['path-differs']], + [ + 'a missing checks-and-context key', + pairWorld(), + { checksContext: new Map([[A, 'k1']]) }, + ['checks-context-missing'], + ], + [ + 'different checks or context', + pairWorld(), + { + checksContext: new Map([ + [A, 'k1'], + [B, 'k2'], + ]), + }, + ['checks-context-differs'], + ], + ['a missing facet', pairWorld(), { facets: new Map([[A, facet()]]) }, ['facet-missing']], + [ + 'different facets', + pairWorld(), + { + facets: new Map([ + [A, facet('f1')], + [B, facet('f2')], + ]), + }, + ['facet-differs'], + ], + [ + 'equal facets with a residual difference (D1)', + pairWorld(), + { + facets: new Map([ + [A, facet('f1', ['body\tcall', 'body.arg\ttrue'])], + [B, facet('f1', ['body\tcall', 'body.arg\tfalse'])], + ]), + }, + ['facet-residual'], + ], + ])('keeps a pair with %s out of the repeat stage', (_title, main, overrides, misses) => { + const [decision] = selectRepeatStagePairs([PAIR], facts(main, overrides)); + expect([decision.selected, decision.misses]).toEqual([false, misses]); + }); +}); + +describe('mutateRangesOf', () => { + it.each([ + ['one mutant, in Babel columns', [M1], ['gitnexus/src/x.ts:2:9-2:21']], + [ + 'two mutators on one span', + [M2, id('3:7-3:16', 'EqualityOperator')], + ['gitnexus/src/x.ts:3:6-3:15'], + ], + [ + 'a mutant nested in another one', + [id('5:53-13:2', 'BlockStatement'), id('6:7-6:16', 'ConditionalExpression')], + ['gitnexus/src/x.ts:5:52-13:1'], + ], + [ + 'mutants of two files', + [M4, 'gitnexus/src/a.ts@0123456789abcdef:1:1-1:5:StringLiteral:bbbbbbbbbbbbbbbb', M1], + ['gitnexus/src/a.ts:1:0-1:4', 'gitnexus/src/x.ts:2:9-2:21', 'gitnexus/src/x.ts:9:0-9:8'], + ], + ])('covers %s', (_title, universe, ranges) => { + expect(mutateRangesOf(universe)).toEqual(ranges); + }); + + it('writes ranges the scope driver reads back unchanged', () => { + const ranges = mutateRangesOf([M1, M2, M4]); + expect(ranges.map((entry) => formatMutateEntry(parseMutateEntry(entry)))).toEqual(ranges); + }); + + it('rejects a string that is no global mutant id', () => { + expect(() => mutateRangesOf(['gitnexus/src/x.ts:2:10-2:22'])).toThrow(/no global id/); + }); +}); + +describe('planIsolation', () => { + it('schedules no run for a pair that never reached the repeat stage', () => { + const decision = decisionOf(replace(pairWorld(), B, M1, { outcome: 'survived' })); + expect([decision.selected, planIsolation([decision]).runs]).toEqual([false, []]); + }); + + it('schedules one together run per pair and alone(A) once over the union for A-B and A-C', () => { + const main = [ + ...pairWorld(), + observation(A, M4), + observation(B, M4, uncovered), + observation(C, M1), + observation(C, M2, { outcome: 'survived' }), + observation(C, M3, uncovered), + observation(C, M4), + ]; + // B is not exposed to M4, so A-B's universe is M1 and M2, and A-C's adds M4. + const ab = decisionOf(); + const [ac] = selectRepeatStagePairs([{ group: 'g1', a: A, b: C }], facts(main)); + const plan = planIsolation([ab, ac]); + expect(plan.runs.map((run) => [run.kind, run.lineage, run.universe, run.pairs])).toEqual([ + ['alone', A, [M1, M2, M4].sort(), [pairKey(A, B), pairKey(A, C)].sort()], + ['alone', B, [M1, M2].sort(), [pairKey(A, B)]], + ['alone', C, [M1, M2, M4].sort(), [pairKey(A, C)]], + ['together', pairKey(A, B), [M1, M2].sort(), [pairKey(A, B)]], + ['together', pairKey(A, C), [M1, M2, M4].sort(), [pairKey(A, C)]], + ]); + }); + + it('gives each run its tests, test files, ranges and a path-safe id', () => { + const [together] = planIsolation([decisionOf()]).runs.filter((run) => run.kind === 'together'); + expect(together).toEqual({ + id: isolationRunId('together', pairKey(A, B)), + group: 'g1', + kind: 'together', + lineage: pairKey(A, B), + tests: [A, B], + testFiles: ['test/unit/a.test.ts'], + universe: [M1, M2].sort(), + mutate: ['gitnexus/src/x.ts:2:9-2:21', 'gitnexus/src/x.ts:3:6-3:15'], + pairs: [pairKey(A, B)], + }); + expect(together.id).toMatch(/^together-[0-9a-f]{16}$/); + }); +}); + +describe('evaluateIsolation', () => { + it('agrees when main, together and alone observations all agree', () => { + const { result, runs } = evaluate(); + expect([result.status, result.reason, result.disagreements, result.unknown]).toEqual([ + 'agrees', + null, + [], + [], + ]); + expect(result.runs).toEqual(runs.map((run) => run.id).sort()); + }); + + it('names M when A kills it in main and together but survives it alone', () => { + const { result } = evaluate({ + alone: { [A]: replace(pairWorld(), A, M1, { outcome: 'survived' }) }, + }); + expect([result.status, result.reason, result.disagreements]).toEqual([ + 'disagrees', + 'isolation-disagrees', + [{ test: A, mutant: M1, field: 'outcome', main: KILL, together: KILL, alone: 'survived' }], + ]); + }); + + it('disagrees when B covers M in main but not alone', () => { + const { result } = evaluate({ + alone: { [B]: replace(pairWorld(), B, M2, uncovered) }, + }); + expect([result.status, result.disagreements]).toEqual([ + 'disagrees', + [ + { + test: B, + mutant: M2, + field: 'coverage', + main: 'covered', + together: 'covered', + alone: 'not-covered', + }, + ], + ]); + }); + + it('disagrees on the baseline when B fails alone unmutated, and its mutants are unknown', () => { + const failing: BaselineSummary = { + ...passed([B]), + outcome: KILL, + failing: [{ test: B, outcome: KILL, message: "expected 'beta-1' to be 'beta-2'" }], + }; + const { result } = evaluate({ + alone: { [B]: [] }, + aloneOverrides: { + [B]: { + status: 'inconclusive', + reason: 'baseline-failed', + detail: 'baseline 1: ...', + baselines: [failing], + }, + }, + }); + expect([result.status, result.disagreements]).toEqual([ + 'disagrees', + [ + { + test: B, + mutant: null, + field: 'baseline', + main: 'passed', + together: 'passed', + alone: KILL, + }, + ], + ]); + expect(result.unknown.map((entry) => [entry.mutant, entry.runs, entry.reasons])).toEqual([ + [M1, ['alone'], ['run:baseline-failed']], + [M2, ['alone'], ['run:baseline-failed']], + ]); + }); + + it('is unknown, never agreeing, when a rerun lacks a mutant', () => { + const { result } = evaluate({ together: pairWorld().filter((entry) => entry.mutant !== M2) }); + expect([result.status, result.reason, result.unknown]).toEqual([ + 'unknown', + 'isolation-unknown', + [ + { test: A, mutant: M2, runs: ['together'], reasons: ['missing'] }, + { test: B, mutant: M2, runs: ['together'], reasons: ['missing'] }, + ], + ]); + }); + + it('is unknown when a rerun observation is invalid', () => { + const { result } = evaluate({ + alone: { [A]: replace(pairWorld(), A, M1, { valid: false, reasons: ['attempts-disagree'] }) }, + }); + expect([result.status, result.unknown]).toEqual([ + 'unknown', + [{ test: A, mutant: M1, runs: ['alone'], reasons: ['attempts-disagree'] }], + ]); + }); + + it('is unknown when a run was verified against another run manifest', () => { + const { result } = evaluate({ aloneOverrides: { [A]: { manifestDigest: 'other' } } }); + expect([result.status, result.unknown.map((entry) => [entry.test, entry.reasons])]).toEqual([ + 'unknown', + [ + [A, ['baseline-unknown', 'run:manifest-mismatch']], + [A, ['run:manifest-mismatch']], + [A, ['run:manifest-mismatch']], + ], + ]); + }); + + it('does not run a pair that never reached the repeat stage', () => { + const main = replace(pairWorld(), B, M1, { outcome: 'survived' }); + const [result] = evaluateIsolation([decisionOf(main)], { + merged: mergeObservations(main, []), + runs: [], + manifestDigest: 'd', + }); + expect([result.status, result.reason, result.misses, result.runs]).toEqual([ + 'not-run', + 'not-repeat-stage', + ['kills-differ'], + [], + ]); + }); + + it('leaves a selected pair not run when one of its runs wrote no result', () => { + const decision = decisionOf(); + const runs = planIsolation([decision]) + .runs.filter((spec) => spec.lineage !== B) + .map((spec) => runResult(spec, pairWorld())); + const [result] = evaluateIsolation([decision], { + merged: mergeObservations(pairWorld(), []), + runs, + manifestDigest: 'd', + }); + expect([result.status, result.reason]).toEqual(['not-run', 'not-scheduled']); + }); + + it.each([ + ['in the main run', pairWorld().map((entry) => ({ ...entry, hookAttributed: true })), {}], + [ + 'in an alone run only', + pairWorld(), + { alone: { [A]: replace(pairWorld(), A, M1, { hookAttributed: true }) } }, + ], + ])('caps a pair touching a mutant hook-attributed %s at weak', (_title, main, world) => { + const { result } = evaluate({ main, ...world }); + expect(result.disqualifiers.map((entry) => [entry.kind, entry.cap])).toEqual([ + ['hook-stratum-unvalidated', 'weak'], + ]); + expect(result.disqualifiers[0].mutants).toContain(M1); + }); + + describe('with not-covered survivals implied by run universes', () => { + /** The pair's explicit observations: M3, covered by neither test, is implied. */ + const explicit = (entries: readonly Observation[]): Observation[] => + entries.filter((entry) => entry.mutant !== M3); + const mainUniverse = universeOf('main', 'main', [A, B], [M1, M2, M3]); + const merged = mergeObservations(explicit(pairWorld()), [mainUniverse]); + const [decision] = selectRepeatStagePairs([PAIR], { ...facts(), merged }); + const runsOf = (alone: Partial>): IsolationRunResult[] => + planIsolation([decision]).runs.map((spec) => + runResult(spec, explicit(alone[spec.lineage] ?? pairWorld()), { + runUniverse: universeOf(spec.kind, spec.lineage, spec.tests, spec.universe), + }), + ); + + it.each([ + ['agrees when every run implies the same survivals', {}, 'agrees', []], + [ + 'disagrees when B covers M2 in main but its alone run implies a not-covered survival', + { [B]: pairWorld().filter((entry) => entry.test !== B || entry.mutant !== M2) }, + 'disagrees', + [[B, M2, 'coverage', 'covered', 'covered', 'not-covered']], + ], + ])('%s', (_title, alone, status, disagreements) => { + const [result] = evaluateIsolation([decision], { + merged, + runs: runsOf(alone), + manifestDigest: 'd', + }); + expect([ + decision.selected, + decision.universe, + result.status, + result.disagreements.map((entry) => [ + entry.test, + entry.mutant, + entry.field, + entry.main, + entry.together, + entry.alone, + ]), + ]).toEqual([true, [M1, M2].sort(), status, disagreements]); + }); + }); +}); + +// ── Runs (fake runners) ──────────────────────────────────────────────────── + +/** A prepared tree: `gitnexus/` sources plus the two tool versions under node_modules. */ +function manifestFor(): ScenarioRunManifest { + const root = tempDir(); + const files: Record = { + 'gitnexus/node_modules/vitest/package.json': JSON.stringify({ version: '5.0.5' }), + 'gitnexus/node_modules/@stryker-mutator/core/package.json': JSON.stringify({ + version: '10.0.0', + }), + 'gitnexus/src/x.ts': 'export const x = 1;\n', + 'gitnexus/test/unit/a.test.ts': 'it("one", () => {});\n', + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } + return buildRunManifest({ preparedRoot: root, mode: { kind: 'normal' }, tests: [A, B] }); +} + +const vitestRun = (tests: readonly string[], failing: readonly string[] = []): VitestRunResult => ({ + tests: tests.map((test, index) => ({ + id: test, + taskId: `t_${index}`, + fullName: test.split('#')[1], + location: { line: index + 1, column: 3 }, + state: failing.includes(test) ? 'failed' : 'passed', + outcome: failing.includes(test) ? KILL : 'survived', + message: null, + })), + status: failing.length > 0 ? 'killed' : 'survived', + outcome: failing.length > 0 ? KILL : 'survived', + reason: null, + outsideTest: [], + problems: [], + unhandledErrors: [], + wallClockMs: 5, +}); + +const normalised = (globalId: string, tests: readonly string[]): NormalisedMutant => ({ + globalId, + localId: globalId.slice(-4), + file: 'gitnexus/src/x.ts', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 5 } }, + mutatorName: 'ArithmeticOperator', + replacement: '-', + static: false, + status: 'Killed', + statusReason: null, + testsCompleted: tests.length, + outcome: KILL, + outcomeSource: 'sidecar', + killedBy: [...tests], + coveredBy: [...tests], + assertionKilledBy: [...tests], + sidecar: null, + attempts: [ + { + file: '/s/1.json', + worker: '0', + attempt: 0, + pid: 1, + writtenAtMs: 1, + status: 'killed', + outcome: KILL, + nrOfTests: tests.length, + }, + ], + perTest: tests.map((testId) => ({ testId, outcome: KILL, attempt: 0 })), + attemptsDisagree: false, + hookAttributedTests: [], + flags: [], +}); + +/** Stryker evaluated M1 (in the universe) and M3 (nested in a range, outside it), not M2. */ +function strykerResults(outDir: string, tests: readonly string[]): RunResults { + return { + schemaVersion: 2, + variant: 'vitest5', + scope: { testFiles: ['test/unit/a.test.ts'], mutate: [], testIds: [...tests] }, + paths: { + outDir, + preparedRoot: path.join(outDir, 'prepared'), + packageDir: path.join(outDir, 'prepared', 'gitnexus'), + configFile: path.join(outDir, 'stryker.config.json'), + reportFile: path.join(outDir, 'mutation.json'), + logFile: path.join(outDir, 'stryker.log'), + runDir: path.join(outDir, 'run'), + sidecarDir: path.join(outDir, 'sidecars'), + sandboxDirs: [], + }, + stryker: { exitCode: 0, wallClockMs: 10 }, + spanMap: { checked: 2, failures: [] }, + manifest: null, + dryRun: { + file: '/s/dry.json', + status: 'survived', + outcome: 'survived', + reason: null, + nrOfTests: tests.length, + attempts: 1, + }, + mutants: [normalised(M1, tests), normalised(M3, tests)], + tests: [], + flags: [], + summary: { + mutants: 2, + byStatus: { + Killed: 2, + Survived: 0, + NoCoverage: 0, + CompileError: 0, + RuntimeError: 0, + Timeout: 0, + Ignored: 0, + Pending: 0, + }, + byOutcome: {}, + mutantsWithSidecar: 2, + mutantsWithOutcome: 2, + testsWithKills: tests.length, + testsWithAssertionKills: tests.length, + zeroTestRuns: 0, + flags: 0, + }, + }; +} + +function fakeDeps(failingAlone: readonly string[] = []): { + deps: GroupRunDeps; + configs: StrykerConfigInput[]; + scopeCalls: RunScopeOptions[]; +} { + const configs: StrykerConfigInput[] = []; + const scopeCalls: RunScopeOptions[] = []; + let tests: readonly string[] = []; + let clock = 0; + const deps: GroupRunDeps = { + runVitest: async (options: VitestRunOptions) => { + tests = options.testIds; + return vitestRun(options.testIds, failingAlone); + }, + runScope: async (options) => { + scopeCalls.push(options); + return { results: strykerResults(options.outDir, tests), exitCode: 0 }; + }, + buildConfig: (input) => { + configs.push(input); + return buildStrykerConfig(input); + }, + readJson: () => buildStrykerConfig({ ...configs[0], outDir: '/tmp/x' }), + readDryRunManifest: (): RunManifest => ({ + schemaVersion: 1, + project: 'mutation', + vitestVersion: '5.0.5', + files: [ + { + file: 'test/unit/a.test.ts', + tests: tests.map((test, index) => ({ + id: test, + taskId: `t_${index}`, + fullName: test.split('#')[1], + location: { line: index + 1, column: 3 }, + notOracleValidated: false, + })), + skipped: [], + }, + ], + }), + now: () => (clock += 1000), + }; + return { deps, configs, scopeCalls }; +} + +const spec: IsolationRunSpec = { + id: isolationRunId('alone', B), + group: 'g1', + kind: 'alone', + lineage: B, + tests: [B], + testFiles: ['test/unit/a.test.ts'], + universe: [M1, M2].sort(), + mutate: mutateRangesOf([M1, M2]), + pairs: [pairKey(A, B)], +}; + +describe('runIsolationRun', () => { + const options = (workDir: string) => ({ + repoRoot: '/repo', + workDir, + shard: 0, + manifest: manifestFor(), + packageDir: path.join(workDir, 'prepared', 'gitnexus'), + stryker: { concurrency: 3 }, + }); + + it('runs the tests alone over the ranges, a fresh process per mutant', async () => { + const workDir = tempDir(); + const { deps, configs, scopeCalls } = fakeDeps(); + await runIsolationRun(spec, options(workDir), deps); + expect(JSON.parse(readFileSync(scopeCalls[0].scopeFile, 'utf8'))).toEqual({ + testFiles: ['test/unit/a.test.ts'], + mutate: spec.mutate, + testIds: [B], + }); + expect([ + configs[0].maxTestRunnerReuse, + configs[0].concurrency, + scopeCalls[0].maxTestRunnerReuse, + ]).toEqual([ISOLATION_TEST_RUNNER_REUSE, 3, 1]); + }); + + it('keeps the universe observations under the run lineage and lists the missing mutants', async () => { + const workDir = tempDir(); + const { deps } = fakeDeps(); + const result = await runIsolationRun(spec, options(workDir), deps); + expect([ + result.status, + result.observations.map((entry) => [entry.kind, entry.lineage, entry.test, entry.mutant]), + result.missing, + result.outsideUniverse, + result.wallClockMs, + ]).toEqual(['complete', [['alone', B, B, M1]], [M2], 1, 1000]); + expect([ + result.runUniverse?.tests, + result.runUniverse?.mutants.map((entry) => entry.id), + ]).toEqual([[B], [M1]]); + expect(readIsolationRuns(workDir)).toEqual([result]); + }); + + it('writes the observations as rows of the run universe and rejects a schema 1 file', async () => { + const workDir = tempDir(); + const { deps } = fakeDeps(); + const result = await runIsolationRun(spec, options(workDir), deps); + const file = path.join(workDir, 'isolation', 'g1', `${spec.id}.json`); + const written = JSON.parse(readFileSync(file, 'utf8')) as { observations: unknown[] }; + writeFileSync(file, JSON.stringify({ ...result, schemaVersion: 1 })); + expect(written.observations).toEqual([[0, 0, 0, KILL, 'row', true, false, []]]); + expect(() => readIsolationRun(file)).toThrow(/has schema 1, not 2: .*re-run its shard/); + }); + + it('stops before Stryker when the test fails alone unmutated, and writes the result', async () => { + const workDir = tempDir(); + const { deps, scopeCalls } = fakeDeps([B]); + const result = await runIsolationRun(spec, options(workDir), deps); + expect([result.status, result.reason, result.observations, scopeCalls.length]).toEqual([ + 'inconclusive', + 'baseline-failed', + [], + 0, + ]); + expect(readIsolationRuns(workDir).map((entry) => entry.reason)).toEqual(['baseline-failed']); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/issue-body.test.ts b/gitnexus/test/unit/scenario-map/issue-body.test.ts new file mode 100644 index 000000000..21c2a8923 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/issue-body.test.ts @@ -0,0 +1,168 @@ +/** + * Locks the tracking issue body of the scenario map (U13, R13, KTD13): the header (run link, run + * id marker, completeness, every degraded flag, "N of M evaluated", precision not measured), the + * strong repeats, weak tests and per-file gaps in that order, truncation below 60,000 characters + * with a count line, the "no findings" body and the freshness check an upsert runs. + */ +import { describe, expect, it } from 'vitest'; +import { + ISSUE_BODY_LIMIT, + NO_FINDINGS, + parseRunMarker, + renderIssueBody, + runMarker, + shouldUpdateIssue, +} from '../../../tools/scenario-map/report/issue-body.js'; +import { mdCode } from '../../../tools/scenario-map/report/markdown.js'; +import { PRECISION_NOT_MEASURED, buildReport } from '../../../tools/scenario-map/report/report.js'; +import type { DegradedFlag } from '../../../tools/scenario-map/value-flow/pdg-walk.js'; +import { computeFingerprints } from '../../../tools/scenario-map/verdict/fingerprint.js'; +import type { FileGaps } from '../../../tools/scenario-map/verdict/verdict.js'; +import { A, B, C, M2, M3, reportInput } from './report-fixtures.js'; + +const report = buildReport(reportInput()); +const body = renderIssueBody(report); +/** The header: everything before the first section. */ +const headerOf = (text: string): string => text.slice(0, text.indexOf('\n### ')); + +describe('issue body header', () => { + it.each([ + ['the run link', '[run 1001](https://github.com/abhigyanpatwari/GitNexus/actions/runs/1001)'], + ['completeness', '**Complete run**: 1 of 1 shards complete.'], + ['N of M', '**3 of 3 eligible tests evaluated.**'], + ['the precision statement', PRECISION_NOT_MEASURED], + ['the baseline banner', 'first run, no baseline'], + ['no degraded flag', '**Degraded**: none.'], + ])('shows %s', (_title, text) => { + expect(headerOf(body)).toContain(text); + }); + + it.each(['no-pdg-layer', 'checker-version', 'checker-unavailable'])( + 'names the degraded flag %s', + (flag) => { + const degraded = renderIssueBody(buildReport(reportInput({ degraded: [flag] }))); + expect(headerOf(degraded)).toContain(`\`${flag}\``); + }, + ); + + it('names a partial run and its incomplete shards', () => { + const shards = { planned: 2, complete: 1, incomplete: [{ shard: '1', reason: 'timeout' }] }; + const partial = renderIssueBody(buildReport(reportInput({ overrides: { shards } }))); + expect(headerOf(partial)).toContain( + '**Partial run**: 1 of 2 shards complete; shard `1`: timeout.', + ); + }); +}); + +describe('run id marker', () => { + it('opens the body with a marker a later upsert can compare', () => { + expect(body.split('\n')[0]).toBe(runMarker(report.run)); + expect(parseRunMarker(body)).toEqual({ runId: '1001', attempt: 1 }); + }); + + it('reads the marker from the first line only', () => { + expect(parseRunMarker(`hello\n${runMarker({ id: '9999', attempt: 1 })}`)).toBeNull(); + }); + + it.each([ + ['no existing body', null, true], + ['a body without a marker', 'hand-written', true], + ['an older run', runMarker({ id: '1000', attempt: 3 }), true], + ['an earlier attempt of this run', runMarker({ id: '1001', attempt: 0 }), true], + ['this very run', runMarker({ id: '1001', attempt: 1 }), true], + ['a newer run', runMarker({ id: '1002', attempt: 1 }), false], + ['a later attempt of this run', runMarker({ id: '1001', attempt: 2 }), false], + ['a non-numeric run id', runMarker({ id: 'local', attempt: 1 }), false], + ])('decides an update over %s', (_title, existing, expected) => { + expect(shouldUpdateIssue(report, existing)).toBe(expected); + }); + + it.each([ + ['a partial run', { shards: { planned: 2, complete: 1, incomplete: [] } }], + ['a run off the default branch', { run: { ...report.run, defaultBranch: false } }], + ])('never updates the body from %s', (_title, overrides) => { + expect(shouldUpdateIssue(buildReport(reportInput({ overrides })), null)).toBe(false); + }); +}); + +describe('issue body fingerprint sections', () => { + // B checks add(2, 3) as A does: the same path, input and expectation, so A + B is a duplicate. + const input = reportInput(); + const facetOfA = input.evidence.tests.find((test) => test.id === A)?.facet ?? null; + const evidence = { + ...input.evidence, + tests: input.evidence.tests.map((test) => + test.id === B ? { ...test, facet: facetOfA } : test, + ), + }; + const withDuplicate = (mutation: 'none' | 'all') => + renderIssueBody( + buildReport({ ...input, mutation, fingerprint: computeFingerprints(evidence) }), + ); + + it('lists the duplicates before every mutation section and names the mutation scope', () => { + const text = withDuplicate('all'); + const duplicates = text.indexOf('### Duplicates (1)'); + expect([duplicates > 0, text.indexOf('### Strong repeat candidates') > duplicates]).toEqual([ + true, + true, + ]); + expect(headerOf(text)).toContain('**Mutation evidence**: gathered for every candidate group'); + }); + + it('says mutation evidence was not gathered for a fingerprint-only run', () => { + expect(headerOf(withDuplicate('none'))).toContain('**Mutation evidence**: not gathered'); + }); +}); + +describe('issue body sections', () => { + it('lists strong repeats, then weak tests, then gaps', () => { + const strong = body.indexOf('### Strong repeat candidates (1)'); + const weak = body.indexOf('### Weak tests (1)'); + const gaps = body.indexOf('### Gaps per production file (1)'); + expect([strong > 0, weak > strong, gaps > weak]).toEqual([true, true, true]); + expect(body).toContain(mdCode(A)); + expect(body).toContain(mdCode(B)); + expect(body.slice(weak, gaps)).toContain(mdCode(C)); + }); + + it('truncates 5,000 gap rows below 60,000 characters, strong and weak first, with counts', () => { + const gapRows: FileGaps[] = Array.from({ length: 5000 }, (_, i) => ({ + file: `gitnexus/src/gap-${String(i).padStart(4, '0')}.ts`, + mutants: 3, + killed: 1, + survived: [M2], + uncovered: [M3], + inconclusive: [], + })); + const big = renderIssueBody( + buildReport(reportInput({ verdicts: (verdicts) => ({ ...verdicts, gaps: gapRows }) })), + ); + const lines = big.trimEnd().split('\n'); + const shownGaps = lines.filter((line) => line.startsWith('| `gitnexus/src/gap-')).length; + expect(big.length).toBeLessThan(ISSUE_BODY_LIMIT); + expect([big.includes(mdCode(A)), big.includes(mdCode(C)), shownGaps > 0]).toEqual([ + true, + true, + true, + ]); + expect(lines.at(-1)).toBe( + 'Truncated below 60,000 characters: ' + + `${5000 - shownGaps} more rows not shown (0 duplicates, 0 same-scenario pairs, ` + + `0 strong repeat candidates, 0 weak tests, ${5000 - shownGaps} gap files); the report ` + + 'artifact has every row.', + ); + }); + + it('states that a complete run with no findings found none', () => { + const clean = renderIssueBody( + buildReport( + reportInput({ + verdicts: (verdicts) => ({ ...verdicts, pairs: [], kept: [], tests: [], gaps: [] }), + }), + ), + ); + expect(clean).toContain(`Complete run: ${NO_FINDINGS}.`); + expect(clean).not.toContain('### Strong repeat candidates'); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/observations.test.ts b/gitnexus/test/unit/scenario-map/observations.test.ts new file mode 100644 index 000000000..469548acc --- /dev/null +++ b/gitnexus/test/unit/scenario-map/observations.test.ts @@ -0,0 +1,826 @@ +/** + * Locks Layer 3's raw observations (U7, R6, R7, R11, KTD8, design §5.4) in the compact model: a + * run universe (the selected tests, never a skipped one, and the evaluated mutants) plus one + * explicit observation per per-test row of every sidecar attempt, keyed by group, run kind, + * lineage, attempt, test and global mutant id; an uncovered test of a non-static mutant is an + * implied survived observation, never stored, while a covered or static mutant without a row is + * an explicit unknown, never a non-kill; untrusted runs, attempt disagreements and integrity flags + * invalidate (implied observations too); merging folds attempts and runs of one lineage and turns + * a class disagreement into one inconclusive observation; observations of different groups stay + * apart, and a pair is compared only over its common universe inside one group run. Work files + * hold the explicit observations as rows of the universe. + */ +import { describe, expect, it } from 'vitest'; +import type { + FlagKind, + NormalisedMutant, + RunResults, + SidecarAttempt, + SidecarSummary, +} from '../../../tools/mutation/driver/normalise-results.js'; +import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; +import { + comparePair, + decodeObservations, + encodeObservations, + extractObservations, + impliedObservation, + indexObservations, + lineageOf, + mergeObservations, + observationKey, + observationOf, + observedMutants, + type MergedObservation, + type Observation, + type RunRef, + type RunUniverse, +} from '../../../tools/scenario-map/mutation/observations.js'; + +const T1 = 'test/unit/a.test.ts#A > one'; +const T2 = 'test/unit/a.test.ts#A > two'; +const M1 = 'gitnexus/src/x.ts@0123456789abcdef:1:1-1:5:ArithmeticOperator:aaaaaaaaaaaaaaaa'; +const M2 = 'gitnexus/src/x.ts@0123456789abcdef:2:1-2:5:EqualityOperator:bbbbbbbbbbbbbbbb'; +const M3 = 'gitnexus/src/x.ts@0123456789abcdef:3:1-3:5:StringLiteral:cccccccccccccccc'; +const MAIN: RunRef = { group: 'g1', kind: 'main', lineage: 'main', shard: 0 }; + +const attempt = (outcome: OutcomeClass, index = 0): SidecarAttempt => ({ + file: `/sidecars/mutant-${index}.json`, + worker: '0', + attempt: index, + pid: 100 + index, + writtenAtMs: 1000 + index, + status: outcome === 'survived' ? 'survived' : outcome === 'timeout' ? 'timeout' : 'killed', + outcome, + nrOfTests: 2, +}); + +const mutant = (globalId: string, overrides: Partial = {}): NormalisedMutant => ({ + globalId, + localId: globalId.slice(-3), + file: 'gitnexus/src/x.ts', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 5 } }, + mutatorName: 'ArithmeticOperator', + replacement: '-', + static: false, + status: 'Killed', + statusReason: null, + testsCompleted: 2, + outcome: 'killed-by-test:assertion', + outcomeSource: 'sidecar', + killedBy: [T1], + coveredBy: [T1, T2], + assertionKilledBy: [T1], + sidecar: null, + attempts: [attempt('killed-by-test:assertion')], + perTest: [ + { testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }, + { testId: T2, outcome: 'survived', attempt: 0 }, + ], + attemptsDisagree: false, + hookAttributedTests: [], + flags: [], + ...overrides, +}); + +const results = ( + mutants: NormalisedMutant[], + extra: { flags?: RunResults['flags']; spanFailures?: string[] } = {}, +): RunResults => ({ + schemaVersion: 2, + variant: 'vitest5', + scope: { + testFiles: ['test/unit/a.test.ts'], + mutate: ['gitnexus/src/x.ts'], + testIds: [T1, T2], + }, + paths: { + outDir: '/out', + preparedRoot: '/out/prepared', + packageDir: '/out/prepared/gitnexus', + configFile: '/out/stryker.config.json', + reportFile: '/out/mutation.json', + logFile: '/out/stryker.log', + runDir: '/out/run', + sidecarDir: '/out/sidecars', + sandboxDirs: [], + }, + stryker: { exitCode: 0, wallClockMs: 1000 }, + spanMap: { + checked: mutants.length, + failures: (extra.spanFailures ?? []).map((globalId) => ({ + localId: '1', + globalId, + message: 'span moved', + })), + }, + manifest: { project: 'mutation', vitestVersion: '5.0.5', files: 1, tests: 2, skipped: 0 }, + dryRun: { + file: '/sidecars/dry-run.json', + status: 'survived', + outcome: 'survived', + reason: null, + nrOfTests: 2, + attempts: 1, + }, + mutants, + tests: [], + flags: extra.flags ?? [], + summary: { + mutants: mutants.length, + byStatus: { + Killed: 0, + Survived: 0, + NoCoverage: 0, + CompileError: 0, + RuntimeError: 0, + Timeout: 0, + Ignored: 0, + Pending: 0, + }, + byOutcome: {}, + mutantsWithSidecar: 0, + mutantsWithOutcome: 0, + testsWithKills: 0, + testsWithAssertionKills: 0, + zeroTestRuns: 0, + flags: 0, + }, +}); + +/** The explicit observations of one test and mutant, as `[outcome, source, valid, reasons]`. */ +const summary = ( + run: { observations: readonly Observation[] }, + test: string, + globalId: string, +): [OutcomeClass | null, string, boolean, string[]][] => + run.observations + .filter((entry) => entry.test === test && entry.mutant === globalId) + .map((entry) => [entry.outcome, entry.source, entry.valid, entry.reasons]); + +/** The merged observation (explicit or implied) of a test and mutant in a run's main lineage. */ +const merged = ( + run: { universe: RunUniverse; observations: readonly Observation[] }, + test: string, + globalId: string, +): MergedObservation | undefined => { + const view = lineageOf( + indexObservations(mergeObservations(run.observations, [run.universe])), + run.universe, + ); + return view === undefined ? undefined : observationOf(view, test, globalId); +}; + +/** `[outcome, sources, valid, reasons, hookAttributed]` of a merged observation. */ +const brief = ( + entry: MergedObservation | undefined, +): [OutcomeClass | null, string[], boolean, string[], boolean | null] | undefined => + entry === undefined + ? undefined + : [entry.outcome, entry.sources, entry.valid, entry.reasons, entry.hookAttributed]; + +const observation = (overrides: Partial): Observation => ({ + group: 'g1', + kind: 'main', + lineage: 'main', + shard: 0, + attempt: 0, + test: T1, + mutant: M1, + file: 'gitnexus/src/x.ts', + covered: true, + hookAttributed: false, + static: false, + outcome: 'killed-by-test:assertion', + source: 'row', + valid: true, + reasons: [], + ...overrides, +}); + +describe('extractObservations', () => { + it('emits one valid observation per per-test row, carrying the run reference', () => { + const { observations } = extractObservations(results([mutant(M1)]), MAIN); + expect(observations.map((entry) => [entry.test, entry.outcome, entry.attempt])).toEqual([ + [T1, 'killed-by-test:assertion', 0], + [T2, 'survived', 0], + ]); + expect(observations.every((entry) => entry.valid && entry.group === 'g1')).toBe(true); + expect(observations.every((entry) => entry.kind === 'main' && entry.lineage === 'main')).toBe( + true, + ); + }); + + it('keeps the rows of every attempt, oldest first', () => { + const flaky = mutant(M1, { + attempts: [attempt('killed-by-test:assertion', 0), attempt('survived', 1)], + perTest: [ + { testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }, + { testId: T1, outcome: 'survived', attempt: 1 }, + ], + coveredBy: [T1], + attemptsDisagree: true, + }); + expect(summary(extractObservations(results([flaky]), MAIN), T1, M1)).toEqual([ + ['killed-by-test:assertion', 'row', false, ['attempts-disagree']], + ['survived', 'row', false, ['attempts-disagree']], + ]); + }); + + it('implies a valid survived observation for an uncovered test of a non-static mutant', () => { + const narrow = mutant(M1, { + coveredBy: [T1], + perTest: [{ testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }], + }); + const run = extractObservations(results([narrow]), MAIN); + expect([summary(run, T2, M1), brief(merged(run, T2, M1))]).toEqual([ + [], + ['survived', ['not-covered'], true, [], false], + ]); + }); + + it('records the selected tests and the evaluated mutants in the run universe', () => { + const run = extractObservations( + results([mutant(M2, { flags: ['status-mismatch'] }), mutant(M1, { static: true })]), + MAIN, + ); + expect(run.universe).toEqual({ + ...MAIN, + tests: [T1, T2], + mutants: [ + { + id: M1, + file: 'gitnexus/src/x.ts', + static: true, + status: 'Killed', + reasons: [], + hookMarker: true, + }, + { + id: M2, + file: 'gitnexus/src/x.ts', + static: false, + status: 'Killed', + reasons: ['mutant-flag:status-mismatch'], + hookMarker: true, + }, + ], + valid: true, + reasons: [], + }); + }); + + it('gives a collected but skipped test no observation, even when it covers the mutant', () => { + const skipped = 'test/unit/a.test.ts#A > skipped'; + const covered = mutant(M1, { + coveredBy: [T1, skipped], + perTest: [{ testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }], + }); + const run = extractObservations( + { + ...results([covered]), + tests: [T1, T2, skipped].map((id) => ({ + id, + file: 'test/unit/a.test.ts', + fullName: id.split('#')[1], + taskId: null, + skipped: id === skipped, + notOracleValidated: false, + killed: [], + assertionKilled: [], + })), + }, + MAIN, + ); + expect([run.universe.tests, summary(run, skipped, M1), merged(run, skipped, M1)]).toEqual([ + [T1, T2], + [], + undefined, + ]); + }); + + it('records a covered test without a row as unknown, never as a non-kill', () => { + const missing = mutant(M1, { + perTest: [{ testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }], + }); + expect(summary(extractObservations(results([missing]), MAIN), T2, M1)).toEqual([ + [null, 'no-row', false, ['missing-result']], + ]); + }); + + it('treats a static mutant without a row as unknown even when coveredBy is empty', () => { + const statik = mutant(M1, { + static: true, + coveredBy: [], + perTest: [{ testId: T1, outcome: 'survived', attempt: 0 }], + }); + const run = extractObservations(results([statik]), MAIN); + expect(summary(run, T2, M1)).toEqual([[null, 'no-row', false, ['missing-result']]]); + expect(summary(run, T1, M1)).toEqual([['survived', 'row', true, []]]); + expect(run.observations.every((entry) => entry.static && !entry.covered)).toBe(true); + }); + + it.each([ + ['timeout', 'run-timeout'], + ['infrastructure', 'run-infrastructure'], + ['inconclusive', 'run-inconclusive'], + ['invalid', 'run-invalid'], + ] as const)('distrusts the rows of an attempt whose run class is %s', (outcome, reason) => { + const untrusted = mutant(M1, { attempts: [attempt(outcome)] }); + expect(summary(extractObservations(results([untrusted]), MAIN), T1, M1)).toEqual([ + ['killed-by-test:assertion', 'row', false, [reason]], + ]); + }); + + /** A static mutant whose latest attempt failed to collect `file`: no per-test rows. */ + const importFailure = (file: string, run: OutcomeClass): NormalisedMutant => { + const latest = attempt(run); + const sidecar: SidecarSummary = { + ...latest, + reason: null, + failureMessage: null, + killedBy: [], + hitCount: 0, + mutantActivation: 'static', + outsideTest: [ + { + file, + suiteTaskId: undefined, + outcome: 'killed-outside-test:collection', + attributedTo: [], + message: 'SyntaxError: Unexpected token', + }, + ], + }; + return mutant(M1, { + static: true, + coveredBy: [], + killedBy: [], + assertionKilledBy: [], + sidecar, + attempts: [latest], + perTest: [], + }); + }; + + it.each([ + { + label: "the test's file failed to collect", + file: 'test/unit/a.test.ts', + run: 'killed-outside-test:collection', + expected: ['killed-outside-test:collection', 'collection', true, []], + }, + { + label: 'the collection failure belongs to an untrusted attempt', + file: 'test/unit/a.test.ts', + run: 'timeout', + expected: ['killed-outside-test:collection', 'collection', false, ['run-timeout']], + }, + { + label: 'another file failed to collect', + file: 'test/unit/b.test.ts', + run: 'killed-outside-test:collection', + expected: [null, 'no-row', false, ['missing-result']], + }, + ] as const)('records a test without a row when $label', ({ file, run, expected }) => { + const extracted = extractObservations(results([importFailure(file, run)]), MAIN); + expect([summary(extracted, T1, M1), summary(extracted, T2, M1)]).toEqual([ + [expected], + [expected], + ]); + }); + + it('records a Stryker timeout without a sidecar as an unattributable timeout', () => { + const killed = mutant(M1, { + status: 'Timeout', + outcome: 'timeout', + outcomeSource: 'stryker-timeout', + coveredBy: [T1], + attempts: [], + perTest: [], + }); + const run = extractObservations(results([killed]), MAIN); + expect(summary(run, T1, M1)).toEqual([ + ['timeout', 'stryker-timeout', false, ['stryker-timeout']], + ]); + expect(brief(merged(run, T2, M1))).toEqual(['survived', ['not-covered'], true, [], false]); + }); + + it('implies a NoCoverage mutant survived by every test and stores no observation', () => { + const uncovered = mutant(M1, { + status: 'NoCoverage', + outcome: 'survived', + outcomeSource: 'no-coverage', + coveredBy: [], + attempts: [], + perTest: [], + }); + const run = extractObservations(results([uncovered]), MAIN); + expect([run.observations, [T1, T2].map((test) => brief(merged(run, test, M1)))]).toEqual([ + [], + [ + ['survived', ['not-covered'], true, [], false], + ['survived', ['not-covered'], true, [], false], + ], + ]); + }); + + it.each(['Ignored', 'Pending'] as const)('leaves a %s mutant out of the universe', (status) => { + const run = extractObservations(results([mutant(M1, { status, perTest: [] })]), MAIN); + expect([run.observations, run.universe.mutants]).toEqual([[], []]); + }); + + it('invalidates every observation of a run with a run-level integrity flag', () => { + const flags: RunResults['flags'] = [ + { kind: 'dry-run-missing', localId: null, globalId: null, detail: 'no dry run' }, + ]; + const run = extractObservations( + results([mutant(M1), mutant(M2, { coveredBy: [], perTest: [] })], { flags }), + MAIN, + ); + expect([ + run.universe.valid, + run.observations.map((entry) => entry.reasons), + brief(merged(run, T1, M2)), + ]).toEqual([ + false, + [['run-flag:dry-run-missing'], ['run-flag:dry-run-missing']], + ['survived', ['not-covered'], false, ['run-flag:dry-run-missing'], false], + ]); + }); + + it.each([ + ['status-mismatch', 'mutant-flag:status-mismatch'], + ['killed-by-not-in-sidecar', 'mutant-flag:killed-by-not-in-sidecar'], + ] as const)('invalidates a mutant flagged %s', (kind: FlagKind, reason) => { + const flagged = mutant(M1, { flags: [kind] }); + expect(summary(extractObservations(results([flagged]), MAIN), T1, M1)).toEqual([ + ['killed-by-test:assertion', 'row', false, [reason]], + ]); + }); + + it('invalidates a mutant whose span-map check failed', () => { + const { observations } = extractObservations( + results([mutant(M1)], { spanFailures: [M1] }), + MAIN, + ); + expect(observations.map((entry) => [entry.valid, entry.reasons])).toEqual([ + [false, ['span-map']], + [false, ['span-map']], + ]); + }); + + it('invalidates the implied observations of a flagged mutant', () => { + const narrow = mutant(M1, { + coveredBy: [T1], + perTest: [{ testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }], + flags: ['status-mismatch'], + }); + expect(brief(merged(extractObservations(results([narrow]), MAIN), T2, M1))).toEqual([ + 'survived', + ['not-covered'], + false, + ['mutant-flag:status-mismatch'], + false, + ]); + }); + + it('marks the hook-attributed tests and leaves the marker unknown without a dry-run record', () => { + const hooked = mutant(M1, { hookAttributedTests: [T2] }); + const unknown = mutant(M2, { hookAttributedTests: null }); + const { observations } = extractObservations(results([hooked, unknown]), MAIN); + expect( + observations.map((entry) => [entry.mutant === M1 ? 'M1' : 'M2', entry.hookAttributed]), + ).toEqual([ + ['M1', false], + ['M1', true], + ['M2', null], + ['M2', null], + ]); + }); + + it.each([ + ['a hook marker naming it', [T2], 1, true], + ['no hook marker for the mutant', null, 0, null], + ] as const)( + 'stores an uncovered test of a mutant with %s only when the marker names it', + (_title, hookAttributedTests, stored, marker) => { + const narrow = mutant(M1, { + coveredBy: [T1], + perTest: [{ testId: T1, outcome: 'survived', attempt: 0 }], + hookAttributedTests: hookAttributedTests === null ? null : [...hookAttributedTests], + }); + const run = extractObservations(results([narrow]), MAIN); + expect([summary(run, T2, M1).length, merged(run, T2, M1)?.hookAttributed]).toEqual([ + stored, + marker, + ]); + }, + ); + + it('keys observations by group, kind, lineage, attempt, test and mutant', () => { + const keys = [ + observation({}), + observation({ group: 'g2' }), + observation({ kind: 'together', lineage: 'pair' }), + observation({ attempt: 1 }), + observation({ test: T2 }), + observation({ mutant: M2 }), + ].map(observationKey); + expect(new Set(keys).size).toBe(6); + }); +}); + +describe('mergeObservations', () => { + it('folds two attempts with different classes into one inconclusive observation', () => { + const { explicit } = mergeObservations( + [ + observation({ attempt: 0, outcome: 'killed-by-test:assertion' }), + observation({ attempt: 1, outcome: 'survived' }), + ], + [], + ); + expect( + explicit.map((entry) => [entry.outcome, entry.valid, entry.reasons, entry.attempts]), + ).toEqual([['inconclusive', false, ['class-disagreement'], [0, 1]]]); + }); + + it('folds agreeing attempts into one valid observation', () => { + const { explicit } = mergeObservations( + [observation({ attempt: 0 }), observation({ attempt: 1 }), observation({ attempt: 1 })], + [], + ); + expect(explicit.map((entry) => [entry.outcome, entry.valid, entry.attempts])).toEqual([ + ['killed-by-test:assertion', true, [0, 1]], + ]); + }); + + it('keeps the observations of one mutant in two groups apart', () => { + const { explicit } = mergeObservations( + [ + observation({ group: 'gx', test: T1, outcome: 'killed-by-test:assertion' }), + observation({ group: 'gy', test: T2, outcome: 'survived' }), + ], + [], + ); + expect(explicit.map((entry) => [entry.group, entry.test, entry.outcome])).toEqual([ + ['gx', T1, 'killed-by-test:assertion'], + ['gy', T2, 'survived'], + ]); + }); + + it('carries the reasons of an invalid observation into the merged one', () => { + const { explicit } = mergeObservations( + [ + observation({ attempt: 0 }), + observation({ attempt: 1, valid: false, reasons: ['run-timeout'] }), + ], + [], + ); + expect(explicit.map((entry) => [entry.valid, entry.reasons])).toEqual([ + [false, ['run-timeout']], + ]); + }); + + /** A universe of the main run of g1 on one shard: T1 and T2 selected, M1 non-static. */ + const universe = (shard: number, hookMarker = true): RunUniverse => ({ + ...MAIN, + shard, + tests: [T1, T2], + mutants: [ + { + id: M1, + file: 'gitnexus/src/x.ts', + static: false, + status: 'Survived', + reasons: [], + hookMarker, + }, + ], + valid: true, + reasons: [], + }); + + it('folds the implied observation of another run of the lineage into an explicit one', () => { + const { explicit } = mergeObservations( + [observation({ test: T1, shard: 0 })], + [universe(0), universe(1)], + ); + expect( + explicit.map((entry) => [ + entry.test, + entry.outcome, + entry.covered, + entry.shards, + entry.reasons, + ]), + ).toEqual([ + [T1, 'inconclusive', true, [0, 1], ['class-disagreement', 'coverage-disagreement']], + ]); + }); + + it('folds the implied observations of two runs on read, marker disagreements included', () => { + const view = lineageOf( + indexObservations(mergeObservations([], [universe(0, true), universe(1, false)])), + MAIN, + ); + expect(brief(view === undefined ? undefined : observationOf(view, T2, M1))).toEqual([ + 'survived', + ['not-covered'], + false, + ['hook-marker-disagreement'], + false, + ]); + }); +}); + +describe('comparePair', () => { + const run = { group: 'g1', kind: 'main', lineage: 'main' } as const; + /** Explicit observations only, indexed. */ + const indexed = (observations: readonly Observation[]) => + indexObservations(mergeObservations(observations, [])); + + it('compares equal kill sets over the common universe', () => { + const merged = indexed([ + observation({ test: T1, mutant: M1 }), + observation({ test: T2, mutant: M1 }), + observation({ test: T1, mutant: M2, outcome: 'survived' }), + observation({ test: T2, mutant: M2, outcome: 'survived', covered: false }), + ]); + const comparison = comparePair(merged, run, T1, T2); + expect([comparison.universe, comparison.killsA, comparison.killsB]).toEqual([ + [M1, M2], + [M1], + [M1], + ]); + expect([comparison.unknown, comparison.sameKills]).toEqual([[], true]); + expect(comparison.universeId).toBe(comparePair(merged, run, T2, T1).universeId); + }); + + it('makes a covered mutant unknown when one test has no observation, and does not compare', () => { + const merged = indexed([ + observation({ test: T1, mutant: M1 }), + observation({ test: T1, mutant: M2 }), + observation({ test: T2, mutant: M2 }), + ]); + const comparison = comparePair(merged, run, T1, T2); + expect([comparison.universe, comparison.unknown, comparison.sameKills]).toEqual([ + [M2], + [M1], + null, + ]); + }); + + it('does not compare two tests of a run that has no observation', () => { + const merged = indexed([observation({ group: 'gx' }), observation({ group: 'gx', test: T2 })]); + expect(comparePair(merged, run, T1, T2)).toMatchObject({ + universe: [], + unknown: [], + sameKills: null, + }); + }); + + it('makes an invalid observation unknown for the pair', () => { + const merged = indexed([ + observation({ test: T1, mutant: M1 }), + observation({ test: T2, mutant: M1, valid: false, reasons: ['missing-result'] }), + ]); + expect(comparePair(merged, run, T1, T2).unknown).toEqual([M1]); + }); + + it('compares only inside the requested group run', () => { + const merged = indexed([ + observation({ group: 'gx', test: T1, mutant: M1 }), + observation({ group: 'gx', test: T2, mutant: M1 }), + observation({ group: 'gy', test: T1, mutant: M1, outcome: 'survived' }), + observation({ group: 'gy', test: T2, mutant: M1, outcome: 'survived' }), + observation({ group: 'gy', test: T1, mutant: M3 }), + ]); + const inX = comparePair(merged, { ...run, group: 'gx' }, T1, T2); + const inY = comparePair(merged, { ...run, group: 'gy' }, T1, T2); + expect([inX.killsA, inX.unknown, inY.killsA, inY.unknown]).toEqual([[M1], [], [], [M3]]); + }); + + it('lists the universe mutants whose class is neither an assertion kill nor survived', () => { + const merged = indexed([ + observation({ test: T1, mutant: M1, outcome: 'killed-by-test:runtime' }), + observation({ test: T2, mutant: M1 }), + observation({ test: T1, mutant: M2, outcome: 'timeout' }), + observation({ test: T2, mutant: M2, outcome: 'survived' }), + ]); + expect(comparePair(merged, run, T1, T2).otherClasses).toEqual([M1, M2]); + }); + + it('counts a collection failure of both tests as a non-kill for both, of one as another class', () => { + const collection = { outcome: 'killed-outside-test:collection', source: 'collection' } as const; + const merged = indexed([ + observation({ test: T1, mutant: M1 }), + observation({ test: T2, mutant: M1 }), + observation({ test: T1, mutant: M2, ...collection }), + observation({ test: T2, mutant: M2, ...collection }), + observation({ test: T1, mutant: M3, ...collection }), + observation({ test: T2, mutant: M3, outcome: 'survived' }), + ]); + const comparison = comparePair(merged, run, T1, T2); + expect([comparison.otherClasses, comparison.killsA, comparison.sameKills]).toEqual([ + [M3], + [M1], + true, + ]); + }); +}); + +describe('comparePair over implied observations', () => { + it('compares two tests whose survivals of uncovered mutants are implied, not stored', () => { + const noCoverage = mutant(M2, { + status: 'NoCoverage', + outcome: 'survived', + outcomeSource: 'no-coverage', + coveredBy: [], + attempts: [], + perTest: [], + }); + const onlyT1 = mutant(M3, { + coveredBy: [T1], + perTest: [{ testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }], + }); + const run = extractObservations(results([mutant(M1), noCoverage, onlyT1]), MAIN); + const comparison = comparePair( + indexObservations(mergeObservations(run.observations, [run.universe])), + MAIN, + T1, + T2, + ); + expect([ + run.observations.length, + comparison.universe, + comparison.killsA, + comparison.killsB, + comparison.unknown, + comparison.sameKills, + ]).toEqual([3, [M1, M2, M3], [M1, M3], [], [], false]); + }); + + it('lists every mutant of a run with a test as observed, and none of a run without tests', () => { + const run = extractObservations( + results([mutant(M1), mutant(M2, { coveredBy: [], perTest: [] })]), + MAIN, + ); + const empty: RunUniverse = { ...run.universe, group: 'g2', tests: [] }; + expect(observedMutants(mergeObservations(run.observations, [run.universe, empty]))).toEqual([ + M1, + M2, + ]); + }); +}); + +describe('work-file rows', () => { + const run = extractObservations( + results([ + mutant(M1), + mutant(M2, { static: true, coveredBy: [], perTest: [], hookAttributedTests: null }), + mutant(M3, { coveredBy: [] }), + ]), + MAIN, + ); + + it('round-trips the explicit observations through rows of the universe', () => { + const rows = encodeObservations(run.universe, run.observations); + expect([ + rows[0], + decodeObservations(run.universe, JSON.parse(JSON.stringify(rows)) as unknown[]), + ]).toEqual([[0, 0, 0, 'killed-by-test:assertion', 'row', true, false, []], run.observations]); + }); + + it.each([ + ['a test outside the universe', { test: 'test/unit/z.test.ts#Z > z' }], + ['a mutant outside the universe', { mutant: 'gitnexus/src/z.ts@0:1:1-1:2:X:0' }], + ['another run', { shard: 3 }], + ])('refuses to encode an observation of %s', (_title, change: Partial) => { + expect(() => encodeObservations(run.universe, [{ ...run.observations[0], ...change }])).toThrow( + /does not fit its run universe/, + ); + }); + + it.each([ + ['a test index out of range', [9, 0, 0, null, 'row', true, null, []]], + ['an unknown source', [0, 0, 0, null, 'guess', true, null, []]], + ['a short row', [0, 0, 0]], + ])('refuses to decode a row with %s', (_title, row) => { + expect(() => decodeObservations(run.universe, [row])).toThrow(/row 0 is not an observation/); + }); + + it('implies the observation the full materialisation stored for an uncovered test', () => { + const [, , uncovered] = run.universe.mutants; + expect(impliedObservation(run.universe, uncovered, T2)).toEqual( + observation({ + attempt: null, + test: T2, + mutant: M3, + covered: false, + outcome: 'survived', + source: 'not-covered', + }), + ); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/partitions.test.ts b/gitnexus/test/unit/scenario-map/partitions.test.ts new file mode 100644 index 000000000..eccda8ddf --- /dev/null +++ b/gitnexus/test/unit/scenario-map/partitions.test.ts @@ -0,0 +1,228 @@ +/** + * Locks the sequential and parallel partitions of scenario-map Vitest runs + * (tools/scenario-map/path-coverage/partitions.ts): the include patterns of the base config's + * `fileParallelism: false` projects, + * glob matching, the split, the merge of partial runs, the partitioned runner and the build output + * copied into a prepared package. + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { + VitestRunOptions, + VitestRunResult, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; +import { + copyBuildOutput, + mergeVitestRuns, + partitionFiles, + runVitestPartitioned, + sequentialMatcher, + sequentialMatcherFor, + sequentialPatterns, +} from '../../../tools/scenario-map/path-coverage/partitions.js'; + +const SEQ = 'test/integration/lbug-pool.test.ts#pool > opens'; +const PAR = 'test/unit/a.test.ts#a > adds'; + +const run = (overrides: Partial): VitestRunResult => ({ + tests: [], + status: 'survived', + outcome: 'survived', + reason: null, + outsideTest: [], + problems: [], + unhandledErrors: [], + wallClockMs: 10, + ...overrides, +}); + +const test = (id: string): VitestRunResult['tests'][number] => ({ + id, + taskId: null, + fullName: null, + location: null, + state: 'passed', + outcome: 'survived', + message: null, +}); + +describe('sequential patterns', () => { + it('reads the includes of every fileParallelism: false project and nothing else', () => { + const config = { + test: { + projects: [ + { + test: { + name: 'lbug-db', + include: ['test/integration/lbug-*.test.ts', 7], + fileParallelism: false, + }, + }, + { test: { name: 'default', include: ['test/**/*.test.ts'] } }, + { + test: { + name: 'cli-e2e', + include: ['test/integration/e2e.test.ts'], + fileParallelism: false, + }, + }, + ], + }, + }; + expect(sequentialPatterns(config)).toEqual([ + 'test/integration/lbug-*.test.ts', + 'test/integration/e2e.test.ts', + ]); + }); + + it.each([ + { file: 'test/integration/lbug-pool.test.ts', match: true }, + { file: 'test/integration/nested/lbug-pool.test.ts', match: false }, + { file: 'test/integration/xlbug-pool.test.ts', match: false }, + ])('matches a single-segment glob: $file is $match', ({ file, match }) => { + expect(sequentialMatcher(['test/integration/lbug-*.test.ts'])(file)).toBe(match); + }); + + it('matches like Vitest: a ** segment may be empty', () => { + expect(sequentialMatcher(['test/**/*.test.ts'])('test/a.test.ts')).toBe(true); + }); + + it('puts the base config sequential files apart, keeping their order', () => { + const files = [ + 'test/unit/a.test.ts', + 'test/integration/lbug-pool.test.ts', + 'test/integration/rename-provider-ts.test.ts', + 'test/integration/skills-e2e.test.ts', + ]; + expect(partitionFiles(files, sequentialMatcherFor('normal'))).toEqual({ + sequential: [ + 'test/integration/lbug-pool.test.ts', + 'test/integration/rename-provider-ts.test.ts', + 'test/integration/skills-e2e.test.ts', + ], + parallel: ['test/unit/a.test.ts'], + }); + }); +}); + +describe('mergeVitestRuns', () => { + it('keeps request order and lets the first run that did not survive decide', () => { + const merged = mergeVitestRuns( + [SEQ, PAR], + [ + run({ tests: [test(PAR)], problems: ['p1'] }), + run({ + tests: [test(SEQ)], + status: 'killed', + outcome: 'killed-by-test:assertion', + reason: 'failed', + unhandledErrors: ['e'], + }), + ], + ); + expect([ + merged.tests.map((entry) => entry.id), + merged.outcome, + merged.reason, + merged.problems, + merged.unhandledErrors, + merged.wallClockMs, + ]).toEqual([[SEQ, PAR], 'killed-by-test:assertion', 'failed', ['p1'], ['e'], 20]); + }); +}); + +describe('runVitestPartitioned', () => { + const options: VitestRunOptions = { + packageDir: '/p', + testIds: [PAR, SEQ], + vitest: { configFile: 'c.ts', project: 'mutation' }, + workDir: '/w', + maxWorkers: 4, + }; + const isSequential = (file: string): boolean => file.startsWith('test/integration/'); + + it('runs the sequential tests with one worker first, then the rest, and merges them', async () => { + const calls: [readonly string[], number | undefined, string][] = []; + const result = await runVitestPartitioned( + async (call) => { + calls.push([call.testIds, call.maxWorkers, call.workDir]); + return run({ tests: call.testIds.map(test) }); + }, + options, + isSequential, + ); + expect([calls, result.tests.map((entry) => entry.id)]).toEqual([ + [ + [[SEQ], 1, '/w/sequential'], + [[PAR], 4, '/w/parallel'], + ], + [PAR, SEQ], + ]); + }); + + it('runs a request without sequential tests as it is', async () => { + const calls: VitestRunOptions[] = []; + await runVitestPartitioned( + async (call) => { + calls.push(call); + return run({}); + }, + { ...options, testIds: [PAR] }, + isSequential, + ); + expect(calls).toEqual([{ ...options, testIds: [PAR] }]); + }); +}); + +describe('copyBuildOutput', () => { + const temps: string[] = []; + afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it('copies vendor/ and dist/ that the checkout has into a normal-mode copy only', () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-build-output-')); + temps.push(base); + const checkout = path.join(base, 'checkout'); + const prepared = path.join(base, 'prepared'); + mkdirSync(path.join(checkout, 'vendor', 'leiden'), { recursive: true }); + mkdirSync(prepared); + writeFileSync(path.join(checkout, 'vendor', 'leiden', 'index.cjs'), 'leiden'); + const controls = path.join(base, 'controls'); + mkdirSync(controls); + copyBuildOutput('normal', checkout, prepared); + copyBuildOutput('controls', checkout, controls); + expect([ + readFileSync(path.join(prepared, 'vendor', 'leiden', 'index.cjs'), 'utf8'), + existsSync(path.join(prepared, 'dist')), + existsSync(path.join(controls, 'vendor')), + ]).toEqual(['leiden', false, false]); + }); + + it('includes the sibling hook fixtures read by package tests', () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-hook-fixtures-')); + temps.push(base); + const checkout = path.join(base, 'checkout', 'gitnexus'); + const prepared = path.join(base, 'prepared', 'gitnexus'); + mkdirSync(checkout, { recursive: true }); + mkdirSync(prepared, { recursive: true }); + const hooks = [ + 'gitnexus-claude-plugin', + 'gitnexus-cursor-integration', + 'gitnexus-factory-plugin', + ]; + for (const plugin of hooks) { + const dir = path.join(checkout, '..', plugin, 'hooks'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'registry-query.cjs'), plugin); + } + copyBuildOutput('normal', checkout, prepared); + expect( + hooks.map((plugin) => + readFileSync(path.join(prepared, '..', plugin, 'hooks', 'registry-query.cjs'), 'utf8'), + ), + ).toEqual(hooks); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/pdg-walk.test.ts b/gitnexus/test/unit/scenario-map/pdg-walk.test.ts new file mode 100644 index 000000000..e7105b622 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/pdg-walk.test.ts @@ -0,0 +1,785 @@ +/** + * Locks the static value-flow check end to end (U10, R9, KTD11 walk rules 1–6, design §5.3a) + * without LadybugDB or the TypeScript server: an injected fake edge source serves REACHING_DEF + * edges in the persisted format (`BasicBlock::::` ids, reasons encoded by + * the shared codec) and CALL_SUMMARY return-flow params, and a fake checker resolves names to + * declarations of the in-memory production modules. Covers the plan's U10 scenarios: Math.round + * vetoes; repo arithmetic is clean; a Map.set/Map.get round trip into a comparison is clean; + * regex uses veto; expected-only is unknown; an `any` entry parameter is unknown; a truncated + * function query is unknown; a fifth call level hits the depth cap; the statement rule; a derived + * binding walked under its own name; repo code outside the mutated files is unknown; CALL_SUMMARY + * continues the walk in the caller; a TypeScript version other than 7.0.2 and a missing PDG layer + * degrade the run; a PDG that misses a use is unknown; a checker whose project fails to open + * closes its server (a fake `typescript/unstable/sync` in a temp package). + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parse } from '@babel/parser'; +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import { afterEach, describe, expect, it } from 'vitest'; +import { encodeReachingDefReasonPairs } from '../../../src/core/ingestion/cfg/reaching-def-reason-codec.js'; +import type { DiscardedValue } from '../../../tools/scenario-map/facet/input-facet.js'; +import type { BindSide } from '../../../tools/scenario-map/value-flow/bind.js'; +import { + openChecker, + PINNED_TYPESCRIPT, + type OpenCheckerResult, + type ValueFlowChecker, +} from '../../../tools/scenario-map/value-flow/checker.js'; +import { + staticValueFlow, + type PdgEdgeSource, + type PdgLayerState, + type RdEdge, + type StaticValueFlowRun, + type ValueFlowPair, +} from '../../../tools/scenario-map/value-flow/pdg-walk.js'; +import type { CalleeInfo } from '../../../tools/scenario-map/value-flow/sinks.js'; + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** A fake TypeScript 7 API whose project never opens; it logs `open` and `close`. */ +const FAKE_SYNC = `export const events = []; +export class API { + constructor() { events.push('open'); } + updateSnapshot() { throw new Error('project failed to open'); } + close() { events.push('close'); } +} +`; + +/** A temp repo whose gitnexus/ resolves `typescript/unstable/sync` to {@link FAKE_SYNC}. */ +function fakeTypescript(): { repoRoot: string; syncFile: string } { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), 'scenario-checker-')); + tempDirs.push(repoRoot); + const tsDir = path.join(repoRoot, 'gitnexus', 'node_modules', 'typescript'); + mkdirSync(tsDir, { recursive: true }); + writeFileSync(path.join(repoRoot, 'gitnexus', 'package.json'), '{"name":"fake","private":true}'); + writeFileSync( + path.join(tsDir, 'package.json'), + JSON.stringify({ + name: 'typescript', + version: PINNED_TYPESCRIPT, + exports: { './package.json': './package.json', './unstable/sync': './sync.mjs' }, + }), + ); + const syncFile = path.join(tsDir, 'sync.mjs'); + writeFileSync(syncFile, FAKE_SYNC); + return { repoRoot, syncFile }; +} + +const eventsOf = (module: unknown): unknown => + typeof module === 'object' && module !== null && 'events' in module ? module.events : null; + +const M = 'gitnexus/src/m.ts'; +const TOOL = 'gitnexus/tools/helper.ts'; +const TEST = 'gitnexus/test/unit/m.test.ts'; + +const library = (name: string, container: string | null): CalleeInfo => ({ + origin: 'library', + name, + container, + file: null, + declarationEnd: null, + isClass: false, + reason: '', +}); + +/** Library members the fake checker knows, by the identifier naming them. */ +const LIBRARY: Record = { + round: library('Math.round', 'Math'), + extname: library('path.extname', 'path'), + RegExp: library('RegExp', null), + match: library('String.match', 'String'), + set: library('Map.set', 'Map'), + get: library('Map.get', 'Map'), + has: library('ReadonlySet.has', 'ReadonlySet'), +}; + +interface FunctionInfo { + file: string; + name: string; + line: number; + column: number; + end: number; + params: { name: string; type: string }[]; +} + +/** Every named function of the modules: `function f`, `const f = (…) =>`. */ +function functionsOf(modules: Record): FunctionInfo[] { + const out: FunctionInfo[] = []; + for (const [file, source] of Object.entries(modules)) { + const ast = parse(source, { sourceType: 'module', plugins: ['typescript'] }); + traverse(ast, { + Function(path) { + const node = path.node; + const parent = path.parent; + const name = t.isFunctionDeclaration(node) + ? node.id?.name + : t.isVariableDeclarator(parent) && t.isIdentifier(parent.id) + ? parent.id.name + : undefined; + if (name === undefined) return; + const declaration = t.isVariableDeclarator(parent) ? parent : node; + out.push({ + file, + name, + line: node.loc?.start.line ?? 0, + column: node.loc?.start.column ?? 0, + end: declaration.end ?? 0, + params: node.params.map((param) => ({ + name: t.isIdentifier(param) ? param.name : '?', + type: + t.isIdentifier(param) && t.isTSTypeAnnotation(param.typeAnnotation) + ? source.slice( + param.typeAnnotation.typeAnnotation.start ?? 0, + param.typeAnnotation.typeAnnotation.end ?? 0, + ) + : 'unknown', + })), + }); + }, + }); + } + return out; +} + +/** A checker that resolves names to the modules' functions, then to {@link LIBRARY}. */ +function fakeChecker(modules: Record, version = '7.0.2'): ValueFlowChecker { + const functions = functionsOf(modules); + const resolveAt = (_file: string, _offset: number, name: string): CalleeInfo => { + const fn = functions.find((entry) => entry.name === name); + if (fn !== undefined) { + return { + origin: 'repo', + name, + container: null, + file: fn.file, + declarationEnd: fn.end, + isClass: false, + reason: '', + }; + } + return LIBRARY[name] ?? { ...library(name, null), origin: 'unresolved', reason: 'unknown' }; + }; + return { + version, + tsconfig: 'gitnexus/tsconfig.test.json', + resolveAt, + resolveEntry: (file, offset, name, argIndex) => { + const fn = functions.find((entry) => entry.name === name); + const param = fn?.params[argIndex]; + return { + callee: resolveAt(file, offset, name), + param: + param === undefined + ? null + : { + index: argIndex, + name: param.name, + type: param.type, + anyOrUnknown: param.type === 'any' || param.type === 'unknown', + literalMembers: [], + }, + paramReason: param === undefined ? 'no parameter' : '', + }; + }, + close: () => undefined, + }; +} + +/** One REACHING_DEF edge of function `fn`: `binding` defined on `defLine`, used on `useLines`. */ +function rd( + fns: readonly FunctionInfo[], + fn: string, + binding: string, + defLine: number, + ...useLines: number[] +): RdEdge { + const info = fns.find((entry) => entry.name === fn) as FunctionInfo; + const block = `BasicBlock:${info.file}:${info.line}:${info.column}`; + return { + src: `${block}:0`, + dst: `${block}:1`, + reason: encodeReachingDefReasonPairs( + binding, + useLines.map((useLine) => ({ defLine, useLine })), + ), + }; +} + +interface World { + modules: Record; + /** REACHING_DEF edges, built from the modules' functions. */ + edges: (fns: FunctionInfo[]) => RdEdge[]; + /** CALL_SUMMARY return-flow params by function name. */ + summaries?: Record; + /** The two test bodies (one statement each); the value is the first `2.4` / `2.5`. */ + tests?: readonly string[]; + mutate?: string[]; + layer?: PdgLayerState; + truncated?: boolean; + checker?: OpenCheckerResult; +} + +/** The bind side of one test line: discarded values at every number literal. */ +function sideOf(source: string, line: number): BindSide { + const text = source.split('\n')[line - 1]; + const discarded: DiscardedValue[] = []; + const pattern = /\b\d+(\.\d+)?\b|'[a-z]+'/g; + let position = 0; + for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) { + const literal = match[0]; + const isString = literal.startsWith("'"); + discarded.push({ + position: position++, + kind: isString ? 'string' : 'number', + value: isString ? literal.slice(1, -1) : literal, + location: { file: TEST, line, column: match.index }, + origin: 'literal', + }); + } + return { file: TEST, source, fn: { start: 0, end: source.length }, discarded }; +} + +async function run(world: World): Promise { + const fns = functionsOf(world.modules); + const [first, second] = world.tests ?? ['expect(f(2.4)).toBe(2);', 'expect(f(2.5)).toBe(3);']; + const source = `${first}\n${second}`; + const edges: PdgEdgeSource = { + layerState: async () => world.layer ?? 'ready', + reachingDefs: async (file, fnLine) => ({ + edges: world + .edges(fns) + .filter((edge) => edge.src.startsWith(`BasicBlock:${file}:${fnLine}:`)), + truncated: world.truncated ?? false, + }), + callSummary: async (_file, _startLine, name) => world.summaries?.[name] ?? null, + }; + const pair: ValueFlowPair = { + a: 'a', + b: 'b', + sides: [sideOf(source, 1), sideOf(source, 2)], + mutate: world.mutate ?? [M], + }; + return staticValueFlow([pair], { + read: (file) => world.modules[file] ?? null, + indexPrefix: '', + edges, + checker: world.checker ?? { status: 'ready', checker: fakeChecker(world.modules) }, + }); +} + +/** The pair's verdict in comparable form. */ +async function verdict(world: World): Promise { + const result = await run(world); + const pair = result.pairs[0]; + return { + status: pair.status, + reasons: pair.reasons, + opaque: pair.opaqueSinks.map((site) => `${site.callee}@${site.line}`), + degraded: result.degraded, + trail: pair.sinks.map( + (sink) => `${sink.depth}:${sink.class}:${sink.what}@${sink.location.line}`, + ), + }; +} + +const lines = (...rows: string[]): string => rows.join('\n'); + +describe('staticValueFlow: sinks through the walk', () => { + it.each([ + { + title: 'Math.round vetoes (design §8 adversarial)', + world: { + modules: { + [M]: lines('export function f(raw: number) {', ' return Math.round(raw);', '}'), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'raw', 1, 2)], + }, + want: { + status: 'veto', + reasons: ['opaque: Math.round (library) at gitnexus/src/m.ts:2'], + opaque: ['Math.round@2'], + degraded: [], + trail: ['1:opaque:Math.round@2'], + }, + }, + { + title: 'repo arithmetic is clean', + world: { + modules: { + [M]: lines('export function f(a: number, b = 1) {', ' return a + b;', '}'), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'a', 1, 2), rd(fns, 'f', 'b', 1, 2)], + }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: ['1:repo-code:arithmetic +@2', '1:pass-through:return@2'], + }, + }, + { + title: 'a Map.set and Map.get round trip into a comparison is clean', + world: { + modules: { + [M]: lines( + 'export function f(v: number) {', + ' const m = new Map();', + " m.set('k', v);", + " return m.get('k') > 3;", + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 3), rd(fns, 'f', 'm', 2, 3, 4)], + }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: [ + '1:pass-through:Map.set@3', + '1:table-read:Map.get read-back@4', + '1:repo-code:comparison >@4', + ], + }, + }, + { + title: 'new RegExp(v) vetoes as a regex use', + world: { + modules: { [M]: lines('export function f(v: string) {', ' return new RegExp(v);', '}') }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 2)], + tests: ["expect(f('ab')).toBeTruthy();", "expect(f('cd')).toBeTruthy();"], + }, + want: { + status: 'veto', + reasons: ['opaque: RegExp (regex) at gitnexus/src/m.ts:2'], + opaque: ['RegExp@2'], + degraded: [], + trail: ['1:opaque:RegExp@2'], + }, + }, + { + title: 'v.match(...) vetoes as a regex use', + world: { + modules: { + [M]: lines('export function f(v: string) {', ' return v.match(/a/) !== null;', '}'), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 2)], + tests: ["expect(f('ab')).toBeTruthy();", "expect(f('cd')).toBeTruthy();"], + }, + want: { + status: 'veto', + reasons: ['opaque: String.match (regex) at gitnexus/src/m.ts:2'], + opaque: ['String.match@2'], + degraded: [], + trail: ['1:opaque:String.match@2'], + }, + }, + { + title: 'the opaque path.extname use vetoes its statement although === is repo code', + world: { + modules: { + [M]: lines( + 'export function f(v: string) {', + " if (path.extname(v) === '.ts') {", + ' return 1;', + ' }', + ' return 0;', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 2)], + tests: ["expect(f('ab')).toBe(1);", "expect(f('cd')).toBe(1);"], + }, + want: { + status: 'veto', + reasons: ['opaque: path.extname (library) at gitnexus/src/m.ts:2'], + opaque: ['path.extname@2'], + degraded: [], + trail: ['1:opaque:path.extname@2'], + }, + }, + { + title: 'a derived binding is walked under its own name', + world: { + modules: { + [M]: lines( + 'export function f(opts: { ratio: number }) {', + ' const { ratio } = opts;', + ' return Math.round(ratio);', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'opts', 1, 2), rd(fns, 'f', 'ratio', 2, 3)], + tests: ['expect(f({ ratio: 2.4 })).toBe(2);', 'expect(f({ ratio: 2.5 })).toBe(3);'], + }, + want: { + status: 'veto', + reasons: ['opaque: Math.round (library) at gitnexus/src/m.ts:3'], + opaque: ['Math.round@3'], + degraded: [], + trail: ['1:opaque:Math.round@3'], + }, + }, + { + title: 'a table read through Set.has is clean', + world: { + modules: { + [M]: lines( + "const WORDS: ReadonlySet = new Set(['class', 'enum']);", + 'export function f(word: string) {', + ' return WORDS.has(word);', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'word', 2, 3)], + tests: ["expect(f('ab')).toBe(false);", "expect(f('cd')).toBe(false);"], + }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: ['1:table-read:ReadonlySet.has@3'], + }, + }, + ])('$title', async ({ world, want }) => { + expect(await verdict(world)).toEqual(want); + }); +}); + +describe('staticValueFlow: repo calls, CALL_SUMMARY and the depth cap', () => { + const chain = (depth: number): string => + Array.from({ length: depth }, (_, index) => + index === depth - 1 + ? `export function g${index + 1}(v: number) { return v + 1; }` + : `export function g${index + 1}(v: number) { return g${index + 2}(v); }`, + ).join('\n'); + const chainEdges = (depth: number) => (fns: FunctionInfo[]) => + Array.from({ length: depth }, (_, index) => + rd(fns, `g${index + 1}`, 'v', index + 1, index + 1), + ); + const chainTests: readonly string[] = ['expect(g1(2.4)).toBe(1);', 'expect(g1(2.5)).toBe(1);']; + + it.each([ + { + title: 'four call levels are walked to the end', + world: { modules: { [M]: chain(4) }, edges: chainEdges(4), tests: chainTests }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: [ + '1:repo-code:call g2@1', + '2:repo-code:call g3@2', + '3:repo-code:call g4@3', + '4:repo-code:arithmetic +@4', + '4:pass-through:return@4', + '3:pass-through:return@3', + '2:pass-through:return@2', + '1:pass-through:return@1', + ], + }, + }, + { + title: 'a fifth call level hits the depth cap', + world: { modules: { [M]: chain(5) }, edges: chainEdges(5), tests: chainTests }, + want: { + status: 'unknown', + reasons: ['unknown: g5 (depth-cap) at gitnexus/src/m.ts:4'], + opaque: [], + degraded: [], + trail: [ + '1:repo-code:call g2@1', + '2:repo-code:call g3@2', + '3:repo-code:call g4@3', + '4:repo-code:call g5@4', + '4:unknown:g5@4', + ], + }, + }, + { + title: 'a value returned unchanged and compared by the caller continues there (clean)', + world: { + modules: { + [M]: lines( + 'export function id(x: number) {', + ' return x;', + '}', + 'export function f(v: number) {', + ' return id(v) > 3;', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'id', 'x', 1, 2), rd(fns, 'f', 'v', 4, 5)], + summaries: { id: [0] }, + }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: ['1:repo-code:call id@5', '2:pass-through:return@2', '1:repo-code:comparison >@5'], + }, + }, + { + title: 'CALL_SUMMARY continues the walk in the caller into an opaque sink', + world: { + modules: { + [M]: lines( + 'const WORDS: ReadonlySet = new Set([1, 2]);', + 'export function known(x: number) {', + ' return WORDS.has(x);', + '}', + 'export function f(v: number) {', + ' return Math.round(known(v));', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'known', 'x', 2, 3), rd(fns, 'f', 'v', 5, 6)], + summaries: { known: [0] }, + }, + want: { + status: 'veto', + reasons: ['opaque: Math.round (library) at gitnexus/src/m.ts:6'], + opaque: ['Math.round@6'], + degraded: [], + trail: [ + '1:repo-code:call known@6', + '2:table-read:ReadonlySet.has@3', + '1:opaque:Math.round@6', + ], + }, + }, + { + title: 'without a return flow the caller does not continue with the result', + world: { + modules: { + [M]: lines( + 'const WORDS: ReadonlySet = new Set([1, 2]);', + 'export function known(x: number) {', + ' return WORDS.has(x);', + '}', + 'export function f(v: number) {', + ' return Math.round(known(v));', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'known', 'x', 2, 3), rd(fns, 'f', 'v', 5, 6)], + summaries: { known: [] }, + }, + want: { + status: 'clean', + reasons: [], + opaque: [], + degraded: [], + trail: ['1:repo-code:call known@6', '2:table-read:ReadonlySet.has@3'], + }, + }, + { + title: 'repo code outside the mutated files is unknown, not clean', + world: { + modules: { + [M]: lines('export function f(v: number) {', ' return helper(v);', '}'), + [TOOL]: lines('export function helper(x: number) {', ' return x + 1;', '}'), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 2), rd(fns, 'helper', 'x', 1, 2)], + }, + want: { + status: 'unknown', + reasons: [ + 'unknown: helper (repo code outside the mutated files: gitnexus/tools/helper.ts) at gitnexus/src/m.ts:2', + ], + opaque: [], + degraded: [], + trail: ['1:unknown:helper@2'], + }, + }, + ])('$title', async ({ world, want }) => { + expect(await verdict(world)).toEqual(want); + }); +}); + +describe('staticValueFlow: unknown and degraded', () => { + const ROUND = lines('export function f(raw: number) {', ' return Math.round(raw);', '}'); + const roundEdges = (fns: FunctionInfo[]) => [rd(fns, 'f', 'raw', 1, 2)]; + + it.each([ + { + title: 'only expected values differ (expected-only)', + world: { + modules: { [M]: ROUND }, + edges: roundEdges, + tests: ["expect(f(7)).toContain('alpha');", "expect(f(7)).toContain('beta');"], + }, + want: { status: 'unknown', reasons: ['expected-only'], opaque: [], degraded: [], trail: [] }, + }, + { + title: 'an entry parameter typed any is unknown', + world: { + modules: { [M]: lines('export function f(raw: any) {', ' return raw + 1;', '}') }, + edges: roundEdges, + }, + want: { + status: 'unknown', + reasons: ['entry f#0: parameter raw is typed any'], + opaque: [], + degraded: [], + trail: [], + }, + }, + { + title: 'a truncated function query is unknown', + world: { modules: { [M]: ROUND }, edges: roundEdges, truncated: true }, + want: { + status: 'unknown', + reasons: ['unknown: f (pdg query truncated) at gitnexus/src/m.ts:1'], + opaque: [], + degraded: [], + trail: ['1:unknown:f@1'], + }, + }, + { + title: 'a use the PDG does not report is unknown', + world: { modules: { [M]: ROUND }, edges: () => [] }, + want: { + status: 'unknown', + reasons: ['unknown: raw (no REACHING_DEF use) at gitnexus/src/m.ts:2'], + opaque: [], + degraded: [], + trail: ['1:unknown:raw@2'], + }, + }, + { + title: 'a value captured by a closure is unknown', + world: { + modules: { + [M]: lines( + 'export function f(v: number, xs: number[]) {', + ' return xs.map((x) => x + v);', + '}', + ), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'xs', 1, 2)], + }, + want: { + status: 'unknown', + reasons: ['unknown: v (captured by a closure) at gitnexus/src/m.ts:2'], + opaque: [], + degraded: [], + trail: ['1:unknown:v@2'], + }, + }, + { + title: 'a compound assignment carries the value to its later uses', + world: { + modules: { + [M]: lines('export function f(v: number) {', ' v += 1;', ' return Math.round(v);', '}'), + }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'v', 1, 2), rd(fns, 'f', 'v', 2, 3)], + }, + want: { + status: 'veto', + reasons: ['opaque: Math.round (library) at gitnexus/src/m.ts:3'], + opaque: ['Math.round@3'], + degraded: [], + trail: ['1:repo-code:arithmetic +=@2', '1:opaque:Math.round@3'], + }, + }, + { + title: 'a PDG def on a line that assigns nothing is unknown, not skipped', + world: { + modules: { [M]: ROUND }, + edges: (fns: FunctionInfo[]) => [rd(fns, 'f', 'raw', 2, 2)], + }, + want: { + status: 'unknown', + reasons: [ + 'unknown: raw (pdg def on line 2 is not an assignment of the binding) at gitnexus/src/m.ts:1', + ], + opaque: [], + degraded: [], + trail: ['1:unknown:raw@1'], + }, + }, + { + title: 'TypeScript 7.0.3 degrades the run and every value-differing pair is unknown', + world: { + modules: { [M]: ROUND }, + edges: roundEdges, + checker: { status: 'checker-version', version: '7.0.3' } as const, + }, + want: { + status: 'unknown', + reasons: ['checker-version'], + opaque: [], + degraded: ['checker-version'], + trail: [], + }, + }, + { + title: 'a missing PDG layer degrades the run and every value-differing pair is vetoed', + world: { modules: { [M]: ROUND }, edges: roundEdges, layer: 'no-layer' as const }, + want: { + status: 'veto', + reasons: ['no-pdg-layer'], + opaque: [], + degraded: ['no-pdg-layer'], + trail: [], + }, + }, + ])('$title', async ({ world, want }) => { + expect(await verdict(world)).toEqual(want); + }); + + it('needs no check when the discarded values are equal, even on a degraded run', async () => { + const result = await run({ + modules: { [M]: ROUND }, + edges: roundEdges, + layer: 'no-layer', + tests: ['expect(f(2.4)).toBe(2);', 'expect(f(2.4)).toBe(2);'], + }); + expect([result.pairs[0].status, result.degraded]).toEqual(['not-required', ['no-pdg-layer']]); + }); + + it.each(['7.0.3', '7.1.0', '6.0.2'])( + 'openChecker refuses TypeScript %s before starting the server', + async (version) => { + expect([PINNED_TYPESCRIPT, await openChecker({ repoRoot: '.', version })]).toEqual([ + '7.0.2', + { status: 'checker-version', version }, + ]); + }, + ); + + it('closes the checker server when its project fails to open', async () => { + const { repoRoot, syncFile } = fakeTypescript(); + const result = await openChecker({ repoRoot, version: PINNED_TYPESCRIPT }); + const events = eventsOf(await import(syncFile)); + expect([result, events]).toEqual([ + { status: 'error', error: 'checker failed to open: project failed to open' }, + ['open', 'close'], + ]); + }); + + it('records the entry with its parameter type for the reviewer', async () => { + const result = await run({ modules: { [M]: ROUND }, edges: roundEdges }); + expect( + result.pairs[0].entries.map((entry) => ({ + callee: entry.callee, + argIndex: entry.argIndex, + declaration: entry.declaration, + param: entry.param?.type, + })), + ).toEqual([{ callee: 'f', argIndex: 0, declaration: `${M}#f`, param: 'number' }]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/plan-shards.test.ts b/gitnexus/test/unit/scenario-map/plan-shards.test.ts new file mode 100644 index 000000000..9c7f34345 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/plan-shards.test.ts @@ -0,0 +1,357 @@ +/** + * Locks Layer 3 shard planning (U7, KTD7, KTD14, KTD17, R11): the whole-file mutate scope of a + * group (production files a test executed or statically imports, with excluded files marked), the + * cost estimate with its isolation term, deterministic bin-packing that never splits a group, + * the group-over-budget rule, the start-only-if-fits deadline check, and how the merge stage reads + * a pair's schedule: never run, not scheduled, a foreign run manifest or a failed group run are + * Inconclusive. + */ +import { describe, expect, it } from 'vitest'; +import { + SHARD_DEADLINE_MS, + buildShardPlan, + estimateGroupCost, + excludedFiles, + fitsBeforeDeadline, + groupScope, + packShards, + resolvePairSchedules, + shardsNeeded, + type CostModel, + type GroupEstimate, + type GroupOutcome, + type PairRef, + type StaticClosure, + type TestProvenance, +} from '../../../tools/scenario-map/mutation/plan-shards.js'; + +const MINUTE = 60_000; +const FIVE: GroupEstimate[] = [ + { id: 'ga', estimateMs: 10 }, + { id: 'gb', estimateMs: 9 }, + { id: 'gc', estimateMs: 5 }, + { id: 'gd', estimateMs: 3 }, + { id: 'ge', estimateMs: 1 }, +]; +const DIGEST = 'manifest-digest'; +const A = 'test/unit/a.test.ts#A > one'; +const B = 'test/unit/a.test.ts#A > two'; +const C = 'test/unit/c.test.ts#C > three'; + +const outcome = (overrides: Partial): GroupOutcome => ({ + group: 'ga', + shard: 0, + manifestDigest: DIGEST, + status: 'complete', + reason: null, + detail: null, + tests: [A, B], + ...overrides, +}); + +const pair = (group: string): PairRef => ({ a: A, b: B, group }); + +describe('packShards', () => { + it('packs five groups into two shards, the same way on every call and in any input order', () => { + const first = packShards(FIVE, 2, 100); + expect(first).toEqual({ + shards: [ + { index: 0, groups: ['ga', 'gd', 'ge'], estimateMs: 14 }, + { index: 1, groups: ['gb', 'gc'], estimateMs: 14 }, + ], + overBudget: [], + }); + expect(packShards(FIVE, 2, 100)).toEqual(first); + expect(packShards([...FIVE].reverse(), 2, 100)).toEqual(first); + }); + + it('puts every group in exactly one shard', () => { + const assigned = packShards(FIVE, 2, 100).shards.flatMap((shard) => shard.groups); + expect(assigned.sort()).toEqual(['ga', 'gb', 'gc', 'gd', 'ge']); + }); + + it('runs no group whose estimate exceeds one shard budget', () => { + expect(packShards(FIVE, 2, 9)).toEqual({ + shards: [ + { index: 0, groups: ['gb'], estimateMs: 9 }, + { index: 1, groups: ['gc', 'gd', 'ge'], estimateMs: 9 }, + ], + overBudget: ['ga'], + }); + }); + + it.each([ + [0, 'zero shards'], + [1.5, 'a fractional shard count'], + ])('rejects %s (%s)', (shards) => { + expect(() => packShards(FIVE, shards, 100)).toThrow(/shard count/); + }); + + it('rejects a group listed twice', () => { + expect(() => packShards([...FIVE, { id: 'ga', estimateMs: 1 }], 2, 100)).toThrow(/twice/); + }); +}); + +describe('shardsNeeded', () => { + it.each([ + [28, 1], + [14, 2], + [10, 3], + ])('needs the fewest shards whose loads fit a budget of %i: %i', (budget, expected) => { + expect(shardsNeeded(FIVE, budget, 8)).toBe(expected); + }); + + it('caps at the maximum when no packing fits', () => { + expect(shardsNeeded(FIVE, 10, 2)).toBe(2); + }); +}); + +describe('fitsBeforeDeadline', () => { + it.each([ + [280 * MINUTE, 30 * MINUTE, false], + [280 * MINUTE, 20 * MINUTE, true], + [0, 300 * MINUTE, true], + [0, 301 * MINUTE, false], + ])('after %i ms a group estimated at %i ms starts: %s', (elapsed, estimate, expected) => { + expect(fitsBeforeDeadline(estimate, elapsed)).toBe(expected); + }); + + it('defaults to the 300-minute deadline', () => { + expect(SHARD_DEADLINE_MS).toBe(300 * MINUTE); + }); +}); + +describe('estimateGroupCost', () => { + const model: CostModel = { + perRunMs: 1000, + perByteTestMs: 2, + mutantsPerByte: 0.5, + freshProcessMs: 10, + }; + + it('adds the fixed run cost, mutated bytes times tests and the isolation term', () => { + // 1000 + 100 * 3 * 2 + 2 pairs * (100 * 0.5) mutants * 3 runs * 10 + expect(estimateGroupCost({ tests: 3, pairs: 2, mutatedBytes: 100 }, model)).toBe(4600); + }); + + it('grows with every candidate pair through the isolation term', () => { + const one = estimateGroupCost({ tests: 3, pairs: 1, mutatedBytes: 100 }, model); + const two = estimateGroupCost({ tests: 3, pairs: 2, mutatedBytes: 100 }, model); + expect(two - one).toBe(1500); + }); +}); + +describe('groupScope', () => { + const provenance = new Map([ + [ + A, + { + known: true, + executedFiles: [ + 'gitnexus/test/unit/a.test.ts', + 'gitnexus/src/a.ts', + 'gitnexus/tools/mutation/shared/cli.ts', + 'node_modules/zod/index.js', + ], + }, + ], + [B, { known: true, executedFiles: ['gitnexus/test/unit/a.test.ts', 'gitnexus/src/b.ts'] }], + ]); + const closures = new Map([ + [ + 'test/unit/a.test.ts', + { + closure: [ + 'gitnexus/test/unit/a.test.ts', + 'gitnexus/test/helpers/h.ts', + 'gitnexus/src/a.ts', + 'gitnexus-shared/src/c.ts', + 'gitnexus/src/data.json', + ], + complete: true, + }, + ], + ]); + const scope = groupScope({ group: { id: 'g1', tests: [B, A], pairs: 1 }, provenance, closures }); + + it('mutates every production file a test executed or statically imports, whole', () => { + expect(scope.mutate).toEqual([ + 'gitnexus-shared/src/c.ts', + 'gitnexus/src/a.ts', + 'gitnexus/src/b.ts', + ]); + expect([scope.tests, scope.testFiles]).toEqual([[A, B], ['test/unit/a.test.ts']]); + }); + + it('marks a test that executed a repo file it neither tests with nor mutates', () => { + expect(scope.excluded).toEqual([ + { test: A, mark: 'excluded-file', files: ['gitnexus/tools/mutation/shared/cli.ts'] }, + ]); + }); + + it('records tests without known provenance and files without a complete closure', () => { + const partial = groupScope({ + group: { id: 'g2', tests: [A, C], pairs: 1 }, + provenance: new Map([[A, { known: false, executedFiles: ['gitnexus/src/a.ts'] }]]), + closures: new Map([['test/unit/a.test.ts', { closure: [], complete: false }]]), + }); + expect([partial.provenanceUnknown, partial.closureIncomplete]).toEqual([ + [A, C], + ['test/unit/a.test.ts', 'test/unit/c.test.ts'], + ]); + expect(partial.mutate).toEqual(['gitnexus/src/a.ts']); + }); + + it("treats a fixture project's src/ modules as production through its extra root", () => { + const fixture = 'gitnexus/test/fixtures/mutation-scenario-map'; + const id = 'test/fixtures/mutation-scenario-map/x.case.ts#x > y'; + const controls = groupScope({ + group: { id: 'g3', tests: [id], pairs: 0 }, + provenance: new Map([ + [id, { known: true, executedFiles: [`${fixture}/x.case.ts`, `${fixture}/src/x.ts`] }], + ]), + closures: new Map(), + extraRoots: [fixture], + }); + expect([controls.mutate, controls.excluded]).toEqual([[`${fixture}/src/x.ts`], []]); + }); +}); + +describe('excludedFiles', () => { + it('returns an executed src file the mutate list lacks', () => { + const executed = ['gitnexus/src/a.ts', 'gitnexus/src/b.ts', 'gitnexus/test/unit/a.test.ts']; + expect(excludedFiles(executed, new Set(['gitnexus/src/b.ts']))).toEqual(['gitnexus/src/a.ts']); + }); + + it.each([ + ['gitnexus/test/helpers/h.ts'], + ['gitnexus/test/fixtures/mutation-x/a.case.ts'], + ['gitnexus/src/a.spec.ts'], + ['node_modules/vitest/index.js'], + ['outside:/usr/lib/node.js'], + ['gitnexus-shared/src/c.ts'], + ])('never excludes %s', (file) => { + expect(excludedFiles([file], new Set())).toEqual([]); + }); +}); + +describe('buildShardPlan', () => { + it('estimates each group from its mutated bytes and packs the groups', () => { + const plan = buildShardPlan({ + scopes: [ + groupScope({ + group: { id: 'g2', tests: [A, B], pairs: 1 }, + provenance: new Map([[A, { known: true, executedFiles: ['gitnexus/src/a.ts'] }]]), + closures: new Map(), + }), + groupScope({ + group: { id: 'g1', tests: [C], pairs: 0 }, + provenance: new Map([[C, { known: true, executedFiles: ['gitnexus/src/big.ts'] }]]), + closures: new Map(), + }), + ], + sizeOf: (file) => (file === 'gitnexus/src/big.ts' ? 1_000_000 : 100), + shards: 2, + budgetMs: 10_000, + costModel: { perRunMs: 1000, perByteTestMs: 1, mutantsPerByte: 0, freshProcessMs: 0 }, + }); + expect(plan.groups.map((group) => [group.id, group.mutatedBytes, group.estimateMs])).toEqual([ + ['g1', 1_000_000, 1_001_000], + ['g2', 100, 1200], + ]); + expect([plan.overBudget, plan.shards.map((shard) => shard.groups)]).toEqual([ + ['g1'], + [['g2'], []], + ]); + }); +}); + +describe('resolvePairSchedules', () => { + const plan = { overBudget: ['gz'] }; + + it('marks a pair of an over-budget group group-over-budget', () => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('gz')], + outcomes: [], + }); + expect([schedule.status, schedule.reason]).toEqual(['inconclusive', 'group-over-budget']); + }); + + it('marks every pair of a group no shard reported not-scheduled', () => { + const schedules = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('gb'), { a: A, b: C, group: 'gb' }], + outcomes: [outcome({ group: 'ga' })], + }); + expect(schedules.map((entry) => [entry.status, entry.reason, entry.shard])).toEqual([ + ['inconclusive', 'not-scheduled', null], + ['inconclusive', 'not-scheduled', null], + ]); + }); + + it('marks a pair whose tests did not both run in the group run not-scheduled', () => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [{ a: A, b: C, group: 'ga' }], + outcomes: [outcome({})], + }); + expect(schedule.reason).toBe('not-scheduled'); + }); + + it.each([ + ['a result verified against another run manifest', outcome({ manifestDigest: 'other' })], + [ + 'a shard whose prepared copy differs from the run manifest', + outcome({ status: 'inconclusive', reason: 'manifest-mismatch', detail: 'gitnexus/src/a.ts' }), + ], + ])('marks the pairs of %s manifest-mismatch', (_title, result) => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('ga')], + outcomes: [result], + }); + expect([schedule.status, schedule.reason]).toEqual(['inconclusive', 'manifest-mismatch']); + }); + + it('passes a failed group run reason on to its pairs', () => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('ga')], + outcomes: [outcome({ status: 'inconclusive', reason: 'baseline-unstable', detail: 'ids' })], + }); + expect([schedule.reason, schedule.detail, schedule.shard]).toEqual([ + 'baseline-unstable', + 'ids', + 0, + ]); + }); + + it('marks a pair of a complete group run evaluated', () => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('ga')], + outcomes: [outcome({ shard: 3 })], + }); + expect([schedule.status, schedule.reason, schedule.shard]).toEqual(['evaluated', null, 3]); + }); + + it('keeps a group Inconclusive when one of two results for it is', () => { + const [schedule] = resolvePairSchedules({ + plan, + manifestDigest: DIGEST, + pairs: [pair('ga')], + outcomes: [ + outcome({}), + outcome({ shard: 1, status: 'inconclusive', reason: 'stryker-failed' }), + ], + }); + expect([schedule.status, schedule.reason]).toEqual(['inconclusive', 'stryker-failed']); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/prefilter.test.ts b/gitnexus/test/unit/scenario-map/prefilter.test.ts new file mode 100644 index 000000000..3f96fb14f --- /dev/null +++ b/gitnexus/test/unit/scenario-map/prefilter.test.ts @@ -0,0 +1,113 @@ +/** + * Locks the Layer 1 prefilter (tools/scenario-map/path-coverage/prefilter.ts): a file stays when a + * test's checks-and-context key is shared, when its tests come from a helper or an alias the + * inventory cannot see, when a call's instance count is not static, or when a body reads a file + * snapshot; a file whose every test has a key no other test shares is skipped. A diff scope runs + * the changed files the rule keeps, the files sharing a changed test's key and every file with a + * test of no static key. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { layer1Files } from '../../../tools/scenario-map/path-coverage/prefilter.js'; + +const SOURCES: Record = { + 'test/unit/shared.test.ts': [ + "import { add } from '../../src/add';", + "it('adds one way', () => { expect(add(1, 2)).toBe(3); });", + "it('adds the same way', () => { expect(add(1, 2)).toBe(3); });", + ].join('\n'), + 'test/unit/unique.test.ts': [ + "import { mul } from '../../src/mul';", + "it('multiplies', () => { expect(mul(2, 3)).toEqual([6]); });", + ].join('\n'), + 'test/unit/helper.test.ts': [ + "import { defineCases } from '../helpers/cases';", + "defineCases('rust');", + ].join('\n'), + 'test/unit/dynamic.test.ts': [ + "import { rows } from '../helpers/rows';", + "import { neg } from '../../src/neg';", + "it.each(rows)('negates %s', (value) => { expect(neg(value)).toBeLessThan(1); });", + ].join('\n'), + 'test/unit/alias.test.ts': [ + "import { sub } from '../../src/sub';", + 'const itSlow = it;', + "describe('subtracts', () => {", + " it('once', () => { expect(sub(3, 1)).toBe(2); });", + " itSlow('twice', () => { expect(sub(sub(5, 1), 1)).toEqual(3); });", + '});', + ].join('\n'), + 'test/unit/named-suite.test.ts': [ + "import { defineCases } from '../helpers/cases';", + "import { div } from '../../src/div';", + "it('divides', () => { expect(div(6, 3)).toBe(2); });", + "describe('generated', defineCases);", + ].join('\n'), + 'test/unit/test-describe.test.ts': [ + "import { defineCases } from '../helpers/cases';", + "import { pow } from '../../src/pow';", + "it('powers', () => { expect(pow(2, 3)).toBe(8); });", + "test.describe('generated', defineCases);", + ].join('\n'), + 'test/unit/snapshot.test.ts': [ + "import { render } from '../../src/render';", + "it('renders', () => { expect(render()).toMatchSnapshot(); });", + ].join('\n'), +}; +const FILES = Object.keys(SOURCES); + +describe('layer1Files', () => { + const root = mkdtempSync(path.join(tmpdir(), 'scenario-map-prefilter-')); + afterAll(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'gitnexus/test/unit'), { recursive: true }); + for (const [file, source] of Object.entries(SOURCES)) { + writeFileSync(path.join(root, 'gitnexus', file), source); + } + + it('skips only the file whose every test has a key no other test shares', () => { + expect(layer1Files(root, FILES)).toEqual({ + files: [ + 'test/unit/shared.test.ts', + 'test/unit/helper.test.ts', + 'test/unit/dynamic.test.ts', + 'test/unit/alias.test.ts', + 'test/unit/named-suite.test.ts', + 'test/unit/test-describe.test.ts', + 'test/unit/snapshot.test.ts', + ], + skipped: ['test/unit/unique.test.ts'], + }); + }); + + // Files with a test of no static key: they can pair with any test, so every diff scope runs them. + const UNKNOWN = FILES.filter( + (file) => !['test/unit/shared.test.ts', 'test/unit/unique.test.ts'].includes(file), + ); + + it.each([ + { changed: 'test/unit/unique.test.ts', files: UNKNOWN }, + { changed: 'test/unit/shared.test.ts', files: ['test/unit/shared.test.ts', ...UNKNOWN] }, + { changed: 'test/unit/helper.test.ts', files: UNKNOWN }, + ])('in a diff scope, runs $changed only with what can pair with it', ({ changed, files }) => { + expect(layer1Files(root, FILES, new Set([changed])).files).toEqual(files); + }); + + it('in a diff scope, runs the files whose tests share a changed test key', () => { + const partner = 'test/unit/partner.test.ts'; + writeFileSync( + path.join(root, 'gitnexus', partner), + SOURCES['test/unit/shared.test.ts'].split('\n').slice(0, 2).join('\n'), + ); + expect( + layer1Files(root, [...FILES, partner], new Set(['test/unit/shared.test.ts'])).files, + ).toEqual(['test/unit/shared.test.ts', ...UNKNOWN, partner]); + }); + + it('keeps a file whose key another file shares', () => { + const twin = 'test/unit/twin.test.ts'; + writeFileSync(path.join(root, 'gitnexus', twin), SOURCES['test/unit/unique.test.ts']); + expect(layer1Files(root, ['test/unit/unique.test.ts', twin]).skipped).toEqual([]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/report-fixtures.ts b/gitnexus/test/unit/scenario-map/report-fixtures.ts new file mode 100644 index 000000000..5232ee3b0 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/report-fixtures.ts @@ -0,0 +1,241 @@ +/** + * Shared in-memory worlds for the report tests (U13): the verdict engine's positive control + * (`add(2, 3)` against `add(5, 5)`, a strong repeat) plus a test that kills nothing, so a report + * has a strong repeat, a Weak pair verdict, a weak test and one production file with gaps. The + * observations follow the compact model: M3, covered by no test, is implied by the run universe. + */ +import type { InputFacet } from '../../../tools/scenario-map/facet/input-facet.js'; +import type { + MergedObservation, + RunUniverse, +} from '../../../tools/scenario-map/mutation/observations.js'; +import type { MutantFingerprint } from '../../../tools/scenario-map/report/anchors.js'; +import type { + CollectedProvenance, + ReportInput, +} from '../../../tools/scenario-map/report/report.js'; +import type { TestAnchor } from '../../../tools/scenario-map/types.js'; +import { computeFingerprints } from '../../../tools/scenario-map/verdict/fingerprint.js'; +import { + computeVerdicts, + type VerdictInput, + type VerdictPairInput, + type VerdictReport, + type VerdictTestInput, +} from '../../../tools/scenario-map/verdict/verdict.js'; + +export const A = 'test/unit/add.test.ts#add > adds 2 and 3'; +export const B = 'test/unit/add.test.ts#add > adds 5 and 5'; +export const C = 'test/unit/add.test.ts#add > logs a sum'; +export const FILE = 'gitnexus/src/add.ts'; +export const mutantId = (span: string, mutator: string): string => + `${FILE}@0123456789abcdef:${span}:${mutator}:aaaaaaaaaaaaaaaa`; +/** Killed by A and B, survived by C. */ +export const M1 = mutantId('2:10-2:15', 'ArithmeticOperator'); +/** Covered and survived by every test. */ +export const M2 = mutantId('3:7-3:16', 'ConditionalExpression'); +/** Covered by no test. */ +export const M3 = mutantId('6:1-8:2', 'BlockStatement'); + +export const observation = ( + test: string, + mutant: string, + overrides: Partial = {}, +): MergedObservation => ({ + group: 'g1', + kind: 'main', + lineage: 'main', + test, + mutant, + file: FILE, + covered: true, + hookAttributed: false, + static: false, + outcome: 'killed-by-test:assertion', + attempts: [0], + shards: [0], + sources: ['row'], + valid: true, + reasons: [], + ...overrides, +}); + +const SURVIVED: Partial = { outcome: 'survived' }; + +export const facet = (values: readonly string[]): InputFacet => ({ + fingerprint: 'facet-add', + parts: { + language: '-', + source: [], + fixtures: [], + data: [], + calls: ['add'], + order: ['', ''], + }, + discarded: values.map((value, position) => ({ + position, + kind: 'number', + value, + location: { file: 'gitnexus/test/unit/add.test.ts', line: 3, column: 10 + position * 3 }, + origin: 'literal', + })), + unresolved: [], + skeleton: ['body.0\tcall add', 'body.0.args.0\t', 'body.0.args.1\t'], +}); + +export const testInput = ( + id: string, + overrides: Partial = {}, +): VerdictTestInput => ({ + id, + eligible: true, + fingerprintEligible: true, + passed: true, + notOracleValidated: false, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + provenance: { known: true, nativeEntries: [], workerSpawns: [] }, + concurrent: false, + excludedFiles: [], + checksContext: 'checks-add', + unsupportedForms: [], + unresolvedSelectors: [], + facet: facet(['2', '3']), + ...overrides, +}); + +export const pairInput = ( + a: string, + b: string, + overrides: Partial = {}, +): VerdictPairInput => ({ + a, + b, + group: 'g1', + identicalBlocks: true, + pathUnattributable: false, + schedule: { status: 'evaluated', reason: null, detail: null }, + isolation: { status: 'agrees', reason: null, disqualifiers: [] }, + rowDeletion: { outcome: 'not-applicable', reasons: ['no-common-table'] }, + staticValueFlow: { status: 'clean', reasons: [] }, + trace: { status: 'clean', reasons: [] }, + ...overrides, +}); + +export interface WorldOptions { + /** Group id of every pair and observation. */ + group?: string; + degraded?: VerdictInput['valueFlowDegraded']; +} + +/** A and B a strong repeat, C kills nothing (the pair A, C is Weak); see the module comment. */ +export function world(options: WorldOptions = {}): VerdictInput { + const group = options.group ?? 'g1'; + const run = (test: string, killsM1: boolean): MergedObservation[] => [ + observation(test, M1, { group, ...(killsM1 ? {} : SURVIVED) }), + observation(test, M2, { group, ...SURVIVED }), + ]; + const universe: RunUniverse = { + group, + kind: 'main', + lineage: 'main', + shard: 0, + tests: [A, B, C], + mutants: [M1, M2, M3].map((id) => ({ + id, + file: FILE, + static: false, + status: 'Survived' as const, + reasons: [], + hookMarker: true, + })), + valid: true, + reasons: [], + }; + return { + tests: [ + testInput(A, { facet: facet(['2', '3']) }), + testInput(B, { facet: facet(['5', '5']) }), + testInput(C, { facet: facet(['1', '1']), checksContext: 'checks-log' }), + ], + pairs: [pairInput(A, B, { group }), pairInput(A, C, { group })], + observations: { + explicit: [...run(A, true), ...run(B, true), ...run(C, false)], + universes: [universe], + }, + valueFlowDegraded: options.degraded ?? [], + }; +} + +export const anchor = (id: string): TestAnchor => ({ + key: `anchor-${id.slice(id.indexOf('>') + 2).replace(/ /g, '-')}`, + file: 'test/unit/add.test.ts', + describePath: ['add'], + title: id.slice(id.indexOf('>') + 2), + rowFingerprint: 'row-0', + bodyFingerprint: `body-${id.slice(id.indexOf('>') + 2)}`, +}); + +/** Fingerprints that ignore the span: the mutator names the mutant. */ +export const fingerprints = (ids: readonly string[]): Map => + new Map( + ids.map((id) => [id, { fingerprint: `m-${id.split(':').at(-2) ?? ''}`, fallback: null }]), + ); + +export const PROVENANCE: CollectedProvenance = { + sourceRevision: '0123456789abcdef0123456789abcdef01234567', + files: { + tests: { 'test/unit/add.test.ts': 'a'.repeat(64) }, + production: { [FILE]: 'b'.repeat(64) }, + fixtures: {}, + }, + lockfiles: { 'gitnexus/package-lock.json': 'c'.repeat(64) }, + configs: { 'gitnexus/vitest.config.ts': 'd'.repeat(64) }, + versions: { vitest: '5.0.0', stryker: '10.0.0', typescript: '7.0.2', runner: 'e'.repeat(64) }, + runtime: { node: 'v24.0.0', os: 'linux 6.0 x64', env: { GITNEXUS_HOME: '/tmp/home' } }, +}; + +export interface InputOptions extends WorldOptions { + verdicts?: (report: VerdictReport) => VerdictReport; + overrides?: Partial; +} + +/** A report input over {@link world}, with every optional stage present. */ +export function reportInput(options: InputOptions = {}): ReportInput { + const evidence = world(options); + const computed = computeVerdicts(evidence); + return { + run: { + id: '1001', + attempt: 1, + url: 'https://github.com/abhigyanpatwari/GitNexus/actions/runs/1001', + ref: 'refs/heads/main', + defaultBranch: true, + }, + seed: 's1', + provenance: PROVENANCE, + eligibility: { + mode: 'normal', + files: ['test/unit/add.test.ts'], + fingerprintFiles: ['test/unit/add.test.ts'], + delta: { notValidated: [], rejected: [] }, + extraRoots: [], + }, + layer1Files: 1, + unrecordedFiles: 0, + changedFiles: null, + notRun: { skipped: 0, unfinished: 0 }, + eligibleTests: [A, B, C], + shards: { planned: 1, complete: 1, incomplete: [] }, + layer0: null, + evidence, + fingerprint: computeFingerprints(evidence), + mutation: 'all', + verdicts: options.verdicts === undefined ? computed : options.verdicts(computed), + anchors: new Map([A, B, C].map((id) => [id, anchor(id)])), + fingerprints: fingerprints([M1, M2, M3]), + baseline: null, + ...options.overrides, + }; +} diff --git a/gitnexus/test/unit/scenario-map/report.test.ts b/gitnexus/test/unit/scenario-map/report.test.ts new file mode 100644 index 000000000..5fb8434de --- /dev/null +++ b/gitnexus/test/unit/scenario-map/report.test.ts @@ -0,0 +1,420 @@ +/** + * Locks the scenario map report (U13, R12, KTD13, KTD15, design §5.5, §7, §8): mutant fingerprints + * (span-free, operator, replacement, enclosing statement, fallbacks), KTD13 universe ids, evidence + * hashes (an engine-version bump changes every one, regrouping keeps them), the seeded spot audit, + * "N of M evaluated", completeness, the Layer 0 section, KTD15 provenance collection and the + * markdown rendering. + */ +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { contentHash } from '../../../tools/mutation/prepare/mutant-identity.js'; +import { + fingerprintMutants, + universeIdOf, + type FingerprintFallback, +} from '../../../tools/scenario-map/report/anchors.js'; +import { mdCode, renderMarkdown } from '../../../tools/scenario-map/report/markdown.js'; +import { + PRECISION_NOT_MEASURED, + REDACTED, + buildReport, + collectProvenance, + spotAudit, + type SpotAuditPick, +} from '../../../tools/scenario-map/report/report.js'; +import { SCENARIO_MAP_SCHEMA_VERSION } from '../../../tools/scenario-map/types.js'; +import { computeFingerprints } from '../../../tools/scenario-map/verdict/fingerprint.js'; +import { VERDICT_ENGINE_VERSION } from '../../../tools/scenario-map/verdict/verdict.js'; +import { A, B, C, FILE, M1, M2, M3, fingerprints, reportInput } from './report-fixtures.js'; + +const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); +const SRC = 'gitnexus/src/math.ts'; +const idOf = (source: string, span: string, mutator: string, replacement: string): string => + `${SRC}@${contentHash(source).slice(0, 16)}:${span}:${mutator}:${sha256(replacement).slice(0, 16)}`; +const fingerprintIn = (source: string, id: string): string => + fingerprintMutants([id], () => source).get(id)?.fingerprint ?? ''; + +// ── Mutant fingerprints ──────────────────────────────────────────────────── + +const ADD = 'export function add(a: number, b: number): number {\n return a + b;\n}\n'; +const ADD_ID = idOf(ADD, '2:10-2:15', 'ArithmeticOperator', 'a - b'); + +describe('mutant fingerprint', () => { + it('survives lines inserted above the mutant (no revision, no span)', () => { + const shifted = `// header\n\n${ADD}`; + const moved = idOf(shifted, '4:10-4:15', 'ArithmeticOperator', 'a - b'); + expect(fingerprintIn(shifted, moved)).toBe(fingerprintIn(ADD, ADD_ID)); + expect(fingerprintMutants([ADD_ID], () => ADD).get(ADD_ID)?.fallback).toBeNull(); + }); + + it.each([ + ['operator', ADD, idOf(ADD, '2:10-2:15', 'StringLiteral', 'a - b')], + ['replacement', ADD, idOf(ADD, '2:10-2:15', 'ArithmeticOperator', 'a * b')], + [ + 'enclosing statement', + ADD.replace('a + b;', 'a + b + 0;'), + idOf(ADD.replace('a + b;', 'a + b + 0;'), '2:10-2:15', 'ArithmeticOperator', 'a - b'), + ], + ])('changes with the %s', (_part, source, id) => { + expect(fingerprintIn(source, id)).not.toBe(fingerprintIn(ADD, ADD_ID)); + }); + + it('tells apart equal mutants of one statement by occurrence', () => { + const source = "export const k = f('a') + f('a');\n"; + const first = idOf(source, '1:20-1:23', 'StringLiteral', '""'); + const second = idOf(source, '1:29-1:32', 'StringLiteral', '""'); + const result = fingerprintMutants([first, second], () => source); + expect(result.get(first)?.fingerprint).not.toBe(result.get(second)?.fingerprint); + }); + + it('takes a class member, not the whole class, as the enclosing statement', () => { + const before = "class K {\n s = new Set(['a']);\n m() { return 1; }\n}\n"; + const after = before.replace('return 1', 'return 2'); + const span = '2:16-2:19'; + expect(fingerprintIn(after, idOf(after, span, 'StringLiteral', '""'))).toBe( + fingerprintIn(before, idOf(before, span, 'StringLiteral', '""')), + ); + }); + + it.each<[FingerprintFallback, string, string | null]>([ + ['malformed-id', 'not-a-global-id', ADD], + ['unreadable', ADD_ID, null], + ['content-changed', ADD_ID, ADD.replace('a + b', 'b + a')], + [ + 'unparsable', + idOf('export const = ;\n', '1:1-1:2', 'StringLiteral', ''), + 'export const = ;\n', + ], + ['span-outside', idOf(ADD, '9:1-9:2', 'ArithmeticOperator', 'a - b'), ADD], + ['no-statement', idOf('a;\nb;\n', '1:1-2:3', 'BlockStatement', '{}'), 'a;\nb;\n'], + ])('falls back to the global id when %s', (fallback, id, source) => { + expect(fingerprintMutants([id], () => source).get(id)?.fallback).toBe(fallback); + }); +}); + +describe('universeIdOf', () => { + it('names a universe by its sorted fingerprints, whatever their order', () => { + expect(universeIdOf(['m2', 'm1'])).toBe(universeIdOf(['m1', 'm2'])); + expect(universeIdOf(['m1'])).not.toBe(universeIdOf(['m1', 'm2'])); + expect(universeIdOf([])).toBeNull(); + }); +}); + +// ── Evidence hashes (KTD13) ──────────────────────────────────────────────── + +describe('evidence hash', () => { + it('changes for every item on a verdict-engine version bump alone', () => { + const before = buildReport(reportInput()); + const bumped = buildReport( + reportInput({ + verdicts: (report) => ({ ...report, engineVersion: report.engineVersion + 1 }), + }), + ); + const hashes = new Set(before.continuity.items.map((item) => item.evidenceHash)); + expect(bumped.continuity.items).toHaveLength(before.continuity.items.length); + expect(bumped.continuity.items.filter((item) => hashes.has(item.evidenceHash))).toEqual([]); + }); + + it('stays when a regrouping leaves the common universe unchanged', () => { + const before = buildReport(reportInput()); + const regrouped = buildReport(reportInput({ group: 'g-regrouped' })); + expect(regrouped.pairs.map((pair) => pair.group)).toEqual(['g-regrouped', 'g-regrouped']); + expect(regrouped.pairs[0].evidence.universeId).not.toBe(before.pairs[0].evidence.universeId); + expect(regrouped.pairs.map((pair) => pair.universe)).toEqual( + before.pairs.map((pair) => pair.universe), + ); + expect(regrouped.continuity.items).toEqual(before.continuity.items); + }); + + it('changes when a mutant of the common universe changes', () => { + const before = buildReport(reportInput()); + const renewed = fingerprints([M1, M2, M3]); + renewed.set(M2, { fingerprint: 'm-renewed', fallback: null }); + const after = buildReport(reportInput({ overrides: { fingerprints: renewed } })); + expect(after.pairs[0].universe.id).not.toBe(before.pairs[0].universe.id); + expect(after.pairs[0].evidenceHash).not.toBe(before.pairs[0].evidenceHash); + }); + + it('is byte-identical for reordered observations', () => { + const input = reportInput(); + const reordered = { + ...input, + evidence: { + ...input.evidence, + observations: { + ...input.evidence.observations, + explicit: [...input.evidence.observations.explicit].reverse(), + }, + }, + }; + expect(JSON.stringify(buildReport(reordered))).toBe(JSON.stringify(buildReport(input))); + }); +}); + +// ── Spot audit (design §8) ───────────────────────────────────────────────── + +const candidates = (count: number): SpotAuditPick[] => + Array.from({ length: count }, (_, i) => ({ key: `pair:k${i}`, a: `a${i}`, b: `b${i}` })); + +describe('spot audit', () => { + it('picks the same 10 candidates with seed s1 on two runs, in any input order', () => { + const first = spotAudit(candidates(25), 's1'); + expect(first.picks).toHaveLength(10); + expect(spotAudit(candidates(25), 's1')).toEqual(first); + expect(spotAudit([...candidates(25)].reverse(), 's1')).toEqual(first); + expect(spotAudit(candidates(25), 's2').picks).not.toEqual(first.picks); + }); + + it('picks every candidate when there are fewer than 10', () => { + const audit = spotAudit(candidates(3), 's1'); + expect(audit.picks.map((pick) => pick.key).sort()).toEqual(['pair:k0', 'pair:k1', 'pair:k2']); + expect(audit).toMatchObject({ seed: 's1', size: 10, candidates: 3 }); + }); + + it('records the seed and samples the fingerprint duplicates of a report', () => { + const input = reportInput(); + // B checks add(2, 3) as A does: the same path, input and expectation. + const facetOfA = input.evidence.tests.find((test) => test.id === A)?.facet ?? null; + const evidence = { + ...input.evidence, + tests: input.evidence.tests.map((test) => + test.id === B ? { ...test, facet: facetOfA } : test, + ), + }; + const report = buildReport({ ...input, fingerprint: computeFingerprints(evidence) }); + const duplicates = report.fingerprint.pairs.filter((pair) => pair.verdict === 'duplicate'); + expect(report.seed).toBe('s1'); + expect(report.spotAudit.picks).toEqual( + duplicates.map((pair) => ({ key: pair.key, a: A, b: B })), + ); + expect(duplicates).toHaveLength(1); + }); +}); + +// ── Report sections ──────────────────────────────────────────────────────── + +describe('buildReport', () => { + it('carries the schema and engine versions, the verdicts and the limits', () => { + const report = buildReport(reportInput()); + expect(report).toMatchObject({ + schemaVersion: SCENARIO_MAP_SCHEMA_VERSION, + engineVersion: VERDICT_ENGINE_VERSION, + summary: { + byVerdict: { 'repeat-strong': 1, weak: 1 }, + testsByStatus: { killing: 2, weak: 1 }, + gapFiles: 1, + survived: 1, + uncovered: 1, + }, + }); + expect(report.pairs.map((pair) => [pair.a, pair.b, pair.verdict])).toEqual([ + [A, B, 'repeat-strong'], + [A, C, 'weak'], + ]); + expect(report.limits[0]).toBe(PRECISION_NOT_MEASURED); + expect(report.provenance.operators).toEqual({ + mutator: null, + names: ['ArithmeticOperator', 'BlockStatement', 'ConditionalExpression'], + }); + }); + + it('counts N of M eligible tests evaluated', () => { + const other = 'test/unit/other.test.ts#other > runs alone'; + const report = buildReport(reportInput({ overrides: { eligibleTests: [A, B, C, other] } })); + expect(report.summary.evaluated).toBe('3 of 4 eligible tests evaluated'); + }); + + it.each([ + [ + 'an incomplete shard', + { planned: 2, complete: 1, incomplete: [{ shard: '1', reason: 'timeout' }] }, + false, + ], + ['every shard complete', { planned: 2, complete: 2, incomplete: [] }, true], + ])('records completeness with %s', (_title, shards, complete) => { + const report = buildReport(reportInput({ degraded: ['no-pdg-layer'], overrides: { shards } })); + expect(report.completeness).toMatchObject({ + complete, + partial: !complete, + degraded: ['no-pdg-layer'], + }); + }); + + it('keeps the Layer 0 findings of eligible files only', () => { + const finding = (file: string) => ({ + kind: 'same-test', + file, + line: 3, + matches: [], + detail: 'same body', + }); + const layer0 = { + version: 1, + scanned: 2, + skipped: [], + findings: [ + finding('gitnexus/test/unit/other.test.ts'), + finding('gitnexus/test/unit/add.test.ts'), + ], + }; + const report = buildReport(reportInput({ overrides: { layer0 } })); + expect(report.layer0).toEqual({ + available: true, + scanned: 2, + findings: [finding('gitnexus/test/unit/add.test.ts')], + skipped: [], + }); + }); + + it('gives a test without a static join an execution-identity anchor', () => { + const report = buildReport(reportInput({ overrides: { anchors: new Map() } })); + expect(report.tests.map((test) => test.anchor.key.startsWith('exec:'))).toEqual([ + true, + true, + true, + ]); + expect(report.tests.map((test) => test.anchor.rowFingerprint)).toEqual([null, null, null]); + }); +}); + +// ── Provenance (KTD15) ───────────────────────────────────────────────────── + +const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-report-')); +afterAll(() => rmSync(tempRoot, { recursive: true, force: true })); + +const writeRepoFile = (repoPath: string, text: string): void => { + mkdirSync(path.dirname(path.join(tempRoot, repoPath)), { recursive: true }); + writeFileSync(path.join(tempRoot, repoPath), text); +}; + +describe('collectProvenance', () => { + writeRepoFile('gitnexus/test/unit/a.test.ts', 'test text'); + writeRepoFile('gitnexus/src/x.ts', 'production text'); + writeRepoFile('gitnexus/package-lock.json', '{}'); + writeRepoFile('gitnexus/node_modules/vitest/package.json', '{"version":"5.0.1"}'); + writeRepoFile('gitnexus/tools/mutation/runner/plugin.ts', 'runner'); + const provenance = collectProvenance({ + repoRoot: tempRoot, + testFiles: ['test/unit/a.test.ts'], + productionFiles: ['gitnexus/src/x.ts'], + fixtureFiles: ['gitnexus/test/fixtures/missing.json'], + sourceRevision: 'abc', + env: { + GITNEXUS_HOME: '/h', + GITNEXUS_API_KEY: 'secret', + GITNEXUS_MCP_DEFAULT_MAX_TOKENS: '5', + PATH: '/bin', + }, + node: 'v24.1.0', + os: 'linux test x64', + }); + + it('hashes test, production and fixture files and the lockfiles', () => { + expect(provenance.files).toEqual({ + tests: { 'test/unit/a.test.ts': sha256('test text') }, + production: { 'gitnexus/src/x.ts': sha256('production text') }, + fixtures: { 'gitnexus/test/fixtures/missing.json': 'absent' }, + }); + expect(provenance.lockfiles).toEqual({ + 'gitnexus-shared/package-lock.json': 'absent', + 'gitnexus/package-lock.json': sha256('{}'), + }); + }); + + it('records versions, runtime and only the GITNEXUS_ variables, secrets redacted', () => { + expect(provenance.versions).toMatchObject({ vitest: '5.0.1', stryker: null, typescript: null }); + expect(provenance.versions.runner).toMatch(/^[0-9a-f]{64}$/); + expect(provenance.runtime).toEqual({ + node: 'v24.1.0', + os: 'linux test x64', + env: { + GITNEXUS_API_KEY: REDACTED, + GITNEXUS_HOME: '/h', + GITNEXUS_MCP_DEFAULT_MAX_TOKENS: '5', + }, + }); + expect(provenance.sourceRevision).toBe('abc'); + }); +}); + +// ── Markdown ─────────────────────────────────────────────────────────────── + +describe('renderMarkdown', () => { + const markdown = renderMarkdown(buildReport(reportInput())); + + it.each([ + { + scope: 'a whole-suite run that ran every file', + overrides: {}, + lines: [], + }, + { + scope: 'a whole-suite run that skipped files', + overrides: { layer1Files: 0 }, + lines: ['Layer 1 ran 0 of them; every test of the other 1 has a checks-and-context key'], + }, + { + scope: 'a diff-scoped run', + overrides: { layer1Files: 0, changedFiles: ['test/unit/add.test.ts'] }, + lines: [ + 'Diff scope: only pairs with a test in the 1 changed test file were looked for: ' + + '`test/unit/add.test.ts`.', + 'Layer 1 ran 0 of them: the changed files whose tests can pair with another test', + ], + }, + { + scope: 'a run with a file that wrote no record', + overrides: { unrecordedFiles: 2 }, + lines: ['2 test files wrote no Layer 1 record'], + }, + { + scope: 'a run with tests that did not run', + overrides: { notRun: { skipped: 3, unfinished: 1 } }, + lines: ['No fingerprint for 3 tests skipped at run time'], + }, + ])('says which files Layer 1 ran in $scope', ({ overrides, lines }) => { + const text = renderMarkdown(buildReport(reportInput({ overrides }))); + const found = text + .split('\n') + .filter((line) => + /^(Diff scope|Layer 1 ran|No fingerprint for|\d+ test files? wrote no)/.test(line), + ); + expect(found).toEqual(lines.map((prefix) => expect.stringContaining(prefix))); + }); + + it('leads with the scenario fingerprint and leaves the mutation sections out of a fingerprint-only run', () => { + const fingerprintOnly = renderMarkdown(buildReport({ ...reportInput(), mutation: 'none' })); + expect([ + fingerprintOnly.includes('## Scenario fingerprint'), + fingerprintOnly.includes('## Table-row candidates (1)'), + fingerprintOnly.includes('**Mutation evidence**: not gathered'), + fingerprintOnly.includes('## Strong repeat candidates'), + ]).toEqual([true, true, true, false]); + }); + + it.each([ + ['the title', '# Test scenario map'], + ['N of M', '**3 of 3 eligible tests evaluated.**'], + ['the precision statement', PRECISION_NOT_MEASURED], + ['the baseline banner', 'first run, no baseline'], + ['the strong repeats', '## Strong repeat candidates (1)'], + ['the spot audit seed', '## Spot audit (seed `s1`'], + ['the weak tests', '## Weak tests (1)'], + ['the gap file', `| ${mdCode(FILE)} |`], + ['a strong pair member', mdCode(A)], + ])('shows %s', (_title, text) => { + expect(markdown).toContain(text); + }); + + it.each([ + ['plain text', 'a > b', '`a > b`'], + ['a pipe', 'a | b', '`a \\| b`'], + ['a backtick', 'a `b`', '`` a `b` ``'], + ['a newline', 'a\nb', '`a b`'], + ])('renders %s as an inline code cell', (_title, text, expected) => { + expect(mdCode(text)).toBe(expected); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/row-deletion.test.ts b/gitnexus/test/unit/scenario-map/row-deletion.test.ts new file mode 100644 index 000000000..e91869b07 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/row-deletion.test.ts @@ -0,0 +1,641 @@ +/** + * Locks the row-deletion operator (U9, R8, KTD10, design §5.4 "Row-deletion operator") without + * spawning Vitest: a pair whose differing discarded values are keys of one supported production + * table (an object literal, `new Set([...])`, `new Map([...])`) gets one planned deletion per + * differing key, with a `RowDeletion` global id and a text splice of the entry and its trailing + * comma; unsupported constructs make it inconclusive with the construct named; values in no common + * supported table make it not applicable. The runs go through an injected `runVitest`: a control + * run that must pass and load the edited file, then one run per deletion in a throwaway copy, + * each a fresh call that sees its own edit, with the file restored afterwards. + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parse } from '@babel/parser'; +import { afterEach, describe, expect, it } from 'vitest'; +import { contentHash, formatGlobalId } from '../../../tools/mutation/prepare/mutant-identity.js'; +import type { PrepareOptions, PrepareReport } from '../../../tools/mutation/prepare/prepare.js'; +import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; +import { + computeFacet, + createFacetContext, + type FacetSources, + type FacetSubject, +} from '../../../tools/scenario-map/facet/input-facet.js'; +import { expandCases } from '../../../tools/scenario-map/inventory/cases.js'; +import { + ROW_DELETION_MUTATOR, + classifyControlRun, + classifyDeletionRun, + combineRowDeletion, + pairTableSearch, + planRowDeletion, + runRowDeletion, + type RowDeletionDeps, + type RowDeletionPlan, +} from '../../../tools/scenario-map/mutation/row-deletion.js'; +import { buildRunManifest } from '../../../tools/scenario-map/mutation/run-group.js'; +import type { + VitestRunOptions, + VitestRunResult, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; + +const TEST_FILE = 'gitnexus/test/unit/a.test.ts'; +const PACKAGE_FILE = 'test/unit/a.test.ts'; +const M = 'gitnexus/src/m.ts'; +const N = 'gitnexus/src/n.ts'; +const A = `${PACKAGE_FILE}#d > a`; +const B = `${PACKAGE_FILE}#d > b`; +const IMPORTS = `import { has, lookup } from '../../src/m.js';`; +const READ_OBJECT = `export const lookup = (k: string) => T[k];\nexport const has = (k: string) => k in T;`; +const READ_SET = `export const has = (v: string) => S.has(v);\nexport const lookup = has;`; +const READ_A = `it('a', () => { expect(lookup('a')).toBeDefined(); });`; +const READ_B = `it('b', () => { expect(lookup('b')).toBeDefined(); });`; +const HAS_X = `it('a', () => { expect(has('x')).toBe(true); });`; +const HAS_Y = `it('b', () => { expect(has('y')).toBe(true); });`; + +const memorySources = (files: Record): FacetSources => ({ + read: (file) => files[file] ?? null, + list: (dir) => + Object.keys(files) + .filter((file) => file.startsWith(`${dir}/`)) + .sort(), +}); + +interface World { + /** Production sources by repo path. */ + files: Record; + /** Executed production files; default every file. */ + executed?: string[]; + /** The two tests; default {@link READ_A} and {@link READ_B}. */ + tests?: [string, string]; +} + +/** The row-deletion plan of two tests written inside one describe, with real U6 facets. */ +async function planOf(world: World, first: string, second: string): Promise { + const source = [IMPORTS, `describe('d', () => {`, first, second, `});`].join('\n'); + const sites = expandCases([{ path: TEST_FILE, source }]).sites; + const production = Object.keys(world.files).sort(); + const sources = memorySources({ ...world.files, [TEST_FILE]: source }); + const ctx = createFacetContext({ + sources, + productionFiles: production, + closures: new Map([[PACKAGE_FILE, [TEST_FILE, ...production]]]), + builtinFiles: [], + }); + const subjects: FacetSubject[] = sites.flatMap((site) => + (site.instances ?? []).map((instance) => ({ + id: `${PACKAGE_FILE}#d > ${site.record.title}`, + file: PACKAGE_FILE, + record: site.record, + instance, + executedFiles: world.executed ?? production, + })), + ); + expect(subjects).toHaveLength(2); + const facets = await Promise.all(subjects.map((subject) => computeFacet(subject, ctx))); + return planRowDeletion( + { a: A, b: B, facets: [facets[0], facets[1]], search: pairTableSearch(ctx, subjects) }, + { tables: ctx.tables, read: (file) => sources.read(file) }, + ); +} + +const object = (table: string, tail = ''): World => ({ + files: { [M]: `${table}\n${READ_OBJECT}${tail}` }, +}); +const set = (table: string, tail = ''): World => ({ + files: { [M]: `${table}\n${READ_SET}${tail}` }, + tests: [HAS_X, HAS_Y], +}); + +/** The plan of a world's own two tests. */ +const planWorld = (world: World): Promise => + planOf(world, ...(world.tests ?? [READ_A, READ_B])); + +/** What a deletion removes from the original text. */ +const spliced = (source: string, plan: RowDeletionPlan): string[] => + plan.deletions.map((deletion) => source.slice(deletion.splice.start, deletion.splice.end)); + +const apply = (source: string, splice: { start: number; end: number }): string => + source.slice(0, splice.start) + source.slice(splice.end); + +describe('planRowDeletion: supported tables', () => { + it('plans one RowDeletion per differing key of an object table, with global ids', async () => { + const world = object(`const T = { a: 1, b: 2, c: 3 };`); + const plan = await planOf(world, READ_A, READ_B); + const source = world.files[M]; + const identity = (start: number, end: number) => ({ + file: M, + fileHash: contentHash(source), + location: { start: { line: 1, column: start }, end: { line: 1, column: end } }, + mutatorName: ROW_DELETION_MUTATOR, + replacement: '', + }); + expect(plan.status).toBe('planned'); + expect(plan.deletions.map((deletion) => deletion.key)).toEqual(['s:a', 's:b']); + expect(plan.deletions.map((deletion) => deletion.globalId)).toEqual([ + formatGlobalId(identity(13, 17)), + formatGlobalId(identity(19, 23)), + ]); + expect(plan.deletions.map((deletion) => deletion.identity.mutatorName)).toEqual([ + 'RowDeletion', + 'RowDeletion', + ]); + expect(spliced(source, plan)).toEqual(['a: 1,', 'b: 2,']); + expect(plan.deletions[0].table).toEqual({ file: M, kind: 'object', binding: 'T', line: 1 }); + }); + + it('splices only the deleted Set element, and the file still parses', async () => { + const world = set(`const S = new Set(['x', 'y']);`); + const plan = await planOf(world, HAS_X, HAS_Y); + const source = world.files[M]; + const edits = plan.deletions.map((deletion) => apply(source, deletion.splice)); + expect(plan.deletions.map((deletion) => deletion.key)).toEqual(['s:x', 's:y']); + expect(edits.map((edit) => edit.split('\n')[0])).toEqual([ + `const S = new Set([ 'y']);`, + `const S = new Set(['x', ]);`, + ]); + expect( + edits.map((edit) => parse(edit, { sourceType: 'module', plugins: ['typescript'] }).type), + ).toEqual(['File', 'File']); + }); + + it.each([ + ['an object literal under `as const`', object(`const T = { a: 1, b: 2, c: 3 } as const;`)], + [ + 'an object literal under Object.freeze', + object(`const T = Object.freeze({ a: 1, b: 2, c: 3 });`), + ], + ['an object literal with string keys', object(`const T = { 'a': 1, 'b': 2, c: 3 };`)], + [ + 'an object literal read in place', + { + files: { + [M]: `export const lookup = (k: string) => ({ a: 1, b: 2, c: 3 } as Record)[k];\nexport const has = lookup;`, + }, + }, + ], + ['a Map from literal entries', object(`const T = new Map([['a', 1], ['b', 2]]);`)], + ])('plans two deletions in %s', async (_label, world) => { + const plan = await planOf(world, READ_A, READ_B); + expect(plan.status).toBe('planned'); + expect(plan.deletions.map((deletion) => deletion.key)).toEqual(['s:a', 's:b']); + }); + + it('deletes a Map entry with its key and value', async () => { + const world = object(`const T = new Map([['a', 1], ['b', 2]]);`); + const plan = await planOf(world, READ_A, READ_B); + expect(spliced(world.files[M], plan)).toEqual([`['a', 1],`, `['b', 2]`]); + }); + + it('searches the executed files before the import closure', async () => { + const files = { + [M]: `import { U } from './n.js';\nconst T = { a: 1, b: 2, c: 3 };\n${READ_OBJECT}\nexport const u = U;`, + [N]: `export const U = { a: 'x', b: 'y', c: 'z' };`, + }; + const executedOnly = await planOf({ files, executed: [M] }, READ_A, READ_B); + const closureOnly = await planOf({ files, executed: [] }, READ_A, READ_B); + expect(executedOnly.deletions.map((deletion) => deletion.table.file)).toEqual([M, M]); + expect([closureOnly.status, closureOnly.reasons]).toEqual([ + 'inconclusive', + ['ambiguous-table'], + ]); + }); +}); + +describe('planRowDeletion: unsupported tables and no table', () => { + it.each([ + ['spread', object(`const BASE = { z: 0 };\nconst T = { ...BASE, a: 1, b: 2 };`)], + ['computed-key', object(`const K = 'c';\nconst T = { [K]: 0, a: 1, b: 2 };`)], + ['object-assign', object(`const T = { a: 1, b: 2, c: 3 };`, `\nObject.assign(T, { d: 4 });`)], + ['write', object(`const T: Record = { a: 1, b: 2, c: 3 };`, `\nT.d = 4;`)], + ['write', object(`const T: Record = { a: 1, b: 2, c: 3 };`, `\ndelete T.c;`)], + ['write', object(`let T: Record = { a: 1, b: 2, c: 3 };`, `\nT = {};`)], + ['write', set(`const S = new Set(['x', 'y']);`, `\nS.add('z');`)], + ['getter', object(`const T = { get a() { return 1; }, b: 2, c: 3 };`)], + ['proxy', object(`const T = new Proxy({ a: 1, b: 2, c: 3 }, {});`)], + ['proxy', object(`const T = { a: 1, b: 2, c: 3 };`, `\nexport const P = new Proxy(T, {});`)], + ['duplicate-key', object(`const T = { a: 1, b: 2, a: 3 };`)], + ['duplicate-key', set(`const S = new Set(['x', 'y', 'x']);`)], + [ + 'untracked', + set(`export const CONFIG = { kinds: new Set(['x', 'y']) };\nconst S = CONFIG.kinds;`), + ], + ])('is inconclusive for a table with %s (%#)', async (construct, world) => { + const plan = await planWorld(world); + expect([plan.status, plan.reasons, plan.deletions]).toEqual([ + 'inconclusive', + [`unsupported-table: ${construct}`], + [], + ]); + }); + + it.each([ + [ + 'no-common-table', + object(`const T = { a: 1, b: 2, c: 3 };`), + READ_A, + `it('b', () => { expect(lookup('q')).toBeDefined(); });`, + ], + [ + 'no-supported-table', + { + files: { + [M]: `const T = ['alpha', 'beta'];\nexport const lookup = (k: string) => T.includes(k);\nexport const has = lookup;`, + }, + }, + `it('a', () => { expect(lookup('alpha')).toBe(true); });`, + `it('b', () => { expect(lookup('beta')).toBe(true); });`, + ], + [ + 'no-differing-values', + object(`const T = { a: 1, b: 2, c: 3 };`), + READ_A, + READ_A.replace("'a', ()", "'b', ()"), + ], + ])('is not applicable with %s', async (reason, world, first, second) => { + const plan = await planOf(world, first, second); + expect([plan.status, plan.reasons, plan.deletions]).toEqual(['not-applicable', [reason], []]); + }); +}); + +// ── Classification ────────────────────────────────────────────────────────── + +const KILL: OutcomeClass = 'killed-by-test:assertion'; +const LOADED = ['src/m.ts']; + +function vitestResult( + outcomes: readonly [OutcomeClass | null, OutcomeClass | null], + overrides: Partial = {}, +): VitestRunResult { + const failing = outcomes.filter((outcome) => outcome !== 'survived' && outcome !== null); + return { + tests: [A, B].map((id, index) => ({ + id, + taskId: `t_${index}`, + fullName: id.split('#')[1], + location: { line: index + 1, column: 3 }, + state: + outcomes[index] === null ? 'missing' : outcomes[index] === 'survived' ? 'passed' : 'failed', + outcome: outcomes[index], + message: outcomes[index] === 'survived' ? null : 'boom', + })), + status: failing.length > 0 ? 'killed' : 'survived', + outcome: failing[0] ?? 'survived', + reason: null, + outsideTest: [], + problems: [], + unhandledErrors: [], + wallClockMs: 5, + loadedFiles: LOADED, + ...overrides, + }; +} + +describe('classifyDeletionRun', () => { + it.each([ + ['only A fails by assertion', vitestResult([KILL, 'survived']), 'asymmetric', null], + ['only B fails by assertion', vitestResult(['survived', KILL]), 'asymmetric', null], + ['both fail by assertion', vitestResult([KILL, KILL]), 'symmetric', null], + ['neither fails', vitestResult(['survived', 'survived']), 'symmetric', null], + [ + 'a runtime error', + vitestResult(['killed-by-test:runtime', 'survived']), + 'inconclusive', + 'killed-by-test:runtime', + ], + [ + 'a timeout', + vitestResult(['timeout', KILL], { status: 'timeout' }), + 'inconclusive', + 'timeout', + ], + [ + 'a failing hook', + vitestResult(['killed-by-test:hook', KILL]), + 'inconclusive', + 'killed-by-test:hook', + ], + [ + 'an unhandled error', + vitestResult([KILL, 'survived'], { status: 'error', outcome: 'infrastructure' }), + 'inconclusive', + 'infrastructure', + ], + [ + 'a failing afterAll hook', + vitestResult([KILL, 'survived'], { + outsideTest: [ + { + file: PACKAGE_FILE, + suiteTaskId: undefined, + outcome: 'killed-outside-test:hook', + attributedTo: [], + message: 'afterAll failed', + }, + ], + }), + 'inconclusive', + 'killed-outside-test:hook', + ], + [ + 'a test that did not run', + vitestResult([null, KILL], { + status: 'error', + outcome: 'invalid', + problems: ['not collected'], + }), + 'inconclusive', + 'invalid', + ], + [ + 'an unloaded edit', + vitestResult([KILL, 'survived'], { loadedFiles: [] }), + 'inconclusive', + 'edit-not-loaded', + ], + ])('classifies %s', (_label, run, outcome, reason) => { + expect(classifyDeletionRun(run, [A, B], LOADED)).toEqual({ outcome, reason }); + }); +}); + +describe('classifyControlRun', () => { + it.each([ + ['two passing tests that loaded the file', vitestResult(['survived', 'survived']), null], + ['a failing test', vitestResult(['survived', KILL]), 'control-failed'], + [ + 'a run that loaded no probe file', + vitestResult(['survived', 'survived'], { loadedFiles: [] }), + 'edit-not-loaded', + ], + [ + 'a run without probe results', + vitestResult(['survived', 'survived'], { loadedFiles: undefined }), + 'edit-not-loaded', + ], + ])('classifies %s', (_label, run, problem) => { + expect(classifyControlRun(run, [A, B], LOADED)).toBe(problem); + }); +}); + +describe('combineRowDeletion', () => { + it.each([ + [['symmetric', 'asymmetric', 'inconclusive'], 'asymmetric'], + [['symmetric', 'inconclusive'], 'inconclusive'], + [['symmetric', 'symmetric', 'not-applicable'], 'symmetric'], + [['not-applicable'], 'not-applicable'], + [[], 'not-applicable'], + ] as const)('combines %j into %s', (outcomes, expected) => { + expect(combineRowDeletion(outcomes)).toBe(expected); + }); +}); + +// ── Runs ──────────────────────────────────────────────────────────────────── + +const temps: string[] = []; +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-u9-')); + temps.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const OBJECT_SOURCE = `const T = { a: 1, b: 2, c: 3 };\n${READ_OBJECT}\n`; + +/** A prepared tree: the package sources plus the two tool versions under node_modules. */ +function writeTree(root: string, source: string): void { + const files: Record = { + 'gitnexus/node_modules/vitest/package.json': JSON.stringify({ version: '5.0.5' }), + 'gitnexus/node_modules/@stryker-mutator/core/package.json': JSON.stringify({ + version: '10.0.0', + }), + [M]: source, + [TEST_FILE]: `${IMPORTS}\n`, + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } +} + +interface Call { + workDir: string; + testIds: readonly string[]; + probeFiles: readonly string[] | undefined; + maxWorkers: number | undefined; + /** The table file's text when the call started. */ + text: string; +} + +interface FakeOptions { + /** Keys whose absence fails each test by assertion. */ + reads: Record; + /** The outcome class a failing test gets; default an assertion failure. */ + failure?: OutcomeClass; + /** The control run fails test B. */ + controlFails?: boolean; + /** The runs load no probe file. */ + loadsNothing?: boolean; + /** The prepared copy differs from the run manifest. */ + foreignCopy?: boolean; + /** The 1-based call that throws. */ + throwsOn?: number; + /** The worker bound passed to runRowDeletion. */ + maxWorkers?: number; +} + +function fakeDeps(options: FakeOptions): { deps: RowDeletionDeps; calls: Call[] } { + const calls: Call[] = []; + let clock = 0; + const deps: RowDeletionDeps = { + prepare: (prepareOptions: PrepareOptions): PrepareReport => { + writeTree(prepareOptions.outDir, `${OBJECT_SOURCE}${options.foreignCopy ? '// x\n' : ''}`); + return { + version: 1, + repoRoot: prepareOptions.repoRoot, + outDir: prepareOptions.outDir, + packageDir: path.join(prepareOptions.outDir, 'gitnexus'), + relocatedShared: '', + pathMap: [], + copiedFiles: 2, + blanked: [], + failed: [], + }; + }, + runVitest: async (runOptions: VitestRunOptions): Promise => { + const text = readFileSync(path.join(runOptions.packageDir, 'src/m.ts'), 'utf8'); + calls.push({ + workDir: runOptions.workDir, + testIds: runOptions.testIds, + probeFiles: runOptions.probeFiles, + maxWorkers: runOptions.maxWorkers, + text, + }); + if (calls.length === options.throwsOn) throw new Error('vitest could not start'); + const control = calls.length === 1; + const outcomeOf = (id: string): OutcomeClass => { + if (control) return options.controlFails === true && id === B ? KILL : 'survived'; + const missing = (options.reads[id] ?? []).some((key) => !text.includes(`${key}: `)); + return missing ? (options.failure ?? KILL) : 'survived'; + }; + return vitestResult([outcomeOf(A), outcomeOf(B)], { + loadedFiles: options.loadsNothing === true ? [] : [...(runOptions.probeFiles ?? [])], + }); + }, + now: () => (clock += 1000), + }; + return { deps, calls }; +} + +async function objectPlan(): Promise { + return planOf({ files: { [M]: OBJECT_SOURCE } }, READ_A, READ_B); +} + +async function runWith(options: FakeOptions) { + const manifestRoot = tempDir(); + writeTree(manifestRoot, OBJECT_SOURCE); + const manifest = buildRunManifest({ + preparedRoot: manifestRoot, + mode: { kind: 'normal' }, + tests: [A, B], + }); + const workDir = tempDir(); + const { deps, calls } = fakeDeps(options); + const [result] = await runRowDeletion( + { + repoRoot: tempDir(), + workDir, + manifest, + plans: [await objectPlan()], + maxWorkers: options.maxWorkers, + }, + deps, + ); + const copied = readFileSync(path.join(workDir, 'row-deletion', 'prepared', M), 'utf8'); + return { result, calls, copied }; +} + +describe('runRowDeletion', () => { + it.each([ + [ + 'only the test reading the deleted key fails', + { [A]: ['a'], [B]: ['b'] }, + undefined, + 'asymmetric', + ], + ['both tests read both keys', { [A]: ['a', 'b'], [B]: ['a', 'b'] }, undefined, 'symmetric'], + ['neither test reads a deleted key', { [A]: ['c'], [B]: ['c'] }, undefined, 'symmetric'], + [ + 'a deletion throws a runtime error', + { [A]: ['a'], [B]: [] }, + 'killed-by-test:runtime' as const, + 'inconclusive', + ], + ])('classifies a pair where %s', async (_label, reads, failure, outcome) => { + const { result } = await runWith({ reads, failure }); + expect(result.outcome).toBe(outcome); + expect(result.deletions).toHaveLength(2); + }); + + it('names the deletions a pair is asymmetric on', async () => { + const { result } = await runWith({ reads: { [A]: ['a'], [B]: ['b'] } }); + expect(result.deletions.map((deletion) => [deletion.key, deletion.outcome])).toEqual([ + ['s:a', 'asymmetric'], + ['s:b', 'asymmetric'], + ]); + expect(result.reasons).toEqual([]); + }); + + it('is inconclusive when the control run has a failing test, and runs no deletion', async () => { + const { result, calls } = await runWith({ reads: { [A]: ['a'] }, controlFails: true }); + expect([result.outcome, result.reasons, calls.length]).toEqual([ + 'inconclusive', + ['control-failed'], + 1, + ]); + }); + + it('is inconclusive when the control run never loads the edited file', async () => { + const { result, calls } = await runWith({ reads: { [A]: ['a'] }, loadsNothing: true }); + expect([result.outcome, result.reasons, calls.length]).toEqual([ + 'inconclusive', + ['edit-not-loaded'], + 1, + ]); + expect(calls[0].probeFiles).toEqual(['src/m.ts']); + }); + + it('is inconclusive without any run when the copy differs from the run manifest', async () => { + const { result, calls } = await runWith({ reads: { [A]: ['a'] }, foreignCopy: true }); + expect([result.outcome, result.reasons, calls.length]).toEqual([ + 'inconclusive', + ['manifest-mismatch'], + 0, + ]); + }); + + it('keeps going after a deletion run throws, and restores the file', async () => { + const { result, calls, copied } = await runWith({ reads: { [A]: ['a'] }, throwsOn: 2 }); + expect( + result.deletions.map((deletion) => [deletion.key, deletion.outcome, deletion.reason]), + ).toEqual([ + ['s:a', 'inconclusive', 'run-error'], + ['s:b', 'symmetric', null], + ]); + expect([result.outcome, result.reasons, result.detail, calls.length]).toEqual([ + 'inconclusive', + ['deletion: run-error'], + 'vitest could not start', + 3, + ]); + expect(copied).toBe(OBJECT_SOURCE); + }); + + it('bounds every run by the worker bound it is given (the CLI --concurrency)', async () => { + const { calls } = await runWith({ reads: { [A]: ['a'], [B]: ['b'] }, maxWorkers: 3 }); + expect(calls.map((call) => call.maxWorkers)).toEqual([3, 3, 3]); + }); + + it('gives each run a fresh call that sees its own edit, and restores the file', async () => { + const { calls, copied } = await runWith({ reads: { [A]: ['a'], [B]: ['b'] } }); + expect(calls.map((call) => call.text.split('\n')[0])).toEqual([ + 'const T = { a: 1, b: 2, c: 3 };', + 'const T = { b: 2, c: 3 };', + 'const T = { a: 1, c: 3 };', + ]); + expect(new Set(calls.map((call) => call.workDir)).size).toBe(3); + expect(calls.map((call) => call.testIds)).toEqual([ + [A, B], + [A, B], + [A, B], + ]); + expect(copied).toBe(OBJECT_SOURCE); + }); + + it('returns a plan without deletions as it is, without preparing a copy', async () => { + const plan = await planOf( + object(`const T = { a: 1, b: 2, c: 3 };`), + READ_A, + READ_A.replace("'a', ()", "'b', ()"), + ); + const { deps, calls } = fakeDeps({ reads: {} }); + const manifestRoot = tempDir(); + writeTree(manifestRoot, OBJECT_SOURCE); + const manifest = buildRunManifest({ + preparedRoot: manifestRoot, + mode: { kind: 'normal' }, + tests: [A, B], + }); + const [result] = await runRowDeletion( + { repoRoot: tempDir(), workDir: tempDir(), manifest, plans: [plan] }, + deps, + ); + expect([result.outcome, result.reasons, calls.length]).toEqual([ + 'not-applicable', + ['no-differing-values'], + 0, + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/run-group.test.ts b/gitnexus/test/unit/scenario-map/run-group.test.ts new file mode 100644 index 000000000..32da5d947 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/run-group.test.ts @@ -0,0 +1,605 @@ +/** + * Locks Layer 3 group and shard runs (U7, R7, KTD8, KTD14, KTD17) without spawning Vitest or + * Stryker: every runner is a fake. The run manifest's prepared-copy hashes and their check, the + * KTD8 config check before any mutant runs, the two baselines (failing or unstable identities make + * the group Inconclusive and emit no observations), Stryker's dry run against the baselines, the + * group result written as soon as the group finishes (schema 2: the run universe and the explicit + * observations as its rows; a schema 1 file is rejected), and a shard that refuses a foreign + * prepared copy or a group that does not fit before its deadline. + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { + NormalisedMutant, + RunResults, +} from '../../../tools/mutation/driver/normalise-results.js'; +import type { RunScopeOptions } from '../../../tools/mutation/driver/run-scope.js'; +import { + DEFAULT_IGNORED_DIRS, + buildStrykerConfig, +} from '../../../tools/mutation/driver/stryker-config.js'; +import type { RunManifest } from '../../../tools/mutation/runner/run-manifest.js'; +import { + type GroupScope, + type ShardPlan, +} from '../../../tools/scenario-map/mutation/plan-shards.js'; +import { + assertMutationConfig, + buildRunManifest, + checkBaselines, + readGroupObservations, + runGroup, + runShard, + sandboxIgnoredDirs, + verifyPreparedCopy, + type BaselineSummary, + type GroupRunDeps, + type ScenarioRunManifest, + type ShardRunDeps, +} from '../../../tools/scenario-map/mutation/run-group.js'; +import type { + VitestRunOptions, + VitestRunResult, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; + +const T1 = 'test/unit/a.test.ts#A > one'; +const T2 = 'test/unit/a.test.ts#A > two'; +const M1 = 'gitnexus/src/a.ts@0123456789abcdef:1:1-1:5:ArithmeticOperator:aaaaaaaaaaaaaaaa'; +const MINUTE = 60_000; + +const temps: string[] = []; +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-u7-')); + temps.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** A prepared tree: `gitnexus/` sources plus the two tool versions under node_modules. */ +function writeTree(root: string, files: Record, vitest = '5.0.5'): void { + const all: Record = { + 'gitnexus/node_modules/vitest/package.json': JSON.stringify({ version: vitest }), + 'gitnexus/node_modules/@stryker-mutator/core/package.json': JSON.stringify({ + version: '10.0.0', + }), + ...files, + }; + for (const [file, text] of Object.entries(all)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } +} + +const SOURCES = { + 'gitnexus/src/a.ts': 'export const a = 1;\n', + 'gitnexus/test/unit/a.test.ts': 'it("one", () => {});\n', +}; + +function manifestFor(files: Record = SOURCES): ScenarioRunManifest { + const root = tempDir(); + writeTree(root, files); + return buildRunManifest({ + preparedRoot: root, + mode: { kind: 'normal' }, + tests: [T1, T2], + }); +} + +const scope: GroupScope = { + id: 'g1', + tests: [T1, T2], + testFiles: ['test/unit/a.test.ts'], + pairs: 1, + mutate: ['gitnexus/src/a.ts'], + excluded: [], + closureIncomplete: [], + provenanceUnknown: [], +}; + +const passing = (overrides: Partial = {}): VitestRunResult => ({ + tests: [T1, T2].map((id, index) => ({ + id, + taskId: `t_${index}`, + fullName: id.split('#')[1], + location: { line: index + 1, column: 3 }, + state: 'passed', + outcome: 'survived', + message: null, + })), + status: 'survived', + outcome: 'survived', + reason: null, + outsideTest: [], + problems: [], + unhandledErrors: [], + wallClockMs: 5, + ...overrides, +}); + +const mutant: NormalisedMutant = { + globalId: M1, + localId: '1', + file: 'gitnexus/src/a.ts', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 5 } }, + mutatorName: 'ArithmeticOperator', + replacement: '-', + static: false, + status: 'Killed', + statusReason: null, + testsCompleted: 2, + outcome: 'killed-by-test:assertion', + outcomeSource: 'sidecar', + killedBy: [T1, T2], + coveredBy: [T1, T2], + assertionKilledBy: [T1, T2], + sidecar: null, + attempts: [ + { + file: '/s/1.json', + worker: '0', + attempt: 0, + pid: 1, + writtenAtMs: 1, + status: 'killed', + outcome: 'killed-by-test:assertion', + nrOfTests: 2, + }, + ], + perTest: [ + { testId: T1, outcome: 'killed-by-test:assertion', attempt: 0 }, + { testId: T2, outcome: 'killed-by-test:assertion', attempt: 0 }, + ], + attemptsDisagree: false, + hookAttributedTests: [], + flags: [], +}; + +function strykerResults(outDir: string): RunResults { + return { + schemaVersion: 2, + variant: 'vitest5', + scope: { testFiles: ['test/unit/a.test.ts'], mutate: ['gitnexus/src/a.ts'], testIds: [T1, T2] }, + paths: { + outDir, + preparedRoot: path.join(outDir, 'prepared'), + packageDir: path.join(outDir, 'prepared', 'gitnexus'), + configFile: path.join(outDir, 'stryker.config.json'), + reportFile: path.join(outDir, 'mutation.json'), + logFile: path.join(outDir, 'stryker.log'), + runDir: path.join(outDir, 'run'), + sidecarDir: path.join(outDir, 'sidecars'), + sandboxDirs: [], + }, + stryker: { exitCode: 0, wallClockMs: 10 }, + spanMap: { checked: 1, failures: [] }, + manifest: null, + dryRun: { + file: '/s/dry.json', + status: 'survived', + outcome: 'survived', + reason: null, + nrOfTests: 2, + attempts: 1, + }, + mutants: [mutant], + tests: [], + flags: [], + summary: { + mutants: 1, + byStatus: { + Killed: 1, + Survived: 0, + NoCoverage: 0, + CompileError: 0, + RuntimeError: 0, + Timeout: 0, + Ignored: 0, + Pending: 0, + }, + byOutcome: {}, + mutantsWithSidecar: 1, + mutantsWithOutcome: 1, + testsWithKills: 2, + testsWithAssertionKills: 2, + zeroTestRuns: 0, + flags: 0, + }, + }; +} + +const dryRunManifest = (location = 3): RunManifest => ({ + schemaVersion: 1, + project: 'mutation', + vitestVersion: '5.0.5', + files: [ + { + file: 'test/unit/a.test.ts', + tests: [ + { + id: T1, + taskId: 't_0', + fullName: 'A > one', + location: { line: 1, column: 3 }, + notOracleValidated: false, + }, + { + id: T2, + taskId: 't_1', + fullName: 'A > two', + location: { line: 2, column: location }, + notOracleValidated: false, + }, + ], + skipped: [], + }, + ], +}); + +interface Fakes { + deps: GroupRunDeps; + vitestCalls: VitestRunOptions[]; + scopeCalls: RunScopeOptions[]; +} + +function fakes( + overrides: { + baselines?: VitestRunResult[]; + config?: (config: Record) => Record; + dryRun?: RunManifest; + } = {}, +): Fakes { + const vitestCalls: VitestRunOptions[] = []; + const scopeCalls: RunScopeOptions[] = []; + const baselines = overrides.baselines ?? [passing(), passing()]; + const edit = overrides.config ?? ((config) => config); + let clock = 0; + const deps: GroupRunDeps = { + runVitest: async (options) => { + vitestCalls.push(options); + return baselines[vitestCalls.length - 1] ?? passing(); + }, + runScope: async (options) => { + scopeCalls.push(options); + return { results: strykerResults(options.outDir), exitCode: 0 }; + }, + buildConfig: (input) => edit({ ...buildStrykerConfig(input) }), + readJson: () => + buildStrykerConfig({ + variant: 'vitest5', + outDir: '/tmp/x', + mutate: [{ file: 'gitnexus/src/a.ts', range: null }], + concurrency: 1, + maxTestRunnerReuse: 0, + timeoutMS: 1000, + }), + readDryRunManifest: () => overrides.dryRun ?? dryRunManifest(), + now: () => (clock += 1000), + }; + return { deps, vitestCalls, scopeCalls }; +} + +const groupOptions = (workDir: string, manifest: ScenarioRunManifest) => ({ + repoRoot: '/repo', + workDir, + shard: 0, + manifest, + packageDir: path.join(workDir, 'prepared', 'gitnexus'), + scope, +}); + +describe('assertMutationConfig', () => { + const generated = (): Record => ({ + ...buildStrykerConfig({ + variant: 'vitest5', + outDir: '/tmp/x', + mutate: [{ file: 'gitnexus/src/a.ts', range: null }], + concurrency: 1, + maxTestRunnerReuse: 0, + timeoutMS: 1000, + testIds: [T1], + }), + }); + + it('accepts the scope driver generated config', () => { + expect(() => assertMutationConfig(generated())).not.toThrow(); + }); + + it.each([ + ['disableBail', { disableBail: false }, /disableBail/], + ['perTest coverage', { coverageAnalysis: 'off' }, /perTest/], + ['static mutants', { ignoreStatic: true }, /static/], + ])('rejects a config without %s', (_name, change, message) => { + expect(() => assertMutationConfig({ ...generated(), ...change })).toThrow(message); + }); + + it('rejects a value that is not a config object', () => { + expect(() => assertMutationConfig(null)).toThrow(/object/); + }); +}); + +describe('checkBaselines', () => { + const summary = (overrides: Partial = {}): BaselineSummary => ({ + wallClockMs: 1, + outcome: 'survived', + identities: [`${T1}@1:3`, `${T2}@2:3`], + failing: [], + problems: [], + ...overrides, + }); + + it('accepts two passing baselines with identical identities', () => { + expect(checkBaselines([summary(), summary()])).toBeNull(); + }); + + it('calls baselines with different identity sets unstable', () => { + const moved = summary({ identities: [`${T1}@1:3`, `${T2}@9:3`] }); + expect(checkBaselines([summary(), moved])?.reason).toBe('baseline-unstable'); + }); + + it('calls a baseline with a failing test failed', () => { + const failing = summary({ + outcome: 'killed-by-test:assertion', + failing: [{ test: T2, outcome: 'killed-by-test:assertion', message: 'expected 1' }], + }); + expect(checkBaselines([summary(), failing])).toEqual({ + reason: 'baseline-failed', + detail: `baseline 2: ${T2} killed-by-test:assertion: expected 1`, + }); + }); +}); + +describe('run manifest', () => { + it('verifies a prepared copy with the same content', () => { + const copy = tempDir(); + writeTree(copy, SOURCES); + expect(verifyPreparedCopy(manifestFor(), copy)).toEqual({ ok: true, mismatches: [] }); + }); + + it('names the files and versions a different prepared copy changes', () => { + const copy = tempDir(); + writeTree( + copy, + { ...SOURCES, 'gitnexus/src/a.ts': 'export const a = 2;\n', 'gitnexus/src/new.ts': '' }, + '5.0.6', + ); + expect(verifyPreparedCopy(manifestFor(), copy)).toEqual({ + ok: false, + mismatches: [ + 'changed: gitnexus/src/a.ts', + 'extra: gitnexus/src/new.ts', + 'vitest 5.0.5 -> 5.0.6', + ], + }); + }); + + it('lists the eligible tests and hashes neither node_modules nor files outside gitnexus/', () => { + const manifest = manifestFor(); + expect(manifest.tests).toEqual([ + { id: T1, file: 'test/unit/a.test.ts' }, + { id: T2, file: 'test/unit/a.test.ts' }, + ]); + expect(Object.keys(manifest.files)).toEqual([ + 'gitnexus/src/a.ts', + 'gitnexus/test/unit/a.test.ts', + ]); + }); +}); + +describe('sandboxIgnoredDirs', () => { + it('keeps the default ignored directories in normal mode', () => { + expect(sandboxIgnoredDirs({ kind: 'normal' }, [])).toEqual([...DEFAULT_IGNORED_DIRS]); + }); + + it('keeps the controls fixture in the sandbox and no other fixture', () => { + const dirs = sandboxIgnoredDirs( + { kind: 'controls', fixtureRoot: 'gitnexus/test/fixtures/mutation-scenario-map' }, + ['mutation-scenario-map', 'mutation-conformance', 'sample.json'], + ); + expect(dirs.filter((dir) => dir.startsWith('test/fixtures'))).toEqual([ + 'test/fixtures/mutation-conformance', + 'test/fixtures/sample.json', + ]); + }); +}); + +describe('runGroup', () => { + it('bounds both baselines\u2019 Vitest workers by the Stryker concurrency', async () => { + const workDir = tempDir(); + const { deps, vitestCalls } = fakes(); + await runGroup({ ...groupOptions(workDir, manifestFor()), stryker: { concurrency: 3 } }, deps); + expect(vitestCalls.map((options) => options.maxWorkers)).toEqual([3, 3]); + }); + + it('runs two baselines, then Stryker, and writes the observations at once', async () => { + const workDir = tempDir(); + const { deps, vitestCalls, scopeCalls } = fakes(); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + expect([result.status, result.reason, vitestCalls.length, scopeCalls.length]).toEqual([ + 'complete', + null, + 2, + 1, + ]); + expect(result.observations.map((entry) => [entry.test, entry.outcome])).toEqual([ + [T1, 'killed-by-test:assertion'], + [T2, 'killed-by-test:assertion'], + ]); + expect([ + result.runUniverse?.tests, + result.runUniverse?.mutants.map((entry) => entry.id), + ]).toEqual([[T1, T2], [M1]]); + expect(readGroupObservations(path.join(workDir, 'observations', 'g1.json'))).toEqual(result); + }); + + it('writes the observations as rows of the run universe and rejects a schema 1 file', async () => { + const workDir = tempDir(); + const { deps } = fakes(); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + const file = path.join(workDir, 'observations', 'g1.json'); + const written = JSON.parse(readFileSync(file, 'utf8')) as { observations: unknown[] }; + writeFileSync(file, JSON.stringify({ ...result, schemaVersion: 1 })); + expect(written.observations).toEqual([ + [0, 0, 0, 'killed-by-test:assertion', 'row', true, false, []], + [1, 0, 0, 'killed-by-test:assertion', 'row', true, false, []], + ]); + expect(() => readGroupObservations(file)).toThrow(/has schema 1, not 2: .*re-run its shard/); + }); + + it('passes testFiles, testIds and the whole-file mutate list to the scope driver', async () => { + const workDir = tempDir(); + const { deps, scopeCalls } = fakes(); + await runGroup(groupOptions(workDir, manifestFor()), deps); + expect(JSON.parse(readFileSync(scopeCalls[0].scopeFile, 'utf8'))).toEqual({ + testFiles: ['test/unit/a.test.ts'], + mutate: ['gitnexus/src/a.ts'], + testIds: [T1, T2], + }); + }); + + it('fails a group whose generated config lacks disableBail before any mutant runs', async () => { + const workDir = tempDir(); + const { deps, scopeCalls } = fakes({ config: (config) => ({ ...config, disableBail: false }) }); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + expect([result.status, result.reason, scopeCalls.length]).toEqual([ + 'inconclusive', + 'config-invalid', + 0, + ]); + }); + + it('makes a group with unstable baseline identities Inconclusive without observations', async () => { + const workDir = tempDir(); + const moved = passing(); + moved.tests[1] = { ...moved.tests[1], location: { line: 9, column: 3 } }; + const { deps, scopeCalls } = fakes({ baselines: [passing(), moved] }); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + expect([result.status, result.reason, result.observations, scopeCalls.length]).toEqual([ + 'inconclusive', + 'baseline-unstable', + [], + 0, + ]); + expect(readGroupObservations(path.join(workDir, 'observations', 'g1.json')).reason).toBe( + 'baseline-unstable', + ); + }); + + it('makes a group whose second baseline has a failing test Inconclusive', async () => { + const workDir = tempDir(); + const failing = passing({ status: 'killed', outcome: 'killed-by-test:assertion' }); + failing.tests[0] = { + ...failing.tests[0], + state: 'failed', + outcome: 'killed-by-test:assertion', + }; + const { deps } = fakes({ baselines: [passing(), failing] }); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + expect([result.status, result.reason]).toEqual(['inconclusive', 'baseline-failed']); + }); + + it("makes a group whose Stryker dry run moved a test's identity Inconclusive", async () => { + const workDir = tempDir(); + const { deps } = fakes({ dryRun: dryRunManifest(7) }); + const result = await runGroup(groupOptions(workDir, manifestFor()), deps); + expect([result.status, result.reason, result.observations]).toEqual([ + 'inconclusive', + 'dry-run-unstable', + [], + ]); + }); + + it('refuses a group test that the run manifest does not list', async () => { + const workDir = tempDir(); + const { deps, vitestCalls } = fakes(); + const manifest = { ...manifestFor(), tests: [{ id: T1, file: 'test/unit/a.test.ts' }] }; + const result = await runGroup(groupOptions(workDir, manifest), deps); + expect([result.reason, vitestCalls.length]).toEqual(['manifest-mismatch', 0]); + }); +}); + +describe('runShard', () => { + const plan = (estimateMs: number): ShardPlan => ({ + schemaVersion: 1, + budgetMs: 300 * MINUTE, + costModel: { perRunMs: 0, perByteTestMs: 0, mutantsPerByte: 0, freshProcessMs: 0 }, + groups: [{ ...scope, mutatedBytes: 20, estimateMs }], + shards: [{ index: 0, groups: ['g1'], estimateMs }], + overBudget: [], + }); + + function shardDeps( + files: Record, + clock: () => number, + ): { + deps: ShardRunDeps; + vitestCalls: VitestRunOptions[]; + } { + const group = fakes(); + return { + deps: { + ...group.deps, + now: clock, + prepare: (options) => { + writeTree(options.outDir, files); + return { + version: 1, + repoRoot: options.repoRoot, + outDir: options.outDir, + packageDir: 'gitnexus', + relocatedShared: 'gitnexus/relocated/gitnexus-shared', + pathMap: [], + copiedFiles: Object.keys(files).length, + blanked: [], + failed: [], + }; + }, + }, + vitestCalls: group.vitestCalls, + }; + } + + it('marks every group of a shard with a foreign prepared copy manifest-mismatch', async () => { + const workDir = tempDir(); + const changed = { ...SOURCES, 'gitnexus/src/a.ts': 'export const a = 3;\n' }; + const { deps, vitestCalls } = shardDeps(changed, () => 0); + const report = await runShard( + { repoRoot: '/repo', workDir, shard: 0, plan: plan(MINUTE), manifest: manifestFor() }, + deps, + ); + expect([report.manifestCheck, vitestCalls.length]).toEqual([ + { ok: false, mismatches: ['changed: gitnexus/src/a.ts'] }, + 0, + ]); + const written = readGroupObservations(path.join(workDir, 'observations', 'g1.json')); + expect([written.status, written.reason]).toEqual(['inconclusive', 'manifest-mismatch']); + }); + + it('does not start a group estimated at 30 minutes with 20 minutes left', async () => { + const workDir = tempDir(); + const times = [0, 280 * MINUTE, 281 * MINUTE]; + const { deps, vitestCalls } = shardDeps(SOURCES, () => times.shift() ?? 300 * MINUTE); + const report = await runShard( + { repoRoot: '/repo', workDir, shard: 0, plan: plan(30 * MINUTE), manifest: manifestFor() }, + deps, + ); + expect([report.notStarted.map((entry) => entry.group), report.ran, vitestCalls.length]).toEqual( + [['g1'], [], 0], + ); + expect(() => readFileSync(path.join(workDir, 'observations', 'g1.json'))).toThrow(/ENOENT/); + }); + + it('runs a group that fits and records it in the shard report', async () => { + const workDir = tempDir(); + const { deps } = shardDeps(SOURCES, () => 0); + const report = await runShard( + { repoRoot: '/repo', workDir, shard: 0, plan: plan(MINUTE), manifest: manifestFor() }, + deps, + ); + expect(report.ran.map((entry) => [entry.group, entry.status])).toEqual([['g1', 'complete']]); + expect(JSON.parse(readFileSync(path.join(workDir, 'shard-0.json'), 'utf8'))).toEqual(report); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/sinks.test.ts b/gitnexus/test/unit/scenario-map/sinks.test.ts new file mode 100644 index 000000000..08a8d0f7d --- /dev/null +++ b/gitnexus/test/unit/scenario-map/sinks.test.ts @@ -0,0 +1,343 @@ +/** + * Locks the value-flow sink classification (U10, R9, KTD11 rules 2–4, design §5.3a step 3) + * without a PDG or a checker: the parameter `v` of a function `f` in a small production module is + * climbed from each reference to its consumers, with a fake callee resolver keyed by the callee's + * source text. Covers the design's classes and order (repo code, supported table read, + * pass-through, opaque, unknown), the statement rule (`if (path.extname(v) === '.ts')` is vetoed + * although `===` is repo code), regex uses, the storage exemption with its read-back, derived + * bindings (KTD11 rule 3) and repo calls outside the pair's mutated files. + */ +import { parse } from '@babel/parser'; +import traverse, { type NodePath } from '@babel/traverse'; +import type * as t from '@babel/types'; +import { describe, expect, it } from 'vitest'; +import { scanSupportedTables } from '../../../tools/scenario-map/mutation/row-deletion.js'; +import { + statementClass, + verdictOf, + walkUp, + type CalleeInfo, + type ClassifyContext, + type Sink, + type TableSupport, + type WalkUpResult, +} from '../../../tools/scenario-map/value-flow/sinks.js'; + +const FILE = 'gitnexus/src/m.ts'; +const OTHER = 'gitnexus/src/other.ts'; +const OUTSIDE = 'gitnexus/tools/helper.ts'; + +const library = (name: string, container: string | null): CalleeInfo => ({ + origin: 'library', + name, + container, + file: null, + declarationEnd: null, + isClass: false, + reason: '', +}); +const repo = (name: string, file: string): CalleeInfo => ({ + origin: 'repo', + name, + container: null, + file, + declarationEnd: 0, + isClass: false, + reason: '', +}); + +/** Callee source text → what the checker would say. */ +const CALLEES: Record = { + 'Math.round': library('Math.round', 'Math'), + 'path.extname': library('path.extname', null), + RegExp: library('RegExp', null), + 'v.match': library('String.match', 'String'), + 'v.split': library('String.split', 'String'), + 'RESERVED.has': library('ReadonlySet.has', 'ReadonlySet'), + 'WRITTEN.has': library('Set.has', 'Set'), + 'seen.has': library('Set.has', 'Set'), + 'm.set': library('Map.set', 'Map'), + 'm.get': library('Map.get', 'Map'), + 'out.push': library('Array.push', 'Array'), + 'this.items.push': library('Array.push', 'Array'), + '(v * 10).toFixed': library('Number.toFixed', 'Number'), + add: repo('add', OTHER), + helper: repo('helper', OUTSIDE), +}; + +interface Classified { + results: WalkUpResult[]; + ctx: ClassifyContext; +} + +/** Walks every reference of `name` in function `f` of `source`. */ +function classify(source: string, name = 'v'): Classified { + const ast = parse(source, { sourceType: 'module', plugins: ['typescript'] }); + let fn: NodePath | undefined; + traverse(ast, { + Function(path) { + const id = path.isFunctionDeclaration() ? path.node.id?.name : undefined; + if (fn === undefined && id === 'f') fn = path; + }, + }); + const found = fn as NodePath; + const tables = new Map(); + for (const [binding, constructs] of scanSupportedTables(FILE, source) ?? new Map()) { + tables.set(binding, { supported: constructs.length === 0, constructs }); + } + const ctx: ClassifyContext = { + file: FILE, + fn: found, + resolve: (site) => { + const callee = site.node.callee; + const text = source.slice(callee.start ?? 0, callee.end ?? 0); + return ( + CALLEES[text] ?? { + ...library(text, null), + origin: 'unresolved', + reason: 'not in the fake checker', + } + ); + }, + inScope: (file) => file === FILE || file === OTHER, + tables, + }; + const binding = found.scope.getBinding(name); + const references = binding?.referencePaths ?? []; + return { results: references.map((reference) => walkUp(reference, ctx)), ctx }; +} + +const sinksOf = (results: readonly WalkUpResult[]): Sink[] => + results.flatMap((result) => result.sinks); +const render = (sinks: readonly Sink[]): string[] => + sinks.map( + (sink) => `${sink.class}: ${sink.what}${sink.reason === '' ? '' : ` (${sink.reason})`}`, + ); + +describe('walkUp: sink classes', () => { + it.each([ + { + title: 'Math.round is opaque (design §8 adversarial)', + source: 'export function f(v: number) { return Math.round(v); }', + verdict: 'veto', + sinks: ['opaque: Math.round (library)'], + }, + { + title: 'repo arithmetic and the return are clean', + source: 'export function f(v: number, w: number) { return v + w; }', + verdict: 'clean', + sinks: ['repo-code: arithmetic +', 'pass-through: return'], + }, + { + title: 'a comparison ends the climb', + source: 'export function f(v: number) { if (v < 0) { return 0; } return 1; }', + verdict: 'clean', + sinks: ['repo-code: comparison <'], + }, + { + title: 'a template literal is repo code', + source: 'export function f(v: string) { return `${v}-1`; }', + verdict: 'clean', + sinks: ['repo-code: template', 'pass-through: return'], + }, + { + title: 'new RegExp(v) is a regex use', + source: 'export function f(v: string) { return new RegExp(v); }', + verdict: 'veto', + sinks: ['opaque: RegExp (regex)'], + }, + { + title: 'v.match(...) is a regex use', + source: 'export function f(v: string) { return v.match(/x/) !== null; }', + verdict: 'veto', + sinks: ['opaque: String.match (regex)'], + }, + { + title: 'split with a regex argument is a regex use', + source: 'export function f(v: string) { return v.split(/,/); }', + verdict: 'veto', + sinks: ['opaque: String.split (regex)'], + }, + { + title: 'a derived value reaching toFixed is opaque', + source: 'export function f(v: number) { return (v * 10).toFixed(1); }', + verdict: 'veto', + sinks: ['repo-code: arithmetic *', 'opaque: Number.toFixed (library)'], + }, + { + title: 'Set.has on a supported module table is a table read', + source: + "const RESERVED = new Set(['class', 'enum']);\nexport function f(v: string) { return RESERVED.has(v); }", + verdict: 'clean', + sinks: ['table-read: ReadonlySet.has'], + }, + { + title: 'a key read of a supported object table is a table read', + source: 'const T = { a: 1, b: 2, c: 3 };\nexport function f(v: string) { return T[v]; }', + verdict: 'clean', + sinks: ['table-read: key read'], + }, + { + title: 'Set.has on a table the module writes is unknown', + source: + "const WRITTEN = new Set(['a', 'b']);\nWRITTEN.add('c');\nexport function f(v: string) { return WRITTEN.has(v); }", + verdict: 'unknown', + sinks: ['unknown: Set.has (unsupported table: write)'], + }, + { + title: 'a key read of a parameter object is unknown', + source: 'export function f(v: string, o: Record) { return o[v]; }', + verdict: 'unknown', + sinks: ['unknown: key read (not a supported table)'], + }, + { + title: 'a repo call in the mutated files is repo code', + source: 'export function f(v: number) { add(v, 1); }', + verdict: 'clean', + sinks: ['repo-code: call add'], + }, + { + title: 'a repo call outside the mutated files is unknown', + source: 'export function f(v: number) { helper(v); }', + verdict: 'unknown', + sinks: ['unknown: helper (repo code outside the mutated files: gitnexus/tools/helper.ts)'], + }, + { + title: 'an unresolved callee is unknown', + source: 'export function f(v: number, g: (x: number) => void) { g(v); }', + verdict: 'unknown', + sinks: ['unknown: g (unresolved callee: not in the fake checker)'], + }, + { + title: 'a store into a property is unknown', + source: 'export function f(v: number, o: { x: number }) { o.x = v; }', + verdict: 'unknown', + sinks: ['unknown: property write (stored into a property)'], + }, + { + title: 'a value captured by a closure is unknown', + source: 'export function f(v: number, xs: number[]) { xs.forEach((x) => x + v); }', + verdict: 'unknown', + sinks: ['repo-code: arithmetic +', 'unknown: function (captured or defaulted by a function)'], + }, + { + title: 'a push into a container of the caller is unknown', + source: 'export function f(v: number, out: number[]) { out.push(v); }', + verdict: 'unknown', + sinks: [ + 'pass-through: Array.push', + 'unknown: Array.push (stored into a container of the caller)', + ], + }, + { + title: 'a push into a property container is unknown', + source: 'export function f(v: number) { this.items.push(v); }', + verdict: 'unknown', + sinks: ['unknown: Array.push (stored into an untracked container)'], + }, + ])('$title', ({ source, verdict, sinks }) => { + const { results } = classify(source); + expect({ verdict: verdictOf(sinksOf(results)), sinks: render(sinksOf(results)) }).toEqual({ + verdict, + sinks, + }); + }); +}); + +describe('walkUp: the statement rule', () => { + it('vetoes `if (path.extname(v) === ".ts")` on the opaque use although === is repo code', () => { + const { results } = classify( + "export function f(v: string) { if (path.extname(v) === '.ts') { return 1; } return 0; }", + ); + const sinks = sinksOf(results); + expect({ statement: statementClass(sinks), sinks: render(sinks) }).toEqual({ + statement: 'opaque', + sinks: ['opaque: path.extname (library)'], + }); + }); + + it.each([ + { + title: 'an opaque use outweighs a clean one', + classes: ['repo-code', 'opaque'], + worst: 'opaque', + }, + { + title: 'an unknown use outweighs a clean one', + classes: ['table-read', 'unknown'], + worst: 'unknown', + }, + { + title: 'an opaque use outweighs an unknown one', + classes: ['unknown', 'opaque'], + worst: 'opaque', + }, + { title: 'clean uses stay clean', classes: ['repo-code', 'pass-through'], worst: 'repo-code' }, + ] as const)('$title', ({ classes, worst }) => { + const sinks: Sink[] = classes.map((cls) => ({ + class: cls, + what: cls, + reason: '', + location: { file: FILE, line: 1, column: 0 }, + })); + expect(statementClass(sinks)).toBe(worst); + }); +}); + +describe('walkUp: derived bindings and storage', () => { + it('walks `const { ratio } = opts` under the name ratio (KTD11 rule 3)', () => { + const source = + 'export function f(opts: { ratio: number }) {\n const { ratio } = opts;\n return Math.round(ratio);\n}'; + const { results, ctx } = classify(source, 'opts'); + const derived = results.flatMap((result) => result.derived); + const followed = derived.flatMap((entry) => + entry.binding.referencePaths.map((reference) => walkUp(reference, ctx, entry.kind)), + ); + expect({ + derived: derived.map((entry) => [entry.name, entry.kind, entry.lines.start]), + sinks: render(sinksOf(followed)), + verdict: verdictOf(sinksOf([...results, ...followed])), + }).toEqual({ + derived: [['ratio', 'value', 2]], + sinks: ['opaque: Math.round (library)'], + verdict: 'veto', + }); + }); + + it('classifies Map.set then Map.get into a comparison as storage and table read', () => { + const source = [ + 'export function f(v: number, k: string) {', + ' const m = new Map();', + ' m.set(k, v);', + ' if (m.get(k) > 3) { return 1; }', + ' return 0;', + '}', + ].join('\n'); + const { results, ctx } = classify(source); + const derived = results.flatMap((result) => result.derived); + const readBack = derived.flatMap((entry) => + entry.binding.referencePaths + .filter((reference) => (reference.node.loc?.start.line ?? 0) > 3) + .map((reference) => walkUp(reference, ctx, entry.kind)), + ); + const sinks = sinksOf([...results, ...readBack]); + expect({ + derived: derived.map((entry) => [entry.name, entry.kind]), + sinks: render(sinks), + verdict: verdictOf(sinks), + }).toEqual({ + derived: [['m', 'container']], + sinks: ['pass-through: Map.set', 'table-read: Map.get read-back', 'repo-code: comparison >'], + verdict: 'clean', + }); + }); + + it('records the repo call the walk descends into, with the argument position', () => { + const { results } = classify('export function f(v: number) { return add(1, v); }'); + expect( + results.flatMap((result) => + result.calls.map((call) => [call.callee.name, call.argIndex, call.call.node.type]), + ), + ).toEqual([['add', 1, 'CallExpression']]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/slices.test.ts b/gitnexus/test/unit/scenario-map/slices.test.ts new file mode 100644 index 000000000..828267da6 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/slices.test.ts @@ -0,0 +1,228 @@ +/** + * Locks Layer 1 in slices (tools/scenario-map/path-coverage/slices.ts): the deterministic, + * complete split of the test files, the merge of the slices' work files into one (block table + * united, indices remapped, files and tests in file order, a missing slice's files unrecorded), + * the merge's refusals, and the slice files read from a directory. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + WORK_FILE_SCHEMA_VERSION, + unknownProvenance, + type CoverageTest, + type CoverageWorkFile, +} from '../../../tools/scenario-map/path-coverage/run-coverage.js'; +import { + COVERAGE_SLICE_SCHEMA_VERSION, + coverageSliceName, + coverageSlices, + mergeCoverageSlices, + readCoverageSlices, + type CoverageSliceFile, +} from '../../../tools/scenario-map/path-coverage/slices.js'; + +const PACKAGE_DIR = fileURLToPath(new URL('../../../', import.meta.url)); + +// Sequential files with measured CI seconds in test/helpers/shard-balance.ts: 488, 173 and 147. +const ORCHESTRATION = 'test/unit/incremental-orchestration.test.ts'; +const RENAME_TS = 'test/integration/rename-provider-typescript.test.ts'; +const LIFECYCLE = 'test/integration/parse-cache-identity-lifecycle.test.ts'; +const SEQUENTIAL = new Set([ORCHESTRATION, RENAME_TS, LIFECYCLE]); +const FILES = [ + 'test/unit/scenario-map/slices.test.ts', + ORCHESTRATION, + RENAME_TS, + 'test/unit/scenario-map/partitions.test.ts', + LIFECYCLE, +]; + +describe('coverageSlices', () => { + const slices = coverageSlices(FILES, 2, PACKAGE_DIR, (file) => SEQUENTIAL.has(file)); + + it('assigns every file to exactly one slice, each slice sorted', () => { + expect([slices.flat().sort(), slices.map((slice) => [...slice].sort()), slices.length]).toEqual( + [[...FILES].sort(), slices, 2], + ); + }); + + it('keeps the heaviest sequential file apart from the next two', () => { + const slowest = slices.findIndex((slice) => slice.includes(ORCHESTRATION)); + expect([ + slices[slowest].filter((file) => SEQUENTIAL.has(file)), + slices[1 - slowest].filter((file) => SEQUENTIAL.has(file)), + ]).toEqual([[ORCHESTRATION], [LIFECYCLE, RENAME_TS]]); + }); + + it('splits the same way every time', () => { + expect(coverageSlices(FILES, 2, PACKAGE_DIR, (file) => SEQUENTIAL.has(file))).toEqual(slices); + }); +}); + +const test = (file: string, name: string, blocks: number[]): CoverageTest => ({ + id: `${file}#${name}`, + taskId: `${file}-${name}`, + file, + fullName: name, + location: { line: 1, column: 1 }, + state: 'passed', + concurrent: false, + attempts: 2, + blocks, + suiteBlocks: blocks.slice(0, 1), + provenance: unknownProvenance(), +}); + +const work = ( + blocks: string[], + files: string[], + tests: CoverageTest[], + overrides: Partial = {}, +): CoverageWorkFile => ({ + schemaVersion: WORK_FILE_SCHEMA_VERSION, + mode: 'normal', + extraRoots: [], + repeats: 1, + blocks, + files: files.map((file) => ({ + file, + recorded: true, + errors: [], + unattributed: unknownProvenance(), + })), + tests, + unhandledErrors: [], + ...overrides, +}); + +const A = 'test/unit/a.test.ts'; +const B = 'test/unit/b.test.ts'; +const C = 'test/unit/c.test.ts'; +const D = 'test/unit/d.test.ts'; +const ASSIGNMENT = [[B], [A, C], [D]]; + +const slice = (index: number, body: CoverageWorkFile): CoverageSliceFile => ({ + schemaVersion: COVERAGE_SLICE_SCHEMA_VERSION, + index, + of: 3, + layer1: [A, B, C, D], + mutation: 'none', + changed: null, + revision: 'abc', + work: body, +}); + +const SLICE_0 = slice( + 0, + work(['src/x.ts#f@1', 'src/z.ts#h@3'], [B], [test(B, 'one', [0, 1])], { + unhandledErrors: ['boom'], + }), +); +const SLICE_1 = slice( + 1, + work(['src/y.ts#g@2', 'src/z.ts#h@3'], [A, C], [test(A, 'one', [1]), test(C, 'two', [0, 1])]), +); + +describe('mergeCoverageSlices', () => { + const merged = mergeCoverageSlices(ASSIGNMENT, [SLICE_1, SLICE_0]); + const ids = (entry: CoverageTest): string[] => entry.blocks.map((index) => merged.blocks[index]); + + it('unites the block tables and remaps every test to the same block ids', () => { + expect([ + merged.blocks, + merged.tests.map((entry) => [entry.id, ids(entry)]), + merged.tests.map((entry) => entry.suiteBlocks.map((index) => merged.blocks[index])), + ]).toEqual([ + ['src/x.ts#f@1', 'src/y.ts#g@2', 'src/z.ts#h@3'], + [ + [`${A}#one`, ['src/z.ts#h@3']], + [`${B}#one`, ['src/x.ts#f@1', 'src/z.ts#h@3']], + [`${C}#two`, ['src/y.ts#g@2', 'src/z.ts#h@3']], + ], + [['src/z.ts#h@3'], ['src/x.ts#f@1'], ['src/y.ts#g@2']], + ]); + }); + + it('lists the files of a slice that did not report as unrecorded, with an error', () => { + expect([ + merged.files.map((entry) => [entry.file, entry.recorded, entry.errors]), + merged.unhandledErrors, + ]).toEqual([ + [ + [A, true, []], + [B, true, []], + [C, true, []], + [D, false, ['coverage slice 2 of 3 did not report']], + ], + ['boom'], + ]); + }); + + it.each([ + { name: 'no slice', reported: [], message: /no slice reported/ }, + { name: 'a slice twice', reported: [SLICE_0, SLICE_0], message: /reported twice/ }, + { + name: 'a slice that ran other files', + reported: [{ ...SLICE_0, work: work([], [A], []) }], + message: /ran other files/, + }, + { + name: 'another number of slices', + reported: [{ ...SLICE_0, of: 2 }], + message: /slice 0 of 2, expected 3/, + }, + { + name: 'slices of different repeats', + reported: [SLICE_0, { ...SLICE_1, work: { ...SLICE_1.work, repeats: 0 } }], + message: /is not the run of slice 0/, + }, + { + name: 'slices of different mutation scopes', + reported: [SLICE_0, { ...SLICE_1, mutation: 'all' as const }], + message: /is not the run of slice 0/, + }, + { + name: 'slices of different diff scopes', + reported: [SLICE_0, { ...SLICE_1, changed: [A] }], + message: /is not the run of slice 0/, + }, + { + name: 'slices of different revisions', + reported: [SLICE_0, { ...SLICE_1, revision: 'def' }], + message: /is not the run of slice 0/, + }, + ])('refuses $name', ({ reported, message }) => { + expect(() => mergeCoverageSlices(ASSIGNMENT, reported)).toThrow(message); + }); +}); + +describe('readCoverageSlices', () => { + const temps: string[] = []; + afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + const dirWith = (entries: Record): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-map-slices-')); + temps.push(dir); + for (const [name, value] of Object.entries(entries)) { + writeFileSync(path.join(dir, name), JSON.stringify(value)); + } + return dir; + }; + + it('reads the slice files in name order and ignores other files', () => { + const dir = dirWith({ + [coverageSliceName(1)]: SLICE_1, + [coverageSliceName(0)]: SLICE_0, + 'plan.json': {}, + }); + expect(readCoverageSlices(dir).map((entry) => entry.index)).toEqual([0, 1]); + }); + + it('throws on a malformed slice file', () => { + const dir = dirWith({ [coverageSliceName(0)]: { ...SLICE_0, index: 3 } }); + expect(() => readCoverageSlices(dir)).toThrow(/is not a coverage slice file/); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/tables.test.ts b/gitnexus/test/unit/scenario-map/tables.test.ts new file mode 100644 index 000000000..af2ed595f --- /dev/null +++ b/gitnexus/test/unit/scenario-map/tables.test.ts @@ -0,0 +1,217 @@ +/** + * Locks the production tables and numeric constants of the input facet (U6, KTD6, design §5.3 + * rule 3, D3): string arrays, Set/Map entries, object-literal keys, switch cases and regex + * alternations are tables; numeric literals and Babel-folded arithmetic are constants for the ±1 + * rule, except Set/Map entries and object keys; the best-fit search goes tier by tier and never + * pools tables across files. + */ +import { describe, expect, it } from 'vitest'; +import { + bestFit, + factsOf, + nearConstant, + numberKey, + regexWords, + stringKey, + TableIndex, + type ProductionTable, +} from '../../../tools/scenario-map/facet/tables.js'; + +const FILE = 'gitnexus/src/m.ts'; + +/** The tables of one source as sorted value lists, in file order. */ +const tablesOf = (source: string): string[][] => + factsOf(FILE, source).tables.map((table) => [...table.values].sort()); + +const constantsOf = (source: string): number[] => + [...factsOf(FILE, source).constants].sort((a, b) => a - b); + +describe('factsOf: tables', () => { + it.each([ + [ + 'a string array', + `export const KINDS = ['Function', 'Class', 'Method'];`, + [['s:Class', 's:Function', 's:Method']], + ], + [ + 'the entries of a Set, numbers included', + `const OK = new Set([200, 204]);`, + [['n:200', 'n:204']], + ], + [ + 'the one-character entries of a Set', + `const UNITS = new Set(['s', 'm', 'h']);`, + [['s:h', 's:m', 's:s']], + ], + ['the keys of a Map', `const M = new Map([['a', 1], ['b', 2]]);`, [['s:a', 's:b']]], + [ + 'the keys of an object literal with three properties', + `const UNIT_FACTORS = { ms: 1, s: 1000, min: 60000 };`, + [['s:min', 's:ms', 's:s']], + ], + [ + 'the string cases of a switch', + `export function f(x: string) { switch (x) { case 'get': return 1; case 'set': return 2; } return 0; }`, + [['s:get', 's:set']], + ], + [ + 'the alternatives of a regex literal', + `export const isAccessor = (n: string) => /^(?:get|set)[A-Z]/.test(n);`, + [['s:get', 's:set']], + ], + [ + 'the alternatives of a RegExp built from a string', + `const R = new RegExp('\\\\b(STRING|UNSTRING)\\\\b');`, + [['s:STRING', 's:UNSTRING']], + ], + ])('finds %s', (_label, source, expected) => { + expect(tablesOf(source)).toEqual(expected); + }); + + it.each([ + ['an array of one string', `const A = ['only'];`], + ['an array with fewer than 80% strings', `const A = ['a1', 'b1', x, y, z];`], + ['an object literal with two properties', `const O = { a: 1, b: 2 };`], + ['an array of one-character strings', `const A = ['a', 'b'];`], + ])('finds no table in %s', (_label, source) => { + expect(tablesOf(source)).toEqual([]); + }); + + it('numbers its tables per file, in source order', () => { + const facts = factsOf(FILE, `const A = ['xa', 'xb']; const B = new Set(['ya', 'yb']);`); + expect(facts.tables.map((table) => table.id)).toEqual([`${FILE}#0`, `${FILE}#1`]); + }); + + it('groups the array values of a property key into a column', () => { + const facts = factsOf( + FILE, + `export const P = { exts: ['.rs', '.go'], aliases: new Set(['ab', 'cd']) };`, + ); + expect([...facts.columns].map(([key, values]) => [key, [...values].sort()])).toEqual([ + ['exts', ['.go', '.rs']], + ['aliases', ['ab', 'cd']], + ]); + }); +}); + +describe('factsOf: numeric constants', () => { + it.each([ + ['a named constant', `export const LIMIT = 100;`, [100]], + ['a negated literal', `export function f() { return -1; }`, [-1]], + ['a folded initialiser', `export const LIMIT = 2 * 50;`, [2, 50, 100]], + ['arithmetic over a const', `const A = 100; export const B = A + 1;`, [1, 100, 101]], + ['an object value', `const F = { ms: 1, s: 1000, min: 60000 };`, [1, 1000, 60000]], + ['no Set entries (D3)', `const OK = new Set([200, 204]);`, []], + ['no Map entries (D3)', `const M = new Map([[3, 'three'], [4, 'four']]);`, []], + ['no numeric object keys', `const O = { 7: 'a' };`, []], + ['no literal types', `type T = 42; export const x: T = 42 as T;`, [42]], + ])('records %s', (_label, source, expected) => { + expect(constantsOf(source)).toEqual(expected); + }); + + it.each([ + [99, true], + [100, true], + [101, true], + [102, false], + [150, false], + ])('treats %d as near the constant 100: %s', (value, expected) => { + expect(nearConstant(new Set([100]), value)).toBe(expected); + }); +}); + +describe('regexWords', () => { + it.each([ + ['^(?:get|set)[A-Z]', ['get', 'set']], + ['\\b(PERFORM|THRU|THROUGH)\\b', ['PERFORM', 'THRU', 'THROUGH']], + ['^(a|bc)$', ['bc']], + ['^plain$', []], + ])('reads the alternatives of /%s/', (pattern, expected) => { + expect(regexWords(pattern)).toEqual(expected); + }); +}); + +describe('bestFit', () => { + const table = (id: string, ...values: string[]): ProductionTable => ({ + id, + values: new Set(values.map(stringKey)), + }); + + it.each([ + [ + 'the table holding more of the test literals', + [table('small', 'a', 'b'), table('big', 'a', 'c', 'd', 'e')], + ['a', 'b'], + 'small', + ], + [ + 'the larger table on a tie', + [table('small', 'a', 'b'), table('big', 'a', 'c', 'd')], + ['a'], + 'big', + ], + [ + 'the first table on a full tie', + [table('one', 'a', 'b'), table('two', 'a', 'c')], + ['a'], + 'one', + ], + ])('picks %s', (_label, tables, literals, expected) => { + expect(bestFit(tables, stringKey('a'), literals.map(stringKey))).toBe(expected); + }); + + it('finds nothing when no table holds the value', () => { + expect(bestFit([table('t', 'x', 'y')], stringKey('a'), [])).toBeNull(); + }); + + it('keeps strings and numbers apart', () => { + const numbers: ProductionTable = { id: 'n', values: new Set([numberKey(1), numberKey(2)]) }; + expect(bestFit([numbers], stringKey('1'), [])).toBeNull(); + }); +}); + +describe('TableIndex', () => { + const SOURCES: Record = { + 'gitnexus/src/executed.ts': `export const A = ['Function', 'Class'];`, + 'gitnexus/src/near.ts': `export const B = ['Function', 'Class', 'Method'];`, + 'gitnexus/src/far.ts': `export const C = ['Interface', 'Enum'];`, + 'gitnexus/src/registry.ts': `export const R = [{ exts: ['.rs', '.go'] }, { exts: ['.py'] }];`, + 'gitnexus/src/limits.ts': `export const LIMIT = 2 * 50;`, + }; + const index = (): TableIndex => + new TableIndex({ + read: (file) => SOURCES[file] ?? null, + columnFiles: Object.keys(SOURCES), + }); + + it.each([ + ['an executed file first', 'Function', 'gitnexus/src/executed.ts#0'], + ['the import closure next', 'Method', 'gitnexus/src/near.ts#0'], + ['the property columns last', '.py', 'prop:exts'], + ])('searches %s', (_label, value, expected) => { + const search = { executed: ['gitnexus/src/executed.ts'], closure: ['gitnexus/src/near.ts'] }; + expect(index().tableOf(search, stringKey(value), [stringKey(value)])).toBe(expected); + }); + + it('never pools a table of a file outside the search', () => { + const search = { executed: ['gitnexus/src/executed.ts'], closure: ['gitnexus/src/near.ts'] }; + expect(index().tableOf(search, stringKey('Interface'), [])).toBeNull(); + }); + + it('merges the constants of the given files, folded initialisers included', () => { + expect([...index().constantsOf(['gitnexus/src/limits.ts'])].sort((a, b) => a - b)).toEqual([ + 2, 50, 100, + ]); + }); + + it('reads the string values of the given files', () => { + expect([...index().stringValues(['gitnexus/src/far.ts'])].sort()).toEqual([ + 'Enum', + 'Interface', + ]); + }); + + it('treats an unreadable file as empty', () => { + expect(index().factsOf('gitnexus/src/missing.ts').tables).toEqual([]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/trace.test.ts b/gitnexus/test/unit/scenario-map/trace.test.ts new file mode 100644 index 000000000..d76e245fa --- /dev/null +++ b/gitnexus/test/unit/scenario-map/trace.test.ts @@ -0,0 +1,1104 @@ +/** + * Locks the value-flow trace run (U11, R9, KTD11 "Trace", design §5.3a step 4) without spawning + * Vitest: the documented wrap list and the mapping of U10's opaque-sink callees (storage reads + * stay unwrapped); the safe, bounded serialisation of receivers and arguments; the call-site rule + * (production frames only, engine-internal calls skipped); the recorder on in-process calls + * through a sandbox object, never the real globals (per-test calls, receivers, arguments and + * counts at the production call site, pass-through behaviour); the pure pair comparison + * (`receiver-differs`, `args-differ`, `asymmetric-sites`, `asymmetric-counts`, and the + * inconclusive reasons); the run-level validity that fails every pair closed (`trace-run-invalid`: + * a baseline or traced run that did not end `survived`, an unhandled error, an outside-test + * failure, an unwrapped entry or a recorder error); and the run's orchestration through an + * injected `runVitest`. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PrepareOptions, PrepareReport } from '../../../tools/mutation/prepare/prepare.js'; +import type { OutcomeClass } from '../../../tools/mutation/runner/classify-outcome.js'; +import { scriptPath } from '../../../tools/scenario-map/path-coverage/records.js'; +import { buildRunManifest } from '../../../tools/scenario-map/mutation/run-group.js'; +import type { + VitestRunOptions, + VitestRunResult, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; +import { + BASE_WRAP_LIST, + isStorageRead, + wrapListOf, + wrapTargetOfCallee, + type WrapEntry, +} from '../../../tools/scenario-map/value-flow/builtins.js'; +import { + LONG_STRING, + RAW_TRACE_SCHEMA_VERSION, + SHORT_VALUE, + TRACE_KEY, + TraceRecorder, + VALUE_NODE_BUDGET, + callSiteOf, + productionResolver, + serializeValue, + valueKey, + writeRawTrace, + type CurrentTest, + type RawTestTrace, + type RawTraceCall, + type RawTraceRecord, + type TraceContext, + type TraceOverflow, +} from '../../../tools/scenario-map/value-flow/trace-records.js'; +import { + TRACE_SETUP_FILE, + compareTracePair, + runTrace, + traceLayout, + traceRunIssues, + tracesOf, + type TestTrace, + type TraceRunDeps, +} from '../../../tools/scenario-map/value-flow/trace.js'; + +const SITE = 'gitnexus/src/x.ts:12:10'; +const OTHER_SITE = 'gitnexus/src/x.ts:20:5'; +const PACKAGE_FILE = 'test/unit/a.test.ts'; +const A = `${PACKAGE_FILE}#d > a`; +const B = `${PACKAGE_FILE}#d > b`; + +// ── Wrap list ────────────────────────────────────────────────────────────── + +describe('wrap list', () => { + it('wraps each documented group, prototype methods included', () => { + const apis = new Set(BASE_WRAP_LIST.map((entry) => entry.api)); + expect( + [ + 'Math.round', + 'Math.max', + 'String.prototype.toUpperCase', + 'String.prototype.localeCompare', + 'String.prototype.normalize', + 'String.prototype.padStart', + 'String.prototype.replaceAll', + 'String.prototype.codePointAt', + 'String.prototype.repeat', + 'RegExp.prototype.test', + 'RegExp.prototype.exec', + 'String.prototype.match', + 'String.prototype.matchAll', + 'String.prototype.search', + 'parseInt', + 'parseFloat', + 'Number', + 'Number.prototype.toFixed', + 'Number.prototype.toPrecision', + 'Number.prototype.toString', + 'JSON.parse', + 'JSON.stringify', + 'Intl.NumberFormat', + 'Intl.Collator.prototype.compare', + 'Intl.DateTimeFormat.prototype.format', + 'Date', + 'Date.UTC', + 'Date.prototype.getUTCDate', + 'Date.prototype.setMonth', + 'Array.prototype.sort', + ].filter((api) => !apis.has(api)), + ).toEqual([]); + }); + + it('never wraps a storage or table read', () => { + expect(BASE_WRAP_LIST.filter((entry) => isStorageRead(entry.api))).toEqual([]); + }); + + it('lists each api once, with the bound getters of Intl as getters', () => { + const apis = BASE_WRAP_LIST.map((entry) => entry.api); + expect(new Set(apis).size).toBe(apis.length); + expect( + BASE_WRAP_LIST.filter((entry) => entry.kind === 'bound-getter').map((entry) => entry.api), + ).toEqual([ + 'Intl.Collator.prototype.compare', + 'Intl.DateTimeFormat.prototype.format', + 'Intl.NumberFormat.prototype.format', + ]); + }); + + it.each([ + ['String.trim', 'String.prototype.trim'], + ['ReadonlyArray.join', 'Array.prototype.join'], + ['NumberConstructor.isFinite', 'Number.isFinite'], + ['DateConstructor.UTC', 'Date.UTC'], + ['Math.max', 'Math.max'], + ['__global.URL', 'URL'], + ['parseInt', 'parseInt'], + ['NumberFormat.format', 'Intl.NumberFormat.prototype.format'], + ])('maps the opaque-sink callee %s to %s', (callee, api) => { + expect(wrapTargetOfCallee(callee)).toMatchObject({ + kind: 'target', + entry: { api, group: 'opaque-sink' }, + }); + }); + + it.each([ + ['Map.set', 'storage read'], + ['ReadonlySet.has', 'storage read'], + ['WeakMap.get', 'storage read'], + ['Array.push', 'storage read'], + ['ReadonlyArray.unshift', 'storage read'], + ['BaseLogger.warn', 'not a global built-in'], + ['load', 'not a global built-in'], + ])('skips the opaque-sink callee %s (%s)', (callee, reason) => { + expect(wrapTargetOfCallee(callee)).toEqual({ kind: 'skip', callee, reason }); + }); + + it('adds new opaque sinks once, after the base list, and lists the skipped ones', () => { + const list = wrapListOf(['Array.join', 'String.trim', 'Array.join', 'Map.set', 'URL']); + expect(list.added).toEqual(['Array.prototype.join', 'URL']); + expect(list.skipped).toEqual([{ callee: 'Map.set', reason: 'storage read' }]); + expect(list.entries.slice(BASE_WRAP_LIST.length).map((entry) => entry.api)).toEqual(list.added); + }); +}); + +// ── Serialisation ────────────────────────────────────────────────────────── + +class Box { + constructor(public readonly value: number) {} +} + +const cyclic = (): Record => { + const node: Record = { name: 'n' }; + node.self = node; + return node; +}; + +const throwingGetter = (): object => + Object.defineProperty({ plain: 1 }, 'trap', { + enumerable: true, + get: (): never => { + throw new Error('a getter ran'); + }, + }); + +describe('serializeValue', () => { + it.each([ + ['a number', 2.4, '2.4'], + ['negative zero', -0, '-0'], + ['NaN', Number.NaN, 'NaN'], + ['a bigint', 10n, '10n'], + ['a string', 'José', '"José"'], + ['undefined', undefined, 'undefined'], + ['null', null, 'null'], + ['an argument list', [24, 'x', true], '[24,"x",true]'], + ['a sparse array', [1, , 3], '[1,,3]'], + ['a plain object', { a: 1, b: [2] }, '{"a":1,"b":[2]}'], + ['a class instance', new Box(3), 'Box{"value":3}'], + ['a date', new Date(0), 'Date(0)'], + ['a regex with its lastIndex', /a|b/gi, '/a|b/gi@0'], + ['a map', new Map([['k', 1]]), 'Map{"k"=>1}'], + ['a set', new Set([1, 2]), 'Set{1,2}'], + ['a function', function named(): void {}, '[Function named]'], + ['a cycle', cyclic(), '{"name":"n","self":[Circular]}'], + ['an accessor, without running it', throwingGetter(), '{"plain":1,"trap":}'], + ])('serialises %s completely', (_title, value, text) => { + expect(serializeValue(value)).toEqual({ text, complete: true }); + }); + + it('names known objects by token', () => { + expect(serializeValue([Math], new Map([[Math, 'Math']])).text).toBe('[[Math]]'); + }); + + it('serialises an Intl instance by its resolved options', () => { + expect(serializeValue(new Intl.NumberFormat('en-US')).text).toMatch( + /^Intl\.NumberFormat\(\{"locale":"en-US",/, + ); + }); + + it('digests a long string, equal for equal text and different otherwise', () => { + const [a, b, c] = ['x'.repeat(LONG_STRING + 1), 'x'.repeat(LONG_STRING + 1), 'y'.repeat(300)]; + expect(serializeValue(a).text).toMatch(/^str\(201\)#[0-9a-f]{32}$/); + expect(serializeValue(a).text).toBe(serializeValue(b).text); + expect(serializeValue(a).text).not.toBe(serializeValue(c).text); + }); + + it.each([ + ['an array over the node budget', Array.from({ length: VALUE_NODE_BUDGET + 1 }, () => 0)], + ['a proxy', new Proxy({}, {})], + ['a nesting deeper than the limit', JSON.parse(`${'['.repeat(40)}${']'.repeat(40)}`)], + ])('marks %s incomplete', (_title, value) => { + expect(serializeValue(value).complete).toBe(false); + }); + + it('stores a long text as a digest plus a preview', () => { + const text = `"${'z'.repeat(SHORT_VALUE)}"`; + expect(valueKey('"short"')).toBe('"short"'); + expect(valueKey(text)).toMatch(/^#[0-9a-f]{32}\(402\) "z+…$/); + }); +}); + +// ── Call sites ───────────────────────────────────────────────────────────── + +const PREPARED = '/work/trace/prepared'; +const resolve = productionResolver(PREPARED, []); +const frame = (file: string | null): { file: string | null } => ({ file }); + +describe('call sites', () => { + it.each([ + ['a production frame', [frame(`${PREPARED}/gitnexus/src/x.ts`)], false, 'gitnexus/src/x.ts'], + [ + 'an engine frame, then production', + [frame(null), frame(`${PREPARED}/gitnexus/src/x.ts`)], + false, + 'gitnexus/src/x.ts', + ], + [ + 'an engine frame inside a wrapped built-in', + [frame(null), frame(`${PREPARED}/gitnexus/src/x.ts`)], + true, + null, + ], + [ + 'a callback frame inside a wrapped built-in', + [frame(`${PREPARED}/gitnexus/src/x.ts`), frame(null)], + true, + 'gitnexus/src/x.ts', + ], + [ + 'a node_modules frame only', + [frame('file:///repo/gitnexus/node_modules/lib/index.js')], + false, + null, + ], + [ + 'a node_modules frame above a production frame', + [frame(`${PREPARED}/gitnexus/node_modules/lib/a.js`), frame(`${PREPARED}/gitnexus/src/x.ts`)], + false, + null, + ], + ['a test frame', [frame(`${PREPARED}/gitnexus/test/unit/a.test.ts`)], false, null], + ['no frame', [], false, null], + ])('%s', (_title, frames, inside, repoPath) => { + expect(callSiteOf(frames, inside, resolve)?.repoPath ?? null).toBe(repoPath); + }); + + it.each([ + [`${PREPARED}/gitnexus/src/core/a.ts`, 'gitnexus/src/core/a.ts'], + [`file://${PREPARED}/gitnexus/src/core/a.ts`, 'gitnexus/src/core/a.ts'], + [`${PREPARED}/gitnexus/relocated/gitnexus-shared/src/b.ts`, 'gitnexus-shared/src/b.ts'], + [`${PREPARED}/gitnexus/src/core/a.test.ts`, null], + [`${PREPARED}/gitnexus/tools/scenario-map/types.ts`, null], + [`${PREPARED}/gitnexus/src/core/types.d.ts`, null], + ['/elsewhere/gitnexus/src/a.ts', null], + ['node:internal/x', null], + ])('resolves the script %s to %s', (script, repoPath) => { + expect(resolve(script)).toBe(repoPath); + }); + + it('accepts the src/ subtree of an extra root (controls mode)', () => { + const fixture = 'gitnexus/test/fixtures/mutation-scenario-map'; + const controls = productionResolver(PREPARED, [fixture]); + expect(controls(`${PREPARED}/${fixture}/src/math-round.ts`)).toBe( + `${fixture}/src/math-round.ts`, + ); + expect(controls(`${PREPARED}/${fixture}/math-round.case.ts`)).toBeNull(); + }); +}); + +// ── Recorder ─────────────────────────────────────────────────────────────── + +const SELF = scriptPath(fileURLToPath(import.meta.url)); + +interface Sandbox { + Math: { round: (value: number) => number }; + Number: { + prototype: { toFixed: (this: number, digits?: number) => string }; + parseInt: (text: string, radix?: number) => number; + }; + String: { + prototype: { trimStart: (this: string) => string; trimLeft: (this: string) => string }; + }; + Map: { prototype: { set: Map['set'] } }; + JSON: { stringify: (value: unknown) => string }; + Array: { prototype: { sort: (this: T[], compare?: (a: T, b: T) => number) => T[] } }; + Intl: { NumberFormat: { prototype: object } }; + parseInt: (text: string, radix?: number) => number; + Box: typeof Box; +} + +const BOX_ENTRY: WrapEntry = { + api: 'Box', + owner: [], + key: 'Box', + kind: 'function', + group: 'opaque-sink', +}; + +function sandboxOf(): Sandbox { + const numberFormat = Object.create(null) as object; + const format = Reflect.getOwnPropertyDescriptor(Intl.NumberFormat.prototype, 'format'); + Reflect.defineProperty(numberFormat, 'format', format ?? { value: undefined }); + return { + Math: { round: Math.round }, + Number: { prototype: { toFixed: Number.prototype.toFixed }, parseInt }, + String: { + prototype: { trimStart: String.prototype.trimStart, trimLeft: String.prototype.trimStart }, + }, + Map: { prototype: { set: Map.prototype.set } }, + JSON: { stringify: JSON.stringify }, + Array: { prototype: { sort: Array.prototype.sort } }, + Intl: { NumberFormat: { prototype: numberFormat } }, + parseInt, + Box, + }; +} + +interface Installed { + sandbox: Sandbox; + recorder: TraceRecorder; + use: (id: string | null, concurrent?: boolean) => void; + traceOf: (id: string) => RawTestTrace | undefined; +} + +/** A recorder over a fresh sandbox, whose calls from this file count as `gitnexus/src/x.ts`. */ +function installed(production: (script: string) => string | null = selfAsProduction): Installed { + let current: CurrentTest | null = null; + const recorder = new TraceRecorder({ currentTest: () => current, production }); + const sandbox = sandboxOf(); + recorder.install(sandbox, [...BASE_WRAP_LIST, BOX_ENTRY]); + return { + sandbox, + recorder, + use: (id, concurrent = false) => { + current = id === null ? null : { id, concurrent }; + }, + traceOf: (id) => recorder.record('/x/a.test.ts').tests.find((test) => test.taskId === id), + }; +} + +function selfAsProduction(script: string): string | null { + return scriptPath(script) === SELF ? 'gitnexus/src/x.ts' : null; +} + +// One call site each: every call through a helper shares the helper's site. +const roundAt = (sandbox: Sandbox, value: number): number => sandbox.Math.round(value); +const scaledFixed = (sandbox: Sandbox, stars: number): string => + sandbox.Number.prototype.toFixed.call(stars * 10, 1); +const otherFixed = (sandbox: Sandbox, value: number): string => + sandbox.Number.prototype.toFixed.call(value, 2); + +const toTestTrace = (id: string, raw: RawTestTrace | undefined): TestTrace => ({ + id, + baseline: 'survived', + traced: 'survived', + recorded: raw !== undefined, + concurrent: raw?.concurrent ?? false, + overflow: raw?.overflow ?? [], + calls: raw?.calls ?? [], +}); + +/** The sandbox's (wrapped) `format` getter read on `nf`. */ +const boundFormat = (sandbox: Sandbox, nf: Intl.NumberFormat): unknown => + Reflect.getOwnPropertyDescriptor(sandbox.Intl.NumberFormat.prototype, 'format')?.get?.call(nf); + +const callBound = (format: unknown, value: number): unknown => + typeof format === 'function' ? Reflect.apply(format, undefined, [value]) : null; + +const messageOf = (run: () => unknown): string => { + try { + run(); + return 'no error'; + } catch (error) { + return error instanceof Error ? `${error.name}: ${error.message}` : 'non-error'; + } +}; + +describe('TraceRecorder', () => { + it('records the receiver, arguments and count per test at the production call site', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + roundAt(sandbox, 2.4); + roundAt(sandbox, 2.4); + use('b'); + roundAt(sandbox, 2.5); + const [a, b] = [traceOf('a'), traceOf('b')]; + expect(a?.calls).toEqual([ + { + site: expect.stringMatching(/^gitnexus\/src\/x\.ts:\d+:\d+$/), + api: 'Math.round', + receiver: '[Math]', + args: '[2.4]', + count: 2, + }, + ]); + expect(b?.calls.map((call) => [call.args, call.count])).toEqual([['[2.5]', 1]]); + expect(b?.calls[0].site).toBe(a?.calls[0].site); + }); + + it('vetoes Math.round reached with 2.4 and 2.5', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + roundAt(sandbox, 2.4); + use('b'); + roundAt(sandbox, 2.5); + const pair = compareTracePair(toTestTrace('a', traceOf('a')), toTestTrace('b', traceOf('b'))); + expect([pair.status, pair.reasons]).toEqual(['veto', ['args-differ']]); + }); + + it('vetoes the derived receivers 24 and 25 of toFixed with equal arguments', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + scaledFixed(sandbox, 2.4); + use('b'); + scaledFixed(sandbox, 2.5); + const pair = compareTracePair(toTestTrace('a', traceOf('a')), toTestTrace('b', traceOf('b'))); + expect(traceOf('a')?.calls.map((call) => [call.api, call.receiver, call.args])).toEqual([ + ['Number.prototype.toFixed', '24', '[1]'], + ]); + expect([pair.status, pair.reasons]).toEqual(['veto', ['receiver-differs']]); + }); + + it('keeps identical traces clean and tells a second site apart', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + scaledFixed(sandbox, 2.4); + use('b'); + scaledFixed(sandbox, 2.4); + use('c'); + scaledFixed(sandbox, 2.4); + otherFixed(sandbox, 2.4); + const [a, b, c] = ['a', 'b', 'c'].map((id) => toTestTrace(id, traceOf(id))); + expect(compareTracePair(a, b).status).toBe('clean'); + expect(compareTracePair(a, c).reasons).toEqual(['asymmetric-sites']); + }); + + it('records nothing outside a test or from a frame that is not production', () => { + const outside = installed(); + outside.use(null); + roundAt(outside.sandbox, 1.5); + const foreign = installed(() => null); + foreign.use('a'); + roundAt(foreign.sandbox, 1.5); + expect(outside.recorder.record('/x/a.test.ts').tests).toEqual([]); + expect(foreign.traceOf('a')).toBeUndefined(); + }); + + it('never wraps or records Map.prototype.set', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + Reflect.apply(sandbox.Map.prototype.set, new Map(), ['k', 1]); + expect(sandbox.Map.prototype.set).toBe(Map.prototype.set); + expect(traceOf('a')).toBeUndefined(); + }); + + it('records a callback that runs inside a wrapped built-in at its own site', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + sandbox.Array.prototype.sort.call( + [3.2, 1.6], + (x, y) => roundAt(sandbox, x) - roundAt(sandbox, y), + ); + expect( + traceOf('a') + ?.calls.map((call) => call.api) + .sort(), + ).toEqual(['Array.prototype.sort', 'Math.round', 'Math.round']); + }); + + it('starts a test over on each attempt and flags a concurrent test', () => { + const { sandbox, recorder, use, traceOf } = installed(); + use('a'); + recorder.testStarted({ id: 'a', concurrent: false }); + roundAt(sandbox, 1); + recorder.testStarted({ id: 'a', concurrent: false }); + roundAt(sandbox, 2); + use('b', true); + roundAt(sandbox, 3); + expect(traceOf('a')).toMatchObject({ attempts: 2, calls: [{ args: '[2]', count: 1 }] }); + expect(traceOf('b')?.concurrent).toBe(true); + }); + + it('marks a test whose argument is over budget incomplete', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + sandbox.JSON.stringify(Array.from({ length: VALUE_NODE_BUDGET + 1 }, () => 0)); + expect(traceOf('a')?.overflow).toEqual(['value']); + }); + + it('gives aliases the same wrapper and keeps one wrapper per bound Intl function', () => { + const { sandbox, use, traceOf } = installed(); + const nf = new Intl.NumberFormat('en-US'); + const format = boundFormat(sandbox, nf); + use('a'); + const text = callBound(format, 1234.5); + expect(sandbox.String.prototype.trimLeft).toBe(sandbox.String.prototype.trimStart); + expect(sandbox.Number.parseInt).toBe(sandbox.parseInt); + expect(boundFormat(sandbox, nf)).toBe(format); + expect(text).toBe(nf.format(1234.5)); + expect(traceOf('a')?.calls.map((call) => [call.api, call.receiver, call.args])).toEqual([ + [ + 'Intl.NumberFormat.prototype.format', + expect.stringMatching(/^Intl\.NumberFormat\(\{"locale":"en-US",/), + '[1234.5]', + ], + ]); + }); + + it.each([ + ['Math.round', (s: Sandbox) => s.Math.round(2.5), () => Math.round(2.5)], + ['toFixed', (s: Sandbox) => s.Number.prototype.toFixed.call(24, 1), () => (24).toFixed(1)], + [ + 'trimStart', + (s: Sandbox) => s.String.prototype.trimStart.call(' x'), + () => ' x'.trimStart(), + ], + ['parseInt', (s: Sandbox) => s.parseInt('17', 8), () => parseInt('17', 8)], + [ + 'JSON.stringify', + (s: Sandbox) => s.JSON.stringify({ a: [1] }), + () => JSON.stringify({ a: [1] }), + ], + ['new on a constructor', (s: Sandbox) => new s.Box(4).value, () => new Box(4).value], + ])('passes %s through unchanged', (_title, wrapped, original) => { + const { sandbox, use } = installed(); + use('a'); + expect(wrapped(sandbox)).toEqual(original()); + }); + + it('keeps errors, instanceof, name, length and the native source text', () => { + const { sandbox, use } = installed(); + use('a'); + const wrapped = sandbox.Number.prototype.toFixed; + expect(messageOf(() => wrapped.call(1, 101))).toBe(messageOf(() => (1).toFixed(101))); + expect(new sandbox.Box(1)).toBeInstanceOf(Box); + expect([wrapped.name, wrapped.length]).toEqual(['toFixed', 1]); + expect(Function.prototype.toString.call(sandbox.Math.round)).toContain('[native code]'); + }); + + it('records a new call with a constructor receiver', () => { + const { sandbox, use, traceOf } = installed(); + use('a'); + new sandbox.Box(7); + expect(traceOf('a')?.calls.map((call) => [call.api, call.receiver, call.args])).toEqual([ + ['Box', '', '[7]'], + ]); + }); +}); + +// ── Comparison ───────────────────────────────────────────────────────────── + +const call = ( + api: string, + site: string, + receiver: string, + args: string, + count = 1, +): RawTraceCall => ({ site, api, receiver, args, count }); + +/** Overrides of a {@link TestTrace}; readonly so `as const` tables fit. */ +type TraceExtra = Readonly>> & { + readonly overflow?: readonly TraceOverflow[]; +}; + +const trace = (id: string, calls: RawTraceCall[], extra: TraceExtra = {}): TestTrace => ({ + id, + baseline: 'survived', + traced: 'survived', + recorded: true, + concurrent: false, + calls, + ...extra, + overflow: [...(extra.overflow ?? [])], +}); + +const ROUND = (args: string, count = 1): RawTraceCall => + call('Math.round', SITE, '[Math]', args, count); +const FIXED = (receiver: string, site = OTHER_SITE): RawTraceCall => + call('Number.prototype.toFixed', site, receiver, '[1]'); + +describe('compareTracePair', () => { + it.each([ + [ + 'Math.round reached with 2.4 and 2.5', + [ROUND('[2.4]')], + [ROUND('[2.5]')], + {}, + 'veto', + ['args-differ'], + ], + ['the same site with equal arguments', [ROUND('[2.4]')], [ROUND('[2.4]')], {}, 'clean', []], + [ + 'toFixed reached with receivers 24 and 25', + [FIXED('24')], + [FIXED('25')], + {}, + 'veto', + ['receiver-differs'], + ], + [ + 'identical traces over several sites', + [ROUND('[2]'), FIXED('20')], + [ROUND('[2]'), FIXED('20')], + {}, + 'clean', + [], + ], + [ + 'toFixed at a site test B never reaches', + [ROUND('[2]'), FIXED('20')], + [ROUND('[2]')], + {}, + 'veto', + ['asymmetric-sites'], + ], + [ + 'a site A calls twice and B once', + [ROUND('[2]', 2)], + [ROUND('[2]')], + {}, + 'veto', + ['asymmetric-counts'], + ], + [ + 'the same values used a different number of times', + [ROUND('[1]', 2), ROUND('[2]')], + [ROUND('[1]'), ROUND('[2]', 2)], + {}, + 'veto', + ['asymmetric-counts'], + ], + [ + 'the same receivers and arguments paired differently', + [FIXED('1', SITE), call('Number.prototype.toFixed', SITE, '2', '[2]')], + [ + call('Number.prototype.toFixed', SITE, '1', '[2]'), + call('Number.prototype.toFixed', SITE, '2', '[1]'), + ], + {}, + 'veto', + ['args-differ'], + ], + [ + 'a test that passes at baseline and fails under tracing', + [ROUND('[2]')], + [ROUND('[2]')], + { traced: 'killed-by-test:assertion' }, + 'inconclusive', + ['trace-changed-outcome'], + ], + ['a test without a record', [], [], { recorded: false }, 'inconclusive', ['trace-missing']], + [ + 'a test that did not run', + [], + [], + { baseline: null, traced: null }, + 'inconclusive', + ['trace-missing'], + ], + ['a concurrent test', [], [], { concurrent: true }, 'inconclusive', ['trace-unattributable']], + ['an incomplete trace', [], [], { overflow: ['calls'] }, 'inconclusive', ['trace-overflow']], + [ + 'differing arguments and a changed outcome', + [ROUND('[2.4]')], + [ROUND('[2.5]')], + { traced: 'killed-by-test:runtime' }, + 'inconclusive', + ['args-differ', 'trace-changed-outcome'], + ], + ] as const)('%s', (_title, callsA, callsB, extraB, status, reasons) => { + const pair = compareTracePair(trace(A, [...callsA]), trace(B, [...callsB], extraB)); + expect([pair.status, pair.reasons]).toEqual([status, reasons]); + }); + + it.each([ + ['a clean pair', [ROUND('[2]')], [ROUND('[2]')], ['trace-run-invalid']], + [ + 'a vetoed pair, its veto reasons kept', + [ROUND('[2.4]')], + [ROUND('[2.5]')], + ['args-differ', 'trace-run-invalid'], + ], + ] as const)( + 'makes %s inconclusive when the run is invalid', + (_title, callsA, callsB, reasons) => { + const pair = compareTracePair(trace(A, [...callsA]), trace(B, [...callsB]), [ + 'traced run error/infrastructure', + ]); + expect([pair.status, pair.reasons]).toEqual(['inconclusive', reasons]); + }, + ); + + it('reports the differing site with both sides', () => { + const pair = compareTracePair(trace(A, [FIXED('24')]), trace(B, [FIXED('25'), ROUND('[1]')])); + expect(pair.sharedSites).toBe(1); + expect(pair.differences).toEqual([ + { + site: SITE, + api: 'Math.round', + reasons: ['asymmetric-sites'], + a: null, + b: { count: 1, receivers: ['[Math]'], args: ['[1]'] }, + }, + { + site: OTHER_SITE, + api: 'Number.prototype.toFixed', + reasons: ['receiver-differs'], + a: { count: 1, receivers: ['24'], args: ['[1]'] }, + b: { count: 1, receivers: ['25'], args: ['[1]'] }, + }, + ]); + }); +}); + +// ── Join and run ─────────────────────────────────────────────────────────── + +const PACKAGE_DIR = '/work/trace/prepared/gitnexus'; + +function vitestResult( + outcomes: readonly [OutcomeClass | null, OutcomeClass | null], + wallClockMs = 100, +): VitestRunResult { + return { + tests: [A, B].map((id, index) => ({ + id, + taskId: `t_${index}`, + fullName: id.split('#')[1], + location: { line: index + 1, column: 3 }, + state: + outcomes[index] === null ? 'missing' : outcomes[index] === 'survived' ? 'passed' : 'failed', + outcome: outcomes[index], + message: null, + })), + status: 'survived', + outcome: 'survived', + reason: null, + outsideTest: [], + problems: [], + unhandledErrors: [], + wallClockMs, + }; +} + +const rawRecord = ( + packageDir: string, + tests: RawTestTrace[], + extra: Partial> = {}, +): RawTraceRecord => ({ + schemaVersion: RAW_TRACE_SCHEMA_VERSION, + file: `${packageDir}/${PACKAGE_FILE}`, + wrapped: ['Math.round'], + unavailable: [], + errors: [], + tests, + ...extra, +}); + +const rawTest = (taskId: string, calls: RawTraceCall[]): RawTestTrace => ({ + taskId, + concurrent: false, + attempts: 1, + overflow: [], + calls, +}); + +describe('traceRunIssues', () => { + it('finds nothing in two survived runs without wrap problems', () => { + const run = vitestResult(['survived', 'survived']); + expect( + traceRunIssues({ baseline: run, traced: run, wrap: { unavailable: [], errors: [] } }), + ).toEqual([]); + }); + + it('fails a run with a selection problem and a failing test closed', () => { + const baseline: VitestRunResult = { + ...vitestResult(['survived', null]), + status: 'error', + outcome: 'invalid', + reason: 'requested test was not collected', + problems: ['requested test "b" was not collected'], + }; + const traced: VitestRunResult = { + ...vitestResult(['survived', 'killed-by-test:assertion']), + status: 'killed', + outcome: 'killed-by-test:assertion', + }; + expect(traceRunIssues({ baseline, traced, wrap: { unavailable: [], errors: [] } })).toEqual([ + 'baseline run error/invalid: requested test was not collected', + 'baseline run: requested test "b" was not collected', + 'traced run killed/killed-by-test:assertion', + ]); + }); +}); + +describe('tracesOf', () => { + it('makes a pair inconclusive when a test passes at baseline and fails under tracing', () => { + const tests = tracesOf({ + ids: [A, B], + baseline: vitestResult(['survived', 'survived']), + traced: vitestResult(['survived', 'killed-by-test:assertion']), + records: [rawRecord(PACKAGE_DIR, [rawTest('t_0', []), rawTest('t_1', [])])], + packageDir: PACKAGE_DIR, + }); + const pair = compareTracePair(tests[0], tests[1]); + expect([pair.status, pair.reasons]).toEqual(['inconclusive', ['trace-changed-outcome']]); + }); + + it('joins the calls by file and task id, and marks a test without a record missing', () => { + const tests = tracesOf({ + ids: [A, B], + baseline: vitestResult(['survived', 'survived']), + traced: vitestResult(['survived', 'survived']), + records: [rawRecord(PACKAGE_DIR, [rawTest('t_0', [ROUND('[2]')])])], + packageDir: PACKAGE_DIR, + }); + expect(tests.map((test) => [test.id, test.recorded, test.calls.length])).toEqual([ + [A, true, 1], + [B, false, 0], + ]); + }); +}); + +const tempDirs: string[] = []; +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-trace-')); + tempDirs.push(dir); + return dir; +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function writeTree(root: string, extra: string): void { + const files: Record = { + 'gitnexus/node_modules/vitest/package.json': JSON.stringify({ version: '5.0.5' }), + 'gitnexus/node_modules/@stryker-mutator/core/package.json': JSON.stringify({ + version: '10.0.0', + }), + 'gitnexus/src/x.ts': `export const x = 1;\n${extra}`, + }; + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } +} + +interface FakeRun { + deps: TraceRunDeps; + calls: VitestRunOptions[]; + provided: [string, unknown][]; +} + +interface FakeRunOptions { + /** The prepared copy differs from the run manifest. */ + foreignCopy?: boolean; + /** Run-level overrides of the baseline and the traced run. */ + baseline?: Partial; + traced?: Partial; + /** Unwrapped entries and recorder errors of the raw record. */ + record?: Partial>; +} + +const isTraceContext = (value: unknown): value is TraceContext => + typeof value === 'object' && value !== null && 'outDir' in value && 'wrap' in value; + +const lastWrappedApi = (context: unknown): string | null => + isTraceContext(context) ? (context.wrap.at(-1)?.api ?? null) : null; + +/** Fake runs: the traced one writes the given calls for A and B through the provided context. */ +function fakeRun( + callsA: RawTraceCall[], + callsB: RawTraceCall[], + world: FakeRunOptions = {}, +): FakeRun { + const calls: VitestRunOptions[] = []; + const provided: [string, unknown][] = []; + let clock = 0; + const deps: TraceRunDeps = { + prepare: (options: PrepareOptions): PrepareReport => { + writeTree(options.outDir, world.foreignCopy === true ? '// changed\n' : ''); + return { + version: 1, + repoRoot: options.repoRoot, + outDir: options.outDir, + packageDir: path.join(options.outDir, 'gitnexus'), + relocatedShared: '', + pathMap: [], + copiedFiles: 3, + blanked: [], + failed: [], + }; + }, + runVitest: async (options: VitestRunOptions): Promise => { + calls.push(options); + options.provide?.({ + provide: (key: string, value: unknown): void => void provided.push([key, value]), + }); + const context = provided.map(([, value]) => value).find(isTraceContext); + const records = + context === undefined + ? [] + : [ + rawRecord( + options.packageDir, + [rawTest('t_0', callsA), rawTest('t_1', callsB)], + world.record, + ), + ]; + for (const record of records) + writeRawTrace(context?.outDir ?? '', 'trace-1-a-a.test.ts.json', record); + const first = calls.length === 1; + return { + ...vitestResult(['survived', 'survived'], first ? 100 : 150), + ...(first ? world.baseline : world.traced), + }; + }, + now: () => (clock += 1000), + }; + return { deps, calls, provided }; +} + +async function runWith( + fake: FakeRun, + opaqueCallees: readonly string[] = [], + maxWorkers: number | undefined = undefined, +) { + const manifestRoot = tempDir(); + writeTree(manifestRoot, ''); + const manifest = buildRunManifest({ + preparedRoot: manifestRoot, + mode: { kind: 'normal' }, + tests: [A, B], + }); + const workDir = tempDir(); + const result = await runTrace( + { repoRoot: tempDir(), workDir, manifest, pairs: [{ a: A, b: B }], opaqueCallees, maxWorkers }, + fake.deps, + ); + return { result, workDir }; +} + +describe('runTrace', () => { + it('runs a baseline and a traced run of the same tests and vetoes differing calls', async () => { + const fake = fakeRun([ROUND('[2.4]')], [ROUND('[2.5]')]); + const { result, workDir } = await runWith(fake, ['Array.join', 'Map.set']); + const context = fake.provided.find(([key]) => key === TRACE_KEY)?.[1]; + expect(fake.calls.map((options) => [options.testIds, options.setupFiles ?? []])).toEqual([ + [[A, B], []], + [[A, B], [TRACE_SETUP_FILE]], + ]); + expect(context).toMatchObject({ outDir: traceLayout(workDir).rawDir, extraRoots: [] }); + expect(lastWrappedApi(context)).toBe('Array.prototype.join'); + expect(result).toMatchObject({ + status: 'ran', + overhead: 1.5, + wrap: { + added: ['Array.prototype.join'], + skipped: [{ callee: 'Map.set', reason: 'storage read' }], + unavailable: [], + errors: [], + }, + issues: [], + }); + expect(result.pairs.map((pair) => [pair.status, pair.reasons])).toEqual([ + ['veto', ['args-differ']], + ]); + }); + + it('bounds the baseline and the traced run by the worker bound it is given', async () => { + const fake = fakeRun([ROUND('[2]')], [ROUND('[2]')]); + await runWith(fake, [], 3); + expect(fake.calls.map((options) => options.maxWorkers)).toEqual([3, 3]); + }); + + it('keeps identical traces of a valid run clean', async () => { + const { result } = await runWith(fakeRun([ROUND('[2]')], [ROUND('[2]')])); + expect([result.issues, result.pairs.map((pair) => [pair.status, pair.reasons])]).toEqual([ + [], + [['clean', []]], + ]); + }); + + it.each([ + [ + 'a traced run with an unhandled error (infrastructure)', + { + traced: { + status: 'error', + outcome: 'infrastructure', + reason: 'unhandled error during the run: Error: boom', + unhandledErrors: ['Error: boom'], + }, + }, + [ + 'traced run error/infrastructure: unhandled error during the run: Error: boom', + 'traced run: unhandled error: Error: boom', + ], + ], + [ + 'a traced run whose selected tests use retry (inconclusive)', + { + traced: { + status: 'error', + outcome: 'inconclusive', + reason: 'selected tests use retry: d > a', + }, + }, + ['traced run error/inconclusive: selected tests use retry: d > a'], + ], + [ + 'a baseline with a failure outside any test', + { + baseline: { + status: 'killed', + outcome: 'killed-outside-test:hook', + outsideTest: [ + { + file: PACKAGE_FILE, + suiteTaskId: undefined, + outcome: 'killed-outside-test:hook', + attributedTo: [], + message: 'afterAll failed', + }, + ], + }, + }, + [ + 'baseline run killed/killed-outside-test:hook', + `baseline run: killed-outside-test:hook outside a test in ${PACKAGE_FILE}`, + ], + ], + [ + 'a wrap-list entry a worker could not wrap', + { record: { unavailable: [{ api: 'Intl.DisplayNames', reason: 'missing' }] } }, + ['not wrapped: Intl.DisplayNames (missing)'], + ], + [ + 'a recorder error outside any test', + { record: { errors: ['TypeError: no stack'] } }, + ['trace error: TypeError: no stack'], + ], + ] as const satisfies readonly (readonly [string, FakeRunOptions, readonly string[]])[])( + 'makes every pair inconclusive after %s', + async (_title, world, issues) => { + const { result } = await runWith(fakeRun([ROUND('[2]')], [ROUND('[2]')], world)); + expect([ + result.status, + result.issues, + result.pairs.map((pair) => [pair.status, pair.reasons]), + ]).toEqual(['ran', issues, [['inconclusive', ['trace-run-invalid']]]]); + }, + ); + + it('runs nothing when the copy does not match the run manifest', async () => { + const fake = fakeRun([], [], { foreignCopy: true }); + const { result } = await runWith(fake); + expect(fake.calls).toEqual([]); + expect([ + result.status, + result.detail?.startsWith('manifest mismatch: changed: gitnexus/src/x.ts'), + ]).toEqual(['not-run', true]); + expect(result.pairs.map((pair) => [pair.status, pair.reasons])).toEqual([ + ['inconclusive', ['trace-not-run']], + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/types.test.ts b/gitnexus/test/unit/scenario-map/types.test.ts new file mode 100644 index 000000000..5efd477bf --- /dev/null +++ b/gitnexus/test/unit/scenario-map/types.test.ts @@ -0,0 +1,74 @@ +/** + * Locks the scenario map's one production-file predicate (KTD7) and its path helpers: + * `gitnexus/src/` and `gitnexus-shared/src/` sources are production, tool, test and fixture + * case files are not, and a mutation fixture's `src/` modules count only when the fixture root is + * passed as an extra root (`controls` mode). + */ +import { describe, expect, it } from 'vitest'; +import { + extraRootsOf, + fromPackagePath, + isProductionFile, + productionRoots, + toPackagePath, +} from '../../../tools/scenario-map/types.js'; + +const FIXTURE = 'gitnexus/test/fixtures/mutation-scenario-map'; +const CONTROLS = extraRootsOf({ kind: 'controls', fixtureRoot: FIXTURE }); +const NORMAL = extraRootsOf({ kind: 'normal' }); + +describe('isProductionFile', () => { + it.each([ + ['a gitnexus/src module', 'gitnexus/src/a.ts', NORMAL, true], + [ + 'a gitnexus-shared/src module (relocated when prepared)', + 'gitnexus-shared/src/b.ts', + NORMAL, + true, + ], + ['a nested gitnexus/src module', 'gitnexus/src/core/lbug/schema.ts', NORMAL, true], + ['a tool module', 'gitnexus/tools/x.ts', NORMAL, false], + ['a unit test', 'gitnexus/test/unit/y.test.ts', NORMAL, false], + ['a fixture case file in controls mode', `${FIXTURE}/rows.case.ts`, CONTROLS, false], + ['a fixture src module in normal mode', `${FIXTURE}/src/rows.ts`, NORMAL, false], + ['a fixture src module in controls mode', `${FIXTURE}/src/rows.ts`, CONTROLS, true], + ['a fixture config in controls mode', `${FIXTURE}/vitest.config.ts`, CONTROLS, false], + ['a declaration file under src', 'gitnexus/src/types.d.ts', NORMAL, false], + ['a test-named file under src', 'gitnexus/src/a.test.ts', NORMAL, false], + ['a non-TypeScript file under src', 'gitnexus/src/README.md', NORMAL, false], + ['a script', 'gitnexus/scripts/run.ts', NORMAL, false], + ['a Windows-separated src path', 'gitnexus\\src\\a.ts', NORMAL, true], + ])('classifies %s', (_label, repoPath, extraRoots, expected) => { + expect(isProductionFile(repoPath, extraRoots)).toBe(expected); + }); + + it.each([ + ['an absolute path', '/abs/gitnexus/src/a.ts'], + ['a parent-relative path', '../gitnexus/src/a.ts'], + ])('throws on %s instead of answering false', (_label, repoPath) => { + expect(() => isProductionFile(repoPath)).toThrow(/not a relative path/); + }); + + it('rejects an extra root that is not a mutation fixture directory', () => { + expect(() => productionRoots(['gitnexus/tools'])).toThrow(/not a mutation fixture directory/); + }); + + it('lists the path-map roots, plus the src/ subtree of each extra root', () => { + expect(productionRoots(CONTROLS)).toEqual([ + 'gitnexus-shared/src/', + 'gitnexus/src/', + `${FIXTURE}/src/`, + ]); + }); +}); + +describe('package paths', () => { + it('converts a repo path under gitnexus/ to a package path and back', () => { + expect(toPackagePath('gitnexus/test/unit/a.test.ts')).toBe('test/unit/a.test.ts'); + expect(fromPackagePath('test/unit/a.test.ts')).toBe('gitnexus/test/unit/a.test.ts'); + }); + + it('throws for a repo path outside gitnexus/', () => { + expect(() => toPackagePath('gitnexus-shared/src/b.ts')).toThrow(/not under gitnexus\//); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/verdict.test.ts b/gitnexus/test/unit/scenario-map/verdict.test.ts new file mode 100644 index 000000000..4fe3329aa --- /dev/null +++ b/gitnexus/test/unit/scenario-map/verdict.test.ts @@ -0,0 +1,975 @@ +/** + * Locks the verdict engine (U12, R10, R11, R15, KTD5, KTD8, KTD9, KTD12, design §2, §5.4, §5.6) + * on in-memory inputs. The base world is the D2 positive control, `add(2, 3)` against + * `add(5, 5)`: identical path, checks, context and facet (the values are discarded and differ), + * identical non-empty kill sets, clean value flow and trace, an agreeing isolation pass and no + * weak disqualifier, so it is a strong repeat. Each case edits one input and checks the deciding + * rule (one case per precedence rule and per weak disqualifier), and the per-test Weak, + * Inconclusive and Subsumed properties, the per-file gaps, the dupe-ok keep list and determinism. + */ +import { describe, expect, it } from 'vitest'; +import type { InputFacet } from '../../../tools/scenario-map/facet/input-facet.js'; +import type { + MergedObservation, + RunUniverse, +} from '../../../tools/scenario-map/mutation/observations.js'; +import type { DegradedFlag } from '../../../tools/scenario-map/value-flow/pdg-walk.js'; +import { + VERDICT_ENGINE_VERSION, + WORDING, + computeVerdicts, + type PairVerdict, + type RuleId, + type Verdict, + type VerdictInput, + type VerdictPairInput, + type VerdictTestInput, + type WeakDisqualifier, +} from '../../../tools/scenario-map/verdict/verdict.js'; + +const A = 'test/unit/add.test.ts#add > adds 2 and 3'; +const B = 'test/unit/add.test.ts#add > adds 5 and 5'; +const C = 'test/unit/add.test.ts#add > adds 4 and 6'; +const D = 'test/unit/add.test.ts#add > adds 1 and 9'; +const FILE = 'gitnexus/src/add.ts'; +const mutantId = (span: string, mutator: string): string => + `${FILE}@0123456789abcdef:${span}:${mutator}:aaaaaaaaaaaaaaaa`; +/** Killed by both tests. */ +const M1 = mutantId('2:10-2:15', 'ArithmeticOperator'); +/** Covered by both tests, survived by both. */ +const M2 = mutantId('3:7-3:16', 'ConditionalExpression'); +/** Covered by neither test. */ +const M3 = mutantId('6:1-8:2', 'BlockStatement'); +/** A static mutant: no coverage, every test runs against it. */ +const M4 = mutantId('1:14-1:15', 'NumericLiteral'); + +// ── Builders ─────────────────────────────────────────────────────────────── + +const observation = ( + test: string, + mutant: string, + overrides: Partial = {}, +): MergedObservation => ({ + group: 'g1', + kind: 'main', + lineage: 'main', + test, + mutant, + file: FILE, + covered: true, + hookAttributed: false, + static: false, + outcome: 'killed-by-test:assertion', + attempts: [0], + shards: [0], + sources: ['row'], + valid: true, + reasons: [], + ...overrides, +}); + +const SURVIVED: Partial = { outcome: 'survived' }; +const UNCOVERED: Partial = { + covered: false, + outcome: 'survived', + attempts: [], + sources: ['not-covered'], +}; +const MISSING: Partial = { + outcome: null, + attempts: [], + sources: ['no-row'], + valid: false, + reasons: ['missing-result'], +}; + +/** The main run of `group`: each test kills M1, survives M2 and does not cover M3. */ +const mainRun = (tests: readonly string[], group = 'g1'): MergedObservation[] => + tests.flatMap((test) => [ + observation(test, M1, { group }), + observation(test, M2, { group, ...SURVIVED }), + observation(test, M3, { group, ...UNCOVERED }), + ]); + +type ObservationEdit = (entries: MergedObservation[]) => MergedObservation[]; + +/** Changes the observations of `test` on `mutant` (every lineage). */ +const change = + (test: string, mutant: string, overrides: Partial): ObservationEdit => + (entries) => + entries.map((entry) => + entry.test === test && entry.mutant === mutant ? { ...entry, ...overrides } : entry, + ); + +const add = + (...extra: MergedObservation[]): ObservationEdit => + (entries) => [...entries, ...extra]; + +const chain = + (...edits: ObservationEdit[]): ObservationEdit => + (entries) => + edits.reduce((current, edit) => edit(current), entries); + +const SKELETON = ['body.0\tcall add', 'body.0.args.0\t', 'body.0.args.1\t']; + +const facet = (values: readonly string[], overrides: Partial = {}): InputFacet => ({ + fingerprint: 'facet-add', + parts: { + language: '-', + source: [], + fixtures: [], + data: [], + calls: ['add'], + order: ['', ''], + }, + discarded: values.map((value, position) => ({ + position, + kind: 'number', + value, + location: { file: 'gitnexus/test/unit/add.test.ts', line: 3, column: 10 + position * 3 }, + origin: 'literal', + })), + unresolved: [], + skeleton: SKELETON, + ...overrides, +}); + +const testInput = (id: string, overrides: Partial = {}): VerdictTestInput => ({ + id, + eligible: true, + fingerprintEligible: true, + passed: true, + notOracleValidated: false, + dupeOk: null, + joinProblems: [], + inventoryUnresolved: [], + provenance: { known: true, nativeEntries: [], workerSpawns: [] }, + concurrent: false, + excludedFiles: [], + checksContext: 'checks-add', + unsupportedForms: [], + unresolvedSelectors: [], + facet: facet(['2', '3']), + ...overrides, +}); + +const pairInput = (overrides: Partial = {}): VerdictPairInput => ({ + a: A, + b: B, + group: 'g1', + identicalBlocks: true, + pathUnattributable: false, + schedule: { status: 'evaluated', reason: null, detail: null }, + isolation: { status: 'agrees', reason: null, disqualifiers: [] }, + rowDeletion: { outcome: 'not-applicable', reasons: ['no-common-table'] }, + staticValueFlow: { status: 'clean', reasons: [] }, + trace: { status: 'clean', reasons: [] }, + ...overrides, +}); + +interface WorldEdit { + a?: Partial; + b?: Partial; + pair?: Partial; + observations?: ObservationEdit; + degraded?: DegradedFlag[]; +} + +/** The positive control, `add(2, 3)` against `add(5, 5)`, with `edit` applied. */ +const world = (edit: WorldEdit = {}): VerdictInput => ({ + tests: [ + testInput(A, { facet: facet(['2', '3']), ...edit.a }), + testInput(B, { facet: facet(['5', '5']), ...edit.b }), + ], + pairs: [pairInput(edit.pair)], + observations: { + explicit: (edit.observations ?? ((entries) => entries))(mainRun([A, B])), + universes: [], + }, + valueFlowDegraded: edit.degraded ?? [], +}); + +const verdictOf = (edit: WorldEdit = {}): PairVerdict => computeVerdicts(world(edit)).pairs[0]; +const kindsOf = (verdict: PairVerdict): WeakDisqualifier[] => + verdict.disqualifiers.map((entry) => entry.kind); +const testOf = (input: VerdictInput, id: string) => + computeVerdicts(input).tests.find((entry) => entry.id === id); + +const SAME_VALUES: Partial = { facet: facet(['2', '3']) }; +const RESIDUAL_FACET = facet(['5', '5'], { skeleton: [...SKELETON, 'body.1\tCallable'] }); +const OTHER_FACET = facet(['5', '5'], { fingerprint: 'facet-other' }); + +// ── The positive control and its strong conditions ───────────────────────── + +describe('positive control add(2, 3) against add(5, 5)', () => { + it('is a strong repeat when every strong condition holds', () => { + const verdict = verdictOf(); + expect(verdict).toMatchObject({ + a: A, + b: B, + verdict: 'repeat-strong', + rule: 'all-strong-conditions', + inconclusive: [], + disqualifiers: [], + keep: [], + }); + expect(verdict.evidence).toMatchObject({ + schedule: 'evaluated', + universe: 3, + unknown: 0, + killsA: 1, + killsB: 1, + path: 'same', + checksContext: 'same', + facet: 'equal', + discardedValues: 'differ', + killSets: 'equal', + rowDeletion: 'not-applicable', + trace: 'clean', + staticValueFlow: 'clean', + isolation: 'agrees', + }); + }); + + it('is a weak repeat with isolation-not-run when the isolation pass did not run', () => { + const verdict = verdictOf({ pair: { isolation: null } }); + expect(verdict.verdict).toBe('repeat-weak'); + expect(verdict.rule).toBe('weak-disqualifier'); + expect(verdict.disqualifiers).toEqual([ + { kind: 'isolation-not-run', tests: [], details: ['no isolation result'] }, + ]); + }); + + it('needs no static value-flow check when the discarded values are equal', () => { + const verdict = verdictOf({ b: SAME_VALUES, pair: { staticValueFlow: null } }); + expect([verdict.verdict, verdict.rule]).toEqual(['repeat-strong', 'all-strong-conditions']); + expect(verdict.evidence.discardedValues).toBe('equal'); + expect(verdict.evidence.staticValueFlow).toBe('not-run'); + }); + + it('sends equal discarded values with a trace veto (asymmetric sites) to Review', () => { + const verdict = verdictOf({ + b: SAME_VALUES, + pair: { staticValueFlow: null, trace: { status: 'veto', reasons: ['asymmetric-sites'] } }, + }); + expect([verdict.verdict, verdict.rule]).toEqual(['review', 'value-flow-veto']); + expect(verdict.details).toEqual(['trace: asymmetric-sites']); + }); + + it('never makes a repeat of a value-flow veto, whatever the disqualifiers (KTD12)', () => { + const verdict = verdictOf({ + a: { excludedFiles: ['gitnexus/tools/x.ts'] }, + pair: { isolation: null, staticValueFlow: { status: 'veto', reasons: ['opaque: parseInt'] } }, + }); + expect([verdict.verdict, verdict.rule]).toEqual(['review', 'value-flow-veto']); + expect(kindsOf(verdict)).toEqual(['isolation-not-run', 'excluded-file']); + }); + + it.each([ + { + label: 'no PDG layer vetoes a clean pair', + degraded: ['no-pdg-layer'] as DegradedFlag[], + rule: 'value-flow-veto', + details: ['static: no-pdg-layer'], + }, + { + label: 'a checker version mismatch makes it unknown', + degraded: ['checker-version'] as DegradedFlag[], + rule: 'value-flow-unknown', + details: ['static: checker-version'], + }, + ])('degraded value flow: $label', ({ degraded, rule, details }) => { + const verdict = verdictOf({ degraded }); + expect([verdict.verdict, verdict.rule, verdict.details]).toEqual(['review', rule, details]); + }); + + it('ignores a degraded value-flow run when the discarded values are equal', () => { + const verdict = verdictOf({ b: SAME_VALUES, degraded: ['no-pdg-layer'] }); + expect(verdict.verdict).toBe('repeat-strong'); + }); + + it('reads a missing static check of differing values as unknown', () => { + const verdict = verdictOf({ pair: { staticValueFlow: null } }); + expect([verdict.verdict, verdict.rule, verdict.details]).toEqual([ + 'review', + 'value-flow-unknown', + ['static: not-run'], + ]); + }); + + it('reads a not-required static check of differing values as unknown, never strong', () => { + const verdict = verdictOf({ + pair: { staticValueFlow: { status: 'not-required', reasons: [] } }, + }); + expect([verdict.verdict, verdict.rule, verdict.details]).toEqual([ + 'review', + 'value-flow-unknown', + ['static: not-required for differing discarded values'], + ]); + }); + + it('reads a missing trace on the repeat path as Inconclusive', () => { + const verdict = verdictOf({ pair: { trace: null } }); + expect([verdict.verdict, verdict.rule, verdict.details]).toEqual([ + 'inconclusive', + 'trace-inconclusive', + ['trace-not-run'], + ]); + }); + + it('ignores the trace and the static check of a pair that is a Row', () => { + const verdict = verdictOf({ + pair: { + rowDeletion: { outcome: 'asymmetric', reasons: [] }, + trace: { status: 'inconclusive', reasons: ['trace-overflow'] }, + staticValueFlow: { status: 'veto', reasons: ['opaque: Math.round'] }, + }, + }); + expect([verdict.verdict, verdict.rule]).toEqual(['row', 'row-deletion-asymmetric']); + }); +}); + +// ── One case per precedence rule ─────────────────────────────────────────── + +const RULE_CASES: { rule: RuleId; verdict: Verdict; edit: WorldEdit }[] = [ + { + rule: 'not-scheduled', + verdict: 'inconclusive', + edit: { pair: { schedule: { status: 'inconclusive', reason: 'not-scheduled', detail: null } } }, + }, + { + rule: 'group-over-budget', + verdict: 'inconclusive', + edit: { + pair: { schedule: { status: 'inconclusive', reason: 'group-over-budget', detail: null } }, + }, + }, + { + rule: 'manifest-mismatch', + verdict: 'inconclusive', + edit: { + pair: { + schedule: { status: 'inconclusive', reason: 'manifest-mismatch', detail: 'digest x' }, + }, + }, + }, + { rule: 'path-unattributable', verdict: 'inconclusive', edit: { a: { concurrent: true } } }, + { + rule: 'kills-unknown', + verdict: 'inconclusive', + edit: { observations: change(B, M2, MISSING) }, + }, + { + rule: 'non-assertion-outcome', + verdict: 'inconclusive', + edit: { observations: change(B, M2, { outcome: 'timeout' }) }, + }, + { + rule: 'isolation-disagrees', + verdict: 'inconclusive', + edit: { + pair: { + isolation: { status: 'disagrees', reason: 'isolation-disagrees', disqualifiers: [] }, + }, + }, + }, + { + rule: 'isolation-unknown', + verdict: 'inconclusive', + edit: { + pair: { isolation: { status: 'unknown', reason: 'isolation-unknown', disqualifiers: [] } }, + }, + }, + { + rule: 'rerun-disagrees', + verdict: 'inconclusive', + edit: { observations: add(observation(A, M2, { kind: 'alone', lineage: A })) }, + }, + { + rule: 'member-inconclusive', + verdict: 'inconclusive', + edit: { + observations: chain( + change(A, M1, SURVIVED), + add(observation(A, M1, { kind: 'alone', lineage: A, ...MISSING })), + ), + }, + }, + { + rule: 'trace-inconclusive', + verdict: 'inconclusive', + edit: { pair: { trace: { status: 'inconclusive', reasons: ['trace-changed-outcome'] } } }, + }, + { rule: 'not-eligible', verdict: 'out-of-scope', edit: { b: { eligible: false } } }, + { + rule: 'covers-no-evaluated-mutant', + verdict: 'out-of-scope', + edit: { observations: chain(change(B, M1, UNCOVERED), change(B, M2, UNCOVERED)) }, + }, + { rule: 'kills-nothing', verdict: 'weak', edit: { observations: change(B, M1, SURVIVED) } }, + { + rule: 'value-flow-veto', + verdict: 'review', + edit: { pair: { staticValueFlow: { status: 'veto', reasons: ['opaque: Math.round'] } } }, + }, + { + rule: 'value-flow-unknown', + verdict: 'review', + edit: { pair: { staticValueFlow: { status: 'unknown', reasons: ['expected-only'] } } }, + }, + { rule: 'all-strong-conditions', verdict: 'repeat-strong', edit: {} }, + { rule: 'weak-disqualifier', verdict: 'repeat-weak', edit: { a: { notOracleValidated: true } } }, + { + rule: 'kills-differ', + verdict: 'row', + edit: { observations: change(B, M2, { outcome: 'killed-by-test:assertion' }) }, + }, + { + rule: 'row-deletion-asymmetric', + verdict: 'row', + edit: { pair: { rowDeletion: { outcome: 'asymmetric', reasons: [] } } }, + }, + { rule: 'residual-difference', verdict: 'review', edit: { b: { facet: RESIDUAL_FACET } } }, + { rule: 'facet-differs-same-kills', verdict: 'review', edit: { b: { facet: OTHER_FACET } } }, + { rule: 'facet-missing-same-kills', verdict: 'review', edit: { b: { facet: null } } }, + { + rule: 'path-differs', + verdict: 'distinct', + edit: { observations: change(B, M3, { covered: true, sources: ['row'] }) }, + }, + { rule: 'checks-context-missing', verdict: 'distinct', edit: { b: { checksContext: null } } }, + { + rule: 'checks-context-differs', + verdict: 'distinct', + edit: { b: { checksContext: 'checks-other' } }, + }, + { + rule: 'facet-differs-kills-differ', + verdict: 'distinct', + edit: { + b: { facet: OTHER_FACET }, + observations: change(B, M2, { outcome: 'killed-by-test:assertion' }), + }, + }, +]; + +describe('precedence rules (design §2)', () => { + it.each(RULE_CASES)('rule $rule decides $verdict', ({ rule, verdict, edit }) => { + const decided = verdictOf(edit); + expect([decided.verdict, decided.rule]).toEqual([verdict, rule]); + }); + + it('lists every Inconclusive cause, the first deciding, above Out of scope', () => { + const verdict = verdictOf({ + b: { eligible: false }, + pair: { + schedule: { status: 'inconclusive', reason: 'not-scheduled', detail: null }, + isolation: { status: 'disagrees', reason: 'isolation-disagrees', disqualifiers: [] }, + }, + }); + expect(verdict.verdict).toBe('inconclusive'); + expect(verdict.inconclusive).toEqual([ + 'not-scheduled', + 'isolation-disagrees', + 'member-inconclusive', + ]); + }); + + it('makes a timeout on a mutant covered by B Inconclusive with the mutant named', () => { + const verdict = verdictOf({ observations: change(B, M2, { outcome: 'timeout' }) }); + expect(verdict.details).toEqual([`${B}: timeout on ${M2}`]); + }); + + it('keeps a residual difference (D1) out of every repeat category', () => { + const verdict = verdictOf({ b: { facet: RESIDUAL_FACET } }); + expect(verdict.evidence.facet).toBe('residual'); + expect(verdict.details).toEqual(['body.1']); + expect(kindsOf(verdict)).toContain('unresolved-facet-input'); + }); + + it('makes a Row of two Set members told apart by row deletion (set-membership)', () => { + const verdict = verdictOf({ + a: { facet: facet(['200']) }, + b: { facet: facet(['204']) }, + pair: { rowDeletion: { outcome: 'asymmetric', reasons: [] } }, + }); + expect([verdict.verdict, verdict.rule]).toEqual(['row', 'row-deletion-asymmetric']); + }); + + it('makes a Row of kill sets that differ by one mutant', () => { + const verdict = verdictOf({ + observations: change(B, M2, { outcome: 'killed-by-test:assertion' }), + }); + expect([verdict.verdict, verdict.evidence.killSets]).toEqual(['row', 'differ']); + }); +}); + +// ── One case per weak disqualifier ───────────────────────────────────────── + +const DISQUALIFIER_CASES: { label: string; kind: WeakDisqualifier; edit: WorldEdit }[] = [ + { + label: 'native entry point', + kind: 'provenance-not-strong-eligible', + edit: { + a: { + provenance: { + known: true, + nativeEntries: ['node_modules/tree-sitter/index.js'], + workerSpawns: [], + }, + }, + }, + }, + { + label: 'unknown provenance', + kind: 'provenance-not-strong-eligible', + edit: { a: { provenance: { known: false, nativeEntries: [], workerSpawns: [] } } }, + }, + { + label: 'worker spawn', + kind: 'provenance-not-strong-eligible', + edit: { + a: { + provenance: { + known: true, + nativeEntries: [], + workerSpawns: [{ api: 'Worker', target: 'gitnexus/src/worker.js', site: null }], + }, + }, + }, + }, + { + label: 'no provenance record', + kind: 'provenance-not-strong-eligible', + edit: { a: { provenance: null } }, + }, + { + label: 'unresolved facet input', + kind: 'unresolved-facet-input', + edit: { a: { facet: facet(['2', '3'], { unresolved: ['row value is not static'] }) } }, + }, + { + label: 'unresolved inventory input', + kind: 'unresolved-facet-input', + edit: { a: { inventoryUnresolved: ['row value is not static'] } }, + }, + { + label: 'unresolved selector', + kind: 'unresolved-facet-input', + edit: { a: { unresolvedSelectors: ['label'] } }, + }, + { + label: 'inconclusive row deletion', + kind: 'row-deletion-inconclusive', + edit: { pair: { rowDeletion: { outcome: 'inconclusive', reasons: ['unsupported-table'] } } }, + }, + { + label: 'row deletion missing for differing values', + kind: 'row-deletion-inconclusive', + edit: { pair: { rowDeletion: null } }, + }, + { + label: 'unsupported assertion form', + kind: 'unsupported-construct', + edit: { + a: { + unsupportedForms: [{ line: 4, form: 'assert.equal', reason: 'not an expect assertion' }], + }, + }, + }, + { + label: 'join that is not one-to-one', + kind: 'unsupported-construct', + edit: { a: { joinProblems: ['join-mismatch'] } }, + }, + { + label: 'isolation not run', + kind: 'isolation-not-run', + edit: { + pair: { isolation: { status: 'not-run', reason: 'not-scheduled', disqualifiers: [] } }, + }, + }, + { + label: 'not oracle-validated', + kind: 'not-oracle-validated', + edit: { a: { notOracleValidated: true } }, + }, + { + label: 'non-identical V8 blocks', + kind: 'non-identical-blocks', + edit: { pair: { identicalBlocks: false } }, + }, + { + label: 'executed gitnexus/tools/x.ts', + kind: 'excluded-file', + edit: { a: { excludedFiles: ['gitnexus/tools/x.ts'] } }, + }, + { + label: 'hook-attributed observation', + kind: 'hook-stratum', + edit: { observations: change(A, M1, { hookAttributed: true }) }, + }, + { + label: 'observation without a hook marker', + kind: 'hook-stratum', + edit: { observations: change(A, M1, { hookAttributed: null }) }, + }, + { + label: 'hook stratum from the isolation pass', + kind: 'hook-stratum', + edit: { + pair: { + isolation: { + status: 'agrees', + reason: null, + disqualifiers: [{ kind: 'hook-stratum-unvalidated', cap: 'weak', mutants: [M1] }], + }, + }, + }, + }, +]; + +describe('weak disqualifiers', () => { + it.each(DISQUALIFIER_CASES)('$label caps the repeat at weak ($kind)', ({ kind, edit }) => { + const verdict = verdictOf(edit); + expect([verdict.verdict, verdict.rule]).toEqual(['repeat-weak', 'weak-disqualifier']); + expect(kindsOf(verdict)).toEqual([kind]); + }); + + it('names the test and the file of an excluded-file disqualifier', () => { + const verdict = verdictOf({ a: { excludedFiles: ['gitnexus/tools/x.ts'] } }); + expect(verdict.disqualifiers).toEqual([ + { kind: 'excluded-file', tests: [A], details: ['gitnexus/tools/x.ts'] }, + ]); + }); + + it('lists the disqualifiers of a pair whatever its verdict', () => { + const verdict = verdictOf({ + pair: { + identicalBlocks: false, + schedule: { status: 'inconclusive', reason: null, detail: null }, + }, + }); + expect([verdict.verdict, verdict.rule]).toEqual(['inconclusive', 'not-scheduled']); + expect(kindsOf(verdict)).toEqual(['non-identical-blocks']); + }); +}); + +// ── Kill witnesses ───────────────────────────────────────────────────────── + +const KILLED: Partial = { outcome: 'killed-by-test:assertion' }; + +describe('kill witnesses', () => { + // B kills M2 by assertion where A survives it: a witness, whatever else the run holds. + it.each([ + { + label: 'an unknown mutant', + observations: chain(change(B, M2, KILLED), change(B, M3, MISSING)), + unknown: 1, + }, + { + label: 'a non-assertion outcome on another mutant', + observations: chain( + change(B, M2, KILLED), + change(B, M1, { outcome: 'killed-by-test:runtime' }), + ), + unknown: 0, + }, + { + // A kills M1 where B survives it; B's own kill, M2, is unknown for A. + label: 'an empty kill set beside an unknown mutant', + observations: chain(change(B, M1, SURVIVED), change(B, M2, KILLED), change(A, M2, MISSING)), + unknown: 1, + }, + ])('decides a pair with a witness and $label as a Row', ({ observations, unknown }) => { + const verdict = verdictOf({ observations }); + expect([verdict.verdict, verdict.rule, verdict.details, verdict.inconclusive]).toEqual([ + 'row', + 'kills-differ', + ['1 mutants killed by one test only'], + [], + ]); + expect([verdict.evidence.killSets, verdict.evidence.unknown]).toEqual(['differ', unknown]); + }); + + it('keeps a pair Inconclusive when its only difference is a non-assertion outcome', () => { + const verdict = verdictOf({ + observations: change(B, M1, { outcome: 'killed-by-test:runtime' }), + }); + expect([verdict.verdict, verdict.rule]).toEqual(['inconclusive', 'non-assertion-outcome']); + }); +}); + +// ── Import failures ──────────────────────────────────────────────────────── + +describe('a test file that fails to import under a static mutant', () => { + const failed = (test: string): MergedObservation => + observation(test, M4, { + static: true, + covered: false, + outcome: 'killed-outside-test:collection', + sources: ['collection'], + }); + + it.each([ + { + label: 'both tests', + observations: add(failed(A), failed(B)), + decided: ['repeat-strong', 'all-strong-conditions'], + }, + { + label: 'one test', + observations: add( + failed(A), + observation(B, M4, { static: true, covered: false, ...SURVIVED }), + ), + decided: ['inconclusive', 'non-assertion-outcome'], + }, + ])('decides a pair whose files failed for $label', ({ observations, decided }) => { + const verdict = verdictOf({ observations }); + expect([verdict.verdict, verdict.rule]).toEqual(decided); + }); +}); + +// ── Per-test properties ──────────────────────────────────────────────────── + +describe('per-test Weak and Inconclusive', () => { + it('reports both tests Weak and the pair Weak when both kill sets are empty', () => { + const input = world({ observations: chain(change(A, M1, SURVIVED), change(B, M1, SURVIVED)) }); + const report = computeVerdicts(input); + expect([report.pairs[0].verdict, report.pairs[0].rule]).toEqual(['weak', 'kills-nothing']); + expect(report.tests.map((entry) => [entry.id, entry.status, entry.statements])).toEqual([ + [A, 'weak', [WORDING.weak]], + [B, 'weak', [WORDING.weak]], + ]); + }); + + it('reports a test with only a runtime kill Inconclusive, not Weak', () => { + const input = world({ observations: change(A, M1, { outcome: 'killed-by-test:runtime' }) }); + expect(testOf(input, A)).toMatchObject({ + status: 'inconclusive', + reasons: ['non-assertion-outcome: killed-by-test:runtime'], + kills: 0, + statements: [], + }); + expect(computeVerdicts(input).pairs[0].rule).toBe('non-assertion-outcome'); + }); + + it.each([ + { label: 'killing', edit: {}, status: 'killing', reasons: [] }, + { + label: 'not eligible', + edit: { a: { eligible: false } }, + status: 'out-of-scope', + reasons: ['not-eligible'], + }, + { + label: 'covering nothing', + edit: { observations: chain(change(A, M1, UNCOVERED), change(A, M2, UNCOVERED)) }, + status: 'out-of-scope', + reasons: ['covers-no-evaluated-mutant'], + }, + { + label: 'never evaluated', + edit: { + pair: { schedule: { status: 'inconclusive', reason: 'group-over-budget', detail: null } }, + }, + status: 'inconclusive', + reasons: ['not-evaluated'], + }, + { + label: 'with an unknown result and no kill', + edit: { observations: chain(change(A, M1, SURVIVED), change(A, M2, MISSING)) }, + status: 'inconclusive', + reasons: ['unknown-result: missing-result'], + }, + { + label: 'killing only a static mutant', + edit: { + observations: chain( + change(A, M1, UNCOVERED), + change(A, M2, UNCOVERED), + add(observation(A, M4, { covered: false, static: true })), + ), + }, + status: 'killing', + reasons: [], + }, + ] as { label: string; edit: WorldEdit; status: string; reasons: string[] }[])( + 'test A $label', + ({ edit, status, reasons }) => { + expect(testOf(world(edit), A)).toMatchObject({ status, reasons }); + }, + ); +}); + +describe('subsumption (design §5.4)', () => { + /** A kills M1; B kills M1 and M2 over the same run. */ + const subsumedWorld = (edit: ObservationEdit = (entries) => entries): VerdictInput => + world({ observations: chain(change(B, M2, { outcome: 'killed-by-test:assertion' }), edit) }); + + it('marks A Subsumed when B kills a strict superset over all of A universe', () => { + const input = subsumedWorld(); + expect(testOf(input, A)?.subsumption).toMatchObject({ + status: 'subsumed', + by: [B], + universe: 3, + }); + expect(testOf(input, A)?.statements).toEqual([WORDING.subsumed]); + expect(testOf(input, B)?.subsumption.status).toBe('not-subsumed'); + }); + + it.each([ + { label: 'B has no valid result on one mutant', edit: change(B, M3, MISSING) }, + { + label: 'B was evaluated in another group run that lacks one mutant', + edit: (entries: MergedObservation[]) => + entries + .filter((entry) => entry.test !== B) + .concat( + observation(B, M1, { group: 'g2' }), + observation(B, M2, { group: 'g2' }), + observation(C, M1, { group: 'g2', ...SURVIVED }), + observation(C, M2, { group: 'g2' }), + ), + }, + ])('makes subsumption unknown when $label', ({ edit }) => { + const input = subsumedWorld(edit); + const extra = { ...input, pairs: [...input.pairs, pairInput({ a: B, b: C, group: 'g2' })] }; + expect( + testOf({ ...extra, tests: [...extra.tests, testInput(C)] }, A)?.subsumption, + ).toMatchObject({ status: 'unknown', by: [B] }); + }); + + it('does not mark tests with equal kill sets Subsumed', () => { + expect(testOf(world(), A)?.subsumption.status).toBe('not-subsumed'); + }); +}); + +// ── Gaps ─────────────────────────────────────────────────────────────────── + +describe('gaps per production file (R11)', () => { + it('recomputes gaps from every trusted observation, across groups', () => { + const input: VerdictInput = { + ...world(), + tests: [...world().tests, testInput(C), testInput(D)], + pairs: [ + pairInput(), + pairInput({ a: C, b: D, group: 'g2' }), + pairInput({ + a: A, + b: C, + group: 'g3', + schedule: { status: 'inconclusive', reason: 'baseline-failed', detail: null }, + }), + ], + observations: { + explicit: [ + ...mainRun([A, B]), + observation(A, M4, { covered: false, static: true, ...SURVIVED }), + observation(B, M4, { covered: false, static: true, ...SURVIVED }), + // Group g2 kills M2, which survives group g1. + observation(C, M2, { group: 'g2' }), + observation(D, M2, { group: 'g2', ...SURVIVED }), + // Group g3 never completed: its kill of M3 does not count. + observation(A, M3, { group: 'g3' }), + ], + universes: [], + }, + }; + expect(computeVerdicts(input).gaps).toEqual([ + { file: FILE, mutants: 4, killed: 2, survived: [M4], uncovered: [M3], inconclusive: [] }, + ]); + }); + + it('never counts a missing result as survived', () => { + const input = world({ observations: chain(change(A, M2, MISSING)) }); + expect(computeVerdicts(input).gaps[0]).toMatchObject({ survived: [], inconclusive: [M2] }); + }); +}); + +// ── dupe-ok and determinism ──────────────────────────────────────────────── + +describe('report', () => { + it('moves a pair with dupe-ok on B to the kept list with its evidence (R15)', () => { + const report = computeVerdicts(world({ b: { dupeOk: 'documents the doubling case' } })); + expect(report.pairs).toEqual([]); + expect(report.kept).toHaveLength(1); + expect(report.kept[0]).toMatchObject({ + verdict: 'repeat-strong', + keep: [{ test: B, reason: 'documents the doubling case' }], + }); + }); + + it('carries the engine version', () => { + expect(computeVerdicts(world()).engineVersion).toBe(VERDICT_ENGINE_VERSION); + }); + + it('gives byte-identical output on a rerun and on reordered input', () => { + const input: VerdictInput = { + ...world({ observations: change(B, M2, { outcome: 'killed-by-test:assertion' }) }), + tests: [...world().tests, testInput(C)], + pairs: [pairInput(), pairInput({ a: C, b: A, group: 'g1' })], + }; + const withC: VerdictInput = { + ...input, + observations: { explicit: [...input.observations.explicit, ...mainRun([C])], universes: [] }, + }; + const reordered: VerdictInput = { + tests: [...withC.tests].reverse(), + pairs: [...withC.pairs].reverse().map((pair) => ({ ...pair, a: pair.b, b: pair.a })), + observations: { explicit: [...withC.observations.explicit].reverse(), universes: [] }, + valueFlowDegraded: [], + }; + const first = JSON.stringify(computeVerdicts(withC)); + expect(JSON.stringify(computeVerdicts(withC))).toBe(first); + expect(JSON.stringify(computeVerdicts(reordered))).toBe(first); + }); +}); + +// ── Compact observations ─────────────────────────────────────────────────── + +describe('compact observations', () => { + /** The main run's universe: A and B selected, M1 to M3 evaluated, none static. */ + const universe: RunUniverse = { + group: 'g1', + kind: 'main', + lineage: 'main', + shard: 0, + tests: [A, B], + mutants: [M1, M2, M3].map((id) => ({ + id, + file: FILE, + static: false, + status: 'Survived' as const, + reasons: [], + hookMarker: true, + })), + valid: true, + reasons: [], + }; + const implied = (input: VerdictInput, reasons: string[] = []): VerdictInput => ({ + ...input, + observations: { + explicit: input.observations.explicit.filter((entry) => entry.mutant !== M3), + universes: [{ ...universe, valid: reasons.length === 0, reasons }], + }, + }); + + it.each([ + ['the positive control', {}], + ['a pair whose kill sets differ', { observations: change(B, M1, SURVIVED) }], + ['a test that kills nothing', { observations: chain(change(A, M1, SURVIVED)) }], + ])('decides %s the same when the not-covered survivals of M3 are implied', (_title, edit) => { + expect(computeVerdicts(implied(world(edit)))).toEqual(computeVerdicts(world(edit))); + }); + + it('makes an implied observation of a run with a run-level reason unknown, never survived', () => { + const report = computeVerdicts(implied(world(), ['run-flag:dry-run-missing'])); + expect([report.pairs[0].rule, report.gaps[0].inconclusive, report.gaps[0].uncovered]).toEqual([ + 'kills-unknown', + [M3], + [], + ]); + }); +}); diff --git a/gitnexus/test/unit/scenario-map/vitest-run.test.ts b/gitnexus/test/unit/scenario-map/vitest-run.test.ts new file mode 100644 index 000000000..277d41fe8 --- /dev/null +++ b/gitnexus/test/unit/scenario-map/vitest-run.test.ts @@ -0,0 +1,85 @@ +/** + * Locks the worker bound of the in-process Vitest runs (U7 vitest-run.ts; the scenario-map CLI's + * `--concurrency` bounds them as it bounds Stryker): `maxWorkers` reaches the options runVitest + * gives `createVitest` (a fake, so no Vitest instance starts), and inProcessVitestOptions, which + * path-coverage/run-coverage.ts shares, sets it only when given. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CliOptions } from 'vitest/node'; +import { + inProcessVitestOptions, + runVitest, + type CreateVitest, +} from '../../../tools/scenario-map/mutation/vitest-run.js'; + +const tempDirs: string[] = []; +const tempDir = (): string => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'scenario-vitest-run-')); + tempDirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** A createVitest that records its options and returns an instance without projects. */ +function fakeCreateVitest(seen: CliOptions[]): CreateVitest { + return (options) => { + seen.push(options); + return Promise.resolve({ + projects: [], + close: () => Promise.resolve(), + collectTests: () => Promise.reject(new Error('not collected')), + runTestSpecifications: () => Promise.reject(new Error('not run')), + }); + }; +} + +describe('runVitest worker bound', () => { + it.each([ + ['the given bound', 3, 3], + ['no bound (Vitest default)', undefined, undefined], + ])('passes %s to createVitest', async (_title, maxWorkers, expected) => { + const seen: CliOptions[] = []; + const run = runVitest( + { + packageDir: tempDir(), + testIds: ['test/unit/a.test.ts#a > b'], + vitest: { configFile: 'vitest.config.ts', project: 'p' }, + workDir: path.join(tempDir(), 'work'), + maxWorkers, + }, + fakeCreateVitest(seen), + ); + await expect(run).rejects.toThrow(/expected one Vitest project named p/); + expect(seen.map((options) => [options.maxWorkers, options.project, options.watch])).toEqual([ + [expected, ['p'], false], + ]); + }); +}); + +describe('inProcessVitestOptions', () => { + it('bounds the workers and keeps the repeats of a coverage run', () => { + expect( + inProcessVitestOptions({ root: '/p', configFile: 'c.config.ts', maxWorkers: 2, repeats: 1 }), + ).toEqual({ + root: '/p', + config: path.join('/p', 'c.config.ts'), + project: undefined, + watch: false, + cache: false, + fsModuleCache: false, + repeats: 1, + maxWorkers: 2, + reporters: [], + }); + }); + + it('leaves the worker count to Vitest without a bound', () => { + const options = inProcessVitestOptions({ root: '/p', configFile: 'c.config.ts' }); + expect(['maxWorkers' in options, 'repeats' in options]).toEqual([false, false]); + }); +}); diff --git a/gitnexus/test/unit/test-dupes.test.ts b/gitnexus/test/unit/test-dupes.test.ts new file mode 100644 index 000000000..882974026 --- /dev/null +++ b/gitnexus/test/unit/test-dupes.test.ts @@ -0,0 +1,434 @@ +/** + * Locks the advisory duplicate-test scan (tools/test-dupes/test-dupes.ts) that CI posts in the + * sticky PR comment. The false-positive table pins every case the 2026-10 audit found + * where an identical `it` body tested something different. + */ +import type { ExpressionStatement } from '@babel/types'; +import { describe, it, expect } from 'vitest'; +import { + dupeOkReason, + extract, + extractCases, + orphanBenchGates, + parseUnifiedDiff, + scan, + suppressed, + type Finding, + type SourceFile, + type Touched, +} from '../../tools/test-dupes/test-dupes.js'; + +const UNIT = 'gitnexus/test/unit'; +const file = (path: string, ...lines: string[]): SourceFile => ({ path, source: lines.join('\n') }); +const touch = (...paths: string[]): Touched => new Map(paths.map((p) => [p, 'all' as const])); +const summary = (findings: Finding[]): string[] => + findings.map( + (f) => + `${f.kind} ${f.file}:${f.line} -> ${f.matches.map((m) => `${m.file}:${m.line}`).join(',')}`, + ); + +/** One test file whose single case sits on line 3, inside `describe`. */ +const suite = (path: string, header: string, body: string, title = 't'): SourceFile => + file(path, header, `describe('d', () => {`, ` it('${title}', () => { ${body} });`, `});`); + +describe('same-test', () => { + it('flags a case whose body and setup match a test in another file', () => { + const header = `import { parseThing } from '../../src/thing.js';`; + const body = `expect(parseThing('x').kind).toBe('y');`; + const files = [ + suite(`${UNIT}/a.test.ts`, header, body, 'parses'), + suite(`${UNIT}/b.test.ts`, header, body, 'parses x into y'), + ]; + expect(summary(scan(files, touch(`${UNIT}/b.test.ts`)).findings)).toEqual([ + `same-test ${UNIT}/b.test.ts:3 -> ${UNIT}/a.test.ts:3`, + ]); + }); + + it.each([ + [ + 'file-local helpers differ (cfg snapshot serialize)', + `function serialize(x) { return x.a; }`, + `function serialize(x) { return x.b; }`, + `expect(serialize(load())).toMatchSnapshot();`, + ], + [ + 'imports resolve to different modules', + `import { parseThing } from '../../src/a.js';`, + `import { parseThing } from '../../src/b.js';`, + `expect(parseThing('x').kind).toBe('y');`, + ], + [ + 'describe-level constants differ (workflow fixture)', + `const workflow = 'ci.yml';`, + `const workflow = 'docker.yml';`, + `expect(load(workflow).jobs).toBeDefined();`, + ], + ])('does not flag identical bodies when %s', (_name, headerA, headerB, body) => { + const files = [ + suite(`${UNIT}/a.test.ts`, headerA, body), + suite(`${UNIT}/b.test.ts`, headerB, body), + ]; + expect(scan(files, touch(`${UNIT}/b.test.ts`)).findings).toEqual([]); + }); + + it.each([ + [ + 'wrapper-call options differ (fts DB options)', + `withDb('n', (db) => { it('t', () => { expect(db.count()).toBe(1); }); }, { fts: true });`, + `withDb('n', (db) => { it('t', () => { expect(db.count()).toBe(1); }); }, { fts: false });`, + ], + [ + 'describe-level env writes differ', + `describe('d', () => { process.env.X = '1'; it('t', () => { expect(run()).toBe(1); }); });`, + `describe('d', () => { process.env.X = '0'; it('t', () => { expect(run()).toBe(1); }); });`, + ], + [ + 'loop heads differ', + `for (const lang of ['ts']) { it('t', () => { expect(parse(lang)).toBeDefined(); }); }`, + `for (const lang of ['py']) { it('t', () => { expect(parse(lang)).toBeDefined(); }); }`, + ], + [ + 'each tables differ', + `it.each([[1]])('t %s', (n) => { expect(double(n)).toBe(n * 2); });`, + `it.each([[2]])('t %s', (n) => { expect(double(n)).toBe(n * 2); });`, + ], + ])('does not flag identical bodies when %s', (_name, a, b) => { + const files = [file(`${UNIT}/a.test.ts`, a), file(`${UNIT}/b.test.ts`, b)]; + expect(scan(files, touch(`${UNIT}/b.test.ts`)).findings).toEqual([]); + }); + + it('reports only cases on touched lines', () => { + const body = `expect(parseThing('x').kind).toBe('y');`; + const files = [suite(`${UNIT}/a.test.ts`, '', body), suite(`${UNIT}/b.test.ts`, '', body)]; + const untouched = new Map([[`${UNIT}/b.test.ts`, new Set([1])]]); + expect(scan(files, untouched).findings).toEqual([]); + }); + + it('honours `// dupe-ok:` on the line above the case', () => { + const body = `expect(parseThing('x').kind).toBe('y');`; + const files = [ + suite(`${UNIT}/a.test.ts`, '', body), + file( + `${UNIT}/b.test.ts`, + `describe('d', () => {`, + ` // dupe-ok: order-dependent control, must run after the opt-out cases`, + ` it('t', () => { ${body} });`, + `});`, + ), + ]; + expect(scan(files, touch(`${UNIT}/b.test.ts`)).findings).toEqual([]); + }); +}); + +describe('same-fixture-setup', () => { + const helpers = file( + 'gitnexus/test/integration/resolvers/helpers.ts', + `import path from 'path';`, + `export const FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang');`, + `export async function runPipeline(p: string) { return p; }`, + ); + const describeWith = (hookBody: string, assertion: string, hook = 'beforeAll'): string[] => [ + `describe('d', () => {`, + ` let r;`, + ` ${hook}(async () => { ${hookBody} });`, + ` it('t', () => { ${assertion} });`, + `});`, + ]; + + it('matches a fixture path spelled locally against the same path imported from a helper', () => { + const local = file( + 'gitnexus/test/integration/a.test.ts', + `import path from 'path';`, + `import { runPipeline } from './resolvers/helpers.js';`, + `const FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'lang');`, + ...describeWith( + `r = await runPipeline(path.join(FIXTURES, 'py'));`, + `expect(r).toBeDefined();`, + ), + ); + const imported = file( + 'gitnexus/test/integration/resolvers/b.test.ts', + `import path from 'path';`, + `import { FIXTURES, runPipeline } from './helpers.js';`, + ...describeWith( + `r = await runPipeline(path.join(FIXTURES, 'py'));`, + `expect(r).toBeTruthy();`, + ), + ); + expect(summary(scan([helpers, local, imported], touch(local.path)).findings)).toEqual([ + `same-fixture-setup ${local.path}:6 -> ${imported.path}:5`, + ]); + }); + + it('keeps a beforeAll fixture apart from the same build in a beforeEach', () => { + const suite = (path: string, hook: string): SourceFile => + file( + path, + `import path from 'path';`, + `import { FIXTURES, runPipeline } from './helpers.js';`, + ...describeWith( + `r = await runPipeline(path.join(FIXTURES, 'py'));`, + `expect(r).toBeDefined();`, + hook, + ), + ); + const once = suite('gitnexus/test/integration/resolvers/a.test.ts', 'beforeAll'); + const each = suite('gitnexus/test/integration/resolvers/b.test.ts', 'beforeEach'); + expect(scan([helpers, once, each], touch(each.path)).findings).toEqual([]); + }); + + it('ignores temp dirs that are merely named fixture', () => { + const tmp = (path: string, assertion: string): SourceFile => + file( + path, + `import { createTempDir } from '../helpers/test-db.js';`, + ...describeWith(`fixture = await createTempDir();`, assertion), + ); + const files = [ + tmp(`${UNIT}/a.test.ts`, `expect(1).toBe(1);`), + tmp(`${UNIT}/b.test.ts`, `expect(2).toBe(2);`), + ]; + expect(scan(files, touch(`${UNIT}/b.test.ts`)).findings).toEqual([]); + }); +}); + +describe('assertions-pinned', () => { + it('flags a pure-expect case whose literal assertions another test already makes', () => { + const header = `import { strip } from '../../src/strip.js';`; + const pinned = file( + `${UNIT}/a.test.ts`, + header, + `it('a', () => { expect(strip('User?')).toBe('User'); expect(strip('X')).toBe('X'); });`, + ); + const fresh = file( + `${UNIT}/b.test.ts`, + header, + `it('b', () => { expect(strip('X')).toBe('X'); });`, + `it('c', () => { process.env.Y = '1'; expect(strip('X')).toBe('X'); });`, + `it('d', () => { expect(strip('Z')).toBe('Z'); });`, + ); + expect(summary(scan([pinned, fresh], touch(fresh.path)).findings)).toEqual([ + `assertions-pinned ${fresh.path}:2 -> ${pinned.path}:2`, + ]); + }); +}); + +describe('copied-helper', () => { + it('points a re-declared helper at the exported copy in test/helpers', () => { + const fn = [ + `async function allocateFreePort(): Promise {`, + ` const server = net.createServer();`, + ` await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));`, + ` const port = (server.address() as net.AddressInfo).port;`, + ` await new Promise((resolve) => server.close(resolve));`, + ` return port;`, + `}`, + ]; + const helper = file('gitnexus/test/helpers/net.ts', `export ${fn[0]}`, ...fn.slice(1)); + const copy = file( + 'gitnexus/test/integration/server.test.ts', + `import net from 'node:net';`, + ...fn, + ); + const [finding] = scan([helper, copy], touch(copy.path)).findings; + expect([finding.kind, finding.line, finding.detail]).toEqual([ + 'copied-helper', + 2, + 'already exported from gitnexus/test/helpers/net.ts — import it', + ]); + }); +}); + +describe('orphanBenchGates', () => { + const bench = file( + 'gitnexus/bench/foo/measure.mjs', + `import './lib.mjs';`, + `const check = process.argv.includes('--check');`, + ); + const lib = file('gitnexus/bench/foo/lib.mjs', `export const usage = 'measure.mjs [--check]';`); + const touched = touch(bench.path, lib.path); + + it('flags a touched --check gate that no workflow or npm script runs', () => { + const workflow = file( + '.github/workflows/ci-tests.yml', + 'run: node bench/bar/measure.mjs --check', + ); + expect(summary(orphanBenchGates([bench, lib, workflow], touched))).toEqual([ + 'orphan-bench-gate gitnexus/bench/foo/measure.mjs:2 -> ', + ]); + }); + + it.each([ + ['an unrelated line of the bench script', 1, []], + ['the --check line itself', 2, ['orphan-bench-gate gitnexus/bench/foo/measure.mjs:2 -> ']], + ])('reports an orphan gate only when the diff touches %s', (_label, line, expected) => { + const workflow = file( + '.github/workflows/ci-tests.yml', + 'run: node bench/bar/measure.mjs --check', + ); + const lines: Touched = new Map([[bench.path, new Set([line])]]); + expect(summary(orphanBenchGates([bench, lib, workflow], lines))).toEqual(expected); + }); + + it('accepts a gate a workflow runs and a library a sibling script imports', () => { + const workflow = file( + '.github/workflows/ci-tests.yml', + 'run: node bench/foo/measure.mjs --check', + ); + expect(orphanBenchGates([bench, lib, workflow], touched)).toEqual([]); + }); +}); + +describe('parseUnifiedDiff', () => { + it('collects new-side lines per file and skips pure deletions', () => { + const diff = [ + 'diff --git a/x.ts b/x.ts', + '--- a/x.ts', + '+++ b/x.ts', + '@@ -1,0 +2,2 @@', + '@@ -9 +11 @@', + '@@ -20,3 +23,0 @@', + '+++ /dev/null', + '@@ -1,4 +0,0 @@', + ].join('\n'); + expect([...parseUnifiedDiff(diff)].map(([f, lines]) => [f, [...lines]])).toEqual([ + ['x.ts', [2, 3, 11]], + ]); + }); +}); + +describe('extractCases', () => { + it('records the describe path, title, line and a setup-specific context hash per case', () => { + const records = extractCases( + file( + `${UNIT}/a.test.ts`, + `describe('outer', () => {`, + ` beforeEach(() => { seed(1); });`, + ` describe('inner', () => {`, + ` beforeEach(() => { seed(2); });`, + ` it('a', () => { expect(run()).toBe(1); });`, + ` });`, + ` it('b', () => { expect(run()).toBe(1); });`, + `});`, + ), + ); + expect(records.map((r) => [r.file, r.describePath, r.title, r.line, r.body])).toEqual([ + [`${UNIT}/a.test.ts`, ['outer', 'inner'], 'a', 5, '{expect(run()).toBe(1);}'], + [`${UNIT}/a.test.ts`, ['outer'], 'b', 7, '{expect(run()).toBe(1);}'], + ]); + expect(new Set(records.map((r) => r.contextHash)).size).toBe(2); + }); + + it('keeps the body out of the context hash: siblings under one setup share it', () => { + const [a, b] = extractCases( + file( + `${UNIT}/a.test.ts`, + `describe('d', () => {`, + ` beforeEach(() => { seed(1); });`, + ` it('a', () => { expect(run()).toBe(1); });`, + ` it('b', () => { expect(run()).toBe(2); });`, + `});`, + ), + ); + expect([a.contextHash === b.contextHash, a.key === b.key]).toEqual([true, false]); + }); + + it('reports a `// dupe-ok:` case as suppressed with its reason', () => { + const records = extractCases( + file( + `${UNIT}/a.test.ts`, + `describe('d', () => {`, + ` // dupe-ok: order-dependent control`, + ` it('t', () => { expect(run()).toBe(1); });`, + ` it('u', () => { expect(run()).toBe(1); });`, + `});`, + ), + ); + expect(records.map((r) => [r.title, r.suppressed])).toEqual([ + ['t', 'order-dependent control'], + ['u', null], + ]); + }); + + it.each([ + [ + 'an it.each array table', + `it.each([[1], [2]])('doubles %s', (n) => { expect(double(n)).toBe(n * 2); });`, + { title: 'doubles %s', each: { type: 'ArrayExpression' }, loop: null }, + ], + [ + 'an it.each tagged-template table', + "it.each`n\n${1}`('doubles $n', ({ n }) => { expect(double(n)).toBe(n * 2); });", + { title: 'doubles $n', each: { type: 'TemplateLiteral' }, loop: null }, + ], + [ + 'a test.for table', + `test.for([[1], [2]])('doubles %s', ([n]) => { expect(double(n)).toBe(n * 2); });`, + { title: 'doubles %s', each: { type: 'ArrayExpression' }, loop: null }, + ], + [ + 'a skipIf condition, which is no table', + `it.skipIf(slow)('doubles', () => { expect(double(1)).toBe(2); });`, + { title: 'doubles', each: null, loop: null }, + ], + [ + 'an enclosing for-of loop', + `for (const n of [1, 2]) { it('doubles', () => { expect(double(n)).toBe(n * 2); }); }`, + { title: 'doubles', each: null, loop: { type: 'ForOfStatement' } }, + ], + ])('returns one record with the instance source of %s', (_label, source, expected) => { + expect(extractCases(file(`${UNIT}/a.test.ts`, source))).toMatchObject([expected]); + }); + + it('records a fixture *.case.ts file only when options.isTest accepts it, and keeps its AST', () => { + const fixture = file('gitnexus/test/fixtures/mutation-x/a.case.ts', `it('t', () => {});`); + const isTest = (path: string): boolean => path.endsWith('.case.ts'); + const corpus = extract([fixture], { isTest }); + expect([extractCases(fixture).length, corpus.records.length]).toEqual([0, 1]); + const statement = corpus.asts.get(fixture.path)?.program.body[0] as + | ExpressionStatement + | undefined; + expect(statement?.expression).toBe(corpus.records[0].call); + }); + + it('feeds Layer 0 exactly the unsuppressed, non-empty records under the same key', () => { + const corpus = extract([ + file( + `${UNIT}/a.test.ts`, + `describe('d', () => {`, + ` it('a', () => { expect(run()).toBe(1); });`, + ` // dupe-ok: kept on purpose`, + ` it('b', () => { expect(run()).toBe(1); });`, + ` it('c', () => {});`, + ` it('d', () => { expect(run()).toBe(2); });`, + `});`, + ), + ]); + expect(corpus.records.map((r) => r.title)).toEqual(['a', 'b', 'c', 'd']); + expect(corpus.cases.map((c) => [c.line, c.key])).toEqual( + corpus.records + .filter((r) => r.suppressed === null && r.body !== '{}') + .map((r) => [r.line, r.key]), + ); + }); +}); + +describe('dupeOkReason', () => { + const lines = [ + `// dupe-ok: two lines up`, + `const x = 1;`, + `it('a', () => {});`, + `// dupe-ok: shared fixture`, + `it('b', () => {});`, + `it('c', () => {}); // dupe-ok: trailing`, + `it('d', () => {}); // dupe-ok:`, + ]; + it.each([ + ['a comment two lines above, which does not count', 3, null, false], + ['a comment on the line above', 5, 'shared fixture', true], + ['a trailing comment on the reported line', 6, 'trailing', true], + ['an empty trailing reason, which wins over the line above', 7, '', true], + ])('reads %s', (_label, line, reason, isSuppressed) => { + expect([dupeOkReason(lines, line), suppressed(lines, line)]).toEqual([reason, isSuppressed]); + }); +}); diff --git a/gitnexus/tools/mutation/UPSTREAM.md b/gitnexus/tools/mutation/UPSTREAM.md new file mode 100644 index 000000000..c2cfa99d7 --- /dev/null +++ b/gitnexus/tools/mutation/UPSTREAM.md @@ -0,0 +1,27 @@ +# Stryker Vitest 5 runner — probe findings and upstream deltas + +Versions probed: Vitest 5.0.5, Stryker 10.0.0 (`@stryker-mutator/core`, `@stryker-mutator/api`), tsx 4.23.15, Node v22.23.3. Probed on 2026-10-23 in a scratch directory outside the repo. + +## Probe findings (U11) + +1. Name filter separator: **yes.** `vitest run test/unit/shared-type-extractors.test.ts -t 'extractElementTypeFromString returns undefined for empty string'` gives "Tests 108 skipped (108)" with exit 0. The same name joined with `' > '` gives "1 passed | 107 skipped". `-t` is a regular expression, so titles that contain `|`, `(`, `[` or `?` also need escaping. +2. Per-spec task ids: **yes.** `project.createSpecification(file, { testIds: ['-1575855114_0_5', '-1575855114_1_15'] })`, passed to `vitest.runTestSpecifications([spec])`, gives `{"skipped":106,"passed":2}`, and the 2 tests that ran are exactly the selected ids. Task ids were the same across runs. A spec with only unknown ids silently skips every test and reports no error. +3. Mutant active from module load, unfiltered run: **yes.** A copy of `shared.ts` was instrumented with Stryker 10's `Instrumenter` (1232 mutants, 245 hit at load). Static mutant 4 (`new Set(['Keys'])` to `[]`) was activated by `__STRYKER_ACTIVE_MUTANT__=4` (CLI, and the Node API with the env set between runs) or by `provide` plus a setup file that sets `globalThis.__stryker__.activeMutant`. Each run executed all 108 tests and gave "1 failed | 107 passed" (AssertionError). With no mutant active, all 108 tests passed. +4. TypeScript plugin in child processes: **yes.** A `.ts` plugin exports `strykerPlugins` built with `declareClassPlugin(PluginKind.TestRunner, ...)` and `strykerValidationSchema`, and imports a `.ts` enum through a `.js` specifier. It is loaded through `appendPlugins` with `testRunnerNodeArgs: ["--import", "tsx"]`, and the main process is started as `node --import tsx stryker.js run`. The dry run and mutant runs passed in a child with `execArgv ["--import","tsx"]` and `NODE_OPTIONS` null. Controls: with no tsx in the main process, the plugin fails to load in the main process. With `testRunnerNodeArgs: []`, the child fails with "Cannot find TestRunner plugin". +5. Setup-file task meta and `afterAll` suite: **yes, with a constraint.** Meta written in a setup file reaches the main process through `testCase.meta()` and `testModule.meta()`: from `beforeEach` and `afterEach`, on finished tests from `afterAll`, and on the file task. `afterAll` receives the file task as its second argument. Vitest 5 parses the first argument as a fixture pattern, so the hook must be written `afterAll(({}, suite) => ...)`. Writing `(ctx, suite) =>` throws a FixtureParseError at registration, which fails the file and skips every test. +6. Suite `beforeAll` has no current test, and the setup-file `beforeAll` runs first: **yes.** The observed order is setup `beforeAll` (file task), then `Outer.beforeAll`. In both, `TestRunner.getCurrentTest()` is undefined and `expect.getState().currentTestName` is undefined. In a later describe's `beforeAll` and in `afterAll`, `expect.getState().currentTestName` still holds the previous test's name ("Outer > t2"). Only `TestRunner.getCurrentTest()` can therefore tell that no test is running. + +## Residual deltas against upstream + +What an upstream `@stryker-mutator/vitest-runner` release needs before this plugin can be dropped (KTD1). Status on 2026-10-24: the latest release is 10.0.0, and nothing has been filed upstream. A release that covers every item must still pass gates 1-4 before the plugin goes. + +- **Per-test failure classes.** Stryker's mutant run result has no field for them, only killed, survived or timeout plus `killedBy`. Gates 2-4 and the assertion-only kill rule need the class of every failing test (assertion, runtime, hook, timeout) and of every failure outside a test (a failed `beforeAll`, a collection error). The plugin writes them to sidecar files (KTD6, KTD17). Upstream needs a channel for them. +- **Name filter fixes in a release.** The equivalent of #6214 (`' > '` separator, merged, unreleased) and #6246 (end anchoring). Even with both, titles hold regex metacharacters and duplicate full names, so name patterns stay fragile: see task ids below. +- **Keep the project's pool (#6236).** Upstream forces the threads pool. The repo runs `forks`, and the plugin refuses to start unless the pool is `forks` with isolation on. +- **NoCoverage, not Survived (#6256).** A mutant no test covers must be reported as NoCoverage. Gate 3 treats the two as one class but keeps the raw status in its report. +- **Selection by Vitest task id.** Select the tests of a mutant run with `project.createSpecification(file, { testIds })` and `runTestSpecifications`, never related mode or name patterns, and check that the executed set equals the requested set: a specification with only unknown ids silently skips every test. Static mutants run exactly the dry run's test set. +- **A dry-run manifest shared across processes.** Only one pool process runs the dry run. The others, and replacements after a crash, a timeout or `maxTestRunnerReuse`, only call `init` and `mutantRun`, so test ids, task ids and the expected test set per file must reach them from outside that process. The plugin writes a run manifest atomically to the run directory, and every process loads it before its first mutant run (KTD16). +- **Hook-time hit attribution.** A mutant hit after collection with no current test (a suite's `beforeAll`) is coverage for every test of the suite whose hook ran. Only load- and collection-time hits make a mutant static. The current test must come from `TestRunner.getCurrentTest()`, because `expect.getState().currentTestName` is stale in later hooks (finding 6). +- **Single-test selection in the dry run (Phase 1).** The runner's `vitest5.testIds` option (from a driver scope's `testIds`) restricts the whole run to chosen tests: the dry run collects the scope's files with `vitest.collectTests`, assigns KTD3 ids, maps the requested ids to task ids, runs one `createSpecification(file, { testIds })` per file and checks that exactly the requested tests ran. An id the collection did not find fails the dry run, naming it, before any mutant run. The run manifest then holds only the selected tests, so static mutants run them alone too. +- **A hook marker per mutant (Phase 1).** Hook-attributed coverage cannot be told apart from a test's own hits once it is merged. The setup file keeps the attributed hook bucket apart in the file metadata, the dry-run sidecar record (schema 2) carries, per mutant, the tests a suite hook attributed it to, and `results.json` (schema 2) exposes it as `hookAttributedTests`, next to every attempt's per-test classes (`perTest`) and `attemptsDisagree`. Upstream needs a channel for both. +- **The `({}, suite)` hook signature.** Vitest 5 parses the first hook parameter as a fixture pattern, so a setup-file hook that needs the suite must be written `afterAll(({}, suite) => ...)`. A plain identifier throws a FixtureParseError and fails the file (finding 5). diff --git a/gitnexus/tools/mutation/bench/oracle-cost.ts b/gitnexus/tools/mutation/bench/oracle-cost.ts new file mode 100644 index 000000000..ee15dde05 --- /dev/null +++ b/gitnexus/tools/mutation/bench/oracle-cost.ts @@ -0,0 +1,698 @@ +/** + * Oracle cost benchmark (U12): what gate 3's oracle (U7) costs, measured on a small sample and + * projected to the full KTD11 sample on a 4 vCPU runner against the 6-hour job limit. + * + * Inputs, per scope: an oracle command run (U13, `oracle-command-run.json`, with its kept + * sandbox) and optionally a vitest5 run of the same scope (U10, `results.json`), which knows which + * mutants are static and which tests cover each timed-out mutant. + * + * Measured per scope: + * - single-test launch cost (launch-single.ts) on a deterministic sample: evenly spaced + * assertion-killed mutants launched with one killer and non-killers, evenly spaced survivors, + * and unmutated baseline launches; wall clock (spawn to exit), CPU (the child and its Vitest + * worker) and Vitest's own run time, as mean, median and p90 (nearest rank); + * - each launch's class against the command run's (a small preview of gate 3); + * - the static mutants after blanking (vitest5 run), the population, the tests, the timed-out + * mutants and the command-runner pass (the command run's Stryker wall clock). + * + * Projection ({@link projectOracleCost}, pure): per scope the sample is `min(sampleSize + topUps, + * population)` mutants and every sampled mutant is launched with every test of the scope. + * Launches of timed-out mutants with a covering test are assumed to run into the wall-clock limit + * (`ceil(sampled * timeoutMutants * hangingTestsPerTimeoutMutant / population)` launches, an upper + * bound). With one launch per vCPU, a launch occupies its slot for `max(wall, cpu)`; a hanging + * launch for the whole limit. The command-runner pass is scaled from its measured concurrency to + * at most `vcpus` workers. A fixed overhead covers checkout, install and prepare. The CPU-bound + * floor is the same sum with launch CPU only (it needs more concurrent launches than vCPUs). + * The break-even launch time is the largest per-launch slot time that still fits the job limit. + * + * Report `/oracle-cost.json` (schemaVersion 1): machine (load average before and after, + * cpus, node), options, scopes[] (inputs, strata observations, launch timings, agreement), + * launches[], timings (all launches), projectionInput, projection. `/oracle-cost.txt` is + * the short text summary, also printed. + * + * Usage (from gitnexus/): + * npm run test:mutation:bench -- --scope [,]... + * --out [--mutants (3)] [--tests-per-mutant (2)] [--baselines (2)] + * [--concurrency (1)] [--wall-clock-ms (180000)] [--sample-size (100)] + * [--top-ups (20)] [--vcpus (4)] [--fixed-overhead-ms (900000)] + * [--job-limit-ms (21600000)] + * Exit codes: 0 report written and every launch valid and in agreement; 1 report written with + * invalid or disagreeing launches; 2 usage or setup error. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { + DEFAULT_WALL_CLOCK_MS, + LaunchUsageError, + launchSingle, + readCommandRun, + readTestInventory, + type CommandRunMutant, + type CommandRunView, + type LaunchResult, + type LaunchTarget, +} from '../oracle/launch-single.js'; +import type { OracleClass } from '../oracle/oracle-classify.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; + +export const BENCH_SCHEMA_VERSION = 1; +export const REPORT_FILE = 'oracle-cost.json'; +export const SUMMARY_FILE = 'oracle-cost.txt'; +const HOUR_MS = 3_600_000; + +// ── projection ───────────────────────────────────────────────────────────── + +export interface CostModel { + vcpus: number; + /** Mean wall clock of one launch. */ + launchWallMs: number; + /** Mean CPU time of one launch (the child and its Vitest worker). */ + launchCpuMs: number; + wallClockLimitMs: number; + jobLimitMs: number; + /** Checkout, install, prepare and reporting. */ + fixedOverheadMs: number; +} + +export interface ScopeCostInput { + name: string; + /** Mutants of the scope. */ + population: number; + /** Tests of the scope (the baseline's executed tests). */ + tests: number; + sampleSize: number; + /** Strata top-up mutants on top of the sample. */ + topUps: number; + /** Mutants of the population the command run classified as timeout. */ + timeoutMutants: number; + /** Tests that may hang per timed-out mutant (its covering tests; an upper bound). */ + hangingTestsPerTimeoutMutant: number; + commandRunnerMs: number; + commandRunnerConcurrency: number; + /** Per-scope launch costs; the model's apply when absent. */ + launchWallMs?: number; + launchCpuMs?: number; +} + +export interface CostProjectionInput { + model: CostModel; + scopes: ScopeCostInput[]; +} + +export interface ScopeProjection { + name: string; + sampledMutants: number; + launches: number; + hangingLaunches: number; + /** All launches one after another. */ + launchSerialMs: number; + /** Launches on `vcpus` concurrent slots. */ + launchMs: number; + commandMs: number; + totalMs: number; +} + +export interface CostProjection { + scopes: ScopeProjection[]; + launches: number; + launchMs: number; + commandMs: number; + fixedOverheadMs: number; + totalMs: number; + /** The same sum with launch CPU only; reachable only with more launches than vCPUs. */ + cpuBoundFloorMs: number; + jobLimitMs: number; + headroomMs: number; + fits: boolean; + /** The largest per-launch slot time (`max(wall, cpu)`) that still fits the job limit. */ + breakEvenLaunchMs: number; +} + +/** Projects the full oracle sample's wall clock; pure and deterministic. */ +export function projectOracleCost(input: CostProjectionInput): CostProjection { + const { model } = input; + let floorMs = model.fixedOverheadMs; + let fixedMs = model.fixedOverheadMs; + let normalLaunches = 0; + const scopes = input.scopes.map((scope): ScopeProjection => { + const wall = scope.launchWallMs ?? model.launchWallMs; + const cpu = scope.launchCpuMs ?? model.launchCpuMs; + const sampledMutants = Math.min(scope.sampleSize + scope.topUps, scope.population); + const launches = sampledMutants * scope.tests; + const hangingLaunches = + scope.population === 0 + ? 0 + : Math.min( + launches, + Math.ceil( + (sampledMutants * scope.timeoutMutants * scope.hangingTestsPerTimeoutMutant) / + scope.population, + ), + ); + const normal = launches - hangingLaunches; + const hangMs = (hangingLaunches * model.wallClockLimitMs) / model.vcpus; + const launchMs = (normal * Math.max(wall, cpu)) / model.vcpus + hangMs; + const commandMs = + (scope.commandRunnerMs * scope.commandRunnerConcurrency) / + Math.min(scope.commandRunnerConcurrency, model.vcpus); + floorMs += (normal * cpu) / model.vcpus + hangMs + commandMs; + fixedMs += hangMs + commandMs; + normalLaunches += normal; + return { + name: scope.name, + sampledMutants, + launches, + hangingLaunches, + launchSerialMs: Math.round(normal * wall + hangingLaunches * model.wallClockLimitMs), + launchMs: Math.round(launchMs), + commandMs: Math.round(commandMs), + totalMs: Math.round(launchMs + commandMs), + }; + }); + const sum = (pick: (scope: ScopeProjection) => number): number => + scopes.reduce((total, scope) => total + pick(scope), 0); + const totalMs = sum((scope) => scope.totalMs) + model.fixedOverheadMs; + return { + scopes, + launches: sum((scope) => scope.launches), + launchMs: sum((scope) => scope.launchMs), + commandMs: sum((scope) => scope.commandMs), + fixedOverheadMs: model.fixedOverheadMs, + totalMs, + cpuBoundFloorMs: Math.round(floorMs), + jobLimitMs: model.jobLimitMs, + headroomMs: model.jobLimitMs - totalMs, + fits: totalMs <= model.jobLimitMs, + breakEvenLaunchMs: + normalLaunches === 0 + ? 0 + : Math.round(((model.jobLimitMs - fixedMs) * model.vcpus) / normalLaunches), + }; +} + +export interface TimingSummary { + count: number; + meanMs: number; + /** Nearest-rank percentiles: the value at rank `ceil(p * count)` of the sorted list. */ + medianMs: number; + p90Ms: number; + minMs: number; + maxMs: number; +} + +/** Mean, nearest-rank median and p90, min and max; pure. */ +export function summarizeTimings(values: readonly number[]): TimingSummary { + const sorted = [...values].sort((a, b) => a - b); + const rank = (p: number): number => sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)] ?? 0; + return { + count: sorted.length, + meanMs: + sorted.length === 0 ? 0 : sorted.reduce((total, value) => total + value, 0) / sorted.length, + medianMs: rank(0.5), + p90Ms: rank(0.9), + minMs: sorted[0] ?? 0, + maxMs: sorted.at(-1) ?? 0, + }; +} + +// ── sample selection ─────────────────────────────────────────────────────── + +/** `count` entries of `items`, evenly spaced, in order; pure. */ +export function evenlySpaced(items: readonly T[], count: number): T[] { + const n = Math.min(count, items.length); + return Array.from({ length: n }, (_, i) => items[Math.floor(((i + 0.5) * items.length) / n)]); +} + +export interface PlannedLaunch { + target: LaunchTarget; + mutantId: string | null; + /** The class the command run implies for this test: a killer's assertion kill, else survived. */ + expected: OracleClass; + role: 'killer' | 'non-killer' | 'survivor' | 'baseline'; +} + +export interface SampleOptions { + mutants: number; + testsPerMutant: number; + baselines: number; +} + +const byLocalId = (a: CommandRunMutant, b: CommandRunMutant): number => + Number(a.localId) - Number(b.localId); + +/** The deterministic launch sample of one scope (see the module comment). */ +export function planLaunches( + mutants: readonly CommandRunMutant[], + inventory: readonly LaunchTarget[], + options: SampleOptions, +): PlannedLaunch[] { + const byId = new Map(inventory.map((target) => [target.id, target])); + const killed = evenlySpaced( + mutants + .filter( + (mutant) => + mutant.oracleClass === 'killed-by-test:assertion' && + mutant.assertionKilledBy.some((id) => byId.has(id)), + ) + .sort(byLocalId), + options.mutants, + ); + const survived = evenlySpaced( + mutants.filter((mutant) => mutant.oracleClass === 'survived').sort(byLocalId), + options.mutants, + ); + const planned: PlannedLaunch[] = []; + killed.forEach((mutant) => { + const killer = byId.get(mutant.assertionKilledBy.find((id) => byId.has(id)) ?? ''); + if (killer !== undefined) { + planned.push({ + target: killer, + mutantId: mutant.localId, + expected: 'killed-by-test:assertion', + role: 'killer', + }); + } + const others = inventory.filter((target) => !mutant.killedBy.includes(target.id)); + for (const target of evenlySpaced(others, options.testsPerMutant - 1)) { + planned.push({ target, mutantId: mutant.localId, expected: 'survived', role: 'non-killer' }); + } + }); + survived.forEach((mutant, index) => { + const rotated = [...inventory.slice(index), ...inventory.slice(0, index)]; + for (const target of evenlySpaced(rotated, options.testsPerMutant)) { + planned.push({ target, mutantId: mutant.localId, expected: 'survived', role: 'survivor' }); + } + }); + for (const target of evenlySpaced(inventory, options.baselines)) { + planned.push({ target, mutantId: null, expected: 'survived', role: 'baseline' }); + } + return planned; +} + +// ── inputs ───────────────────────────────────────────────────────────────── + +interface Vitest5View { + staticMutants: number; + /** Covering-test counts of the mutants the vitest5 run timed out, by global id. */ + timeoutCoverage: Map; +} + +type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function readVitest5Results(file: string): Vitest5View { + const data: unknown = JSON.parse(readFileSync(file, 'utf8')); + if (!isObject(data) || data.variant !== 'vitest5' || !Array.isArray(data.mutants)) { + throw new LaunchUsageError(`${file} is not the results.json of a vitest5 run`); + } + let staticMutants = 0; + const timeoutCoverage = new Map(); + for (const mutant of data.mutants) { + if (!isObject(mutant)) continue; + if (mutant.static === true) staticMutants++; + if (mutant.outcome === 'timeout' && typeof mutant.globalId === 'string') { + timeoutCoverage.set( + mutant.globalId, + Array.isArray(mutant.coveredBy) ? mutant.coveredBy.length : 0, + ); + } + } + return { staticMutants, timeoutCoverage }; +} + +interface CommandRunExtras { + name: string; + mutate: string[]; + testFiles: string[]; + strykerWallClockMs: number; + concurrency: number; + meanWrapperWallClockMs: number | null; +} + +function readCommandRunExtras(run: CommandRunView): CommandRunExtras { + const data: unknown = JSON.parse(readFileSync(run.file, 'utf8')); + const scope = isObject(data) && isObject(data.scope) ? data.scope : {}; + const stryker = isObject(data) && isObject(data.stryker) ? data.stryker : {}; + const summary = isObject(data) && isObject(data.summary) ? data.summary : {}; + const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : []; + const configFile = path.join(run.outDir, 'stryker.config.json'); + const config: unknown = existsSync(configFile) + ? JSON.parse(readFileSync(configFile, 'utf8')) + : null; + const concurrency = isObject(config) ? config.concurrency : undefined; + const mutate = strings(scope.mutate); + return { + name: mutate[0] ?? path.basename(run.outDir), + mutate, + testFiles: strings(scope.testFiles), + strykerWallClockMs: typeof stryker.wallClockMs === 'number' ? stryker.wallClockMs : 0, + concurrency: typeof concurrency === 'number' ? concurrency : 1, + meanWrapperWallClockMs: + typeof summary.meanWrapperWallClockMs === 'number' ? summary.meanWrapperWallClockMs : null, + }; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +export interface BenchOptions { + scopes: { oracleRun: string; vitest5Results: string | null }[]; + outDir: string; + sample: SampleOptions; + concurrency: number; + wallClockMs: number; + sampleSize: number; + topUps: number; + vcpus: number; + fixedOverheadMs: number; + jobLimitMs: number; +} + +const positive = (value: string | undefined, name: string, fallback: number): number => { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value) || Number(value) < 1) { + throw new LaunchUsageError(`--${name} must be a positive integer, got ${value}`); + } + return Number(value); +}; + +export function parseBenchArgs(argv: readonly string[], cwd: string): BenchOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + scope: { type: 'string', multiple: true }, + out: { type: 'string' }, + mutants: { type: 'string' }, + 'tests-per-mutant': { type: 'string' }, + baselines: { type: 'string' }, + concurrency: { type: 'string' }, + 'wall-clock-ms': { type: 'string' }, + 'sample-size': { type: 'string' }, + 'top-ups': { type: 'string' }, + vcpus: { type: 'string' }, + 'fixed-overhead-ms': { type: 'string' }, + 'job-limit-ms': { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }); + if (values.scope === undefined || values.scope.length === 0) { + throw new LaunchUsageError('--scope [,] is required'); + } + if (values.out === undefined) throw new LaunchUsageError('--out is required'); + const topUps = values['top-ups']; + if (topUps !== undefined && !/^\d+$/.test(topUps)) { + throw new LaunchUsageError(`--top-ups must be a non-negative integer, got ${topUps}`); + } + return { + scopes: values.scope.map((entry) => { + const [oracleRun, vitest5Results, ...rest] = entry.split(','); + if (oracleRun === undefined || oracleRun === '' || rest.length > 0) { + throw new LaunchUsageError( + `--scope ${entry}: expected [,]`, + ); + } + return { + oracleRun: path.resolve(cwd, oracleRun), + vitest5Results: + vitest5Results === undefined || vitest5Results === '' + ? null + : path.resolve(cwd, vitest5Results), + }; + }), + outDir: path.resolve(cwd, values.out), + sample: { + mutants: positive(values.mutants, 'mutants', 3), + testsPerMutant: positive(values['tests-per-mutant'], 'tests-per-mutant', 2), + baselines: positive(values.baselines, 'baselines', 2), + }, + concurrency: positive(values.concurrency, 'concurrency', 1), + wallClockMs: positive(values['wall-clock-ms'], 'wall-clock-ms', DEFAULT_WALL_CLOCK_MS), + sampleSize: positive(values['sample-size'], 'sample-size', 100), + topUps: topUps === undefined ? 20 : Number(topUps), + vcpus: positive(values.vcpus, 'vcpus', 4), + fixedOverheadMs: positive(values['fixed-overhead-ms'], 'fixed-overhead-ms', 900_000), + jobLimitMs: positive(values['job-limit-ms'], 'job-limit-ms', 6 * HOUR_MS), + }; +} + +async function inPool( + items: readonly T[], + concurrency: number, + work: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next++; + results[index] = await work(items[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + return results; +} + +const formatHours = (ms: number): string => `${(ms / HOUR_MS).toFixed(2)} h`; +const formatSeconds = (ms: number): string => `${(ms / 1000).toFixed(2)} s`; + +interface LaunchRow { + scope: string; + role: PlannedLaunch['role']; + testId: string; + mutantId: string | null; + expected: OracleClass; + outcome: OracleClass; + validity: 'valid' | 'invalid'; + agrees: boolean; + covering: boolean | null; + hits: LaunchResult['observation']['hits']; + executed: number; + reason: string | null; + wallClockMs: number; + cpuMs: number | null; + vitestWallClockMs: number | null; + launchDir: string; +} + +export async function runBench(options: BenchOptions): Promise<{ text: string; exitCode: number }> { + mkdirSync(options.outDir, { recursive: true }); + const loadBefore = os.loadavg(); + const started = performance.now(); + const scopeReports = []; + const rows: LaunchRow[] = []; + const costInputs: ScopeCostInput[] = []; + + for (const [scopeIndex, scope] of options.scopes.entries()) { + const run = readCommandRun(scope.oracleRun); + const extras = readCommandRunExtras(run); + const vitest5 = scope.vitest5Results === null ? null : readVitest5Results(scope.vitest5Results); + const inventory = readTestInventory(run); + const planned = planLaunches(run.mutants, inventory, options.sample); + const scopeDir = path.join(options.outDir, 'launches', `scope-${scopeIndex + 1}`); + const results = await inPool(planned, options.concurrency, (launch, index) => + launchSingle({ + sandboxDir: run.sandboxDir, + scopeFile: run.scopeFile, + target: launch.target, + mutantId: launch.mutantId, + wallClockMs: options.wallClockMs, + launchDir: path.join(scopeDir, `launch-${String(index + 1).padStart(3, '0')}`), + }), + ); + const scopeRows = results.map((result, index): LaunchRow => { + const launch = planned[index]; + const { observation } = result; + return { + scope: extras.name, + role: launch.role, + testId: launch.target.id, + mutantId: launch.mutantId, + expected: launch.expected, + outcome: observation.outcome, + validity: observation.validity, + agrees: observation.validity === 'valid' && observation.outcome === launch.expected, + covering: observation.covering, + hits: observation.hits, + executed: observation.executed.length, + reason: observation.reason, + wallClockMs: result.wallClockMs, + cpuMs: result.cpuMs, + vitestWallClockMs: result.vitestWallClockMs, + launchDir: result.launchDir, + }; + }); + rows.push(...scopeRows); + + const valid = scopeRows.filter((row) => row.validity === 'valid' && row.outcome !== 'timeout'); + const wall = summarizeTimings(valid.map((row) => row.wallClockMs)); + const cpu = summarizeTimings(valid.flatMap((row) => row.cpuMs ?? [])); + const timeoutIds = new Set( + run.mutants.filter((mutant) => mutant.oracleClass === 'timeout').map((m) => m.globalId), + ); + const timeoutCoverage = [...timeoutIds].flatMap((id) => vitest5?.timeoutCoverage.get(id) ?? []); + const hangingTests = + timeoutIds.size === 0 + ? 0 + : timeoutCoverage.length === 0 + ? inventory.length + : Math.ceil(timeoutCoverage.reduce((a, b) => a + b, 0) / timeoutCoverage.length); + const locationKey = (target: LaunchTarget): string => + `${target.file}:${target.location?.line ?? '?'}:${target.location?.column ?? '?'}`; + const perLocation = new Map(); + const perNameAndLocation = new Map(); + for (const target of inventory) { + perLocation.set(locationKey(target), (perLocation.get(locationKey(target)) ?? 0) + 1); + const key = `${locationKey(target)}#${target.fullName}`; + perNameAndLocation.set(key, (perNameAndLocation.get(key) ?? 0) + 1); + } + costInputs.push({ + name: extras.name, + population: run.mutants.length, + tests: inventory.length, + sampleSize: options.sampleSize, + topUps: options.topUps, + timeoutMutants: timeoutIds.size, + hangingTestsPerTimeoutMutant: hangingTests, + commandRunnerMs: extras.strykerWallClockMs, + commandRunnerConcurrency: extras.concurrency, + launchWallMs: Math.round(wall.meanMs), + launchCpuMs: Math.round(cpu.meanMs), + }); + scopeReports.push({ + name: extras.name, + mutate: extras.mutate, + testFiles: extras.testFiles, + oracleRun: run.file, + vitest5Results: scope.vitest5Results, + sandboxDir: run.sandboxDir, + population: run.mutants.length, + tests: inventory.length, + staticMutants: vitest5?.staticMutants ?? null, + timeoutMutants: timeoutIds.size, + hangingTestsPerTimeoutMutant: hangingTests, + commandRunner: { + wallClockMs: extras.strykerWallClockMs, + concurrency: extras.concurrency, + meanWrapperWallClockMs: extras.meanWrapperWallClockMs, + }, + strata: { + eachRows: inventory.filter((target) => (perLocation.get(locationKey(target)) ?? 0) > 1) + .length, + rowsNeedingOrdinal: inventory.filter( + (target) => + (perNameAndLocation.get(`${locationKey(target)}#${target.fullName}`) ?? 0) > 1, + ).length, + sampledLaunchesWithLoadHits: scopeRows.filter( + (row) => (row.hits?.loadAndCollection ?? 0) > 0, + ).length, + sampledLaunchesWithHookHits: scopeRows.filter((row) => (row.hits?.hook ?? 0) > 0).length, + sampledLaunchesWithTestHits: scopeRows.filter((row) => (row.hits?.test ?? 0) > 0).length, + }, + launches: { + count: scopeRows.length, + valid: scopeRows.filter((row) => row.validity === 'valid').length, + agree: scopeRows.filter((row) => row.agrees).length, + wall, + cpu, + vitest: summarizeTimings(valid.flatMap((row) => row.vitestWallClockMs ?? [])), + }, + }); + } + + const loadAfter = os.loadavg(); + const measured = rows.filter((row) => row.validity === 'valid' && row.outcome !== 'timeout'); + const timings = { + wall: summarizeTimings(measured.map((row) => row.wallClockMs)), + cpu: summarizeTimings(measured.flatMap((row) => row.cpuMs ?? [])), + vitest: summarizeTimings(measured.flatMap((row) => row.vitestWallClockMs ?? [])), + }; + const projectionInput: CostProjectionInput = { + model: { + vcpus: options.vcpus, + launchWallMs: Math.round(timings.wall.meanMs), + launchCpuMs: Math.round(timings.cpu.meanMs), + wallClockLimitMs: options.wallClockMs, + jobLimitMs: options.jobLimitMs, + fixedOverheadMs: options.fixedOverheadMs, + }, + scopes: costInputs, + }; + const projection = projectOracleCost(projectionInput); + const report = { + schemaVersion: BENCH_SCHEMA_VERSION, + kind: 'oracle-cost-bench', + machine: { + cpus: os.cpus().length, + node: process.version, + loadAverageBefore: loadBefore, + loadAverageAfter: loadAfter, + }, + options: { ...options, scopes: options.scopes }, + benchWallClockMs: Math.round(performance.now() - started), + scopes: scopeReports, + launches: rows, + timings, + projectionInput, + projection, + }; + writeFileSync(path.join(options.outDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`); + + const lines = [ + `oracle cost bench: ${rows.length} launches at concurrency ${options.concurrency}; load ` + + `${loadBefore.map((v) => v.toFixed(2)).join(' ')} -> ${loadAfter.map((v) => v.toFixed(2)).join(' ')} ` + + `on ${os.cpus().length} cpus`, + ` per launch (valid, non-timeout): wall mean ${formatSeconds(timings.wall.meanMs)}, median ` + + `${formatSeconds(timings.wall.medianMs)}, p90 ${formatSeconds(timings.wall.p90Ms)}; cpu mean ` + + `${formatSeconds(timings.cpu.meanMs)}, median ${formatSeconds(timings.cpu.medianMs)}, p90 ` + + `${formatSeconds(timings.cpu.p90Ms)}`, + ...scopeReports.map( + (scope, index) => + ` ${scope.name}: ${scope.population} mutants (${scope.staticMutants ?? '?'} static, ` + + `${scope.timeoutMutants} timeout), ${scope.tests} tests; command pass ` + + `${formatSeconds(scope.commandRunner.wallClockMs)} at concurrency ${scope.commandRunner.concurrency}; ` + + `launches ${scope.launches.valid}/${scope.launches.count} valid, ${scope.launches.agree} agree; ` + + `wall mean ${formatSeconds(scope.launches.wall.meanMs)}; projected ` + + `${projection.scopes[index]?.launches ?? 0} launches, ${formatHours(projection.scopes[index]?.totalMs ?? 0)}`, + ), + ` projected full sample on ${options.vcpus} vCPU: ${formatHours(projection.totalMs)} ` + + `(launches ${formatHours(projection.launchMs)}, command passes ${formatHours(projection.commandMs)}, ` + + `overhead ${formatHours(projection.fixedOverheadMs)}); CPU-bound floor ` + + `${formatHours(projection.cpuBoundFloorMs)}; job limit ${formatHours(projection.jobLimitMs)}: ` + + (projection.fits ? 'fits' : 'DOES NOT FIT') + + `; break-even launch ${formatSeconds(projection.breakEvenLaunchMs)}`, + ` report: ${path.join(options.outDir, REPORT_FILE)}`, + ]; + const text = lines.join('\n'); + writeFileSync(path.join(options.outDir, SUMMARY_FILE), `${text}\n`); + const clean = rows.every((row) => row.validity === 'valid' && row.agrees); + return { text, exitCode: clean ? 0 : 1 }; +} + +async function main(): Promise { + try { + const options = parseBenchArgs(process.argv.slice(2), process.cwd()); + const { text, exitCode } = await runBench(options); + console.log(text); + process.exitCode = exitCode; + } catch (error) { + console.error(errorMessage(error)); + if (error instanceof LaunchUsageError || error instanceof TypeError) { + console.error( + 'usage: npm run test:mutation:bench -- --scope [,]... ' + + '--out [--mutants ] [--tests-per-mutant ] [--baselines ] [--concurrency ] ' + + '[--wall-clock-ms ] [--sample-size ] [--top-ups ] [--vcpus ] ' + + '[--fixed-overhead-ms ] [--job-limit-ms ]', + ); + } + process.exitCode = 2; + } +} + +if (isMainModule(import.meta.url)) { + void main(); +} diff --git a/gitnexus/tools/mutation/classify/classify.ts b/gitnexus/tools/mutation/classify/classify.ts new file mode 100644 index 000000000..cf2d96c1f --- /dev/null +++ b/gitnexus/tools/mutation/classify/classify.ts @@ -0,0 +1,1755 @@ +/** + * Pure-JS category classifier (U5, KTD11, KTD19). + * + * Decides which `gitnexus/test/unit` files a mutation run can use in a prepared copy + * (tools/mutation/prepare) without native code, workers, child processes, or file reads that the + * prepared copy breaks. Gate 1 (U6) records the modules each accepted test loads at runtime and + * fails on a native binding, worker or child_process load, so a miss here surfaces there. + * + * Walk. Each test file's import closure, parsed with @babel/parser: + * - `import` declarations, except `import type`, type-only specifiers, and value imports whose + * bindings are never used as values (the TypeScript transform erases those, because the repo + * does not set `verbatimModuleSyntax`); `export ... from`; `import x = require()`. + * - `import()`, `require()`, `createRequire()` results called directly or through a const, + * `vi.importActual()`, `vi.importMock()`, and `vi.mock()`/`vi.doMock()` unless the factory + * takes no parameter (automocking and `importOriginal` load the real module). + * A specifier must evaluate statically: a string literal, a template or `+` concatenation of + * those, an identifier whose binding (resolved lexically; a redeclared name is unknown) is a + * `const` initialised with such a value, or an absolute path or file URL built from + * `import.meta.url`, `__dirname`, `process.cwd()`, `path.join/resolve/dirname/sep`, `new URL()`, + * `fileURLToPath()` and `pathToFileURL().href`. Type assertions are looked through. + * Anything else is "unresolvable dynamic import". Relative specifiers resolve like Vite (exact + * file, `.js` to `.ts`/`.tsx`, appended extensions, directory `index`); `gitnexus-shared[/x]` + * resolves through that package's `exports` (`./dist/X.js` maps to `src/X.ts`, KTD8); node + * builtins and other bare packages are leaves (package code is not walked). + * + * Rejection reasons: + * - `native package: `: a bare package on {@link KNOWN_NATIVE_PACKAGES}, or one that ships a + * `.node` file, a `binding.gyp` or `"gypfile": true`, or reaches such a package through the + * installed packages of its `dependencies`/`optionalDependencies`. `vitest` and `@vitest/*` are + * the harness and exempt. + * - `native binding: `: an import resolves to a `.node` file. + * - `spawns processes: child_process|cluster`: the closure imports that builtin, or a package on + * {@link KNOWN_PROCESS_PACKAGES} (its own code loads `child_process` when imported). + * - `spawns workers: worker_threads`: the closure imports `Worker` from `worker_threads`, or the + * whole module (default/namespace binding, non-destructured `require`/`import()`). + * - `imports outside gitnexus/`, `imports from ignored dir: /`: a relative import leaves the + * package (gitnexus-shared/src is reachable only through its alias, and a shared module may + * not leave gitnexus-shared/src), or lands in a directory the Stryker sandbox omits + * (stryker-config.ts `DEFAULT_IGNORED_DIRS`). + * - `unresolved import: `, `unsupported import specifier: `, + * `unresolvable dynamic import`, `parse error`. + * - Reads, checked in test-side modules only (every module of the closure outside `gitnexus/src` + * and `gitnexus-shared/src`; production modules that import fs do not count): + * `reads source under src/`, `reads test fixtures`, `reads ignored dir: /`, + * `reads source outside gitnexus/`, `unresolvable file path`. Two rules: (a) every path + * expression anchored to a location (`__dirname`, `__filename`, `import.meta.url|dirname| + * filename`, `process.cwd()`, or a const bound to one) is evaluated through the functions above, + * templates and `+`, whether or not it reaches an fs call; (b) the first argument of an + * `fs`/`fs/promises` call that is a relative literal resolves against the package dir (the + * Vitest root and cwd). When a trailing part is not static, the known prefix decides, and a + * prefix that contains a checked directory (the package root, `test/`, anything above the + * package) is "unresolvable file path". + * + * Limits: paths built by helper functions or passed through parameters are not followed, and a + * production function reading a path the test hands it is not seen; an unknown suffix is assumed + * not to contain `..`; package internals are not walked for child_process or worker use (gate 1 + * sees them, and the packages it finds go on {@link KNOWN_PROCESS_PACKAGES}); `vi.mock` factories + * do not neutralise a module other imports load (conservative); Babel parse failures reject the + * file; hook and test counts look at the test file only. + * + * CLI (from gitnexus/): + * node --import tsx tools/mutation/classify/classify.ts [--root test/unit] [--file ]... + * [--out ] [--closure] + * Without `--out` the JSON goes to stdout; with it, a summary does. `--closure` keeps each file's + * closure, package and builtin lists in the JSON. + */ +import { readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { parse, type ParserOptions } from '@babel/parser'; +import { + VISITOR_KEYS, + type CallExpression, + type ExportNamedDeclaration, + type File, + type ImportDeclaration, + type MemberExpression, + type NewExpression, + type Node, +} from '@babel/types'; +import { DEFAULT_IGNORED_DIRS } from '../driver/stryker-config.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; + +export const CLASSIFY_SCHEMA_VERSION = 1; + +/** + * Packages rejected by name, installed or not, so the verdict does not depend on what a checkout + * installed; detection on disk adds the rest. `@huggingface/transformers` always depends on + * `onnxruntime-node` and `sharp`. + */ +export const KNOWN_NATIVE_PACKAGES: readonly RegExp[] = Object.freeze([ + /^tree-sitter$/, + /^tree-sitter-/, + /^@ladybugdb\//, + /^onnxruntime-/, + /^@huggingface\/transformers$/, + /^sharp$/, + /^fsevents$/, + /^node-gyp-build$/, + /^bindings$/, +]); + +/** + * Pure-JS packages whose own code loads `child_process` when imported. The walk does not enter + * package code, so gate 1's runtime load check (tools/mutation/fidelity) finds them; each entry + * names its evidence. `commander`: lib/command.js requires `node:child_process` at the top (for + * executable subcommands); gate 1 saw it load in test/unit/cli-command-banner.test.ts. + */ +export const KNOWN_PROCESS_PACKAGES: readonly RegExp[] = Object.freeze([/^commander$/]); + +/** The test harness itself: always loaded, never a reason to reject. */ +const isHarnessPackage = (name: string): boolean => + name === 'vitest' || name.startsWith('@vitest/'); + +const SHARED_PACKAGE = 'gitnexus-shared'; +const PROCESS_BUILTINS = new Set(['child_process', 'cluster']); +const WORKER_BUILTIN = 'worker_threads'; +const FS_MODULES = new Set(['fs', 'fs/promises']); +const PATH_MODULES = new Set(['path', 'url']); +const PATH_ONLY = new Set(['path']); +const BUILTINS = new Set(builtinModules); + +/** Vite's default `resolve.extensions` order. */ +const APPENDED_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']; +const TS_FOR_JS: Readonly> = { + '.js': ['.ts', '.tsx'], + '.jsx': ['.tsx'], + '.mjs': ['.mts'], + '.cjs': ['.cts'], +}; +const WALKED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); + +const HOOK_NAMES = ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'] as const; +const TEST_ROOTS = new Set(['it', 'test']); +const EACH_ROOTS = new Set(['it', 'test', 'describe', 'suite']); + +// ── Public types ─────────────────────────────────────────────────────────── + +export type HookCounts = Record<(typeof HOOK_NAMES)[number], number>; + +export interface Evidence { + reason: string; + /** Repo-relative POSIX path of the module where the reason was found. */ + module: string; + line: number; + detail: string; + /** Import chain from the test file to {@link module}, both included. */ + via: string[]; +} + +export interface FileVerdict { + /** Package-relative POSIX path, e.g. `test/unit/schema.test.ts`. */ + file: string; + status: 'accepted' | 'rejected'; + /** Sorted, distinct; empty when accepted. */ + reasons: string[]; + /** The first occurrence of each reason in breadth-first closure order, sorted by reason. */ + evidence: Evidence[]; + hooks: HookCounts; + /** `.each`/`.for` calls on `it`/`test`/`describe`/`suite` in the test file. */ + eachCalls: number; + /** Outermost `it`/`test` call sites in the test file (an `it.each` row set counts once). */ + testCalls: number; + /** Repo-relative local modules and data files of the import closure, test file included. */ + closure: string[]; + /** Bare packages the closure imports (leaves). */ + packages: string[]; + /** Node builtins the closure imports, without the `node:` prefix. */ + builtins: string[]; +} + +export interface ClassificationSummary { + total: number; + accepted: number; + rejected: number; + /** Rejected files per reason; a file counts once for each of its reasons. */ + byReason: Record; + /** Rejected files per reason category (the text before `: `). */ + byCategory: Record; +} + +export interface Classification { + schemaVersion: typeof CLASSIFY_SCHEMA_VERSION; + /** Package-relative directory that was listed, or null for an explicit file list. */ + testRoot: string | null; + files: FileVerdict[]; + accepted: string[]; + rejected: { file: string; reasons: string[] }[]; + acceptedWithBeforeAll: string[]; + summary: ClassificationSummary; +} + +export interface ClassifyOptions { + /** Directory holding `gitnexus/` and `gitnexus-shared/`. */ + repoRoot: string; + /** Package-relative test files; defaults to every `*.test.ts` under {@link testRoot}. */ + testFiles?: readonly string[]; + /** Package-relative directory to list; default `test/unit`. */ + testRoot?: string; +} + +// ── Static values ────────────────────────────────────────────────────────── + +/** + * What an expression evaluates to: plain text, an absolute file path (or file URL) anchored to a + * known location, or unknown. A `file` value with `exact: false` is a prefix: the expression + * names something inside `abs` whose remaining segments are not static. + */ +type Value = + | { kind: 'text'; text: string } + | { kind: 'file'; abs: string; url: boolean; exact: boolean } + | { kind: 'unknown' }; + +const UNKNOWN: Value = { kind: 'unknown' }; +const text = (value: string): Value => ({ kind: 'text', text: value }); +const file = (abs: string, url = false): Value => ({ kind: 'file', abs, url, exact: true }); + +/** A name's declaration; a `const` keeps the scopes around it to evaluate its initialiser. */ +type Binding = + | { kind: 'const'; init: Node | null | undefined; scopes: readonly Node[] } + | { kind: 'import'; source: string; imported: string } + | { kind: 'other' }; + +const OTHER: Binding = { kind: 'other' }; + +type EdgeTarget = + | { kind: 'specifier'; specifier: string; fromDir: string } + | { kind: 'file'; abs: string }; + +interface ImportEdge { + target: EdgeTarget; + line: number; + /** Imported names when known; null when the importer can reach every export. */ + names: readonly string[] | null; +} + +interface Finding { + reason: string; + line: number; + detail: string; +} + +interface ModuleFacts { + edges: ImportEdge[]; + /** Reasons that apply wherever the module sits (unresolvable dynamic imports, parse errors). */ + findings: Finding[]; + /** Read-rule hits; computed for test-side modules only. */ + reads: Finding[]; + hooks: HookCounts; + eachCalls: number; + testCalls: number; +} + +type Resolved = + | { kind: 'module'; abs: string } + | { kind: 'leaf'; abs: string } + | { kind: 'builtin'; name: string } + | { kind: 'package'; name: string } + | { kind: 'reject'; reason: string; detail: string }; + +// ── AST helpers ──────────────────────────────────────────────────────────── + +/** Keys holding type syntax, which never runs. */ +const TYPE_KEYS = new Set([ + 'typeAnnotation', + 'typeParameters', + 'typeArguments', + 'returnType', + 'superTypeArguments', + 'superTypeParameters', + 'implements', + 'predicate', +]); + +/** Declarations that are types only. */ +const TYPE_ONLY_NODES = new Set([ + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSDeclareFunction', + 'TSDeclareMethod', + 'TSIndexSignature', + 'TSImportType', +]); + +const KEYED_NODES = new Set([ + 'ObjectProperty', + 'ObjectMethod', + 'ClassProperty', + 'ClassMethod', + 'ClassAccessorProperty', + 'ClassPrivateProperty', + 'ClassPrivateMethod', +]); + +const FUNCTION_NODES = new Set([ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', + 'ObjectMethod', + 'ClassMethod', + 'ClassPrivateMethod', +]); + +/** Nodes that open a lexical scope (`var` goes to the nearest function or the program). */ +const SCOPE_NODES = new Set([ + 'Program', + 'BlockStatement', + 'CatchClause', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'SwitchStatement', + 'StaticBlock', + ...FUNCTION_NODES, +]); + +/** Type-only wrappers around a runtime expression. */ +const TS_WRAPPERS = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'ParenthesizedExpression', +]); + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string'; + +const flag = (node: Node, key: 'computed' | 'declare'): boolean => + (node as unknown as Record)[key] === true; + +/** Child keys of `node` that hold runtime values (identifiers in name positions are skipped). */ +function valueKeys(node: Node): readonly string[] { + const keys = VISITOR_KEYS[node.type] ?? []; + if (node.type === 'MetaProperty') return []; + return keys.filter((key) => { + if (TYPE_KEYS.has(key)) return false; + if (key === 'property' && !flag(node, 'computed')) { + return node.type !== 'MemberExpression' && node.type !== 'OptionalMemberExpression'; + } + if (key === 'key' && KEYED_NODES.has(node.type)) return flag(node, 'computed'); + if (key === 'label') return false; + if (node.type === 'ExportSpecifier') return key === 'local'; + if (node.type === 'TSEnumMember') return key !== 'id'; + return true; + }); +} + +/** `scopes` lists the scope nodes around `node`, outermost first, excluding `node` itself. */ +type Visitor = (node: Node, ancestors: readonly Node[], scopes: readonly Node[]) => boolean; + +/** Depth-first walk over runtime syntax; the visitor returns false to skip a node's children. */ +function walk(root: Node, visit: Visitor): void { + const ancestors: Node[] = []; + const scopes: Node[] = []; + const go = (node: Node): void => { + if (TYPE_ONLY_NODES.has(node.type) || flag(node, 'declare')) return; + if (!visit(node, ancestors, scopes)) return; + const scoped = SCOPE_NODES.has(node.type); + ancestors.push(node); + if (scoped) scopes.push(node); + for (const key of valueKeys(node)) { + const child = (node as unknown as Record)[key]; + if (Array.isArray(child)) { + for (const item of child) if (isNode(item)) go(item); + } else if (isNode(child)) { + go(child); + } + } + if (scoped) scopes.pop(); + ancestors.pop(); + }; + go(root); +} + +/** `node` without type-only wrappers (`x as T`, `x!`, `(x)`). */ +function unwrap(node: Node | null | undefined): Node | null | undefined { + let current = node; + while (current && TS_WRAPPERS.has(current.type)) { + current = (current as Node & { expression: Node }).expression; + } + return current; +} + +/** The module a `require('m')` / `await import('m')` initialiser loads, or null. */ +function loadedModule(init: Node | null | undefined): string | null { + const unwrapped = unwrap(init); + const call = unwrapped?.type === 'AwaitExpression' ? unwrap(unwrapped.argument) : unwrapped; + if (call?.type === 'ImportExpression' && call.source.type === 'StringLiteral') { + return call.source.value; + } + if ( + call?.type === 'CallExpression' && + call.callee.type === 'Identifier' && + call.callee.name === 'require' && + call.arguments[0]?.type === 'StringLiteral' + ) { + return call.arguments[0].value; + } + return null; +} + +const lineOf = (node: Node): number => node.loc?.start.line ?? 0; + +/** Strips `node:` and maps `path/posix` to `path` so module checks see one name. */ +const normaliseModule = (source: string): string => { + const bare = source.startsWith('node:') ? source.slice('node:'.length) : source; + return bare === 'path/posix' ? 'path' : bare; +}; + +const propertyName = (node: MemberExpression): string | null => + !node.computed && node.property.type === 'Identifier' ? node.property.name : null; + +const keyName = (key: Node): string | null => + key.type === 'Identifier' ? key.name : key.type === 'StringLiteral' ? key.value : null; + +/** Root identifier of a callee chain such as `it.concurrent.each(rows)`. */ +function calleeRoot(node: Node): string | null { + if (node.type === 'Identifier') return node.name; + if (node.type === 'MemberExpression') return calleeRoot(node.object); + if (node.type === 'CallExpression') return calleeRoot(node.callee); + return null; +} + +const isCreateRequireCall = (node: Node | null | undefined): node is CallExpression => + node?.type === 'CallExpression' && + ((node.callee.type === 'Identifier' && node.callee.name === 'createRequire') || + (node.callee.type === 'MemberExpression' && propertyName(node.callee) === 'createRequire')); + +const toPosix = (value: string): string => value.split(path.sep).join('/'); + +/** True when POSIX `rel` is `dir` or inside it. */ +const isWithin = (rel: string, dir: string): boolean => rel === dir || rel.startsWith(`${dir}/`); + +/** True when POSIX `rel` is `dir` or one of its ancestors (`''` is the root). */ +const contains = (rel: string, dir: string): boolean => rel === '' || isWithin(dir, rel); + +const isInside = (abs: string, dir: string): boolean => { + const rel = path.relative(dir, abs); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +}; + +/** `@scope/name` or `name` of a bare specifier, or null when it is not a package specifier. */ +function packageName(specifier: string): string | null { + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 && segments[1] !== '' ? `${segments[0]}/${segments[1]}` : null; + } + return segments[0] === '' || specifier.startsWith('#') ? null : segments[0]; +} + +// ── Module analysis ──────────────────────────────────────────────────────── + +interface AnalysisContext { + packageDir: string; + /** Read rules apply (the module is not production code). */ + testSide: boolean; + /** Repo-relative display path. */ + display: (abs: string) => string; +} + +/** Facts of one module: import edges, dynamic-import failures, read-rule hits and test API use. */ +class ModuleAnalysis { + private readonly scopeBindings = new Map>(); + private readonly references = new Map(); + private readonly values = new Map(); + private readonly evaluating = new Set(); + private readonly skipped = new WeakSet(); + private readonly calleeCalls = new WeakSet(); + readonly facts: ModuleFacts = { + edges: [], + findings: [], + reads: [], + hooks: { beforeAll: 0, beforeEach: 0, afterAll: 0, afterEach: 0 }, + eachCalls: 0, + testCalls: 0, + }; + + constructor( + private readonly file: string, + private readonly source: string, + ast: File, + private readonly context: AnalysisContext, + ) { + walk(ast.program, (node, ancestors, scopes) => this.collect(node, ancestors, scopes)); + walk(ast.program, (node, ancestors, scopes) => this.analyse(node, ancestors, scopes)); + } + + // Pass 1: bindings per scope, and value references. + + private bind(scope: Node | undefined, name: string, binding: Binding): void { + if (!scope) return; + let names = this.scopeBindings.get(scope); + if (!names) { + names = new Map(); + this.scopeBindings.set(scope, names); + } + const list = names.get(name); + if (list) list.push(binding); + else names.set(name, [binding]); + } + + private bindPattern(scope: Node | undefined, pattern: Node | null | undefined): void { + if (!pattern) return; + switch (pattern.type) { + case 'Identifier': + this.bind(scope, pattern.name, { kind: 'other' }); + return; + case 'ObjectPattern': + for (const property of pattern.properties) { + this.bindPattern( + scope, + property.type === 'RestElement' ? property.argument : property.value, + ); + } + return; + case 'ArrayPattern': + for (const element of pattern.elements) this.bindPattern(scope, element); + return; + case 'AssignmentPattern': + this.bindPattern(scope, pattern.left); + return; + case 'RestElement': + this.bindPattern(scope, pattern.argument); + return; + case 'TSParameterProperty': + this.bindPattern(scope, pattern.parameter); + return; + default: + return; + } + } + + private collect(node: Node, ancestors: readonly Node[], scopes: readonly Node[]): boolean { + const current = scopes[scopes.length - 1]; + switch (node.type) { + case 'ImportDeclaration': + if (node.importKind !== 'type') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') continue; + this.bind(current, specifier.local.name, { + kind: 'import', + source: node.source.value, + imported: + specifier.type === 'ImportDefaultSpecifier' + ? 'default' + : specifier.type === 'ImportNamespaceSpecifier' + ? '*' + : (keyName(specifier.imported) ?? '*'), + }); + } + } + return false; + case 'TSImportEqualsDeclaration': + if (node.moduleReference.type === 'TSExternalModuleReference') { + this.bind(current, node.id.name, { + kind: 'import', + source: node.moduleReference.expression.value, + imported: '*', + }); + } + return false; + case 'ExportAllDeclaration': + return false; + case 'ExportNamedDeclaration': + return !node.source; + case 'Identifier': + this.references.set(node.name, (this.references.get(node.name) ?? 0) + 1); + return true; + case 'VariableDeclarator': { + const declaration = ancestors[ancestors.length - 1]; + const kind = declaration?.type === 'VariableDeclaration' ? declaration.kind : 'var'; + const scope = + kind === 'var' + ? [...scopes].reverse().find((s) => s.type === 'Program' || FUNCTION_NODES.has(s.type)) + : current; + const loaded = loadedModule(node.init); + if (node.id.type === 'Identifier') { + this.bind( + scope, + node.id.name, + kind === 'const' ? { kind: 'const', init: node.init, scopes: [...scopes] } : OTHER, + ); + } else if (node.id.type === 'ObjectPattern' && loaded !== null) { + for (const property of node.id.properties) { + const name = + property.type === 'ObjectProperty' && !property.computed + ? keyName(property.key) + : null; + if (property.type === 'ObjectProperty' && name !== null) { + if (property.value.type === 'Identifier') { + this.bind(scope, property.value.name, { + kind: 'import', + source: loaded, + imported: name, + }); + } else { + this.bindPattern(scope, property.value); + } + } else { + this.bindPattern( + scope, + property.type === 'RestElement' ? property.argument : property, + ); + } + } + } else { + this.bindPattern(scope, node.id); + } + return true; + } + case 'ClassDeclaration': + case 'TSEnumDeclaration': + if (node.id?.type === 'Identifier') this.bind(current, node.id.name, OTHER); + return true; + case 'CatchClause': + this.bindPattern(node, node.param); + return true; + default: + if (FUNCTION_NODES.has(node.type)) { + const fn = node as Node & { id?: Node | null; params: readonly Node[] }; + this.bindPattern(node.type === 'FunctionDeclaration' ? current : node, fn.id); + for (const param of fn.params) this.bindPattern(node, param); + } + return true; + } + } + + /** The binding `name` resolves to from a position inside `scopes` (outermost first). */ + private lookup(name: string, scopes: readonly Node[]): Binding | 'global' | 'ambiguous' { + for (let index = scopes.length - 1; index >= 0; index -= 1) { + const list = this.scopeBindings.get(scopes[index])?.get(name); + if (list) return list.length === 1 ? list[0] : 'ambiguous'; + } + return 'global'; + } + + /** The module and export an identifier stands for, when its binding is import-like. */ + private refersTo( + name: string, + scopes: readonly Node[], + ): { module: string; imported: string } | null { + const binding = this.lookup(name, scopes); + if (typeof binding === 'string') return null; + if (binding.kind === 'import') { + return { module: normaliseModule(binding.source), imported: binding.imported }; + } + if (binding.kind === 'const') { + const loaded = loadedModule(binding.init); + return loaded === null ? null : { module: normaliseModule(loaded), imported: '*' }; + } + return null; + } + + private isModuleObject(node: Node, modules: ReadonlySet, scopes: readonly Node[]) { + if (node.type !== 'Identifier') return false; + const target = this.refersTo(node.name, scopes); + return ( + target !== null && + modules.has(target.module) && + (target.imported === '*' || target.imported === 'default') + ); + } + + // Static evaluation. + + private evaluateIdentifier(name: string, scopes: readonly Node[]): Value { + const binding = this.lookup(name, scopes); + if (binding === 'global') { + if (name === '__dirname') return file(path.dirname(this.file)); + if (name === '__filename') return file(this.file); + return UNKNOWN; + } + if (binding === 'ambiguous' || binding.kind !== 'const') return UNKNOWN; + const cached = this.values.get(binding); + if (cached) return cached; + if (this.evaluating.has(binding)) return UNKNOWN; + this.evaluating.add(binding); + const value = this.evaluate(binding.init, binding.scopes); + this.evaluating.delete(binding); + this.values.set(binding, value); + return value; + } + + /** `path`/`url` function a callee names, or `cwd` for `process.cwd`. */ + private pathFunction(callee: Node, scopes: readonly Node[]): string | null { + if (callee.type === 'Identifier') { + const target = this.refersTo(callee.name, scopes); + return target && (target.module === 'path' || target.module === 'url') + ? target.imported + : null; + } + if (callee.type !== 'MemberExpression') return null; + const name = propertyName(callee); + if (name === null) return null; + const object = callee.object; + if ( + object.type === 'Identifier' && + object.name === 'process' && + this.lookup('process', scopes) === 'global' + ) { + return name === 'cwd' ? 'cwd' : null; + } + if (this.isModuleObject(object, PATH_MODULES, scopes)) return name; + if ( + object.type === 'MemberExpression' && + propertyName(object) === 'posix' && + this.isModuleObject(object.object, PATH_ONLY, scopes) + ) { + return name; + } + return null; + } + + private evaluate(node: Node | null | undefined, scopes: readonly Node[]): Value { + if (!node) return UNKNOWN; + switch (node.type) { + case 'StringLiteral': + return text(node.value); + case 'TemplateLiteral': { + const parts: Value[] = []; + node.quasis.forEach((quasi, index) => { + parts.push(quasi.value.cooked == null ? UNKNOWN : text(quasi.value.cooked)); + const expression = node.expressions[index]; + if (expression) parts.push(this.evaluate(expression, scopes)); + }); + return this.concat(parts); + } + case 'BinaryExpression': + return node.operator === '+' + ? this.concat([this.evaluate(node.left, scopes), this.evaluate(node.right, scopes)]) + : UNKNOWN; + case 'Identifier': + return this.evaluateIdentifier(node.name, scopes); + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'TSTypeAssertion': + case 'ParenthesizedExpression': + return this.evaluate(node.expression, scopes); + case 'MemberExpression': + return this.evaluateMember(node, scopes); + case 'CallExpression': + return this.evaluateCall(node, scopes); + case 'NewExpression': + return this.evaluateNew(node, scopes); + default: + return UNKNOWN; + } + } + + private evaluateMember(node: MemberExpression, scopes: readonly Node[]): Value { + const name = propertyName(node); + if ( + node.object.type === 'MetaProperty' && + node.object.meta.name === 'import' && + node.object.property.name === 'meta' + ) { + if (name === 'url') return file(this.file, true); + if (name === 'filename') return file(this.file); + if (name === 'dirname') return file(path.dirname(this.file)); + return UNKNOWN; + } + if ( + (name === 'sep' || name === 'delimiter') && + this.isModuleObject(node.object, PATH_ONLY, scopes) + ) { + return text(name === 'sep' ? path.sep : path.delimiter); + } + const object = this.evaluate(node.object, scopes); + if (object.kind !== 'file' || !object.url) return UNKNOWN; + if (name === 'href') return object; + if (name === 'pathname') return { ...object, url: false }; + return UNKNOWN; + } + + private evaluateCall(node: CallExpression, scopes: readonly Node[]): Value { + if ( + node.callee.type === 'MemberExpression' && + propertyName(node.callee) === 'toString' && + node.arguments.length === 0 + ) { + const object = this.evaluate(node.callee.object, scopes); + return object.kind === 'file' && object.url ? object : UNKNOWN; + } + const fn = this.pathFunction(node.callee, scopes); + if (fn === null) return UNKNOWN; + const args = node.arguments.map((arg) => this.evaluate(arg, scopes)); + switch (fn) { + case 'cwd': + return file(this.context.packageDir); + case 'join': + return this.combine(args, 'join'); + case 'resolve': + return this.combine(args, 'resolve'); + case 'normalize': + return args[0]?.kind === 'text' + ? text(path.normalize(args[0].text)) + : args[0]?.kind === 'file' && !args[0].url + ? { ...args[0], abs: path.normalize(args[0].abs) } + : UNKNOWN; + case 'dirname': { + const arg = args[0]; + if (arg?.kind === 'text') return text(path.dirname(arg.text)); + if (arg?.kind !== 'file') return UNKNOWN; + return arg.exact ? { ...arg, abs: path.dirname(arg.abs) } : arg; + } + case 'fileURLToPath': + return args[0]?.kind === 'file' && args[0].url ? { ...args[0], url: false } : UNKNOWN; + case 'pathToFileURL': + return args[0]?.kind === 'file' && !args[0].url ? { ...args[0], url: true } : UNKNOWN; + default: + return UNKNOWN; + } + } + + private isUrlConstructor(callee: Node, scopes: readonly Node[]): boolean { + if (callee.type !== 'Identifier') return false; + if (callee.name === 'URL' && this.lookup('URL', scopes) === 'global') return true; + const target = this.refersTo(callee.name, scopes); + return target?.module === 'url' && target.imported === 'URL'; + } + + private evaluateNew(node: NewExpression, scopes: readonly Node[]): Value { + if (!this.isUrlConstructor(node.callee, scopes) || node.arguments.length !== 2) return UNKNOWN; + const spec = this.evaluate(node.arguments[0], scopes); + const base = this.evaluate(node.arguments[1], scopes); + if (base.kind !== 'file' || !base.url) return UNKNOWN; + if (!base.exact) return base; + if (spec.kind === 'unknown') return { ...base, abs: path.dirname(base.abs), exact: false }; + if (spec.kind !== 'text' || /^[a-z][a-z0-9+.-]*:/i.test(spec.text)) return UNKNOWN; + return file(path.resolve(path.dirname(base.abs), spec.text), true); + } + + /** `path.join` / `path.resolve` over evaluated arguments. */ + private combine(parts: readonly Value[], mode: 'join' | 'resolve'): Value { + let acc: Value | undefined; + for (const part of parts) { + if (acc?.kind === 'file' && !acc.exact) return acc; + if (part.kind === 'unknown') { + return acc?.kind === 'file' ? { ...acc, exact: false } : UNKNOWN; + } + if (part.kind === 'file') { + if (part.url || (acc !== undefined && mode === 'join')) return UNKNOWN; + acc = part; + } else if (acc === undefined || (mode === 'resolve' && path.isAbsolute(part.text))) { + acc = part; + } else if (acc.kind === 'file') { + acc = { ...acc, abs: path.join(acc.abs, part.text) }; + } else if (acc.kind === 'text') { + acc = text(path.join(acc.text, part.text)); + } + } + if (acc === undefined) return mode === 'resolve' ? file(this.context.packageDir) : text('.'); + if (mode === 'resolve' && acc.kind === 'text' && !path.isAbsolute(acc.text)) { + return file(path.resolve(this.context.packageDir, acc.text)); + } + return acc; + } + + /** String concatenation (templates and `+`). */ + private concat(parts: readonly Value[]): Value { + let acc: Value = text(''); + for (const part of parts) { + if (acc.kind === 'unknown' || (acc.kind === 'file' && !acc.exact)) return acc; + if (part.kind === 'unknown') { + if (acc.kind !== 'file') return UNKNOWN; + const prefix = /[\\/]$/.test(acc.abs) ? acc.abs : path.dirname(acc.abs); + return { ...acc, abs: path.normalize(prefix), exact: false }; + } + if (part.kind === 'file') { + if (acc.kind !== 'text' || acc.text !== '') return UNKNOWN; + acc = part; + } else { + acc = + acc.kind === 'file' ? { ...acc, abs: acc.abs + part.text } : text(acc.text + part.text); + } + } + return acc.kind === 'file' && acc.exact ? { ...acc, abs: path.normalize(acc.abs) } : acc; + } + + // Pass 2: edges, reads and test API. + + private snippet(node: Node): string { + const raw = this.source.slice(node.start ?? 0, node.end ?? 0).replace(/\s+/g, ' '); + return raw.length > 100 ? `${raw.slice(0, 97)}...` : raw; + } + + private edge(specifier: string, line: number, names: readonly string[] | null): void { + this.facts.edges.push({ + target: { kind: 'specifier', specifier, fromDir: path.dirname(this.file) }, + line, + names, + }); + } + + /** A load whose specifier is an expression; `base` is null when it is not statically known. */ + private loadEdge( + arg: Node | undefined, + base: string | null, + node: Node, + names: readonly string[] | null, + scopes: readonly Node[], + ): void { + const line = lineOf(node); + const value = arg ? this.evaluate(arg, scopes) : UNKNOWN; + if (value.kind === 'text' && (base !== null || !value.text.startsWith('.'))) { + this.facts.edges.push({ + target: { + kind: 'specifier', + specifier: value.text, + fromDir: path.dirname(base ?? this.file), + }, + line, + names, + }); + } else if (value.kind === 'file' && value.exact) { + this.facts.edges.push({ target: { kind: 'file', abs: value.abs }, line, names }); + } else { + this.facts.findings.push({ + reason: 'unresolvable dynamic import', + line, + detail: this.snippet(arg ?? node), + }); + } + if (arg) this.skipped.add(arg); + } + + /** Names a `require()`/`import()` result is destructured into, or null for the whole module. */ + private destructuredNames(node: Node, ancestors: readonly Node[]): readonly string[] | null { + let child = node; + let index = ancestors.length - 1; + while ( + index >= 0 && + (ancestors[index].type === 'AwaitExpression' || TS_WRAPPERS.has(ancestors[index].type)) + ) { + child = ancestors[index]; + index -= 1; + } + const parent = ancestors[index]; + if (parent?.type === 'MemberExpression' && parent.object === child) { + const name = propertyName(parent); + return name === null ? null : [name]; + } + if (parent?.type !== 'VariableDeclarator' || parent.init !== child) return null; + if (parent.id.type !== 'ObjectPattern') return null; + const names: string[] = []; + for (const property of parent.id.properties) { + const name = property.type === 'ObjectProperty' ? keyName(property.key) : null; + if (name === null) return null; + names.push(name); + } + return names; + } + + private importDeclaration(node: ImportDeclaration): void { + if (node.importKind === 'type') return; + if (node.specifiers.length === 0) { + this.edge(node.source.value, lineOf(node), []); + return; + } + const used = node.specifiers.filter( + (specifier) => + !(specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') && + (this.references.get(specifier.local.name) ?? 0) > 0, + ); + if (used.length === 0) return; + const names: string[] = []; + for (const specifier of used) { + if (specifier.type !== 'ImportSpecifier') { + this.edge(node.source.value, lineOf(node), null); + return; + } + names.push(keyName(specifier.imported) ?? '*'); + } + this.edge(node.source.value, lineOf(node), names); + } + + private reexport(node: ExportNamedDeclaration): void { + if (!node.source || node.exportKind === 'type') return; + const names: string[] = []; + for (const specifier of node.specifiers) { + if (specifier.type === 'ExportSpecifier') { + if (specifier.exportKind !== 'type') names.push(keyName(specifier.local) ?? '*'); + } else { + this.edge(node.source.value, lineOf(node), null); + return; + } + } + if (names.length > 0) this.edge(node.source.value, lineOf(node), names); + } + + /** Base file of a `createRequire(...)` call, or null when it is not static. */ + private requireBase(call: CallExpression, scopes: readonly Node[]): string | null { + const value = this.evaluate(call.arguments[0], scopes); + return value.kind === 'file' && value.exact ? value.abs : null; + } + + /** Load semantics of a call: the base its specifier resolves against, or null for none. */ + private loadCall(node: CallExpression, scopes: readonly Node[]): { base: string | null } | null { + const callee = node.callee; + if (callee.type === 'Identifier') { + const binding = this.lookup(callee.name, scopes); + if (binding === 'global') return callee.name === 'require' ? { base: this.file } : null; + const init = typeof binding === 'string' || binding.kind !== 'const' ? null : binding; + const call = init ? unwrap(init.init) : null; + return init && isCreateRequireCall(call) + ? { base: this.requireBase(call, init.scopes) } + : null; + } + if (isCreateRequireCall(callee)) return { base: this.requireBase(callee, scopes) }; + if ( + callee.type !== 'MemberExpression' || + callee.object.type !== 'Identifier' || + callee.object.name !== 'vi' + ) { + return null; + } + switch (propertyName(callee)) { + case 'importActual': + case 'importMock': + return { base: this.file }; + case 'mock': + case 'doMock': { + const factory = node.arguments[1]; + const loadsOriginal = + !factory || + !FUNCTION_NODES.has(factory.type) || + (factory as Node & { params: readonly Node[] }).params.length > 0; + return loadsOriginal ? { base: this.file } : null; + } + default: + return null; + } + } + + private isFsCall(callee: Node, scopes: readonly Node[]): boolean { + if (callee.type === 'Identifier') { + const target = this.refersTo(callee.name, scopes); + return target !== null && FS_MODULES.has(target.module) && target.imported !== '*'; + } + if (callee.type !== 'MemberExpression') return false; + const object = callee.object; + return ( + this.isModuleObject(object, FS_MODULES, scopes) || + (object.type === 'MemberExpression' && + propertyName(object) === 'promises' && + this.isModuleObject(object.object, FS_MODULES, scopes)) + ); + } + + private countTestApi(node: CallExpression): void { + const callee = node.callee; + if (callee.type === 'Identifier') { + const hook = HOOK_NAMES.find((name) => name === callee.name); + if (hook) this.facts.hooks[hook] += 1; + } + if (callee.type === 'CallExpression') this.calleeCalls.add(callee); + if (callee.type === 'MemberExpression') { + const name = propertyName(callee); + const root = calleeRoot(callee.object); + if ((name === 'each' || name === 'for') && root !== null && EACH_ROOTS.has(root)) { + this.facts.eachCalls += 1; + } + } + const root = calleeRoot(callee); + if (root !== null && TEST_ROOTS.has(root) && !this.calleeCalls.has(node)) { + this.facts.testCalls += 1; + } + } + + private recordRead(node: Node, abs: string, exact: boolean): void { + const rel = toPosix(path.relative(this.context.packageDir, abs)); + let reason: string | null = null; + if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) { + // A prefix above the package may still end inside it. + reason = + !exact && isInside(this.context.packageDir, abs) + ? 'unresolvable file path' + : 'reads source outside gitnexus/'; + } else if (isWithin(rel, 'src')) { + reason = 'reads source under src/'; + } else { + const ignored = DEFAULT_IGNORED_DIRS.find((dir) => isWithin(rel, dir)); + if (ignored === 'test/fixtures') reason = 'reads test fixtures'; + else if (ignored !== undefined) reason = `reads ignored dir: ${ignored}/`; + else if (!exact && ['src', ...DEFAULT_IGNORED_DIRS].some((dir) => contains(rel, dir))) { + reason = 'unresolvable file path'; + } + } + if (reason !== null) { + this.facts.reads.push({ + reason, + line: lineOf(node), + detail: `${this.snippet(node)} -> ${this.context.display(abs)}${exact ? '' : '/...'}`, + }); + } + } + + /** Read rule (a): evaluates an anchored path expression; returns false when it was decided. */ + private pathExpression(node: Node, scopes: readonly Node[]): boolean { + const value = this.evaluate(node, scopes); + if (value.kind !== 'file') return value.kind === 'unknown'; + this.recordRead(node, value.abs, value.exact); + return false; + } + + private isPathExpression(node: Node, scopes: readonly Node[]): boolean { + switch (node.type) { + case 'Identifier': + case 'MemberExpression': + case 'TemplateLiteral': + return true; + case 'BinaryExpression': + return node.operator === '+'; + case 'NewExpression': + return this.isUrlConstructor(node.callee, scopes); + case 'CallExpression': + return ( + this.pathFunction(node.callee, scopes) !== null || + (node.callee.type === 'MemberExpression' && propertyName(node.callee) === 'toString') + ); + default: + return false; + } + } + + private analyse(node: Node, ancestors: readonly Node[], scopes: readonly Node[]): boolean { + if (this.skipped.has(node)) return false; + switch (node.type) { + case 'ImportDeclaration': + this.importDeclaration(node); + return false; + case 'ExportNamedDeclaration': + if (!node.source) return true; + this.reexport(node); + return false; + case 'ExportAllDeclaration': + if (node.exportKind !== 'type') this.edge(node.source.value, lineOf(node), null); + return false; + case 'TSImportEqualsDeclaration': + if ( + node.importKind !== 'type' && + node.moduleReference.type === 'TSExternalModuleReference' && + (this.references.get(node.id.name) ?? 0) > 0 + ) { + this.edge(node.moduleReference.expression.value, lineOf(node), null); + } + return false; + case 'ImportExpression': + this.loadEdge( + node.source, + this.file, + node, + this.destructuredNames(node, ancestors), + scopes, + ); + return false; + case 'CallExpression': { + this.countTestApi(node); + const load = this.loadCall(node, scopes); + if (load) { + const arg = node.arguments[0]; + this.loadEdge(arg, load.base, node, this.destructuredNames(node, ancestors), scopes); + } + if (!this.context.testSide) return true; + const first = node.arguments[0]; + if (first && this.isFsCall(node.callee, scopes)) { + const value = this.evaluate(first, scopes); + if (value.kind === 'text' && !path.isAbsolute(value.text) && !value.text.includes(':')) { + this.recordRead(first, path.resolve(this.context.packageDir, value.text), true); + } + } + return this.isPathExpression(node, scopes) ? this.pathExpression(node, scopes) : true; + } + default: + if (this.context.testSide && this.isPathExpression(node, scopes)) { + return this.pathExpression(node, scopes); + } + return true; + } + } +} + +// ── Classifier ───────────────────────────────────────────────────────────── + +const PARSER_PLUGINS: NonNullable = ['typescript', 'decorators-legacy']; + +function parseModule(abs: string, source: string): File { + const ext = path.extname(abs); + return parse(source, { + sourceType: ext === '.cjs' || ext === '.cts' ? 'commonjs' : 'module', + plugins: ext === '.tsx' || ext === '.jsx' ? [...PARSER_PLUGINS, 'jsx'] : PARSER_PLUGINS, + errorRecovery: false, + }); +} + +const emptyFacts = (finding: Finding): ModuleFacts => ({ + edges: [], + findings: [finding], + reads: [], + hooks: { beforeAll: 0, beforeEach: 0, afterAll: 0, afterEach: 0 }, + eachCalls: 0, + testCalls: 0, +}); + +const reject = (reason: string, detail: string): Resolved => ({ kind: 'reject', reason, detail }); + +/** Native hit of a package: the native package, the dependency chain to it and why. */ +interface NativeHit { + pkg: string; + chain: string[]; + why: string; +} + +/** Classifies test files of one repo checkout; caches parsed modules and resolutions. */ +export class Classifier { + readonly packageDir: string; + private readonly sharedDir: string; + private readonly sharedSrcDir: string; + private readonly productionDirs: readonly string[]; + private readonly factsCache = new Map(); + private readonly resolutions = new Map(); + private readonly kinds = new Map(); + private readonly directNative = new Map(); + private readonly packageNative = new Map(); + private readonly displays = new Map(); + private sharedExports: ReadonlyMap | undefined; + + constructor(readonly repoRoot: string) { + this.packageDir = path.join(repoRoot, 'gitnexus'); + this.sharedDir = path.join(repoRoot, SHARED_PACKAGE); + this.sharedSrcDir = path.join(this.sharedDir, 'src'); + this.productionDirs = [path.join(this.packageDir, 'src'), this.sharedSrcDir]; + } + + private display(abs: string): string { + let shown = this.displays.get(abs); + if (shown === undefined) { + shown = isInside(abs, this.repoRoot) ? toPosix(path.relative(this.repoRoot, abs)) : abs; + this.displays.set(abs, shown); + } + return shown; + } + + private kind(abs: string): 'file' | 'dir' | null { + let kind = this.kinds.get(abs); + if (kind === undefined) { + const stat = statSync(abs, { throwIfNoEntry: false }); + kind = stat?.isFile() ? 'file' : stat?.isDirectory() ? 'dir' : null; + this.kinds.set(abs, kind); + } + return kind; + } + + private facts(abs: string): ModuleFacts { + const cached = this.factsCache.get(abs); + if (cached) return cached; + let facts: ModuleFacts; + try { + const source = readFileSync(abs, 'utf8'); + facts = new ModuleAnalysis(abs, source, parseModule(abs, source), { + packageDir: this.packageDir, + testSide: !this.productionDirs.some((dir) => isInside(abs, dir)), + display: (target) => this.display(target), + }).facts; + } catch (error) { + const message = errorMessage(error); + facts = emptyFacts({ reason: 'parse error', line: 0, detail: message.split('\n')[0] }); + } + this.factsCache.set(abs, facts); + return facts; + } + + // Resolution. + + private findFile(abs: string): string | null { + if (this.kind(abs) === 'file') return abs; + const ext = path.extname(abs); + for (const tsExt of TS_FOR_JS[ext] ?? []) { + const candidate = abs.slice(0, -ext.length) + tsExt; + if (this.kind(candidate) === 'file') return candidate; + } + for (const appended of APPENDED_EXTENSIONS) { + if (this.kind(abs + appended) === 'file') return abs + appended; + } + if (this.kind(abs) === 'dir') { + for (const appended of APPENDED_EXTENSIONS) { + const index = path.join(abs, `index${appended}`); + if (this.kind(index) === 'file') return index; + } + } + return null; + } + + private locationReason(importer: string, target: string): string | null { + if (isInside(importer, this.sharedSrcDir)) { + const allowed = + isInside(target, this.sharedSrcDir) || target === path.join(this.sharedDir, 'package.json'); + return allowed ? null : 'imports outside gitnexus/'; + } + if (!isInside(target, this.packageDir)) return 'imports outside gitnexus/'; + const rel = toPosix(path.relative(this.packageDir, target)); + const ignored = DEFAULT_IGNORED_DIRS.find((dir) => isWithin(rel, dir)); + return ignored === undefined ? null : `imports from ignored dir: ${ignored}/`; + } + + private resolveFile(importer: string, abs: string, shown: string, aliased: boolean): Resolved { + const found = this.findFile(abs); + if (found === null) return reject(`unresolved import: ${shown}`, `imports ${shown}`); + if (found.endsWith('.node')) { + return reject(`native binding: ${this.display(found)}`, `imports ${shown}`); + } + const location = aliased ? null : this.locationReason(importer, found); + if (location !== null) { + return reject(location, `imports ${shown} -> ${this.display(found)}`); + } + return WALKED_EXTENSIONS.has(path.extname(found)) && !found.endsWith('.d.ts') + ? { kind: 'module', abs: found } + : { kind: 'leaf', abs: found }; + } + + /** `exports` subpath of gitnexus-shared to its `src/` file (`./dist/X.js` -> `src/X.ts`). */ + private sharedExportMap(): ReadonlyMap { + if (this.sharedExports) return this.sharedExports; + const map = new Map(); + try { + const manifest: unknown = JSON.parse( + readFileSync(path.join(this.sharedDir, 'package.json'), 'utf8'), + ); + const exportsField = + typeof manifest === 'object' && manifest !== null && 'exports' in manifest + ? manifest.exports + : undefined; + if (typeof exportsField === 'object' && exportsField !== null) { + for (const [subpath, target] of Object.entries(exportsField)) { + const resolved: unknown = + typeof target === 'string' + ? target + : typeof target === 'object' && target !== null && 'default' in target + ? target.default + : undefined; + const match = typeof resolved === 'string' ? /^\.\/dist\/(.+)\.js$/.exec(resolved) : null; + if (match) map.set(subpath, path.join(this.sharedSrcDir, `${match[1]}.ts`)); + } + } + } catch { + // A missing or broken manifest leaves the alias unresolved. + } + this.sharedExports = map; + return map; + } + + private findPackageDir(fromDir: string, name: string): string | null { + let dir = fromDir; + for (;;) { + const candidate = path.join(dir, 'node_modules', name); + if (this.kind(path.join(candidate, 'package.json')) === 'file') return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } + + private readManifest(dir: string): Record { + try { + const data: unknown = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + return typeof data === 'object' && data !== null ? (data as Record) : {}; + } catch { + return {}; + } + } + + /** First `.node` file of a package, outside nested `node_modules`, in sorted order. */ + private findNodeBinary(dir: string, relative = ''): string | null { + let entries; + try { + entries = readdirSync(path.join(dir, relative), { withFileTypes: true }); + } catch { + return null; + } + const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of sorted) { + const child = relative === '' ? entry.name : `${relative}/${entry.name}`; + if (entry.isFile() && entry.name.endsWith('.node')) return child; + if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) { + const found = this.findNodeBinary(dir, child); + if (found !== null) return found; + } + } + return null; + } + + /** Why a package itself is native, or null. */ + private nativeSelf(dir: string, name: string): string | null { + const key = `${dir}\0${name}`; + const cached = this.directNative.get(key); + if (cached !== undefined) return cached; + let why: string | null = null; + if (KNOWN_NATIVE_PACKAGES.some((pattern) => pattern.test(name))) why = 'known native package'; + else if (this.readManifest(dir).gypfile === true) why = 'declares gypfile'; + else if (this.kind(path.join(dir, 'binding.gyp')) === 'file') why = 'has binding.gyp'; + else { + const binary = this.findNodeBinary(dir); + if (binary !== null) why = `ships ${binary}`; + } + this.directNative.set(key, why); + return why; + } + + /** Breadth-first over installed runtime dependencies; the first native package found. */ + private nativeOf(rootDir: string, rootName: string): NativeHit | null { + const cached = this.packageNative.get(rootDir); + if (cached !== undefined) return cached; + const realOf = (dir: string): string => { + try { + return realpathSync(dir); + } catch { + return dir; + } + }; + const queue: { dir: string; name: string; chain: string[] }[] = [ + { dir: rootDir, name: rootName, chain: [rootName] }, + ]; + const seen = new Set([realOf(rootDir)]); + let hit: NativeHit | null = null; + for (let index = 0; index < queue.length && hit === null; index += 1) { + const { dir, name, chain } = queue[index]; + const why = this.nativeSelf(dir, name); + if (why !== null) { + hit = { pkg: name, chain, why }; + break; + } + const manifest = this.readManifest(dir); + const deps = new Set(); + for (const field of ['dependencies', 'optionalDependencies']) { + const value = manifest[field]; + if (typeof value === 'object' && value !== null) { + for (const dep of Object.keys(value)) deps.add(dep); + } + } + for (const dep of [...deps].sort()) { + const depDir = this.findPackageDir(dir, dep); + if (depDir === null || seen.has(realOf(depDir))) continue; + seen.add(realOf(depDir)); + queue.push({ dir: depDir, name: dep, chain: [...chain, dep] }); + } + } + this.packageNative.set(rootDir, hit); + return hit; + } + + private resolveSpecifier(importer: string, specifier: string, fromDir: string): Resolved { + if (specifier.startsWith('.') || path.isAbsolute(specifier)) { + if (/[?#]/.test(specifier)) { + return reject(`unsupported import specifier: ${specifier}`, `imports ${specifier}`); + } + return this.resolveFile(importer, path.resolve(fromDir, specifier), specifier, false); + } + if (specifier.startsWith('file:')) { + let abs: string; + try { + abs = fileURLToPath(specifier); + } catch { + return reject(`unresolved import: ${specifier}`, `imports ${specifier}`); + } + return this.resolveFile(importer, abs, specifier, false); + } + if (specifier.startsWith('node:') || BUILTINS.has(specifier)) { + return { kind: 'builtin', name: normaliseModule(specifier) }; + } + const name = packageName(specifier); + if (name === null) return reject(`unresolved import: ${specifier}`, `imports ${specifier}`); + if (name === SHARED_PACKAGE) { + const subpath = specifier === name ? '.' : `.${specifier.slice(name.length)}`; + const target = this.sharedExportMap().get(subpath); + return target === undefined + ? reject(`unresolved import: ${specifier}`, `imports ${specifier} (not in exports)`) + : this.resolveFile(importer, target, specifier, true); + } + if (isHarnessPackage(name)) return { kind: 'package', name }; + if (KNOWN_NATIVE_PACKAGES.some((pattern) => pattern.test(name))) { + return reject(`native package: ${name}`, `imports ${specifier} (known native package)`); + } + if (KNOWN_PROCESS_PACKAGES.some((pattern) => pattern.test(name))) { + return reject( + 'spawns processes: child_process', + `imports ${specifier} (the package loads node:child_process when imported)`, + ); + } + const dir = this.findPackageDir(fromDir, name); + if (dir === null) { + return reject(`unresolved import: ${specifier}`, `imports ${specifier} (not installed)`); + } + const native = this.nativeOf(dir, name); + return native === null + ? { kind: 'package', name } + : reject( + `native package: ${native.pkg}`, + `imports ${specifier} (${native.chain.join(' -> ')}: ${native.why})`, + ); + } + + private resolve(importer: string, target: EdgeTarget): Resolved { + const zone = isInside(importer, this.sharedSrcDir) ? 'shared' : 'package'; + const key = + target.kind === 'file' + ? `${zone}\0file\0${target.abs}` + : `${zone}\0${target.fromDir}\0${target.specifier}`; + let resolved = this.resolutions.get(key); + if (resolved === undefined) { + resolved = + target.kind === 'file' + ? this.resolveFile(importer, target.abs, this.display(target.abs), false) + : this.resolveSpecifier(importer, target.specifier, target.fromDir); + this.resolutions.set(key, resolved); + } + return resolved; + } + + // Closure walk. + + /** Classifies one package-relative test file. */ + classifyFile(testFile: string): FileVerdict { + const testAbs = path.join(this.packageDir, testFile); + const parents = new Map([[testAbs, null]]); + const queue = [testAbs]; + const closure = new Set(); + const packages = new Set(); + const builtins = new Set(); + const evidence = new Map(); + const chain = (abs: string): string[] => { + const via: string[] = []; + for (let at: string | null | undefined = abs; at; at = parents.get(at)) { + via.unshift(this.display(at)); + } + return via; + }; + const record = (module: string, finding: Finding): void => { + if (evidence.has(finding.reason)) return; + evidence.set(finding.reason, { + reason: finding.reason, + module: this.display(module), + line: finding.line, + detail: finding.detail, + via: chain(module), + }); + }; + if (this.kind(testAbs) !== 'file') { + record(testAbs, { reason: 'parse error', line: 0, detail: 'test file not found' }); + queue.length = 0; + } + + for (let index = 0; index < queue.length; index += 1) { + const module = queue[index]; + closure.add(this.display(module)); + const facts = this.facts(module); + for (const finding of facts.findings) record(module, finding); + for (const finding of facts.reads) record(module, finding); + for (const edge of facts.edges) { + const resolved = this.resolve(module, edge.target); + switch (resolved.kind) { + case 'module': + if (!parents.has(resolved.abs)) { + parents.set(resolved.abs, module); + queue.push(resolved.abs); + } + break; + case 'leaf': + closure.add(this.display(resolved.abs)); + break; + case 'package': + packages.add(resolved.name); + break; + case 'reject': + record(module, { reason: resolved.reason, line: edge.line, detail: resolved.detail }); + break; + case 'builtin': + builtins.add(resolved.name); + if (PROCESS_BUILTINS.has(resolved.name)) { + record(module, { + reason: `spawns processes: ${resolved.name}`, + line: edge.line, + detail: `imports node:${resolved.name}`, + }); + } + if ( + resolved.name === WORKER_BUILTIN && + (edge.names === null || edge.names.includes('Worker')) + ) { + record(module, { + reason: `spawns workers: ${WORKER_BUILTIN}`, + line: edge.line, + detail: `imports ${edge.names === null ? 'all of' : 'Worker from'} node:${WORKER_BUILTIN}`, + }); + } + break; + } + } + } + + const sortedEvidence = [...evidence.values()].sort((a, b) => + a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0, + ); + const testFacts = this.kind(testAbs) === 'file' ? this.facts(testAbs) : undefined; + return { + file: testFile, + status: sortedEvidence.length === 0 ? 'accepted' : 'rejected', + reasons: sortedEvidence.map((entry) => entry.reason), + evidence: sortedEvidence, + hooks: { + ...(testFacts?.hooks ?? { beforeAll: 0, beforeEach: 0, afterAll: 0, afterEach: 0 }), + }, + eachCalls: testFacts?.eachCalls ?? 0, + testCalls: testFacts?.testCalls ?? 0, + closure: [...closure].sort(), + packages: [...packages].sort(), + builtins: [...builtins].sort(), + }; + } +} + +// ── Classification ───────────────────────────────────────────────────────── + +/** Every `*.test.ts` file under a package-relative directory, package-relative and sorted. */ +export function listTestFiles(packageDir: string, testRoot: string): string[] { + const found: string[] = []; + const visit = (relative: string): void => { + for (const entry of readdirSync(path.join(packageDir, relative), { withFileTypes: true })) { + const child = `${relative}/${entry.name}`; + if (entry.isDirectory() && entry.name !== 'node_modules') visit(child); + else if (entry.isFile() && entry.name.endsWith('.test.ts')) found.push(child); + } + }; + visit(testRoot.replace(/\/+$/, '')); + return found.sort(); +} + +const countBy = (keys: readonly string[]): Record => { + const counts = new Map(); + for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1); + return Object.fromEntries([...counts.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +}; + +const categoryOf = (reason: string): string => { + const index = reason.indexOf(': '); + return index === -1 ? reason : reason.slice(0, index); +}; + +/** Classifies the test files of a checkout; output order is deterministic (sorted by path). */ +export function classify(options: ClassifyOptions): Classification { + const classifier = new Classifier(options.repoRoot); + const testRoot = options.testFiles ? null : (options.testRoot ?? 'test/unit'); + const testFiles = options.testFiles + ? [...new Set(options.testFiles)].sort() + : listTestFiles(classifier.packageDir, testRoot ?? 'test/unit'); + const files = testFiles.map((testFile) => classifier.classifyFile(testFile)); + const accepted = files.filter((verdict) => verdict.status === 'accepted'); + const rejected = files.filter((verdict) => verdict.status === 'rejected'); + return { + schemaVersion: CLASSIFY_SCHEMA_VERSION, + testRoot, + files, + accepted: accepted.map((verdict) => verdict.file), + rejected: rejected.map(({ file: testFile, reasons }) => ({ file: testFile, reasons })), + acceptedWithBeforeAll: accepted + .filter((verdict) => verdict.hooks.beforeAll > 0) + .map((verdict) => verdict.file), + summary: { + total: files.length, + accepted: accepted.length, + rejected: rejected.length, + byReason: countBy(rejected.flatMap((verdict) => verdict.reasons)), + byCategory: countBy( + rejected.flatMap((verdict) => [...new Set(verdict.reasons.map(categoryOf))]), + ), + }, + }; +} + +/** Human-readable summary: counts, rejection categories and accepted files with `beforeAll`. */ +export function renderSummary(result: Classification): string { + const { summary } = result; + const lines = [ + `classify: ${summary.total} test files: ${summary.accepted} accepted, ${summary.rejected} rejected`, + 'rejected files per reason category (a file counts once per category):', + ...Object.entries(summary.byCategory).map(([category, count]) => ` ${category}: ${count}`), + `accepted files with beforeAll: ${result.acceptedWithBeforeAll.length}`, + ]; + const byFile = new Map(result.files.map((verdict) => [verdict.file, verdict])); + for (const testFile of result.acceptedWithBeforeAll) { + const verdict = byFile.get(testFile); + lines.push( + ` ${testFile} (beforeAll ${verdict?.hooks.beforeAll ?? 0}, test call sites ` + + `${verdict?.testCalls ?? 0}, each ${verdict?.eachCalls ?? 0}, closure ` + + `${verdict?.closure.length ?? 0} files)`, + ); + } + return lines.join('\n'); +} + +/** JSON form for the CLI; drops closure lists unless asked for. */ +export function toJson(result: Classification, withClosure: boolean): string { + const files = withClosure + ? result.files + : result.files.map( + ({ closure: _closure, packages: _packages, builtins: _builtins, ...rest }) => rest, + ); + return `${JSON.stringify({ ...result, files }, null, 2)}\n`; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +if (isMainModule(import.meta.url)) { + const usage = + 'usage: node --import tsx tools/mutation/classify/classify.ts [--root ] ' + + '[--file ]... [--out ] [--closure]'; + let values: { root?: string; file?: string[]; out?: string; closure?: boolean }; + try { + ({ values } = parseArgs({ + options: { + root: { type: 'string' }, + file: { type: 'string', multiple: true }, + out: { type: 'string' }, + closure: { type: 'boolean', default: false }, + }, + strict: true, + })); + } catch (error) { + console.error(`${errorMessage(error)}\n${usage}`); + process.exit(2); + } + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + try { + const result = classify({ + repoRoot, + testFiles: values.file && values.file.length > 0 ? values.file : undefined, + testRoot: values.root, + }); + const json = toJson(result, values.closure === true); + if (values.out) { + writeFileSync(path.resolve(values.out), json); + console.log(renderSummary(result)); + } else { + process.stdout.write(json); + } + } catch (error) { + console.error(errorMessage(error)); + process.exitCode = 2; + } +} diff --git a/gitnexus/tools/mutation/conformance/compare.ts b/gitnexus/tools/mutation/conformance/compare.ts new file mode 100644 index 000000000..1ed2e5050 --- /dev/null +++ b/gitnexus/tools/mutation/conformance/compare.ts @@ -0,0 +1,856 @@ +/** + * Gate 2 conformance (U4): the expected-results file of the conformance fixture, the observed view + * of its runs, and the comparison between them. This module is pure; run-conformance.ts runs the + * fixture through the scope driver and does the file I/O. + * + * `test/fixtures/mutation-conformance/expected.json` ({@link ExpectedConformance}): + * profiles[] {name, concurrency, maxTestRunnerReuse}: one Stryker run per scope each + * scopes[] {name, testFiles, mutate, testIds, tests}: fixture-relative case and production + * files, the fixture-relative test ids the run selects (the driver scope's `testIds`, + * Phase 1 KTD2; null runs every test) and the complete pinned test inventory {id, + * skipped, notOracleValidated} + * cases[] one entry per pinned case, by `kind`: + * mutant a planted mutant, selected by {file, location "L:C-L:C", mutatorName, + * replacement}, with every pinned fact ({@link MutantFacts}); the same in every + * profile + * tests tests of a scope with pinned ids and flags + * processes a profile's process rule ({@link ProcessRule}), optionally with two mutant cases + * that one process must run back to back (`consecutive`) + * environment a profile that runs with `__STRYKER_ACTIVE_MUTANT__` exported to the Stryker + * process (past the driver's scrub), set to the pinned local id of another + * case's mutant; only the runner's own removal keeps it from the Vitest workers + * + * Test ids and file paths are fixture-relative: `runtime.case.ts#doubles two` stands for the + * runner id `test/fixtures/mutation-conformance/runtime.case.ts#doubles two`. Mutants Stryker + * reports as Ignored (disabled by a `// Stryker disable` comment in the fixture) are not compared; + * every other mutant must match exactly one mutant case, or it is reported as unexpected. + * + * Mutant facts. `selected` is the run's requested test set from the latest sidecar record (`"all"` + * when it is the scope's whole manifest test set). `tests` lists the per-test outcome classes of + * that record other than `survived`, so `{}` means every selected test passed. `reason` is a + * substring the record's reason must contain, or null when it must have none. `selected`, `tests`, + * `outsideTest` and `reason` are null for a mutant without a sidecar record (NoCoverage). + * `hookAttributedTests` is the results' hook marker (the tests a suite hook attributed the mutant + * to in the dry run, null without a dry-run record) and `attemptsDisagree` the results' flag for + * attempts whose classes differ (normalise-results.ts). + */ +import type { + FlagKind, + NormalisedMutant, + NormalisedTest, + OutcomeSource, + RunResults, +} from '../driver/normalise-results.js'; +import { MUTANT_STATUSES, type MutantStatus } from '../prepare/mutant-identity.js'; +import type { OutcomeClass } from '../runner/classify-outcome.js'; +import type { SidecarRecord } from '../runner/sidecar.js'; +import { errorMessage } from '../shared/cli.js'; + +export const EXPECTED_SCHEMA_VERSION = 1; + +export const OUTCOME_CLASSES: readonly OutcomeClass[] = [ + 'killed-by-test:assertion', + 'killed-by-test:runtime', + 'killed-by-test:hook', + 'killed-outside-test:hook', + 'killed-outside-test:collection', + 'timeout', + 'infrastructure', + 'inconclusive', + 'invalid', + 'survived', +]; + +const OUTCOME_SOURCES: readonly OutcomeSource[] = [ + 'sidecar', + 'no-coverage', + 'stryker-timeout', + 'none', +]; + +const FLAG_KINDS: readonly FlagKind[] = [ + 'dry-run-missing', + 'unmapped-test-id', + 'orphan-sidecar', + 'killed-by-without-sidecar', + 'killed-without-sidecar', + 'missing-sidecar', + 'killed-by-not-in-sidecar', + 'status-mismatch', + 'zero-tests', +]; + +/** + * `one-mutant-run-per-process`: no runner process handles more than one mutant run, so every run + * but at most one comes from a process that never ran the dry run (`maxTestRunnerReuse: 1`). + * `every-run-in-the-dry-run-process`: the process that ran the dry run handles every mutant run. + */ +export const PROCESS_RULES = [ + 'one-mutant-run-per-process', + 'every-run-in-the-dry-run-process', +] as const; +export type ProcessRule = (typeof PROCESS_RULES)[number]; + +export interface Profile { + name: string; + concurrency: number; + maxTestRunnerReuse: number; +} + +export interface TestPin { + id: string; + skipped: boolean; + notOracleValidated: boolean; +} + +export interface ScopeSpec { + name: string; + /** Fixture-relative case files. */ + testFiles: string[]; + /** Fixture-relative production files. */ + mutate: string[]; + /** Fixture-relative ids of the only tests the run selects; null runs every test. */ + testIds: string[] | null; + /** The complete test inventory of the scope's run manifest. */ + tests: TestPin[]; +} + +export interface MutantSelector { + /** Fixture-relative production file. */ + file: string; + /** `:-:`, 1-based, end exclusive. */ + location: string; + mutatorName: string; + replacement: string; +} + +export interface OutsideTestFact { + file: string; + outcome: OutcomeClass; + attributedTo: string[]; +} + +export interface MutantFacts { + status: MutantStatus; + static: boolean | null; + outcome: OutcomeClass | null; + outcomeSource: OutcomeSource; + coveredBy: string[] | null; + selected: 'all' | string[] | null; + killedBy: string[]; + assertionKilledBy: string[]; + tests: Record | null; + outsideTest: OutsideTestFact[] | null; + reason: string | null; + flags: FlagKind[]; + hookAttributedTests: string[] | null; + attemptsDisagree: boolean; +} + +export interface MutantCase { + kind: 'mutant'; + name: string; + scope: string; + mutant: MutantSelector; + expect: MutantFacts; +} + +export interface TestsCase { + kind: 'tests'; + name: string; + scope: string; + tests: TestPin[]; +} + +export interface ProcessesCase { + kind: 'processes'; + name: string; + profile: string; + rule: ProcessRule; + /** Two mutant case names whose runs must follow each other in one process; null for none. */ + consecutive: [string, string] | null; +} + +export interface EnvironmentCase { + kind: 'environment'; + name: string; + profile: string; + /** The mutant case whose local id is exported as `__STRYKER_ACTIVE_MUTANT__`. */ + activeMutantOf: string; + localId: string; +} + +export type ExpectedCase = MutantCase | TestsCase | ProcessesCase | EnvironmentCase; + +export interface ExpectedConformance { + schemaVersion: typeof EXPECTED_SCHEMA_VERSION; + profiles: Profile[]; + scopes: ScopeSpec[]; + cases: ExpectedCase[]; +} + +export interface MutantRun { + pid: number; + writtenAtMs: number; +} + +export interface ObservedMutant extends MutantSelector { + localId: string; + /** `reason` holds the full sidecar reason here. */ + facts: MutantFacts; + /** Every sidecar attempt, oldest first. */ + runs: MutantRun[]; +} + +export interface ObservedScopeRun { + profile: string; + scope: string; + /** Every mutant that is not Ignored. */ + mutants: ObservedMutant[]; + tests: TestPin[]; + dryRunPid: number | null; + /** Run-level integrity flags of the scope driver (`: `). */ + runFlags: string[]; + spanMapFailures: number; + /** The `__STRYKER_ACTIVE_MUTANT__` value the driver exported for this run, if any. */ + exportedActiveMutant: string | null; +} + +export interface Difference { + profile: string | null; + scope: string | null; + /** The case (or check) that failed. */ + case: string; + detail: string; +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +const sorted = (values: readonly T[]): T[] => [...values].sort(); + +const show = (value: unknown): string => JSON.stringify(value); + +type Json = Record; + +class ExpectedFileError extends Error { + override name = 'ExpectedFileError'; +} + +const fail = (where: string, detail: string): never => { + throw new ExpectedFileError(`expected conformance file: ${where}: ${detail}`); +}; + +const isRecord = (value: unknown): value is Json => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function object(value: unknown, where: string, keys: readonly string[]): Json { + if (!isRecord(value)) return fail(where, 'must be an object'); + const unknown = Object.keys(value).filter((key) => !keys.includes(key)); + if (unknown.length > 0) fail(where, `unknown key ${JSON.stringify(unknown[0])}`); + const missing = keys.filter((key) => !(key in value)); + if (missing.length > 0) fail(where, `missing key ${JSON.stringify(missing[0])}`); + return value; +} + +function text(value: unknown, where: string): string { + if (typeof value !== 'string' || value === '') return fail(where, 'must be a non-empty string'); + return value; +} + +function integer(value: unknown, where: string, minimum: number): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum) { + return fail(where, `must be an integer of at least ${minimum}`); + } + return value; +} + +function bool(value: unknown, where: string): boolean { + if (typeof value !== 'boolean') return fail(where, 'must be a boolean'); + return value; +} + +function list(value: unknown, where: string, item: (entry: unknown, at: string) => T): T[] { + if (!Array.isArray(value)) return fail(where, 'must be an array'); + return value.map((entry, index) => item(entry, `${where}[${index}]`)); +} + +function oneOf(value: unknown, where: string, allowed: readonly T[]): T { + const found = allowed.find((candidate) => candidate === value); + return found ?? fail(where, `must be one of ${allowed.map((a) => JSON.stringify(a)).join(', ')}`); +} + +const nullable = (value: unknown, parse: (entry: unknown) => T): T | null => + value === null ? null : parse(value); + +const texts = (value: unknown, where: string): string[] => list(value, where, text); + +function parseTestPin(value: unknown, where: string): TestPin { + const data = object(value, where, ['id', 'skipped', 'notOracleValidated']); + return { + id: text(data.id, `${where}.id`), + skipped: bool(data.skipped, `${where}.skipped`), + notOracleValidated: bool(data.notOracleValidated, `${where}.notOracleValidated`), + }; +} + +const LOCATION = /^\d+:\d+-\d+:\d+$/; + +function parseSelector(value: unknown, where: string): MutantSelector { + const data = object(value, where, ['file', 'location', 'mutatorName', 'replacement']); + const location = text(data.location, `${where}.location`); + if (!LOCATION.test(location)) fail(`${where}.location`, 'must look like "7:12-7:20"'); + if (typeof data.replacement !== 'string') fail(`${where}.replacement`, 'must be a string'); + return { + file: text(data.file, `${where}.file`), + location, + mutatorName: text(data.mutatorName, `${where}.mutatorName`), + replacement: String(data.replacement), + }; +} + +const FACT_KEYS: readonly (keyof MutantFacts)[] = [ + 'status', + 'static', + 'outcome', + 'outcomeSource', + 'coveredBy', + 'selected', + 'killedBy', + 'assertionKilledBy', + 'tests', + 'outsideTest', + 'reason', + 'flags', + 'hookAttributedTests', + 'attemptsDisagree', +]; + +function parseFacts(value: unknown, where: string): MutantFacts { + const data = object(value, where, FACT_KEYS); + const at = (key: string): string => `${where}.${key}`; + return { + status: oneOf(data.status, at('status'), MUTANT_STATUSES), + static: nullable(data.static, (entry) => bool(entry, at('static'))), + outcome: nullable(data.outcome, (entry) => oneOf(entry, at('outcome'), OUTCOME_CLASSES)), + outcomeSource: oneOf(data.outcomeSource, at('outcomeSource'), OUTCOME_SOURCES), + coveredBy: nullable(data.coveredBy, (entry) => texts(entry, at('coveredBy'))), + selected: nullable(data.selected, (entry) => + entry === 'all' ? 'all' : texts(entry, at('selected')), + ), + killedBy: texts(data.killedBy, at('killedBy')), + assertionKilledBy: texts(data.assertionKilledBy, at('assertionKilledBy')), + tests: nullable(data.tests, (entry) => { + if (!isRecord(entry)) return fail(at('tests'), 'must be an object or null'); + return Object.fromEntries( + Object.entries(entry).map(([id, outcome]) => [ + id, + oneOf(outcome, `${at('tests')}[${JSON.stringify(id)}]`, OUTCOME_CLASSES), + ]), + ); + }), + outsideTest: nullable(data.outsideTest, (entry) => + list(entry, at('outsideTest'), (row, rowAt) => { + const fields = object(row, rowAt, ['file', 'outcome', 'attributedTo']); + return { + file: text(fields.file, `${rowAt}.file`), + outcome: oneOf(fields.outcome, `${rowAt}.outcome`, OUTCOME_CLASSES), + attributedTo: texts(fields.attributedTo, `${rowAt}.attributedTo`), + }; + }), + ), + reason: nullable(data.reason, (entry) => text(entry, at('reason'))), + flags: list(data.flags, at('flags'), (flag, flagAt) => oneOf(flag, flagAt, FLAG_KINDS)), + hookAttributedTests: nullable(data.hookAttributedTests, (entry) => + texts(entry, at('hookAttributedTests')), + ), + attemptsDisagree: bool(data.attemptsDisagree, at('attemptsDisagree')), + }; +} + +const CASE_KINDS = ['mutant', 'tests', 'processes', 'environment'] as const; + +function parseCase(value: unknown, where: string): ExpectedCase { + if (!isRecord(value)) return fail(where, 'must be an object'); + const kind = oneOf(value.kind, `${where}.kind`, CASE_KINDS); + switch (kind) { + case 'mutant': { + const data = object(value, where, ['kind', 'name', 'scope', 'mutant', 'expect']); + return { + kind, + name: text(data.name, `${where}.name`), + scope: text(data.scope, `${where}.scope`), + mutant: parseSelector(data.mutant, `${where}.mutant`), + expect: parseFacts(data.expect, `${where}.expect`), + }; + } + case 'tests': { + const data = object(value, where, ['kind', 'name', 'scope', 'tests']); + return { + kind, + name: text(data.name, `${where}.name`), + scope: text(data.scope, `${where}.scope`), + tests: list(data.tests, `${where}.tests`, parseTestPin), + }; + } + case 'processes': { + const data = object(value, where, ['kind', 'name', 'profile', 'rule', 'consecutive']); + const consecutive = nullable(data.consecutive, (entry) => { + const names = texts(entry, `${where}.consecutive`); + if (names.length !== 2) fail(`${where}.consecutive`, 'must name two mutant cases'); + return [names[0], names[1]] satisfies [string, string]; + }); + return { + kind, + name: text(data.name, `${where}.name`), + profile: text(data.profile, `${where}.profile`), + rule: oneOf(data.rule, `${where}.rule`, PROCESS_RULES), + consecutive, + }; + } + case 'environment': { + const data = object(value, where, ['kind', 'name', 'profile', 'activeMutantOf', 'localId']); + return { + kind, + name: text(data.name, `${where}.name`), + profile: text(data.profile, `${where}.profile`), + activeMutantOf: text(data.activeMutantOf, `${where}.activeMutantOf`), + localId: text(data.localId, `${where}.localId`), + }; + } + } +} + +function checkReferences(doc: ExpectedConformance): void { + const unique = (names: readonly string[], what: string): void => { + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) fail(what, `${JSON.stringify(name)} is listed twice`); + seen.add(name); + } + }; + unique( + doc.profiles.map((profile) => profile.name), + 'profiles', + ); + unique( + doc.scopes.map((scope) => scope.name), + 'scopes', + ); + unique( + doc.cases.map((entry) => entry.name), + 'cases', + ); + const profiles = new Set(doc.profiles.map((profile) => profile.name)); + const scopes = new Set(doc.scopes.map((scope) => scope.name)); + const mutantCases = new Set( + doc.cases.filter((entry) => entry.kind === 'mutant').map((entry) => entry.name), + ); + for (const entry of doc.cases) { + const where = `case ${JSON.stringify(entry.name)}`; + if ('scope' in entry && !scopes.has(entry.scope)) fail(where, `unknown scope ${entry.scope}`); + if ('profile' in entry && !profiles.has(entry.profile)) { + fail(where, `unknown profile ${entry.profile}`); + } + const refs = + entry.kind === 'processes' + ? (entry.consecutive ?? []) + : entry.kind === 'environment' + ? [entry.activeMutantOf] + : []; + for (const ref of refs.filter((name) => !mutantCases.has(name))) { + fail(where, `${JSON.stringify(ref)} is not a mutant case`); + } + } +} + +/** Parses and validates the expected-results file. */ +export function parseExpected(source: string): ExpectedConformance { + let data: unknown; + try { + data = JSON.parse(source); + } catch (error) { + return fail('file', `not valid JSON (${errorMessage(error)})`); + } + const root = object(data, 'root', ['schemaVersion', 'profiles', 'scopes', 'cases']); + if (root.schemaVersion !== EXPECTED_SCHEMA_VERSION) { + fail('schemaVersion', `must be ${EXPECTED_SCHEMA_VERSION}`); + } + const doc: ExpectedConformance = { + schemaVersion: EXPECTED_SCHEMA_VERSION, + profiles: list(root.profiles, 'profiles', (value, where) => { + const data = object(value, where, ['name', 'concurrency', 'maxTestRunnerReuse']); + return { + name: text(data.name, `${where}.name`), + concurrency: integer(data.concurrency, `${where}.concurrency`, 1), + maxTestRunnerReuse: integer(data.maxTestRunnerReuse, `${where}.maxTestRunnerReuse`, 0), + }; + }), + scopes: list(root.scopes, 'scopes', (value, where) => { + const data = object(value, where, ['name', 'testFiles', 'mutate', 'testIds', 'tests']); + return { + name: text(data.name, `${where}.name`), + testFiles: texts(data.testFiles, `${where}.testFiles`), + mutate: texts(data.mutate, `${where}.mutate`), + testIds: nullable(data.testIds, (entry) => { + const ids = texts(entry, `${where}.testIds`); + if (ids.length === 0) fail(`${where}.testIds`, 'must be null or a non-empty array'); + return ids; + }), + tests: list(data.tests, `${where}.tests`, parseTestPin), + }; + }), + cases: list(root.cases, 'cases', parseCase), + }; + checkReferences(doc); + return doc; +} + +// ── Observation ──────────────────────────────────────────────────────────── + +export interface ObserveInput { + profile: string; + scope: string; + results: Pick; + /** Sidecar records by file path; must hold the latest record of every mutant that has one. */ + records: ReadonlyMap; + dryRunPid: number | null; + exportedActiveMutant: string | null; + /** Package-relative fixture directory, e.g. `test/fixtures/mutation-conformance`. */ + fixtureRoot: string; +} + +/** Strips the fixture directory from package-relative or repo-relative paths and test ids. */ +export function fixtureRelative(value: string, fixtureRoot: string): string { + for (const prefix of [`${fixtureRoot}/`, `gitnexus/${fixtureRoot}/`]) { + if (value.startsWith(prefix)) return value.slice(prefix.length); + } + return value; +} + +const formatLocation = ({ location: { start, end } }: NormalisedMutant): string => + `${start.line}:${start.column}-${end.line}:${end.column}`; + +function observeMutant( + mutant: NormalisedMutant, + input: ObserveInput, + allTests: ReadonlySet, +): ObservedMutant { + const relative = (value: string): string => fixtureRelative(value, input.fixtureRoot); + const ids = (values: readonly string[]): string[] => sorted(values.map(relative)); + const sidecar = mutant.sidecar; + const record = sidecar === null ? undefined : input.records.get(sidecar.file); + if (sidecar !== null && record === undefined) { + throw new Error(`conformance: the sidecar record ${sidecar.file} was not read`); + } + const requested = record?.requestedTestIds ?? []; + const isAll = + record !== undefined && + requested.length === allTests.size && + requested.every((id) => allTests.has(id)); + return { + file: relative(mutant.file), + location: formatLocation(mutant), + mutatorName: mutant.mutatorName, + replacement: mutant.replacement, + localId: mutant.localId, + facts: { + status: mutant.status, + static: mutant.static, + outcome: mutant.outcome, + outcomeSource: mutant.outcomeSource, + coveredBy: mutant.coveredBy === null ? null : ids(mutant.coveredBy), + selected: record === undefined ? null : isAll ? 'all' : ids(requested), + killedBy: ids(mutant.killedBy), + assertionKilledBy: ids(mutant.assertionKilledBy), + tests: + record === undefined + ? null + : Object.fromEntries( + record.result.tests + .filter((test) => test.outcome !== 'survived') + .map((test) => [relative(test.id), test.outcome] as const) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ), + outsideTest: + record === undefined + ? null + : record.result.outsideTest.map((row) => ({ + file: relative(row.file), + outcome: row.outcome, + attributedTo: ids(row.attributedTo), + })), + reason: record === undefined ? null : (record.result.reason ?? null), + flags: sorted(mutant.flags), + hookAttributedTests: + mutant.hookAttributedTests === null ? null : ids(mutant.hookAttributedTests), + attemptsDisagree: mutant.attemptsDisagree, + }, + runs: mutant.attempts.map(({ pid, writtenAtMs }) => ({ pid, writtenAtMs })), + }; +} + +const observeTest = (test: NormalisedTest, fixtureRoot: string): TestPin => ({ + id: fixtureRelative(test.id, fixtureRoot), + skipped: test.skipped, + notOracleValidated: test.notOracleValidated, +}); + +/** The observed view of one scope run; see the module comment for each fact. */ +export function observeScopeRun(input: ObserveInput): ObservedScopeRun { + const { results } = input; + const allTests = new Set(results.tests.filter((test) => !test.skipped).map((test) => test.id)); + return { + profile: input.profile, + scope: input.scope, + mutants: results.mutants + .filter((mutant) => mutant.status !== 'Ignored') + .map((mutant) => observeMutant(mutant, input, allTests)), + tests: results.tests.map((test) => observeTest(test, input.fixtureRoot)), + dryRunPid: input.dryRunPid, + runFlags: results.flags + .filter((flag) => flag.localId === null) + .map((flag) => `${flag.kind}: ${flag.detail}`), + spanMapFailures: results.spanMap.failures.length, + exportedActiveMutant: input.exportedActiveMutant, + }; +} + +// ── Comparison ───────────────────────────────────────────────────────────── + +const sameList = (a: readonly string[], b: readonly string[]): boolean => + show(sorted(a)) === show(sorted(b)); + +const sameNullableList = (a: readonly string[] | null, b: readonly string[] | null): boolean => + a === null || b === null ? a === b : sameList(a, b); + +const sameSelection = (a: MutantFacts['selected'], b: MutantFacts['selected']): boolean => + a === 'all' || b === 'all' || a === null || b === null ? a === b : sameList(a, b); + +const sameTests = (a: MutantFacts['tests'], b: MutantFacts['tests']): boolean => + a === null || b === null + ? a === b + : show(Object.entries(a).sort()) === show(Object.entries(b).sort()); + +const outsideKey = (rows: readonly OutsideTestFact[] | null): string => + rows === null + ? 'null' + : show(rows.map((row) => show({ ...row, attributedTo: sorted(row.attributedTo) })).sort()); + +/** `null` expected means no reason; a string must occur in the observed reason. */ +const reasonMatches = (expected: string | null, observed: string | null): boolean => + expected === null ? observed === null : observed !== null && observed.includes(expected); + +const FACT_CHECKS: readonly { + key: keyof MutantFacts; + same: (expected: MutantFacts, observed: MutantFacts) => boolean; +}[] = [ + { key: 'status', same: (e, o) => e.status === o.status }, + { key: 'static', same: (e, o) => e.static === o.static }, + { key: 'outcome', same: (e, o) => e.outcome === o.outcome }, + { key: 'outcomeSource', same: (e, o) => e.outcomeSource === o.outcomeSource }, + { key: 'coveredBy', same: (e, o) => sameNullableList(e.coveredBy, o.coveredBy) }, + { key: 'selected', same: (e, o) => sameSelection(e.selected, o.selected) }, + { key: 'killedBy', same: (e, o) => sameList(e.killedBy, o.killedBy) }, + { key: 'assertionKilledBy', same: (e, o) => sameList(e.assertionKilledBy, o.assertionKilledBy) }, + { key: 'tests', same: (e, o) => sameTests(e.tests, o.tests) }, + { key: 'outsideTest', same: (e, o) => outsideKey(e.outsideTest) === outsideKey(o.outsideTest) }, + { key: 'reason', same: (e, o) => reasonMatches(e.reason, o.reason) }, + { key: 'flags', same: (e, o) => sameList(e.flags, o.flags) }, + { + key: 'hookAttributedTests', + same: (e, o) => sameNullableList(e.hookAttributedTests, o.hookAttributedTests), + }, + { key: 'attemptsDisagree', same: (e, o) => e.attemptsDisagree === o.attemptsDisagree }, +]; + +const selects = (selector: MutantSelector, mutant: MutantSelector): boolean => + selector.file === mutant.file && + selector.location === mutant.location && + selector.mutatorName === mutant.mutatorName && + selector.replacement === mutant.replacement; + +export const formatSelector = (selector: MutantSelector): string => + `${selector.file}:${selector.location} ${selector.mutatorName} ${show(selector.replacement)}`; + +function compareTests( + pins: readonly TestPin[], + observed: readonly TestPin[], + complete: boolean, +): string[] { + const byId = new Map(observed.map((test) => [test.id, test])); + const problems: string[] = []; + for (const pin of pins) { + const test = byId.get(pin.id); + if (test === undefined) problems.push(`test ${show(pin.id)} is missing`); + else if (show(test) !== show(pin)) + problems.push(`test ${show(pin.id)}: expected ${show(pin)}, observed ${show(test)}`); + } + if (complete) { + const pinned = new Set(pins.map((pin) => pin.id)); + for (const test of observed.filter((entry) => !pinned.has(entry.id))) { + problems.push(`unexpected test ${show(test)}`); + } + } + return problems; +} + +function processProblems( + entry: ProcessesCase, + run: ObservedScopeRun, + mutantCases: ReadonlyMap, +): string[] { + const runs = run.mutants.flatMap((mutant) => + mutant.runs.map((attempt) => ({ ...attempt, localId: mutant.localId })), + ); + const problems: string[] = []; + if (run.dryRunPid === null) problems.push('no dry-run record names its process'); + const perProcess = new Map(); + for (const attempt of runs) + perProcess.set(attempt.pid, [...(perProcess.get(attempt.pid) ?? []), attempt]); + if (entry.rule === 'one-mutant-run-per-process') { + for (const [pid, handled] of perProcess) { + if (handled.length > 1) { + problems.push( + `process ${pid} handled ${handled.length} mutant runs (${handled.map((r) => r.localId).join(', ')})`, + ); + } + } + if (runs.length > 0 && runs.every((attempt) => attempt.pid === run.dryRunPid)) { + problems.push('no mutant run came from a process that never ran the dry run'); + } + } else { + const elsewhere = runs.filter((attempt) => attempt.pid !== run.dryRunPid); + if (elsewhere.length > 0) { + problems.push( + `${elsewhere.length} mutant run(s) came from other processes than the dry run's ` + + `(${elsewhere.map((attempt) => attempt.localId).join(', ')})`, + ); + } + } + // The pair is checked in the scope its mutant cases belong to; other scopes do not run them. + const pairScopes = (entry.consecutive ?? []).map((name) => mutantCases.get(name)?.scope); + if (entry.consecutive !== null && pairScopes.includes(run.scope)) { + const [first, second] = entry.consecutive.map((name) => { + const selector = mutantCases.get(name)?.mutant; + return run.mutants.find((mutant) => selector !== undefined && selects(selector, mutant)); + }); + const firstRun = first?.runs.at(-1); + const secondRun = second?.runs.at(-1); + if ( + first === undefined || + second === undefined || + firstRun === undefined || + secondRun === undefined + ) { + problems.push( + `the consecutive cases ${show(entry.consecutive)} do not both have a run in this scope`, + ); + } else { + const order = (perProcess.get(firstRun.pid) ?? []).sort( + (a, b) => a.writtenAtMs - b.writtenAtMs, + ); + const at = order.findIndex( + (attempt) => + attempt.localId === first.localId && attempt.writtenAtMs === firstRun.writtenAtMs, + ); + const next = order[at + 1]; + if (secondRun.pid !== firstRun.pid || next === undefined || next.localId !== second.localId) { + problems.push( + `mutant ${second.localId} (${entry.consecutive[1]}) did not run right after mutant ` + + `${first.localId} (${entry.consecutive[0]}) in process ${firstRun.pid}`, + ); + } + } + } + return problems; +} + +/** Compares every observed run with the expectations; an empty list means conformance holds. */ +export function compareConformance( + expected: ExpectedConformance, + observed: readonly ObservedScopeRun[], +): Difference[] { + const differences: Difference[] = []; + const mutantCases = new Map( + expected.cases.flatMap((entry) => + entry.kind === 'mutant' ? [[entry.name, entry] as const] : [], + ), + ); + for (const profile of expected.profiles) { + for (const scope of expected.scopes) { + const run = observed.find( + (entry) => entry.profile === profile.name && entry.scope === scope.name, + ); + const differ = (name: string, detail: string): void => { + differences.push({ profile: profile.name, scope: scope.name, case: name, detail }); + }; + if (run === undefined) { + differ('run', 'no observed run'); + continue; + } + if (run.spanMapFailures > 0) differ('span map', `${run.spanMapFailures} span-map failure(s)`); + for (const flag of run.runFlags) differ('run flags', flag); + for (const problem of compareTests(scope.tests, run.tests, true)) { + differ(`test inventory of scope ${scope.name}`, problem); + } + + const matched = new Set(); + for (const entry of expected.cases) { + if (entry.kind === 'tests' && entry.scope === scope.name) { + for (const problem of compareTests(entry.tests, run.tests, false)) + differ(entry.name, problem); + } + if (entry.kind === 'processes' && entry.profile === profile.name) { + for (const problem of processProblems(entry, run, mutantCases)) + differ(entry.name, problem); + } + if (entry.kind === 'environment' && entry.profile === profile.name) { + if (run.exportedActiveMutant !== entry.localId) { + differ( + entry.name, + `the driver exported ${show(run.exportedActiveMutant)}, expected ${show(entry.localId)}`, + ); + } + const target = mutantCases.get(entry.activeMutantOf); + const mutant = run.mutants.find( + (candidate) => target !== undefined && selects(target.mutant, candidate), + ); + if (target?.scope === scope.name && mutant?.localId !== entry.localId) { + differ( + entry.name, + `the exported local id ${entry.localId} is not the mutant of ${show(entry.activeMutantOf)} ` + + `(its local id is ${show(mutant?.localId ?? null)})`, + ); + } + } + if (entry.kind !== 'mutant' || entry.scope !== scope.name) continue; + const candidates = run.mutants.filter((mutant) => selects(entry.mutant, mutant)); + candidates.forEach((mutant) => matched.add(mutant)); + if (candidates.length !== 1) { + differ( + entry.name, + `${candidates.length} mutants match ${formatSelector(entry.mutant)}, expected exactly one`, + ); + continue; + } + const [mutant] = candidates; + for (const check of FACT_CHECKS.filter(({ same }) => !same(entry.expect, mutant.facts))) { + differ( + entry.name, + `${check.key}: expected ${show(entry.expect[check.key])}, observed ${show(mutant.facts[check.key])}`, + ); + } + } + for (const mutant of run.mutants.filter((candidate) => !matched.has(candidate))) { + differ( + 'unexpected mutant', + `${formatSelector(mutant)} (local id ${mutant.localId}, ${mutant.facts.status}) matches no case`, + ); + } + } + } + return differences; +} + +export const formatDifference = (difference: Difference): string => + `[${difference.profile ?? '-'} / ${difference.scope ?? '-'}] case ${show(difference.case)}: ${difference.detail}`; diff --git a/gitnexus/tools/mutation/conformance/fingerprint.ts b/gitnexus/tools/mutation/conformance/fingerprint.ts new file mode 100644 index 000000000..9d4cf8413 --- /dev/null +++ b/gitnexus/tools/mutation/conformance/fingerprint.ts @@ -0,0 +1,168 @@ +/** + * Input fingerprint of a gate 2 conformance run. `--compare-only` reads results an earlier run + * left in `--out`, so before it compares them it checks that they still describe this checkout: + * every run records the fingerprint of its inputs in its conformance-run.json, and compare-only + * recomputes it and reports the run as stale on any difference. + * + * Inputs, relative to the `gitnexus/` package ({@link FINGERPRINT_ROOTS}): every file of the + * conformance fixture project except its expected.json (compare-only is how observed facts are + * re-checked against an edited expected file), tools/mutation/{runner,driver,prepare,shared}/, + * tools/mutation/scope-file.ts and tools/mutation/vitest.mutation.config.ts, plus the installed + * @stryker-mutator/core and vitest versions. The fingerprint keeps each file's sha256, so a stale + * report can name what changed; its digest is a sha256 over the sorted file hashes and versions. + */ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { readJsonFile } from '../shared/fs.js'; + +/** The conformance fixture project, relative to the `gitnexus/` package. */ +export const FIXTURE_ROOT = 'test/fixtures/mutation-conformance'; + +/** Package-relative directories and files whose content decides a conformance run. */ +export const FINGERPRINT_ROOTS: readonly string[] = [ + FIXTURE_ROOT, + 'tools/mutation/driver', + 'tools/mutation/prepare', + 'tools/mutation/runner', + 'tools/mutation/shared', + 'tools/mutation/scope-file.ts', + 'tools/mutation/vitest.mutation.config.ts', +]; + +/** Under a root, but compared rather than run: editing it does not make a run stale. */ +const EXCLUDED: readonly string[] = [`${FIXTURE_ROOT}/expected.json`]; + +/** The start of the message for stale artefacts. */ +export const STALE_ARTEFACTS = 'stale conformance artefacts; re-run without --compare-only'; + +const MAX_NAMED = 5; + +export interface ToolVersions { + stryker: string; + vitest: string; +} + +export interface InputFingerprint { + /** sha256 over {@link files} and {@link versions}. */ + digest: string; + /** Package-relative POSIX path to the sha256 of its content. */ + files: Record; + versions: ToolVersions; +} + +const sha256 = (data: string | Buffer): string => createHash('sha256').update(data).digest('hex'); + +/** Whether a package-relative POSIX path is a fingerprint input; pure. */ +export function isFingerprintInput(file: string): boolean { + if (EXCLUDED.includes(file)) return false; + return FINGERPRINT_ROOTS.some((root) => file === root || file.startsWith(`${root}/`)); +} + +/** The fingerprint of the given file hashes and versions; pure and independent of key order. */ +export function fingerprintOf( + files: Readonly>, + versions: ToolVersions, +): InputFingerprint { + const sorted = Object.fromEntries( + Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); + const ordered = { stryker: versions.stryker, vitest: versions.vitest }; + return { + digest: sha256(JSON.stringify({ files: sorted, versions: ordered })), + files: sorted, + versions: ordered, + }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** A recorded fingerprint, or null when the value is not one. */ +export function parseFingerprint(value: unknown): InputFingerprint | null { + if (!isRecord(value) || typeof value.digest !== 'string') return null; + const { files, versions } = value; + if (!isRecord(files)) return null; + const hashes = Object.entries(files).flatMap(([file, hash]): [string, string][] => + typeof hash === 'string' ? [[file, hash]] : [], + ); + if (hashes.length !== Object.keys(files).length) return null; + if ( + !isRecord(versions) || + typeof versions.stryker !== 'string' || + typeof versions.vitest !== 'string' + ) { + return null; + } + return { + digest: value.digest, + files: Object.fromEntries(hashes), + versions: { stryker: versions.stryker, vitest: versions.vitest }, + }; +} + +/** + * Null when a run recorded the current fingerprint, else the stale-artefacts message naming what + * changed (files added, removed or edited since the run, and version changes); pure. + */ +export function staleConformance(recorded: unknown, current: InputFingerprint): string | null { + const run = parseFingerprint(recorded); + if (run === null) return `${STALE_ARTEFACTS} (the run records no input fingerprint)`; + // The recorded digest must also match the recorded files and versions it claims to cover. + const consistent = fingerprintOf(run.files, run.versions).digest === run.digest; + if (consistent && run.digest === current.digest) return null; + const paths = [...new Set([...Object.keys(run.files), ...Object.keys(current.files)])].sort(); + const changed = paths.filter((file) => run.files[file] !== current.files[file]); + const details = [ + ...(changed.length === 0 + ? [] + : [ + `changed since the run: ${changed.slice(0, MAX_NAMED).join(', ')}` + + (changed.length > MAX_NAMED ? ` and ${changed.length - MAX_NAMED} more` : ''), + ]), + ...(run.versions.stryker === current.versions.stryker + ? [] + : [`Stryker ${run.versions.stryker} -> ${current.versions.stryker}`]), + ...(run.versions.vitest === current.versions.vitest + ? [] + : [`Vitest ${run.versions.vitest} -> ${current.versions.vitest}`]), + ]; + const detail = + details.length === 0 ? 'the recorded input digest does not match' : details.join('; '); + return `${STALE_ARTEFACTS} (${detail})`; +} + +/** Every regular file under `dir`, as absolute paths. */ +function walk(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return entry.isFile() ? [full] : []; + }); +} + +function installedVersion(packageDir: string, name: string): string { + const file = path.join(packageDir, 'node_modules', name, 'package.json'); + const data = readJsonFile(file); + if (!isRecord(data) || typeof data.version !== 'string') { + throw new Error(`${file} names no version`); + } + return data.version; +} + +/** The fingerprint of the checkout's inputs and installed tools (`packageDir` is `gitnexus/`). */ +export function currentFingerprint(packageDir: string): InputFingerprint { + const files: Record = {}; + for (const root of FINGERPRINT_ROOTS) { + const absolute = path.join(packageDir, root); + const found = root.endsWith('.ts') ? [absolute] : walk(absolute); + for (const file of found) { + const relative = path.relative(packageDir, file).split(path.sep).join('/'); + if (isFingerprintInput(relative)) files[relative] = sha256(readFileSync(file)); + } + } + return fingerprintOf(files, { + stryker: installedVersion(packageDir, '@stryker-mutator/core'), + vitest: installedVersion(packageDir, 'vitest'), + }); +} diff --git a/gitnexus/tools/mutation/conformance/run-conformance.ts b/gitnexus/tools/mutation/conformance/run-conformance.ts new file mode 100644 index 000000000..b71cbba34 --- /dev/null +++ b/gitnexus/tools/mutation/conformance/run-conformance.ts @@ -0,0 +1,355 @@ +/** + * Gate 2 conformance check (U4): runs the conformance fixture project through the scope driver + * (U10) with the vitest5 runner, in every profile of the expected file, and compares the + * normalised results with the pinned expectations (compare.ts documents the file and every fact). + * + * The fixture lives in test/fixtures/mutation-conformance: tiny production modules under `src/` + * whose planted mutants are the only ones a `// Stryker restore` comment enables, `*.case.ts` + * test files (no repo Vitest project collects them), its own Vitest config (project + * `conformance`) and `expected.json`. Each (profile, scope) pair is one scope-driver run, with: + * - the fixture directory as an extra mapped root (path-map.ts `buildPathMap`), so its files + * can be mutated and get global ids under their repo path; + * - the default ignored directories, except that only the fixture itself is kept from + * test/fixtures (every other entry there is ignored); + * - Stryker's per-mutant timeout deviation at {@link TIMEOUT_MS}: far above any fixture run, so + * only the fixture's own limits (Vitest testTimeout and hookTimeout of 2000 ms, the hit + * limit) decide its timeout cases; + * - `__STRYKER_ACTIVE_MUTANT__` exported to the Stryker process (runScope `childEnv`, applied + * after the driver's own scrub) while a profile with an `environment` case runs, so only the + * vitest5 runner's removal of it (runner/plugin.ts init, runner/setup.ts) keeps it from the + * instrumented code; + * - the fingerprint of its inputs (fingerprint.ts) recorded in the run directory's + * conformance-run.json. + * Profiles and scopes run one after another. + * + * Usage (from gitnexus/): + * npm run test:mutation:conformance -- [--out ] [--expected ] [--profile ]... + * [--scope ]... [--compare-only] + * `--out` defaults to `/gitnexus-mutation-conformance` (it must lie outside the + * checkout); each run lands in `///` and the observed facts of every run in + * `/observed.json`, in the shape of expected.json's mutant facts, for refreshing it. + * `--compare-only` skips the runs and compares the results already in ``; a run whose + * recorded input fingerprint differs from the checkout's is reported as stale artefacts instead. + * Exit codes: 0 every pinned case holds; 1 a case differs (each difference names its case), a + * run failed or (`--compare-only`) a run is stale; 2 usage or setup error. + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import type { RunResults } from '../driver/normalise-results.js'; +import { runScope } from '../driver/run-scope.js'; +import { DEFAULT_IGNORED_DIRS } from '../driver/stryker-config.js'; +import type { SidecarRecord } from '../runner/sidecar.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; +import { isInside, readJsonFile } from '../shared/fs.js'; +import { + compareConformance, + formatDifference, + observeScopeRun, + parseExpected, + type Difference, + type ExpectedConformance, + type ObservedScopeRun, + type Profile, + type ScopeSpec, +} from './compare.js'; +import { + FIXTURE_ROOT, + currentFingerprint, + staleConformance, + type InputFingerprint, +} from './fingerprint.js'; + +/** The fixture project, relative to the `gitnexus/` package. */ +export { FIXTURE_ROOT }; +const FIXTURE_REPO_ROOT = `gitnexus/${FIXTURE_ROOT}`; +const VITEST = { configFile: `${FIXTURE_ROOT}/vitest.config.ts`, project: 'conformance' }; +/** Stryker `timeoutMS`; see the module comment. */ +export const TIMEOUT_MS = 60_000; +const ACTIVE_MUTANT_ENV = INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE; +const MARKER_FILE = '.gitnexus-mutation-conformance'; +/** + * Written into each run directory: what the driver exported to Stryker while that run ran and + * the fingerprint of the run's inputs. + */ +const RUN_RECORD_FILE = 'conformance-run.json'; + +interface RunRecord { + exportedActiveMutant: string | null; + inputs: InputFingerprint; +} + +class UsageError extends Error { + override name = 'UsageError'; +} + +/** + * The scope driver's default ignored directories, with `test/fixtures` replaced by each of its + * entries except the fixture project, so the Stryker sandbox holds the fixture and no other. + */ +export function conformanceIgnoredDirs(fixtureEntries: readonly string[]): string[] { + const parent = path.posix.dirname(FIXTURE_ROOT); + const own = path.posix.basename(FIXTURE_ROOT); + return [ + ...DEFAULT_IGNORED_DIRS.filter((dir) => dir !== parent), + ...[...fixtureEntries] + .filter((entry) => entry !== own) + .sort() + .map((entry) => `${parent}/${entry}`), + ]; +} + +/** + * The scope-driver scope file of one expected scope: package- and repo-relative paths, and the + * package-relative test ids of a scope that selects tests (no `testIds` key otherwise). + */ +export function driverScope(scope: ScopeSpec): { + testFiles: string[]; + mutate: string[]; + testIds?: string[]; +} { + return { + testFiles: scope.testFiles.map((file) => `${FIXTURE_ROOT}/${file}`), + mutate: scope.mutate.map((file) => `${FIXTURE_REPO_ROOT}/${file}`), + ...(scope.testIds === null + ? {} + : { testIds: scope.testIds.map((id) => `${FIXTURE_ROOT}/${id}`) }), + }; +} + +/** The local id a profile exports as `__STRYKER_ACTIVE_MUTANT__`, or null. */ +export function exportedActiveMutant( + expected: ExpectedConformance, + profile: string, +): string | null { + const entry = expected.cases.find( + (candidate) => candidate.kind === 'environment' && candidate.profile === profile, + ); + return entry?.kind === 'environment' ? entry.localId : null; +} + +interface Layout { + out: string; + runDir: (profile: Profile, scope: ScopeSpec) => string; + scopeFile: (scope: ScopeSpec) => string; + observedFile: string; +} + +const layoutOf = (out: string): Layout => ({ + out, + runDir: (profile, scope) => path.join(out, profile.name, scope.name), + scopeFile: (scope) => path.join(out, 'scopes', `${scope.name}.json`), + observedFile: path.join(out, 'observed.json'), +}); + +/** Reads one finished run back from disk. */ +function observeRun(layout: Layout, profile: Profile, scope: ScopeSpec): ObservedScopeRun { + const runDir = layout.runDir(profile, scope); + const resultsFile = path.join(runDir, 'results.json'); + if (!existsSync(resultsFile)) throw new Error(`no results at ${resultsFile}`); + const results = readJsonFile(resultsFile) as RunResults; + const records = new Map(); + for (const file of results.mutants.flatMap((mutant) => mutant.sidecar?.file ?? [])) { + records.set(file, readJsonFile(file) as SidecarRecord); + } + const dryRun = + results.dryRun === null ? null : (readJsonFile(results.dryRun.file) as SidecarRecord); + const exported = readRunRecord(runDir)?.exportedActiveMutant ?? null; + return observeScopeRun({ + profile: profile.name, + scope: scope.name, + results, + records, + dryRunPid: dryRun?.pid ?? null, + exportedActiveMutant: exported, + fixtureRoot: FIXTURE_ROOT, + }); +} + +/** The run record of a run directory, or null; `inputs` is checked by staleConformance. */ +function readRunRecord(runDir: string): Partial | null { + const file = path.join(runDir, RUN_RECORD_FILE); + return existsSync(file) ? (readJsonFile(file) as Partial) : null; +} + +async function runPair( + repoRoot: string, + layout: Layout, + profile: Profile, + scope: ScopeSpec, + exported: string | null, +): Promise { + const scopeFile = layout.scopeFile(scope); + mkdirSync(path.dirname(scopeFile), { recursive: true }); + writeFileSync(scopeFile, `${JSON.stringify(driverScope(scope), null, 2)}\n`); + const inputs = currentFingerprint(path.join(repoRoot, 'gitnexus')); + const fixtureEntries = readdirSync(path.join(repoRoot, 'gitnexus', path.dirname(FIXTURE_ROOT))); + await runScope({ + repoRoot, + scopeFile, + outDir: layout.runDir(profile, scope), + variant: 'vitest5', + concurrency: profile.concurrency, + maxTestRunnerReuse: profile.maxTestRunnerReuse, + timeoutMS: TIMEOUT_MS, + command: undefined, + ignoredDirs: conformanceIgnoredDirs(fixtureEntries), + extraRoots: [FIXTURE_REPO_ROOT], + vitest: VITEST, + // Past the driver's scrub: the runner itself must keep it from the instrumented code. + childEnv: exported === null ? undefined : { [ACTIVE_MUTANT_ENV]: exported }, + }); + const record: RunRecord = { exportedActiveMutant: exported, inputs }; + writeFileSync( + path.join(layout.runDir(profile, scope), RUN_RECORD_FILE), + `${JSON.stringify(record, null, 2)}\n`, + ); +} + +function prepareOut(repoRoot: string, out: string, compareOnly: boolean): void { + if (isInside(repoRoot, out) || isInside(out, repoRoot)) { + throw new UsageError(`--out ${out} must lie outside the checkout ${repoRoot}`); + } + if (compareOnly) { + if (!existsSync(path.join(out, MARKER_FILE))) { + throw new UsageError(`--compare-only: ${out} holds no conformance run`); + } + return; + } + if (existsSync(out) && readdirSync(out).length > 0 && !existsSync(path.join(out, MARKER_FILE))) { + throw new UsageError( + `${out} is not empty and was not created by this tool; choose another --out`, + ); + } + mkdirSync(out, { recursive: true }); + writeFileSync(path.join(out, MARKER_FILE), ''); +} + +const select = ( + items: readonly T[], + names: readonly string[], + what: string, +): T[] => { + const unknown = names.filter((name) => !items.some((item) => item.name === name)); + if (unknown.length > 0) throw new UsageError(`unknown ${what} ${JSON.stringify(unknown[0])}`); + return names.length === 0 ? [...items] : items.filter((item) => names.includes(item.name)); +}; + +const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)} s`; + +async function main(): Promise { + const packageDir = fileURLToPath(new URL('../../../', import.meta.url)); + const repoRoot = path.dirname(path.resolve(packageDir)); + const started = performance.now(); + try { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + out: { type: 'string', default: path.join(os.tmpdir(), 'gitnexus-mutation-conformance') }, + expected: { type: 'string', default: path.join(packageDir, FIXTURE_ROOT, 'expected.json') }, + profile: { type: 'string', multiple: true, default: [] }, + scope: { type: 'string', multiple: true, default: [] }, + 'compare-only': { type: 'boolean', default: false }, + }, + strict: true, + allowPositionals: false, + }); + const full = parseExpected(readFileSync(path.resolve(values.expected), 'utf8')); + const expected: ExpectedConformance = { + ...full, + profiles: select(full.profiles, values.profile, 'profile'), + scopes: select(full.scopes, values.scope, 'scope'), + }; + const layout = layoutOf(path.resolve(values.out)); + prepareOut(repoRoot, layout.out, values['compare-only']); + const current = values['compare-only'] ? currentFingerprint(packageDir) : null; + + const observed: ObservedScopeRun[] = []; + const failures: Difference[] = []; + for (const profile of expected.profiles) { + const exported = exportedActiveMutant(expected, profile.name); + for (const scope of expected.scopes) { + const runStarted = performance.now(); + try { + if (current !== null) { + const stale = staleConformance( + readRunRecord(layout.runDir(profile, scope))?.inputs, + current, + ); + if (stale !== null) { + failures.push({ + profile: profile.name, + scope: scope.name, + case: 'stale', + detail: stale, + }); + continue; + } + } else { + console.log( + `conformance: ${profile.name} / ${scope.name}: running (concurrency ` + + `${profile.concurrency}, maxTestRunnerReuse ${profile.maxTestRunnerReuse}` + + `${exported === null ? '' : `, ${ACTIVE_MUTANT_ENV}=${exported} exported`})`, + ); + await runPair(repoRoot, layout, profile, scope, exported); + } + observed.push(observeRun(layout, profile, scope)); + console.log( + `conformance: ${profile.name} / ${scope.name}: ` + + `${values['compare-only'] ? 'read' : `done in ${seconds(performance.now() - runStarted)}`}`, + ); + } catch (error) { + failures.push({ + profile: profile.name, + scope: scope.name, + case: 'run', + detail: errorMessage(error), + }); + } + } + } + writeFileSync(layout.observedFile, `${JSON.stringify(observed, null, 2)}\n`); + const differences = [ + ...failures, + ...compareConformance(expected, observed).filter( + (difference) => + difference.case !== 'run' || + !failures.some((f) => f.profile === difference.profile && f.scope === difference.scope), + ), + ]; + const total = seconds(performance.now() - started); + if (differences.length === 0) { + const cases = expected.cases.filter((entry) => + 'scope' in entry + ? expected.scopes.some((scope) => scope.name === entry.scope) + : expected.profiles.some((profile) => profile.name === entry.profile), + ).length; + console.log( + `conformance: all ${cases} pinned cases hold in ${expected.profiles.length} profile(s) ` + + `x ${expected.scopes.length} scope(s); total ${total}; observed facts in ${layout.observedFile}`, + ); + process.exitCode = 0; + return; + } + console.error(`conformance: ${differences.length} difference(s); total ${total}`); + for (const difference of differences) console.error(` ${formatDifference(difference)}`); + console.error(` observed facts: ${layout.observedFile}`); + process.exitCode = 1; + } catch (error) { + console.error(errorMessage(error)); + if (error instanceof UsageError || error instanceof TypeError) { + console.error( + 'usage: npm run test:mutation:conformance -- [--out ] [--expected ] ' + + '[--profile ]... [--scope ]... [--compare-only]', + ); + } + process.exitCode = 2; + } +} + +if (isMainModule(import.meta.url)) { + void main(); +} diff --git a/gitnexus/tools/mutation/controls/run-controls.ts b/gitnexus/tools/mutation/controls/run-controls.ts new file mode 100644 index 000000000..fdf67ee13 --- /dev/null +++ b/gitnexus/tools/mutation/controls/run-controls.ts @@ -0,0 +1,1439 @@ +/** + * Gate 4 (U8; KTD17, KTD19): planted positive controls and a two-profile determinism check over + * vitest5 runner runs (U10 `results.json`) of the KTD11 scopes. + * + * Controls. A control is one of a run's own Stryker mutants, named by its global id (U2); no source + * is edited. `test/fixtures/mutation-controls/planted.json` records per scope the driver scope, + * and per control its expected killers (the tests that kill it with an assertion, KTD17) and a + * sample of expected non-killers of two kinds: `ran` tests run with the mutant active and pass + * (covering non-killers; every test of a static mutant runs), `notRun` tests do not reach it (the + * runner's per-test coverage does not select them). In every run, every expected killer must kill + * the control with an assertion, no other test may kill it in any class (Stryker's `killedBy` or + * the assertion kill set), and every `ran` non-killer must run with the mutant active and pass. A + * control whose global id the run lacks while the run mutated another version of its file, or a + * planted test whose id the run lacks, means the checkout changed: "re-select required" (exit 3), + * and nothing else is checked for that control. A control the run lacks although its file's + * content hash is unchanged was not generated (a broken blanking step, KTD7, or another mutator + * set) and fails. + * + * Required tags (KTD19). At least one control sits in relocated `gitnexus-shared` code + * (`relocated-shared`: its file is under `gitnexus-shared/`, which the prepare step relocates into + * the package, KTD8) and one inside an `as const` table (`as-const`: its span lies inside the + * operand of an `as const` assertion of the original file, code only the prepare step's blanking + * makes mutable, KTD7). A mis-pointed alias or a broken blanking step then fails a control instead + * of every shared mutant quietly surviving. Tags are recomputed from the checkout's source at every + * check and must equal the recorded ones. + * + * Determinism. Every scope runs once per profile of the planted file: A at Stryker concurrency 1 + * with unlimited runner reuse (`maxTestRunnerReuse: 0`), B at concurrency 3 with + * `maxTestRunnerReuse: 1` (a fresh runner process per mutant). The first profile is compared with + * every other: the same global mutant ids and test ids (skipped flag included), and per mutant the + * same outcome class (KTD17), Stryker status, `killedBy` set and assertion kill set. Each + * difference names the mutant and, for kill sets, the test. + * + * Runs. Each (scope, profile) pair is one scope-driver run (U10, vitest5 variant). A run must have + * run the planted scope, at its profile's concurrency and runner reuse, from a prepared copy whose + * input files (oracle/freeze.ts KTD12 groups: the scope's import closure, the Vitest configs and the + * prepare tool) and mutated files hash like the checkout's; otherwise it is stale (exit 2). Its + * integrity flags, span-map failures and a failed Stryker run are problems (exit 1). Runs go in + * parallel while the Stryker concurrency of the running ones stays within `--max-workers` + * (default 3): the three concurrency-1 runs of profile A together, profile B's runs one at a time. + * + * Selection (`--select`) regenerates the controls from the runs of every selected profile; with + * `--scope` only those scopes are re-selected. A candidate is a mutant that, in every run, is + * Killed with outcome `killed-by-test:assertion`, whose every killer kills with an assertion + * (`killedBy` equals the assertion kill set), with the same 1 to `maxKillers` killers in every run, + * one attempt, no flags and a sidecar record. Candidates rank by: a covering non-killer exists, + * then fewer killers, then freeze.ts `sampleRank(seed, global id)`. Per scope: first, for each + * required tag that no control has yet, the best candidate with the tag; then the best candidates + * with a mutator and killers no control has yet; then the best remaining, up to `perScope`. Listed + * non-killers are the `maxNonKillers` lowest-ranked tests of each kind (`sampleRank(seed + "\n" + + * id, test)`), sorted. The selection goes to `/planted.json`, and to `--planted` only when + * every selected scope's runs were usable and every required tag is carried; the check then runs + * on the same runs (a missing required tag fails it). + * + * Planted file ({@link PlantedControls}, schemaVersion 1, JSON): + * kind, description, reselect what the file is, the re-select command + * profiles[] {name, concurrency, maxTestRunnerReuse} + * timeoutMS Stryker `timeoutMS` of every run + * selection {seed, perScope, maxKillers, maxNonKillers, selectedFrom[]} + * scopes[] {name, scope {testFiles, mutate} (driver scope), mutants and + * tests (inventory sizes at selection), controls[] {id, tags[], + * static, expectedKillers[], expectedNonKillers {ran[], notRun[]}}} + * + * Work dir (absolute, outside the checkout): `scopes/.json` driver scope files, + * `runs///` scope-driver runs, `planted.json` (select), and the report. + * Report ({@link ControlsReport}, `--report`, default `/controls-report.json`): result, + * exitCode, requirements {required, carried, missing}, scopes[] {name, runs[] {profile, results, + * wallClockMs, mutants, tests, stale[], problems[]}, controls {checked, failures[]}, determinism + * [{profiles, mutants, tests, differences[]}]}, selection problems, summary lines. + * + * Usage (from gitnexus/): + * npm run test:mutation:controls -- --work [--planted ] [--profile ]... + * [--scope ]... [--compare-only] [--timeout-ms ] [--max-workers ] [--report ] + * [--select [--define =]... [--seed ] [--per-scope ]] + * `--compare-only` reads the runs already in ``; `--profile`/`--scope` restrict the runs (CI + * can run the profiles in separate jobs, then compare with `--compare-only`). `--define` adds or + * replaces a scope (select only); `--seed` and `--per-scope` default to the planted file's. + * Exit codes: 0 pass; 1 control failures, determinism differences, run or selection problems; + * 2 usage or setup error, or a stale run; 3 re-select required (only stale controls); 130 + * interrupted (SIGINT or SIGTERM: no further run starts, the scope driver forwards the signal to + * the running Stryker processes, nothing is selected or checked). + */ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { parse } from '@babel/parser'; +import { + RESULTS_SCHEMA_VERSION, + type NormalisedMutant, + type RunResults, +} from '../driver/normalise-results.js'; +import { runScope } from '../driver/run-scope.js'; +import { formatMutateEntry, parseDriverScope } from '../driver/stryker-config.js'; +import { + diffInputs, + hashInputs, + inputFiles, + preparedInputs, + sampleRank, + type InputHashes, +} from '../oracle/freeze.js'; +import { contentHash, type ReportLocation } from '../prepare/mutant-identity.js'; +import { PACKAGE_DIR } from '../prepare/path-map.js'; +import { indexSource, locationToOffsets, type Offsets } from '../prepare/span-map-check.js'; +import type { OutcomeClass } from '../runner/classify-outcome.js'; +import type { SidecarRecord } from '../runner/sidecar.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; +import { isInside, readJsonFile } from '../shared/fs.js'; + +export const PLANTED_SCHEMA_VERSION = 1; +export const PLANTED_KIND = 'gitnexus-mutation-controls'; +export const REPORT_KIND = 'gitnexus-mutation-gate4-report'; +/** Package-relative default of `--planted`. */ +export const PLANTED_FILE = 'test/fixtures/mutation-controls/planted.json'; +export const WORK_MARKER = '.gitnexus-mutation-controls'; + +export const EXIT_PASS = 0; +export const EXIT_FAILURES = 1; +export const EXIT_USAGE = 2; +export const EXIT_RESELECT = 3; +export const EXIT_INTERRUPTED = 130; + +export const CONTROL_TAGS = ['relocated-shared', 'as-const'] as const; +export type ControlTag = (typeof CONTROL_TAGS)[number]; +/** KTD19: every planted file carries a control with each of these tags; selection order. */ +export const REQUIRED_TAGS: readonly ControlTag[] = ['as-const', 'relocated-shared']; +const SHARED_PREFIX = 'gitnexus-shared/'; + +export interface Profile { + name: string; + concurrency: number; + maxTestRunnerReuse: number; +} + +export const DEFAULT_PROFILES: readonly Profile[] = [ + { name: 'A', concurrency: 1, maxTestRunnerReuse: 0 }, + { name: 'B', concurrency: 3, maxTestRunnerReuse: 1 }, +]; +export const DEFAULT_TIMEOUT_MS = 10000; +export const DEFAULT_PER_SCOPE = 3; +export const DEFAULT_MAX_KILLERS = 3; +export const DEFAULT_MAX_NON_KILLERS = 3; +/** Default `--max-workers`: profile B's concurrency, so its runs go one at a time. */ +export const DEFAULT_MAX_WORKERS = 3; + +export const RESELECT_COMMAND = + 'npm run test:mutation:controls -- --select --work '; +const DESCRIPTION = + 'Gate 4 positive controls (U8, KTD19): Stryker mutants of the scopes, named by global id. In ' + + 'every determinism profile each must be killed with an assertion by exactly its expected ' + + 'killers, and every listed covering non-killer must run with it and pass. Regenerate with ' + + '`reselect` when a control or test goes missing (tools/mutation/controls/run-controls.ts).'; + +export interface ExpectedNonKillers { + /** Ran with the mutant active and passed (covering non-killers). */ + ran: string[]; + /** Not reached: the runner's coverage did not select them. */ + notRun: string[]; +} + +export interface PlantedControl { + /** Global mutant id (U2). */ + id: string; + tags: ControlTag[]; + static: boolean | null; + expectedKillers: string[]; + expectedNonKillers: ExpectedNonKillers; +} + +export interface DriverScopeSpec { + testFiles: string[]; + mutate: string[]; +} + +export interface PlantedScope { + name: string; + scope: DriverScopeSpec; + /** Mutants and non-skipped tests of the runs the controls were selected from. */ + mutants: number; + tests: number; + controls: PlantedControl[]; +} + +export interface SelectionRecord { + seed: string; + perScope: number; + maxKillers: number; + maxNonKillers: number; + /** Profiles whose runs the controls were selected from. */ + selectedFrom: string[]; +} + +export interface PlantedControls { + schemaVersion: typeof PLANTED_SCHEMA_VERSION; + kind: typeof PLANTED_KIND; + description: string; + reselect: string; + profiles: Profile[]; + timeoutMS: number; + selection: SelectionRecord; + scopes: PlantedScope[]; +} + +// ── run views ────────────────────────────────────────────────────────────── + +export interface RanTest { + id: string; + outcome: OutcomeClass; +} + +/** What gate 4 reads of one mutant of a vitest5 run. */ +export interface RunMutantView { + globalId: string; + /** Repo-relative original file. */ + file: string; + location: ReportLocation; + mutatorName: string; + static: boolean | null; + /** Stryker's raw status. */ + status: string; + outcome: OutcomeClass | null; + killedBy: string[]; + assertionKilledBy: string[]; + /** The tests the latest sidecar record ran, with their classes; null without a record. */ + ran: RanTest[] | null; + attempts: number; + flags: string[]; +} + +export interface RunView { + profile: string; + tests: { id: string; skipped: boolean }[]; + mutants: RunMutantView[]; +} + +export type TagsOf = (mutant: Pick) => ControlTag[]; + +const sortedUnique = (values: Iterable): string[] => [...new Set(values)].sort(); +const byCodeUnits = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +const sameSet = (a: readonly string[], b: readonly string[]): boolean => + JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b)); + +/** The run view of a normalised mutant; `readRan` reads a sidecar record's test rows. */ +export function mutantView( + mutant: NormalisedMutant, + readRan: (sidecarFile: string) => RanTest[], +): RunMutantView { + return { + globalId: mutant.globalId, + file: mutant.file, + location: mutant.location, + mutatorName: mutant.mutatorName, + static: mutant.static, + status: mutant.status, + outcome: mutant.outcome, + killedBy: mutant.killedBy, + assertionKilledBy: mutant.assertionKilledBy, + ran: mutant.sidecar === null ? null : readRan(mutant.sidecar.file), + attempts: mutant.attempts.length, + flags: mutant.flags, + }; +} + +// ── tags (KTD19) ─────────────────────────────────────────────────────────── + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const spanOf = (value: unknown): Offsets | null => + isRecord(value) && typeof value.start === 'number' && typeof value.end === 'number' + ? { start: value.start, end: value.end } + : null; + +function isAsConst(node: Record): boolean { + if (node.type !== 'TSAsExpression') return false; + const annotation = node.typeAnnotation; + if (!isRecord(annotation) || annotation.type !== 'TSTypeReference') return false; + const name = annotation.typeName; + return isRecord(name) && name.type === 'Identifier' && name.name === 'const'; +} + +const SKIPPED_KEYS = new Set(['loc', 'leadingComments', 'trailingComments', 'innerComments']); + +/** 0-based, end-exclusive offsets of the operand of every `as const` assertion, in order. */ +export function asConstRanges(source: string): Offsets[] { + const ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'decorators-legacy'] }); + const ranges: Offsets[] = []; + const stack: unknown[] = [ast.program]; + while (stack.length > 0) { + const node = stack.pop(); + if (Array.isArray(node)) { + stack.push(...node); + continue; + } + if (!isRecord(node)) continue; + const span = isAsConst(node) ? spanOf(node.expression) : null; + if (span !== null) ranges.push(span); + for (const [key, value] of Object.entries(node)) { + if (!SKIPPED_KEYS.has(key) && isRecord(value)) stack.push(value); + } + } + return ranges.sort((a, b) => a.start - b.start || a.end - b.end); +} + +/** + * The KTD19 tags of a mutant, in {@link CONTROL_TAGS} order; `source` is its original file and + * `ranges` that file's {@link asConstRanges}. + */ +export function mutantTags( + mutant: Pick, + source: string, + ranges: readonly Offsets[] = asConstRanges(source), +): ControlTag[] { + const offsets = locationToOffsets(indexSource(source, mutant.file), mutant.location); + const inTable = ranges.some((range) => range.start <= offsets.start && offsets.end <= range.end); + return CONTROL_TAGS.filter((tag) => + tag === 'relocated-shared' ? mutant.file.startsWith(SHARED_PREFIX) : inTable, + ); +} + +// ── controls check ───────────────────────────────────────────────────────── + +export type ControlFailureKind = + | 'stale-mutant' + | 'stale-test' + | 'mutant-missing' + | 'killer-missed' + | 'unexpected-killer' + | 'non-killer-not-run' + | 'tags'; + +const STALE_KINDS: ReadonlySet = new Set(['stale-mutant', 'stale-test']); + +export interface ControlFailure { + scope: string; + profile: string; + /** Global mutant id of the control. */ + control: string; + kind: ControlFailureKind; + test: string | null; + detail: string; +} + +/** Checks every control of a planted scope against one run; pure. */ +export function checkControls(scope: PlantedScope, run: RunView, tagsOf: TagsOf): ControlFailure[] { + const mutants = new Map(run.mutants.map((mutant) => [mutant.globalId, mutant])); + const inventory = new Set(run.tests.filter((test) => !test.skipped).map((test) => test.id)); + return scope.controls.flatMap((control): ControlFailure[] => { + const fail = ( + kind: ControlFailureKind, + test: string | null, + detail: string, + ): ControlFailure => ({ + scope: scope.name, + profile: run.profile, + control: control.id, + kind, + test, + detail, + }); + const mutant = mutants.get(control.id); + if (mutant === undefined) { + // `@:` prefixes every mutant of the same source text (U2). + const source = control.id.slice(0, control.id.indexOf(':', control.id.indexOf('@')) + 1); + return run.mutants.some((entry) => entry.globalId.startsWith(source)) + ? [ + fail( + 'mutant-missing', + null, + 'the source is unchanged but the run has no such mutant (prepare step or mutator ' + + 'set changed; re-select only if that is intended)', + ), + ] + : [fail('stale-mutant', null, 'the run mutated another version of its file; re-select')]; + } + const { ran: ranNonKillers, notRun } = control.expectedNonKillers; + const unknown = sortedUnique([...control.expectedKillers, ...ranNonKillers, ...notRun]).filter( + (id) => !inventory.has(id), + ); + if (unknown.length > 0) { + return unknown.map((id) => + fail('stale-test', id, 'the run has no such test, or skipped it; re-select'), + ); + } + + const failures: ControlFailure[] = []; + const tags = tagsOf(mutant); + if (!sameSet(tags, control.tags)) { + failures.push( + fail( + 'tags', + null, + `recorded [${control.tags.join(', ')}], source has [${tags.join(', ')}]`, + ), + ); + } + const assertion = new Set(mutant.assertionKilledBy); + const killers = new Set(mutant.killedBy); + const ran = new Map((mutant.ran ?? []).map((test) => [test.id, test.outcome])); + for (const id of control.expectedKillers) { + if (assertion.has(id)) continue; + failures.push( + fail( + 'killer-missed', + id, + killers.has(id) + ? `killed it without an assertion (${ran.get(id) ?? 'no test row'})` + : `did not kill it (Stryker ${mutant.status}, class ${mutant.outcome ?? 'none'})`, + ), + ); + } + const expected = new Set(control.expectedKillers); + const listed = new Set([...ranNonKillers, ...notRun]); + for (const id of sortedUnique([...mutant.killedBy, ...mutant.assertionKilledBy])) { + if (expected.has(id)) continue; + failures.push( + fail( + 'unexpected-killer', + id, + `${assertion.has(id) ? 'killed it with an assertion' : `killed it (${ran.get(id) ?? 'no test row'})`}` + + `; ${listed.has(id) ? 'listed as an expected non-killer' : 'not an expected killer'}`, + ), + ); + } + for (const id of ranNonKillers) { + if (killers.has(id) || assertion.has(id)) continue; + const outcome = ran.get(id); + if (outcome === 'survived') continue; + failures.push( + fail( + 'non-killer-not-run', + id, + outcome === undefined + ? 'did not run with the mutant active' + : `ran with class ${outcome}`, + ), + ); + } + return failures; + }); +} + +/** The required tags no control of the planted file carries, in {@link REQUIRED_TAGS} order. */ +export function checkRequirements(planted: PlantedControls): ControlTag[] { + const carried = new Set( + planted.scopes.flatMap((scope) => scope.controls.flatMap((control) => control.tags)), + ); + return REQUIRED_TAGS.filter((tag) => !carried.has(tag)); +} + +// ── determinism ──────────────────────────────────────────────────────────── + +export type DeterminismKind = + | 'mutant-inventory' + | 'test-inventory' + | 'outcome' + | 'status' + | 'killed-by' + | 'assertion-killed-by'; + +export interface DeterminismDifference { + kind: DeterminismKind; + mutant: string | null; + test: string | null; + detail: string; +} + +/** Differences between two runs of one scope in two profiles; pure. */ +export function compareDeterminism(a: RunView, b: RunView): DeterminismDifference[] { + const diffs: DeterminismDifference[] = []; + const onlyIn = (name: string): string => `only in profile ${name}`; + + const testsA = new Map(a.tests.map((test) => [test.id, test.skipped])); + const testsB = new Map(b.tests.map((test) => [test.id, test.skipped])); + for (const id of sortedUnique([...testsA.keys(), ...testsB.keys()])) { + const skippedA = testsA.get(id); + const skippedB = testsB.get(id); + if (skippedA === skippedB) continue; + const detail = + skippedA === undefined + ? onlyIn(b.profile) + : skippedB === undefined + ? onlyIn(a.profile) + : `skipped only in profile ${skippedA ? a.profile : b.profile}`; + diffs.push({ kind: 'test-inventory', mutant: null, test: id, detail }); + } + + const mutantsB = new Map(b.mutants.map((mutant) => [mutant.globalId, mutant])); + const idsA = new Set(a.mutants.map((mutant) => mutant.globalId)); + for (const id of sortedUnique([...idsA, ...mutantsB.keys()])) { + if (idsA.has(id) && mutantsB.has(id)) continue; + const detail = onlyIn(idsA.has(id) ? a.profile : b.profile); + diffs.push({ kind: 'mutant-inventory', mutant: id, test: null, detail }); + } + + const sorted = [...a.mutants].sort((x, y) => byCodeUnits(x.globalId, y.globalId)); + for (const mutantA of sorted) { + const mutantB = mutantsB.get(mutantA.globalId); + if (mutantB === undefined) continue; + const id = mutantA.globalId; + const versus = (x: string, y: string): string => `${a.profile}: ${x}, ${b.profile}: ${y}`; + const outcomeA = mutantA.outcome ?? 'none'; + const outcomeB = mutantB.outcome ?? 'none'; + if (outcomeA !== outcomeB) { + diffs.push({ kind: 'outcome', mutant: id, test: null, detail: versus(outcomeA, outcomeB) }); + } + if (mutantA.status !== mutantB.status) { + const detail = versus(mutantA.status, mutantB.status); + diffs.push({ kind: 'status', mutant: id, test: null, detail }); + } + const sets: [DeterminismKind, string[], string[]][] = [ + ['killed-by', mutantA.killedBy, mutantB.killedBy], + ['assertion-killed-by', mutantA.assertionKilledBy, mutantB.assertionKilledBy], + ]; + for (const [kind, inA, inB] of sets) { + const setA = new Set(inA); + const setB = new Set(inB); + for (const test of sortedUnique([...inA, ...inB])) { + if (setA.has(test) && setB.has(test)) continue; + const detail = `kills only in profile ${setA.has(test) ? a.profile : b.profile}`; + diffs.push({ kind, mutant: id, test, detail }); + } + } + } + return diffs; +} + +// ── selection ────────────────────────────────────────────────────────────── + +export interface SelectOptions { + seed: string; + perScope: number; + maxKillers: number; + maxNonKillers: number; +} + +export interface ScopeSelection { + /** Mutants that qualified as controls. */ + candidates: number; + /** Sorted by global id. */ + controls: PlantedControl[]; +} + +interface Candidate { + mutant: RunMutantView; + killers: string[]; + ranPassed: string[]; + notRun: string[]; + tags: ControlTag[]; + rank: string; +} + +const qualifies = (mutant: RunMutantView | undefined, maxKillers: number): boolean => + mutant !== undefined && + mutant.status === 'Killed' && + mutant.outcome === 'killed-by-test:assertion' && + mutant.attempts === 1 && + mutant.flags.length === 0 && + mutant.ran !== null && + sameSet(mutant.killedBy, mutant.assertionKilledBy) && + mutant.assertionKilledBy.length >= 1 && + new Set(mutant.assertionKilledBy).size <= maxKillers; + +/** Picks a scope's controls from its runs (one per profile); pure and order-independent. */ +export function selectScopeControls( + runs: readonly RunView[], + options: SelectOptions, + tagsOf: TagsOf, +): ScopeSelection { + if (runs.length === 0) throw new Error('selection needs at least one run'); + const maps = runs.map((run) => new Map(run.mutants.map((mutant) => [mutant.globalId, mutant]))); + const inventories = runs.map( + (run) => new Set(run.tests.filter((test) => !test.skipped).map((test) => test.id)), + ); + const inventory = [...inventories[0]].filter((id) => inventories.every((set) => set.has(id))); + + const candidates: Candidate[] = []; + for (const id of sortedUnique(runs[0].mutants.map((mutant) => mutant.globalId))) { + const views = maps.map((map) => map.get(id)); + if (!views.every((view) => qualifies(view, options.maxKillers))) continue; + const present = views.filter((view): view is RunMutantView => view !== undefined); + const killers = sortedUnique(present[0].assertionKilledBy); + if (!present.every((view) => sameSet(view.assertionKilledBy, killers))) continue; + if (!killers.every((test) => inventory.includes(test))) continue; + const ranRows = present.map((view) => view.ran ?? []); + const passed = ranRows.map( + (rows) => new Set(rows.filter((row) => row.outcome === 'survived').map((row) => row.id)), + ); + const reached = new Set(ranRows.flatMap((rows) => rows.map((row) => row.id))); + candidates.push({ + mutant: present[0], + killers, + ranPassed: inventory.filter((test) => passed.every((set) => set.has(test))), + notRun: inventory.filter((test) => !reached.has(test) && !killers.includes(test)), + tags: tagsOf(present[0]), + rank: sampleRank(options.seed, id), + }); + } + const quality = (c: Candidate): [number, number, string] => [ + c.ranPassed.length > 0 ? 0 : 1, + c.killers.length, + c.rank, + ]; + candidates.sort((x, y) => { + const [qx, qy] = [quality(x), quality(y)]; + return qx[0] - qy[0] || qx[1] - qy[1] || byCodeUnits(qx[2], qy[2]); + }); + + const selected: Candidate[] = []; + for (const tag of REQUIRED_TAGS) { + if (selected.some((entry) => entry.tags.includes(tag))) continue; + const pick = candidates.find((entry) => entry.tags.includes(tag) && !selected.includes(entry)); + if (pick !== undefined) selected.push(pick); + } + for (const entry of candidates) { + if (selected.length >= options.perScope) break; + if (selected.includes(entry)) continue; + const mutators = new Set(selected.map((chosen) => chosen.mutant.mutatorName)); + const killers = new Set(selected.flatMap((chosen) => chosen.killers)); + const fresh = !mutators.has(entry.mutant.mutatorName); + if (fresh && entry.killers.every((test) => !killers.has(test))) selected.push(entry); + } + for (const entry of candidates) { + if (selected.length >= options.perScope) break; + if (!selected.includes(entry)) selected.push(entry); + } + + const sample = (id: string, tests: readonly string[]): string[] => + [...tests] + .sort((x, y) => + byCodeUnits( + sampleRank(`${options.seed}\n${id}`, x), + sampleRank(`${options.seed}\n${id}`, y), + ), + ) + .slice(0, options.maxNonKillers) + .sort(byCodeUnits); + return { + candidates: candidates.length, + controls: selected + .map( + (entry): PlantedControl => ({ + id: entry.mutant.globalId, + tags: entry.tags, + static: entry.mutant.static, + expectedKillers: entry.killers, + expectedNonKillers: { + ran: sample(entry.mutant.globalId, entry.ranPassed), + notRun: sample(entry.mutant.globalId, entry.notRun), + }, + }), + ) + .sort((x, y) => byCodeUnits(x.id, y.id)), + }; +} + +// ── planted file ─────────────────────────────────────────────────────────── + +export class ControlsUsageError extends Error { + override name = 'ControlsUsageError'; +} + +export class ControlsInterruptedError extends Error { + override name = 'ControlsInterruptedError'; +} + +const invalid = (at: string, what: string): never => { + throw new ControlsUsageError(`planted file: ${at} ${what}`); +}; + +function field(data: unknown, key: string, at: string): unknown { + if (!isRecord(data) || Array.isArray(data) || !(key in data)) { + return invalid(`${at}.${key}`, 'is missing'); + } + return data[key]; +} + +function stringField(data: unknown, key: string, at: string): string { + const value = field(data, key, at); + return typeof value === 'string' ? value : invalid(`${at}.${key}`, 'must be a string'); +} + +function integerField(data: unknown, key: string, at: string, min: number): number { + const value = field(data, key, at); + return typeof value === 'number' && Number.isInteger(value) && value >= min + ? value + : invalid(`${at}.${key}`, `must be an integer >= ${min}`); +} + +function stringsField(data: unknown, key: string, at: string): string[] { + const value = field(data, key, at); + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? value.map(String) + : invalid(`${at}.${key}`, 'must be an array of strings'); +} + +function arrayField(data: unknown, key: string, at: string): unknown[] { + const value = field(data, key, at); + return Array.isArray(value) ? value : invalid(`${at}.${key}`, 'must be an array'); +} + +function toTag(value: string, at: string): ControlTag { + const tag = CONTROL_TAGS.find((entry) => entry === value); + return tag ?? invalid(at, `has an unknown tag ${JSON.stringify(value)}`); +} + +function parseControl(data: unknown, at: string): PlantedControl { + const staticValue = field(data, 'static', at); + if (staticValue !== null && typeof staticValue !== 'boolean') { + invalid(`${at}.static`, 'must be a boolean or null'); + } + const nonKillers = field(data, 'expectedNonKillers', at); + return { + id: stringField(data, 'id', at), + tags: stringsField(data, 'tags', at).map((tag) => toTag(tag, `${at}.tags`)), + static: typeof staticValue === 'boolean' ? staticValue : null, + expectedKillers: stringsField(data, 'expectedKillers', at), + expectedNonKillers: { + ran: stringsField(nonKillers, 'ran', `${at}.expectedNonKillers`), + notRun: stringsField(nonKillers, 'notRun', `${at}.expectedNonKillers`), + }, + }; +} + +function parseScope(data: unknown, at: string): PlantedScope { + const scope = field(data, 'scope', at); + return { + name: stringField(data, 'name', at), + scope: { + testFiles: stringsField(scope, 'testFiles', `${at}.scope`), + mutate: stringsField(scope, 'mutate', `${at}.scope`), + }, + mutants: integerField(data, 'mutants', at, 0), + tests: integerField(data, 'tests', at, 0), + controls: arrayField(data, 'controls', at).map((entry, index) => + parseControl(entry, `${at}.controls[${index}]`), + ), + }; +} + +/** Parses and validates a planted file. */ +export function parsePlanted(text: string): PlantedControls { + const data: unknown = JSON.parse(text); + const at = 'planted'; + if (field(data, 'schemaVersion', at) !== PLANTED_SCHEMA_VERSION) { + invalid(`${at}.schemaVersion`, `must be ${PLANTED_SCHEMA_VERSION}`); + } + if (field(data, 'kind', at) !== PLANTED_KIND) invalid(`${at}.kind`, `must be ${PLANTED_KIND}`); + const selection = field(data, 'selection', at); + const planted: PlantedControls = { + schemaVersion: PLANTED_SCHEMA_VERSION, + kind: PLANTED_KIND, + description: stringField(data, 'description', at), + reselect: stringField(data, 'reselect', at), + profiles: arrayField(data, 'profiles', at).map((entry, index) => ({ + name: stringField(entry, 'name', `${at}.profiles[${index}]`), + concurrency: integerField(entry, 'concurrency', `${at}.profiles[${index}]`, 1), + maxTestRunnerReuse: integerField(entry, 'maxTestRunnerReuse', `${at}.profiles[${index}]`, 0), + })), + timeoutMS: integerField(data, 'timeoutMS', at, 1), + selection: { + seed: stringField(selection, 'seed', `${at}.selection`), + perScope: integerField(selection, 'perScope', `${at}.selection`, 1), + maxKillers: integerField(selection, 'maxKillers', `${at}.selection`, 1), + maxNonKillers: integerField(selection, 'maxNonKillers', `${at}.selection`, 0), + selectedFrom: stringsField(selection, 'selectedFrom', `${at}.selection`), + }, + scopes: arrayField(data, 'scopes', at).map((entry, index) => + parseScope(entry, `${at}.scopes[${index}]`), + ), + }; + const duplicate = (names: string[]): string | undefined => + names.find((name, index) => names.indexOf(name) !== index); + const profile = duplicate(planted.profiles.map((entry) => entry.name)); + if (profile !== undefined) invalid(`${at}.profiles`, `name ${profile} twice`); + const scope = duplicate(planted.scopes.map((entry) => entry.name)); + if (scope !== undefined) invalid(`${at}.scopes`, `name ${scope} twice`); + return planted; +} + +export const formatPlanted = (planted: PlantedControls): string => + `${JSON.stringify(planted, null, 2)}\n`; + +// ── gate result ──────────────────────────────────────────────────────────── + +export interface GateInput { + staleRuns: number; + /** Run problems, failed runs, selection problems and missing required tags. */ + problems: number; + failures: readonly ControlFailure[]; + differences: number; +} + +/** Stale runs first, then failures of any kind, then stale controls (re-select); pure. */ +export function gateExitCode(input: GateInput): number { + if (input.staleRuns > 0) return EXIT_USAGE; + const failed = input.failures.some((failure) => !STALE_KINDS.has(failure.kind)); + if (input.problems > 0 || input.differences > 0 || failed) return EXIT_FAILURES; + return input.failures.length > 0 ? EXIT_RESELECT : EXIT_PASS; +} + +const RESULT_BY_EXIT: Record = { + [EXIT_PASS]: 'pass', + [EXIT_FAILURES]: 'failures', + [EXIT_USAGE]: 'stale-run', + [EXIT_RESELECT]: 'reselect-required', +}; + +// ── runs (impure) ────────────────────────────────────────────────────────── + +export interface RunRecord { + profile: string; + results: string; + wallClockMs: number | null; + mutants: number; + tests: number; + stale: string[]; + problems: string[]; +} + +export interface DeterminismRecord { + profiles: [string, string]; + mutants: number; + tests: number; + differences: DeterminismDifference[]; +} + +export interface ScopeRecord { + name: string; + runs: RunRecord[]; + controls: { checked: number; failures: ControlFailure[] }; + determinism: DeterminismRecord[]; +} + +export interface ControlsReport { + schemaVersion: 1; + kind: typeof REPORT_KIND; + result: string; + exitCode: number; + planted: string; + requirements: { + required: ControlTag[]; + carried: Record; + missing: ControlTag[]; + }; + scopes: ScopeRecord[]; + selectionProblems: string[]; + summary: string[]; +} + +function readRanTests(file: string): RanTest[] { + const record = readJsonFile(file) as SidecarRecord; + return record.result.tests.map((test) => ({ id: test.id, outcome: test.outcome })); +} + +const isRunResults = (value: unknown): value is RunResults => + isRecord(value) && + value.schemaVersion === RESULTS_SCHEMA_VERSION && + Array.isArray(value.mutants) && + Array.isArray(value.tests) && + Array.isArray(value.flags); + +function readResults(file: string): RunResults { + const data = readJsonFile(file); + if (!isRunResults(data)) { + throw new ControlsUsageError( + `${file} is not a results.json of schema ${RESULTS_SCHEMA_VERSION}`, + ); + } + return data; +} + +/** Why a run cannot stand for the planted scope, profile and the checkout; empty when it can. */ +function runStaleness( + results: RunResults, + scope: PlantedScope, + profile: Profile, + repoRoot: string, + inputs: InputHashes, +): string[] { + const reasons: string[] = []; + if (results.variant !== 'vitest5') reasons.push(`the run used the ${results.variant} variant`); + if ( + !sameSet(results.scope.testFiles, scope.scope.testFiles) || + !sameSet(results.scope.mutate, scope.scope.mutate) + ) { + reasons.push('the run ran another scope than the planted file'); + } + const config = readJsonFile(results.paths.configFile); + const concurrency = isRecord(config) ? config.concurrency : undefined; + const reuse = isRecord(config) ? config.maxTestRunnerReuse : undefined; + if (concurrency !== profile.concurrency || reuse !== profile.maxTestRunnerReuse) { + reasons.push( + `the run used concurrency ${String(concurrency)} and maxTestRunnerReuse ${String(reuse)}; ` + + `profile ${profile.name} needs ${profile.concurrency} and ${profile.maxTestRunnerReuse}`, + ); + } + const mutated = new Set(scope.scope.mutate.map((entry) => entry.split(':')[0])); + for (const mismatch of diffInputs( + inputs, + preparedInputs(results.paths.preparedRoot, inputs), + mutated, + )) { + reasons.push(`the run was prepared from another ${mismatch.path} (${mismatch.group})`); + } + const hashes = new Map(); + for (const mutant of results.mutants) { + const at = mutant.globalId.indexOf('@'); + const hash = mutant.globalId.slice(at + 1, at + 17); + const file = path.join(repoRoot, mutant.file); + if (!hashes.has(mutant.file)) { + hashes.set(mutant.file, existsSync(file) ? contentHash(readFileSync(file, 'utf8')) : ''); + } + if (!(hashes.get(mutant.file) ?? '').startsWith(hash)) { + reasons.push(`the run mutated another version of ${mutant.file} than the checkout's`); + break; + } + } + return reasons; +} + +function runProblems(results: RunResults): string[] { + return [ + ...(results.stryker.exitCode === 0 ? [] : [`Stryker exited with ${results.stryker.exitCode}`]), + ...(results.spanMap.failures.length === 0 + ? [] + : [`${results.spanMap.failures.length} span-map failures`]), + ...results.flags.map( + (flag) => + `flag ${flag.kind}${flag.localId === null ? '' : ` ${flag.localId}`}: ${flag.detail}`, + ), + ]; +} + +function prepareWork(repoRoot: string, work: string, compareOnly: boolean): void { + if (!path.isAbsolute(work)) throw new ControlsUsageError(`--work ${work} must be absolute`); + if (isInside(repoRoot, work) || isInside(work, repoRoot)) { + throw new ControlsUsageError(`--work ${work} must lie outside the checkout ${repoRoot}`); + } + const marked = existsSync(path.join(work, WORK_MARKER)); + if (compareOnly) { + if (!marked) throw new ControlsUsageError(`--compare-only: ${work} holds no gate 4 runs`); + return; + } + if (existsSync(work) && readdirSync(work).length > 0 && !marked) { + throw new ControlsUsageError(`${work} is not empty and was not created by this tool`); + } + mkdirSync(work, { recursive: true }); + writeFileSync(path.join(work, WORK_MARKER), ''); +} + +/** + * Runs `run` over `items` in order, starting an item whenever the costs of the running items plus + * its own stay within `budget`; an item costlier than the budget runs alone. `run` must not reject. + */ +export async function runBounded( + items: readonly T[], + budget: number, + run: (item: T) => Promise, +): Promise { + const pending = [...items]; + const running = new Set>(); + let used = 0; + while (pending.length > 0) { + const index = pending.findIndex((item) => running.size === 0 || used + item.cost <= budget); + if (index === -1) { + await Promise.race(running); + continue; + } + const [item] = pending.splice(index, 1); + used += item.cost; + const task: Promise = run(item).finally(() => { + used -= item.cost; + running.delete(task); + }); + running.add(task); + } + await Promise.all(running); +} + +export interface SelectRequest { + defines: { name: string; scopeFile: string }[]; + seed: string | undefined; + perScope: number | undefined; +} + +export interface ControlsOptions { + repoRoot: string; + work: string; + plantedFile: string; + reportFile: string; + profiles: readonly string[]; + scopes: readonly string[]; + compareOnly: boolean; + timeoutMS: number | undefined; + /** Budget of Stryker workers for runs in parallel ({@link runBounded}). */ + maxWorkers: number; + /** Aborted on SIGINT/SIGTERM: no further run starts and runControls throws. */ + signal: AbortSignal | undefined; + select: SelectRequest | null; + log: (line: string) => void; +} + +const pick = ( + items: readonly T[], + names: readonly string[], + what: string, +): T[] => { + const unknown = names.filter((name) => !items.some((item) => item.name === name)); + if (unknown.length > 0) + throw new ControlsUsageError(`unknown ${what} ${JSON.stringify(unknown[0])}`); + return names.length === 0 ? [...items] : items.filter((item) => names.includes(item.name)); +}; + +/** The planted file a selection starts from: the current one, with `--define` scopes applied. */ +function selectionBase(options: ControlsOptions, request: SelectRequest): PlantedControls { + const current = existsSync(options.plantedFile) + ? parsePlanted(readFileSync(options.plantedFile, 'utf8')) + : null; + const seed = request.seed ?? current?.selection.seed; + if (seed === undefined) throw new ControlsUsageError('--select needs --seed (no planted file)'); + const scopes = [...(current?.scopes ?? [])]; + for (const { name, scopeFile } of request.defines) { + const driver = parseDriverScope(readFileSync(scopeFile, 'utf8')); + const spec: PlantedScope = { + name, + scope: { + testFiles: driver.testFiles, + mutate: driver.mutate.map(formatMutateEntry), + }, + mutants: 0, + tests: 0, + controls: [], + }; + const index = scopes.findIndex((entry) => entry.name === name); + if (index === -1) scopes.push(spec); + else scopes[index] = spec; + } + if (scopes.length === 0) + throw new ControlsUsageError('--select needs --define (no planted file)'); + return { + schemaVersion: PLANTED_SCHEMA_VERSION, + kind: PLANTED_KIND, + description: DESCRIPTION, + reselect: RESELECT_COMMAND, + profiles: current?.profiles ?? DEFAULT_PROFILES.map((profile) => ({ ...profile })), + timeoutMS: options.timeoutMS ?? current?.timeoutMS ?? DEFAULT_TIMEOUT_MS, + selection: { + seed, + perScope: request.perScope ?? current?.selection.perScope ?? DEFAULT_PER_SCOPE, + maxKillers: current?.selection.maxKillers ?? DEFAULT_MAX_KILLERS, + maxNonKillers: current?.selection.maxNonKillers ?? DEFAULT_MAX_NON_KILLERS, + selectedFrom: current?.selection.selectedFrom ?? [], + }, + scopes, + }; +} + +const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)} s`; + +/** Runs (or reads) the gate 4 runs, selects when asked, and checks; writes the report. */ +export async function runControls(options: ControlsOptions): Promise { + const { repoRoot, work, log } = options; + prepareWork(repoRoot, work, options.compareOnly); + let planted = + options.select === null + ? parsePlanted(readFileSync(options.plantedFile, 'utf8')) + : selectionBase(options, options.select); + const timeoutMS = options.timeoutMS ?? planted.timeoutMS; + const profiles = pick(planted.profiles, options.profiles, 'profile'); + const scopes = pick(planted.scopes, options.scopes, 'scope'); + + const sources = new Map(); + const tagsOf: TagsOf = (mutant) => { + let source = sources.get(mutant.file); + if (source === undefined) { + const text = readFileSync(path.join(repoRoot, mutant.file), 'utf8'); + source = { text, ranges: asConstRanges(text) }; + sources.set(mutant.file, source); + } + return mutantTags(mutant, source.text, source.ranges); + }; + + interface Pair { + scope: PlantedScope; + profile: Profile; + scopeFile: string; + outDir: string; + inputs: InputHashes; + cost: number; + runError: string | null; + } + const pairs: Pair[] = []; + for (const scope of scopes) { + const scopeFile = path.join(work, 'scopes', `${scope.name}.json`); + mkdirSync(path.dirname(scopeFile), { recursive: true }); + writeFileSync(scopeFile, `${JSON.stringify(scope.scope, null, 2)}\n`); + const inputs = hashInputs(inputFiles(repoRoot, scope.scope.testFiles), (repoPath) => + path.join(repoRoot, repoPath), + ); + for (const profile of profiles) { + const outDir = path.join(work, 'runs', scope.name, profile.name); + const cost = profile.concurrency; + pairs.push({ scope, profile, scopeFile, outDir, inputs, cost, runError: null }); + } + } + if (!options.compareOnly) { + await runBounded(pairs, options.maxWorkers, async (pair) => { + const { scope, profile, outDir } = pair; + if (options.signal?.aborted === true) return; + const started = performance.now(); + log( + `gate 4: ${scope.name} / ${profile.name}: running (concurrency ${profile.concurrency}, ` + + `maxTestRunnerReuse ${profile.maxTestRunnerReuse}); log ${path.join(outDir, 'stryker.log')}`, + ); + try { + await runScope({ + repoRoot, + scopeFile: pair.scopeFile, + outDir, + variant: 'vitest5', + concurrency: profile.concurrency, + maxTestRunnerReuse: profile.maxTestRunnerReuse, + timeoutMS, + command: undefined, + }); + } catch (error) { + pair.runError = errorMessage(error); + } + log( + `gate 4: ${scope.name} / ${profile.name}: done in ${seconds(performance.now() - started)}`, + ); + }); + if (options.signal?.aborted === true) throw new ControlsInterruptedError('gate 4 interrupted'); + } + + const records = new Map(); + const views = new Map(); + for (const { scope, profile, outDir, inputs, runError } of pairs) { + const resultsFile = path.join(outDir, 'results.json'); + const record: RunRecord = { + profile: profile.name, + results: resultsFile, + wallClockMs: null, + mutants: 0, + tests: 0, + stale: [], + problems: runError === null ? [] : [`run failed: ${runError}`], + }; + if (existsSync(resultsFile)) { + const results = readResults(resultsFile); + record.wallClockMs = results.stryker.wallClockMs; + record.mutants = results.mutants.length; + record.tests = results.tests.filter((test) => !test.skipped).length; + record.stale = runStaleness(results, scope, profile, repoRoot, inputs); + record.problems.push(...runProblems(results)); + if (record.stale.length === 0) { + const view: RunView = { + profile: profile.name, + tests: results.tests.map((test) => ({ id: test.id, skipped: test.skipped })), + mutants: results.mutants.map((mutant) => mutantView(mutant, readRanTests)), + }; + views.set(scope.name, [...(views.get(scope.name) ?? []), view]); + } + } else if (record.problems.length === 0) { + record.problems.push(`no results at ${resultsFile}`); + } + records.set(scope.name, [...(records.get(scope.name) ?? []), record]); + log( + `gate 4: ${scope.name} / ${profile.name}: ${record.mutants} mutants, ${record.tests} tests` + + `${record.wallClockMs === null ? '' : `, Stryker ${seconds(record.wallClockMs)}`}` + + `${record.stale.length + record.problems.length === 0 ? '' : `; ${record.stale.length} stale, ${record.problems.length} problems`}`, + ); + } + + const selectionProblems: string[] = []; + if (options.select !== null) { + const selectOptions: SelectOptions = { + seed: planted.selection.seed, + perScope: planted.selection.perScope, + maxKillers: planted.selection.maxKillers, + maxNonKillers: planted.selection.maxNonKillers, + }; + const selected = new Map(); + for (const scope of scopes) { + const runs = views.get(scope.name) ?? []; + const unusable = (records.get(scope.name) ?? []).some( + (record) => record.stale.length > 0 || record.problems.length > 0, + ); + if (unusable || runs.length !== profiles.length) { + selectionProblems.push(`${scope.name}: not every run is usable; nothing selected`); + continue; + } + const selection = selectScopeControls(runs, selectOptions, tagsOf); + if (selection.controls.length === 0) selectionProblems.push(`${scope.name}: no candidate`); + selected.set(scope.name, { + ...scope, + mutants: runs[0].mutants.length, + tests: runs[0].tests.filter((test) => !test.skipped).length, + controls: selection.controls, + }); + log( + `gate 4: ${scope.name}: ${selection.candidates} candidates, ` + + `${selection.controls.length} controls selected`, + ); + } + planted = { + ...planted, + timeoutMS, + selection: { ...planted.selection, selectedFrom: profiles.map((profile) => profile.name) }, + scopes: planted.scopes.map((scope) => selected.get(scope.name) ?? scope), + }; + writeFileSync(path.join(work, 'planted.json'), formatPlanted(planted)); + if (selectionProblems.length === 0 && checkRequirements(planted).length === 0) { + mkdirSync(path.dirname(options.plantedFile), { recursive: true }); + writeFileSync(options.plantedFile, formatPlanted(planted)); + log(`gate 4: planted controls written to ${options.plantedFile}`); + } + } + + const plantedScopes = new Map(planted.scopes.map((scope) => [scope.name, scope])); + const scopeRecords: ScopeRecord[] = scopes.map((entry) => { + const scope = plantedScopes.get(entry.name) ?? entry; + const runs = views.get(scope.name) ?? []; + const failures = runs.flatMap((run) => checkControls(scope, run, tagsOf)); + const determinism = runs.slice(1).map( + (other): DeterminismRecord => ({ + profiles: [runs[0].profile, other.profile], + mutants: new Set([...runs[0].mutants, ...other.mutants].map((m) => m.globalId)).size, + tests: new Set([...runs[0].tests, ...other.tests].map((t) => t.id)).size, + differences: compareDeterminism(runs[0], other), + }), + ); + return { + name: scope.name, + runs: records.get(scope.name) ?? [], + controls: { checked: scope.controls.length * runs.length, failures }, + determinism, + }; + }); + + const missing = checkRequirements(planted); + const carried: Record = Object.fromEntries( + REQUIRED_TAGS.map((tag) => [ + tag, + planted.scopes.flatMap((scope) => + scope.controls.filter((control) => control.tags.includes(tag)).map((control) => control.id), + ), + ]), + ); + const allRuns = scopeRecords.flatMap((scope) => scope.runs); + const failures = scopeRecords.flatMap((scope) => scope.controls.failures); + const differences = scopeRecords.flatMap((scope) => + scope.determinism.flatMap((entry) => entry.differences), + ); + const exitCode = gateExitCode({ + staleRuns: allRuns.filter((run) => run.stale.length > 0).length, + problems: + allRuns.reduce((sum, run) => sum + run.problems.length, 0) + + selectionProblems.length + + missing.length, + failures, + differences: differences.length, + }); + const report: ControlsReport = { + schemaVersion: 1, + kind: REPORT_KIND, + result: RESULT_BY_EXIT[exitCode] ?? 'failures', + exitCode, + planted: options.plantedFile, + requirements: { required: [...REQUIRED_TAGS], carried, missing }, + scopes: scopeRecords, + selectionProblems, + summary: [], + }; + report.summary = summarise(report); + mkdirSync(path.dirname(options.reportFile), { recursive: true }); + writeFileSync(options.reportFile, `${JSON.stringify(report, null, 2)}\n`); + return report; +} + +const LISTED = 20; + +/** The printed lines of a report. */ +export function summarise(report: ControlsReport): string[] { + const lines: string[] = []; + for (const scope of report.scopes) { + const controls = scope.controls; + lines.push( + `gate 4: ${scope.name}: controls ${controls.checked} checks (controls x runs), ` + + `${controls.failures.length} failures`, + ); + for (const run of scope.runs) { + for (const reason of run.stale) lines.push(` ${run.profile}: stale run: ${reason}`); + for (const problem of run.problems.slice(0, LISTED)) { + lines.push(` ${run.profile}: run problem: ${problem}`); + } + } + for (const failure of controls.failures.slice(0, LISTED)) { + lines.push( + ` ${failure.profile}: ${failure.kind} ${failure.control}` + + `${failure.test === null ? '' : ` test ${failure.test}`}: ${failure.detail}`, + ); + } + for (const entry of scope.determinism) { + lines.push( + `gate 4: ${scope.name}: determinism ${entry.profiles.join(' vs ')}: ${entry.mutants} ` + + `mutants, ${entry.tests} tests compared, ${entry.differences.length} differences`, + ); + for (const diff of entry.differences.slice(0, LISTED)) { + lines.push( + ` ${diff.kind}${diff.mutant === null ? '' : ` ${diff.mutant}`}` + + `${diff.test === null ? '' : ` test ${diff.test}`}: ${diff.detail}`, + ); + } + } + if (scope.determinism.length === 0) { + lines.push(`gate 4: ${scope.name}: determinism not compared (fewer than two usable runs)`); + } + } + for (const problem of report.selectionProblems) lines.push(`gate 4: selection: ${problem}`); + const carried = report.requirements.required.map( + (tag) => `${tag} (${report.requirements.carried[tag]?.length ?? 0} controls)`, + ); + lines.push(`gate 4: required tags: ${carried.join(', ')}`); + for (const tag of report.requirements.missing) { + lines.push(`gate 4: no control carries the required tag ${tag} (KTD19); re-select`); + } + lines.push(`gate 4: ${report.result} (exit ${report.exitCode})`); + return lines; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +const USAGE = + 'usage: npm run test:mutation:controls -- --work [--planted ] ' + + '[--profile ]... [--scope ]... [--compare-only] [--timeout-ms ] ' + + '[--max-workers ] [--report ] [--select [--define =]... [--seed ] ' + + '[--per-scope ]]'; + +const parseInteger = (value: string | undefined, name: string): number | undefined => { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value) || Number(value) < 1) { + throw new ControlsUsageError(`--${name} must be a positive integer, got ${value}`); + } + return Number(value); +}; + +/** Parses the CLI arguments; relative paths resolve against `cwd`. */ +export function parseControlsArgs( + argv: readonly string[], + cwd: string, + repoRoot: string, + log: (line: string) => void, +): ControlsOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + work: { type: 'string' }, + planted: { type: 'string' }, + report: { type: 'string' }, + profile: { type: 'string', multiple: true, default: [] }, + scope: { type: 'string', multiple: true, default: [] }, + 'compare-only': { type: 'boolean', default: false }, + 'timeout-ms': { type: 'string' }, + 'max-workers': { type: 'string' }, + select: { type: 'boolean', default: false }, + define: { type: 'string', multiple: true, default: [] }, + seed: { type: 'string' }, + 'per-scope': { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }); + if (values.work === undefined) throw new ControlsUsageError('--work is required'); + const selectOnly = values.define.length > 0 || values.seed !== undefined; + if (!values.select && (selectOnly || values['per-scope'] !== undefined)) { + throw new ControlsUsageError('--define, --seed and --per-scope need --select'); + } + const work = path.resolve(cwd, values.work); + return { + repoRoot, + work, + plantedFile: path.resolve( + cwd, + values.planted ?? path.join(repoRoot, PACKAGE_DIR, PLANTED_FILE), + ), + reportFile: path.resolve(cwd, values.report ?? path.join(work, 'controls-report.json')), + profiles: values.profile, + scopes: values.scope, + compareOnly: values['compare-only'], + timeoutMS: parseInteger(values['timeout-ms'], 'timeout-ms'), + maxWorkers: parseInteger(values['max-workers'], 'max-workers') ?? DEFAULT_MAX_WORKERS, + select: values.select + ? { + defines: values.define.map((entry) => { + const at = entry.indexOf('='); + if (at <= 0) throw new ControlsUsageError(`--define ${entry}: expected =`); + return { name: entry.slice(0, at), scopeFile: path.resolve(cwd, entry.slice(at + 1)) }; + }), + seed: values.seed, + perScope: parseInteger(values['per-scope'], 'per-scope'), + } + : null, + log, + signal: undefined, + }; +} + +async function main(): Promise { + const packageDir = fileURLToPath(new URL('../../../', import.meta.url)); + const repoRoot = path.dirname(path.resolve(packageDir)); + const started = performance.now(); + try { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + process.once('SIGINT', abort); + process.once('SIGTERM', abort); + const parsed = parseControlsArgs(process.argv.slice(2), process.cwd(), repoRoot, (line) => + console.log(line), + ); + const options = { ...parsed, signal: controller.signal }; + const report = await runControls(options); + for (const line of report.summary) console.log(line); + console.log( + `gate 4: total ${seconds(performance.now() - started)}; report ${options.reportFile}`, + ); + process.exitCode = report.exitCode; + } catch (error) { + console.error(errorMessage(error)); + if (error instanceof ControlsUsageError || error instanceof TypeError) console.error(USAGE); + process.exitCode = error instanceof ControlsInterruptedError ? EXIT_INTERRUPTED : EXIT_USAGE; + } +} + +if (isMainModule(import.meta.url)) { + void main(); +} diff --git a/gitnexus/tools/mutation/driver/normalise-results.ts b/gitnexus/tools/mutation/driver/normalise-results.ts new file mode 100644 index 000000000..ba58241c0 --- /dev/null +++ b/gitnexus/tools/mutation/driver/normalise-results.ts @@ -0,0 +1,674 @@ +/** + * Normalised results of one scope-driver run (U10): Stryker's report, the vitest5 run manifest + * (KTD16) and the sidecar records (KTD6), joined per mutant through U2's local-to-global id map. + * + * `/results.json` ({@link RunResults}, schemaVersion 2): + * variant, scope {testFiles, mutate, what ran (`mutate` as given in the scope file; + * testIds} `testIds` null when every test ran) + * paths output dir, prepared copy, config, report, log, + * runDir/sidecarDir (null for command), sandboxDirs + * (kept Stryker sandboxes; empty once cleaned) + * stryker {exitCode, wallClockMs} + * spanMap {checked, failures[]} span-map-check.ts over every mutant + * manifest run-manifest counts (null for command) + * dryRun latest dry-run sidecar (null for command or missing) + * mutants[] {@link NormalisedMutant}, in report order + * tests[] {@link NormalisedTest}: per-test kill sets + * flags[] {@link Flag}: run-level first, then per mutant + * summary {@link RunSummary} + * + * Test ids. Stryker's report renumbers test ids (`"0"`, `"1"`, ...) in the dry run's test order + * and lists each test under its file with the runner's reported name (the Vitest full name). The + * vitest5 runner reports a file's tests in collection order, ran and skipped interleaved, and the + * run manifest keeps each of those two lists in collection order. So report tests pair with + * manifest tests by walking both in order and matching full names: tests that share a full name + * (KTD3 suffixes) pair by position, and a name that matches neither list is a problem. Every + * `killedBy`/`coveredBy` entry is mapped to the runner's Stryker id (KTD3); an entry that cannot + * be mapped stays as the report id and is flagged. The command variant has no manifest: its + * report ids map to the report's test names. + * + * Outcome classes (KTD17). A mutant's class comes from its latest sidecar record: records are + * ordered by write time, then numeric worker id (Stryker numbers replacement processes upwards), + * then attempt number; every attempt is listed. Without a record: NoCoverage is `survived` + * (KTD13 treats them as one class), a Stryker Timeout is `timeout` (Stryker killed the process + * before it could write, KTD6), anything else has no class. The command variant joins no sidecar. + * `assertionKilledBy` lists the latest record's `killed-by-test:assertion` rows when that run was + * a kill (status killed): the only kills gates 3 and 4 count. A run classified timeout, + * inconclusive, invalid or infrastructure can still hold assertion rows (a test that failed after + * the hit-limit error was swallowed, or under a retry), but its kills cannot be trusted. + * + * Every attempt's per-test rows (Phase 1 KTD2, KTD8). `perTest` keeps the `result.tests` rows of + * every attempt, oldest first, as `{testId, outcome, attempt}`, where `attempt` is the attempt's + * index in `attempts`. `attemptsDisagree` is true when two attempts differ in their run outcome + * class, their per-test rows or their outside-test rows (file, suite and class): such a mutant is + * inconclusive for Phase 1, whichever attempt is latest. A missing record is unknown and is not + * compared. + * + * Hook marker (Phase 1 KTD2, KTD9). `hookAttributedTests` lists the runner ids of the tests a + * suite hook (`beforeAll`, `afterAll`) attributed the mutant's dry-run hits to, from the latest + * dry-run record; `[]` when no hook hit it, null when there is no dry-run record (the command + * variant, a missing record, or a record of schema 1, which had no marker). + * + * Flags (vitest5 only, except unmapped test ids): + * dry-run-missing no dry-run record + * unmapped-test-id a report test that pairs with no manifest test, a manifest test + * the report lacks, or a mutant killer/coverage id that cannot be mapped + * orphan-sidecar a record whose local id is not in the report + * killed-by-without-sidecar a killedBy entry of a mutant that has no record (one per entry) + * killed-without-sidecar a Killed mutant without a record + * missing-sidecar a Survived or RuntimeError mutant (the runner ran it) without a record + * killed-by-not-in-sidecar a killedBy entry the latest record does not list + * status-mismatch the latest record's status disagrees with Stryker's status + * zero-tests an attempt that ran zero tests, unless an outside-test row explains + * it: the file failed to collect or a file or suite hook failed, by + * a kill or a timeout (as runner/classify-outcome.ts decides) + */ +import { + MUTANT_STATUSES, + type MutantStatus, + type ReportLocation, + type RunIdentities, +} from '../prepare/mutant-identity.js'; +import type { SpanMapFailure } from '../prepare/span-map-check.js'; +import type { OutcomeClass, OutsideTestOutcome, RunnerStatus } from '../runner/classify-outcome.js'; +import type { RunManifest } from '../runner/run-manifest.js'; +import type { SidecarRecord } from '../runner/sidecar.js'; +import type { Variant } from './stryker-config.js'; + +/** 2: per mutant `perTest`, `attemptsDisagree` and `hookAttributedTests`; `scope.testIds`. */ +export const RESULTS_SCHEMA_VERSION = 2; + +export interface ReportTest { + /** The report's renumbered test id. */ + id: string; + name: string; +} + +/** Stryker report `testFiles`: package-relative file (or `''`) to its tests, in dry-run order. */ +export type ReportTestFiles = Record; + +export interface SidecarInput { + record: SidecarRecord; + /** Absolute path of the record file. */ + file: string; + /** The record file's modification time. */ + writtenAtMs: number; +} + +export type OutcomeSource = 'sidecar' | 'no-coverage' | 'stryker-timeout' | 'none'; + +export interface SidecarAttempt { + file: string; + worker: string; + attempt: number; + pid: number; + writtenAtMs: number; + status: RunnerStatus; + outcome: OutcomeClass; + nrOfTests: number; +} + +/** One per-test row of one attempt's sidecar record. */ +export interface PerTestRow { + /** Runner Stryker id (KTD3). */ + testId: string; + outcome: OutcomeClass; + /** Index of the attempt in the mutant's `attempts` (oldest first). */ + attempt: number; +} + +export interface SidecarSummary extends SidecarAttempt { + reason: string | null; + failureMessage: string | null; + /** Runner Stryker ids that Stryker saw as killers. */ + killedBy: string[]; + hitCount: number; + mutantActivation: SidecarRecord['mutantActivation']; + outsideTest: OutsideTestOutcome[]; +} + +export type FlagKind = + | 'dry-run-missing' + | 'unmapped-test-id' + | 'orphan-sidecar' + | 'killed-by-without-sidecar' + | 'killed-without-sidecar' + | 'missing-sidecar' + | 'killed-by-not-in-sidecar' + | 'status-mismatch' + | 'zero-tests'; + +export interface Flag { + kind: FlagKind; + localId: string | null; + globalId: string | null; + detail: string; +} + +export interface NormalisedMutant { + globalId: string; + localId: string; + /** Repo-relative path of the original file. */ + file: string; + location: ReportLocation; + mutatorName: string; + replacement: string; + static: boolean | null; + /** Stryker's raw status. */ + status: MutantStatus; + statusReason: string | null; + testsCompleted: number | null; + outcome: OutcomeClass | null; + outcomeSource: OutcomeSource; + /** Runner test ids (report ids that cannot be mapped stay as-is and are flagged). */ + killedBy: string[]; + coveredBy: string[] | null; + assertionKilledBy: string[]; + /** The latest attempt; null when there is none. */ + sidecar: SidecarSummary | null; + /** Every attempt, oldest first. */ + attempts: SidecarAttempt[]; + /** The per-test rows of every attempt, oldest attempt first. */ + perTest: PerTestRow[]; + /** True when two attempts differ in outcome class, per-test rows or outside-test rows. */ + attemptsDisagree: boolean; + /** + * Runner ids of the tests a suite hook attributed this mutant to in the dry run, sorted; null + * when no dry-run record carries the marker. + */ + hookAttributedTests: string[] | null; + flags: FlagKind[]; +} + +export interface NormalisedTest { + /** Runner Stryker id (KTD3); the report name for the command variant. */ + id: string; + file: string; + fullName: string; + /** Vitest task id; null for the command variant. */ + taskId: string | null; + skipped: boolean; + notOracleValidated: boolean; + /** Global ids of mutants whose Stryker `killedBy` lists this test. */ + killed: string[]; + /** Global ids of mutants this test killed with an assertion (KTD17). */ + assertionKilled: string[]; +} + +export interface DryRunSummary { + file: string; + status: RunnerStatus; + outcome: OutcomeClass; + reason: string | null; + nrOfTests: number; + attempts: number; +} + +export interface ManifestSummary { + project: string; + vitestVersion: string; + files: number; + tests: number; + skipped: number; +} + +export interface RunSummary { + mutants: number; + byStatus: Record; + /** Outcome class counts; `unclassified` counts mutants without a class. */ + byOutcome: Record; + mutantsWithSidecar: number; + mutantsWithOutcome: number; + testsWithKills: number; + testsWithAssertionKills: number; + zeroTestRuns: number; + flags: number; +} + +export interface NormalisedRun { + manifest: ManifestSummary | null; + dryRun: DryRunSummary | null; + mutants: NormalisedMutant[]; + tests: NormalisedTest[]; + flags: Flag[]; + summary: RunSummary; +} + +/** The full `results.json` document; see the module comment. */ +export interface RunResults extends NormalisedRun { + schemaVersion: typeof RESULTS_SCHEMA_VERSION; + variant: Variant; + /** `testIds` is null when every test of `testFiles` ran. */ + scope: { testFiles: string[]; mutate: string[]; testIds: string[] | null }; + paths: { + outDir: string; + preparedRoot: string; + packageDir: string; + configFile: string; + reportFile: string; + logFile: string; + runDir: string | null; + sidecarDir: string | null; + sandboxDirs: string[]; + }; + stryker: { exitCode: number; wallClockMs: number }; + spanMap: { checked: number; failures: SpanMapFailure[] }; +} + +export interface NormaliseInput { + identities: RunIdentities; + testFiles: ReportTestFiles; + /** Null for the command variant. */ + manifest: RunManifest | null; + /** Every published record of the run; null for the command variant. */ + sidecars: readonly SidecarInput[] | null; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Parses the `testFiles` section of Stryker's report text; a report without one has no tests. */ +export function parseReportTestFiles(text: string): ReportTestFiles { + const data: unknown = JSON.parse(text); + const section = isRecord(data) ? data.testFiles : undefined; + if (section === undefined) return {}; + if (!isRecord(section)) throw new Error('mutation report: testFiles must be an object'); + return Object.fromEntries( + Object.entries(section).map(([file, value]) => { + const tests: unknown = isRecord(value) ? value.tests : undefined; + if (!Array.isArray(tests)) { + throw new Error( + `mutation report: testFiles[${JSON.stringify(file)}].tests must be an array`, + ); + } + return [ + file, + { + tests: tests.map((test: unknown): ReportTest => { + if (!isRecord(test) || typeof test.id !== 'string' || typeof test.name !== 'string') { + throw new Error( + `mutation report: every test of testFiles[${JSON.stringify(file)}] needs an id and name`, + ); + } + return { id: test.id, name: test.name }; + }), + }, + ]; + }), + ); +} + +/** Report test id to runner test id; see the module comment. */ +export function mapReportTestIds( + testFiles: ReportTestFiles, + manifest: RunManifest | null, +): { ids: Map; problems: string[] } { + const ids = new Map(); + const problems: string[] = []; + if (manifest === null) { + for (const { tests } of Object.values(testFiles)) { + for (const test of tests) ids.set(test.id, test.name); + } + return { ids, problems }; + } + const byFile = new Map(manifest.files.map((file) => [file.file, file])); + const seen = new Set(); + for (const [file, { tests }] of Object.entries(testFiles)) { + const entry = byFile.get(file); + if (entry === undefined) { + problems.push(`report test file ${JSON.stringify(file)} is not in the run manifest`); + continue; + } + seen.add(file); + let ran = 0; + let skipped = 0; + for (const test of tests) { + const nextRan = entry.tests[ran]; + const nextSkipped = entry.skipped[skipped]; + const ranMatches = nextRan !== undefined && nextRan.fullName === test.name; + const skippedMatches = nextSkipped !== undefined && nextSkipped.fullName === test.name; + if (ranMatches && skippedMatches) { + problems.push( + `report test ${test.id} ${JSON.stringify(test.name)} in ${file} matches both a ran and a ` + + 'skipped manifest test; paired with the ran test', + ); + } + if (ranMatches) { + ids.set(test.id, nextRan.id); + ran++; + } else if (skippedMatches) { + ids.set(test.id, nextSkipped.id); + skipped++; + } else { + problems.push( + `report test ${test.id} ${JSON.stringify(test.name)} in ${file} matches no run manifest test`, + ); + } + } + for (const left of [...entry.tests.slice(ran), ...entry.skipped.slice(skipped)]) { + problems.push(`run manifest test ${left.id} is missing from the report`); + } + } + for (const entry of manifest.files.filter((file) => !seen.has(file.file))) { + for (const left of [...entry.tests, ...entry.skipped]) { + problems.push(`run manifest test ${left.id} is missing from the report`); + } + } + return { ids, problems }; +} + +const compareWorkers = (a: string, b: string): number => { + const [x, y] = [Number(a), Number(b)]; + if (Number.isFinite(x) && Number.isFinite(y) && x !== y) return x - y; + return a < b ? -1 : a > b ? 1 : 0; +}; + +/** Attempts oldest first: write time, then numeric worker id, then attempt number. */ +export function orderAttempts(inputs: readonly SidecarInput[]): SidecarInput[] { + return [...inputs].sort( + (a, b) => + a.writtenAtMs - b.writtenAtMs || + compareWorkers(a.record.worker, b.record.worker) || + a.record.attempt - b.record.attempt, + ); +} + +const STRYKER_STATUS: Record = { + killed: 'Killed', + survived: 'Survived', + timeout: 'Timeout', + error: 'RuntimeError', +}; + +const toAttempt = ({ record, file, writtenAtMs }: SidecarInput): SidecarAttempt => ({ + file, + worker: record.worker, + attempt: record.attempt, + pid: record.pid, + writtenAtMs, + status: record.result.status, + outcome: record.result.outcome, + nrOfTests: record.result.nrOfTests, +}); + +const toSummary = (input: SidecarInput): SidecarSummary => { + const { result, mutantActivation } = input.record; + return { + ...toAttempt(input), + reason: result.reason ?? null, + failureMessage: result.failureMessage ?? null, + killedBy: [...result.killedBy], + hitCount: result.hitCount, + mutantActivation, + outsideTest: result.outsideTest, + }; +}; + +/** What attempts must agree on: the run class, per-test rows and outside-test rows. */ +const attemptKey = ({ record: { result } }: SidecarInput): string => + JSON.stringify([ + result.outcome, + result.tests.map((row) => `${row.id}\u0000${row.outcome}`).sort(), + result.outsideTest + .map((row) => `${row.file}\u0000${row.suiteTaskId ?? ''}\u0000${row.outcome}`) + .sort(), + ]); + +const isOutsideTestKill = (outcome: OutcomeClass): boolean => + outcome === 'killed-outside-test:collection' || outcome === 'killed-outside-test:hook'; + +/** + * Whether an attempt's zero tests are explained by a file-level row: a collection failure or a + * failed file or suite hook that killed or timed out (a hit limit while collecting, a beforeAll + * hook timeout). runner/classify-outcome.ts accepts the same rows before it calls a run that + * ran zero tests invalid. + */ +const zeroTestsExplained = (result: SidecarRecord['result']): boolean => + isOutsideTestKill(result.outcome) || + result.outsideTest.some((row) => isOutsideTestKill(row.outcome) || row.outcome === 'timeout'); + +/** Joins report, manifest and sidecars; pure. See the module comment for every rule. */ +export function normaliseRun(input: NormaliseInput): NormalisedRun { + const { identities, manifest, sidecars } = input; + const flags: Flag[] = []; + const flag = (kind: FlagKind, detail: string, localId: string | null = null): void => { + const globalId = localId === null ? null : (identities.localToGlobal.get(localId) ?? null); + flags.push({ kind, localId, globalId, detail }); + }; + + const byMutant = new Map(); + const dryRuns: SidecarInput[] = []; + for (const entry of orderAttempts(sidecars ?? [])) { + const { kind, mutantId } = entry.record; + if (kind === 'dry-run' || mutantId === null) { + dryRuns.push(entry); + continue; + } + byMutant.set(mutantId, [...(byMutant.get(mutantId) ?? []), entry]); + } + const latestDryRun = dryRuns.at(-1); + // A schema 1 record has no marker: unknown, never "no hook". + const hookMarker = latestDryRun?.record.hookAttributedTests ?? null; + if (sidecars !== null && latestDryRun === undefined) { + flag('dry-run-missing', 'the run wrote no dry-run sidecar record'); + } + + const mapping = mapReportTestIds(input.testFiles, manifest); + for (const problem of mapping.problems) flag('unmapped-test-id', problem); + for (const mutantId of byMutant.keys()) { + if (!identities.localToGlobal.has(mutantId)) { + flag( + 'orphan-sidecar', + `sidecar records for local id ${mutantId}, which the report lacks`, + mutantId, + ); + } + } + + const mapIds = (list: readonly string[] | undefined, unmapped: string[]): string[] => + (list ?? []).map((id) => { + const mapped = mapping.ids.get(id); + if (mapped === undefined) unmapped.push(id); + return mapped ?? id; + }); + + let zeroTestRuns = 0; + const mutants = identities.mutants.map(({ localId, globalId, identity, mutant }) => { + const before = flags.length; + const unmapped: string[] = []; + const killedBy = mapIds(mutant.killedBy, unmapped); + const coveredBy = mutant.coveredBy === undefined ? null : mapIds(mutant.coveredBy, unmapped); + if (unmapped.length > 0) { + flag( + 'unmapped-test-id', + `report test ids ${JSON.stringify(unmapped)} map to no test`, + localId, + ); + } + const attempts = byMutant.get(localId) ?? []; + const latest = attempts.at(-1); + let outcome: OutcomeClass | null = null; + let outcomeSource: OutcomeSource = 'none'; + if (latest !== undefined) { + outcome = latest.record.result.outcome; + outcomeSource = 'sidecar'; + } else if (sidecars !== null && mutant.status === 'NoCoverage') { + outcome = 'survived'; + outcomeSource = 'no-coverage'; + } else if (sidecars !== null && mutant.status === 'Timeout') { + outcome = 'timeout'; + outcomeSource = 'stryker-timeout'; + } + + if (sidecars !== null && latest === undefined) { + for (const killer of killedBy) { + flag( + 'killed-by-without-sidecar', + `killedBy entry ${killer} has no sidecar record`, + localId, + ); + } + if (mutant.status === 'Killed') { + flag( + 'killed-without-sidecar', + 'Stryker reports Killed but no sidecar record exists', + localId, + ); + } else if (mutant.status === 'Survived' || mutant.status === 'RuntimeError') { + flag( + 'missing-sidecar', + `Stryker reports ${mutant.status} but no sidecar record exists`, + localId, + ); + } + } + if (latest !== undefined) { + const recorded = new Set(latest.record.result.killedBy); + for (const killer of killedBy.filter((id) => !recorded.has(id))) { + flag( + 'killed-by-not-in-sidecar', + `killedBy entry ${killer} is not a killer in ${latest.file}`, + localId, + ); + } + const expected = STRYKER_STATUS[latest.record.result.status]; + if (expected !== mutant.status) { + flag( + 'status-mismatch', + `Stryker reports ${mutant.status}, the latest sidecar ${latest.record.result.status}`, + localId, + ); + } + } + for (const attempt of attempts) { + const { nrOfTests, outcome: attemptOutcome } = attempt.record.result; + if (nrOfTests === 0 && !zeroTestsExplained(attempt.record.result)) { + zeroTestRuns++; + flag('zero-tests', `${attempt.file} ran zero tests (${attemptOutcome})`, localId); + } + } + + const normalised: NormalisedMutant = { + globalId, + localId, + file: identity.file, + location: identity.location, + mutatorName: identity.mutatorName, + replacement: identity.replacement, + static: mutant.static ?? null, + status: mutant.status, + statusReason: mutant.statusReason ?? null, + testsCompleted: mutant.testsCompleted ?? null, + outcome, + outcomeSource, + killedBy, + coveredBy, + assertionKilledBy: + latest?.record.result.status === 'killed' + ? latest.record.result.tests + .filter((test) => test.outcome === 'killed-by-test:assertion') + .map((test) => test.id) + : [], + sidecar: latest === undefined ? null : toSummary(latest), + attempts: attempts.map(toAttempt), + perTest: attempts.flatMap((entry, attempt) => + entry.record.result.tests.map((row) => ({ testId: row.id, outcome: row.outcome, attempt })), + ), + attemptsDisagree: new Set(attempts.map(attemptKey)).size > 1, + hookAttributedTests: hookMarker === null ? null : [...(hookMarker[localId] ?? [])].sort(), + flags: flags.slice(before).map((entry) => entry.kind), + }; + return normalised; + }); + + const tests = new Map(); + if (manifest === null) { + for (const [file, { tests: reportTests }] of Object.entries(input.testFiles)) { + for (const test of reportTests) { + tests.set(test.name, { + id: test.name, + file, + fullName: test.name, + taskId: null, + skipped: false, + notOracleValidated: false, + killed: [], + assertionKilled: [], + }); + } + } + } else { + for (const entry of manifest.files) { + for (const [list, skipped] of [ + [entry.tests, false], + [entry.skipped, true], + ] as const) { + for (const test of list) { + tests.set(test.id, { + id: test.id, + file: entry.file, + fullName: test.fullName, + taskId: test.taskId, + skipped, + notOracleValidated: test.notOracleValidated, + killed: [], + assertionKilled: [], + }); + } + } + } + } + for (const mutant of mutants) { + for (const id of mutant.killedBy) tests.get(id)?.killed.push(mutant.globalId); + for (const id of mutant.assertionKilledBy) tests.get(id)?.assertionKilled.push(mutant.globalId); + } + + const byStatus = Object.fromEntries(MUTANT_STATUSES.map((status) => [status, 0])) as Record< + MutantStatus, + number + >; + const byOutcome: Record = {}; + for (const mutant of mutants) { + byStatus[mutant.status]++; + const key = mutant.outcome ?? 'unclassified'; + byOutcome[key] = (byOutcome[key] ?? 0) + 1; + } + const testList = [...tests.values()]; + return { + manifest: + manifest === null + ? null + : { + project: manifest.project, + vitestVersion: manifest.vitestVersion, + files: manifest.files.length, + tests: manifest.files.reduce((sum, file) => sum + file.tests.length, 0), + skipped: manifest.files.reduce((sum, file) => sum + file.skipped.length, 0), + }, + dryRun: + latestDryRun === undefined + ? null + : { + file: latestDryRun.file, + status: latestDryRun.record.result.status, + outcome: latestDryRun.record.result.outcome, + reason: latestDryRun.record.result.reason ?? null, + nrOfTests: latestDryRun.record.result.nrOfTests, + attempts: dryRuns.length, + }, + mutants, + tests: testList, + flags, + summary: { + mutants: mutants.length, + byStatus, + byOutcome, + mutantsWithSidecar: mutants.filter((mutant) => mutant.sidecar !== null).length, + mutantsWithOutcome: mutants.filter((mutant) => mutant.outcome !== null).length, + testsWithKills: testList.filter((test) => test.killed.length > 0).length, + testsWithAssertionKills: testList.filter((test) => test.assertionKilled.length > 0).length, + zeroTestRuns, + flags: flags.length, + }, + }; +} diff --git a/gitnexus/tools/mutation/driver/run-scope.ts b/gitnexus/tools/mutation/driver/run-scope.ts new file mode 100644 index 000000000..e195c3c06 --- /dev/null +++ b/gitnexus/tools/mutation/driver/run-scope.ts @@ -0,0 +1,455 @@ +/** + * Scope driver (U10): turns a scope file into a prepared copy, a generated Stryker config, a + * Stryker run and normalised results. + * + * 1. prepare.ts builds `/prepared` with the mutate files' `as` assertions blanked (KTD7); + * 2. `/scope.json` names the test files for tools/mutation/vitest.mutation.config.ts; + * 3. `/stryker.config.json` comes from stryker-config.ts (never with `testFiles`, KTD4; a + * scope's `testIds` become the runner's `vitest5.testIds`, Phase 1 KTD2); + * 4. Stryker runs as `node --import tsx /gitnexus/node_modules/@stryker-mutator/core/ + * bin/stryker.js run ` from `/gitnexus`, with `GITNEXUS_MUTATION_SCOPE` set + * and the inherited `__STRYKER_ACTIVE_MUTANT__` and `STRYKER_MUTATOR_WORKER` removed; its + * output goes to `/stryker.log`. On POSIX it runs detached, as the leader of its own + * process group: SIGINT and SIGTERM to the driver are forwarded to that whole group, and an + * abnormal Stryker exit kills what is left of it, so no Stryker or Vitest worker outlives a + * cancelled or failed run. Windows has no process groups to signal: the signal goes to the + * Stryker process only; + * 5. the report gets global mutant ids and the span-map check (U2), and is joined with the run + * manifest and sidecars (normalise-results.ts) into `/results.json`. + * + * Programmatic callers may also pass `extraRoots` (mutation fixture project directories, see + * path-map.ts `buildPathMap`), `ignoredDirs` and a `vitest` config and project for the runner; the + * gate 2 conformance driver (tools/mutation/conformance) runs its fixture project this way. The + * CLI keeps the defaults. + * + * The scope file format and the run layout are documented in stryker-config.ts, the results + * schema in normalise-results.ts. The output directory must lie outside the checkout; an existing + * non-empty one is replaced only when it holds a previous driver run's marker file. + * + * Usage (from gitnexus/): + * npm run test:mutation:run -- --scope --out [--variant vitest5|command] + * [--command ] [--concurrency ] [--max-test-runner-reuse ] [--timeout-ms ] + * Exit codes: 0 clean run; 1 results written but with integrity flags or span-map failures; + * 2 usage, setup or Stryker failure. + */ +import { spawn } from 'node:child_process'; +import { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import { identifyMutants, readMutationReport, readRepoFile } from '../prepare/mutant-identity.js'; +import { buildPathMap } from '../prepare/path-map.js'; +import { prepare } from '../prepare/prepare.js'; +import { assertSpanMap, checkSpanMap } from '../prepare/span-map-check.js'; +import { readRunManifest } from '../runner/run-manifest.js'; +import { readSidecars, sidecarFileName } from '../runner/sidecar.js'; +import { MUTATION_SCOPE_ENV, parseMutationScope } from '../scope-file.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; +import { isInside } from '../shared/fs.js'; +import { + RESULTS_SCHEMA_VERSION, + normaliseRun, + parseReportTestFiles, + type RunResults, + type SidecarInput, +} from './normalise-results.js'; +import { + DEFAULT_IGNORED_DIRS, + assertTestFilesInSandbox, + buildStrykerConfig, + formatMutateEntry, + parseDriverScope, + parseVariant, + runLayout, + type RunLayout, + type Variant, + type VitestSelection, +} from './stryker-config.js'; + +/** Marks an output directory as created by this driver, so a re-run may replace it. */ +export const RUN_MARKER_FILE = '.gitnexus-mutation-run'; + +/** Stryker's worker id variable: an inherited value would override each worker's own id. */ +const WORKER_ENV = 'STRYKER_MUTATOR_WORKER'; +const LOG_TAIL_LINES = 40; + +export interface CliOptions { + scopeFile: string; + outDir: string; + variant: Variant; + concurrency: number; + maxTestRunnerReuse: number; + timeoutMS: number; + command: string | undefined; +} + +export interface RunScopeOptions extends CliOptions { + /** Absolute repo checkout. */ + repoRoot: string; + /** Defaults to {@link DEFAULT_IGNORED_DIRS}. */ + ignoredDirs?: readonly string[]; + /** Mutation fixture project roots whose files may be mutated (path-map.ts `buildPathMap`). */ + extraRoots?: readonly string[]; + /** vitest5 only: the runner's Vitest config file and project; the runner defaults otherwise. */ + vitest?: VitestSelection; + /** + * Gate 2 conformance only: variables set in the Stryker process environment after + * {@link strykerChildEnv} scrubbed the inherited ones, so the environment case can hand Stryker + * an active mutant the vitest5 runner itself must remove (KTD5). Every other caller leaves it + * unset and keeps the default scrub. + */ + childEnv?: Readonly>; +} + +export class UsageError extends Error { + override name = 'UsageError'; +} + +const parseInteger = (value: string | undefined, name: string, fallback: number): number => { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value)) throw new UsageError(`--${name} must be an integer, got ${value}`); + return Number(value); +}; + +/** Parses the CLI arguments; relative paths resolve against `cwd`. */ +export function parseCliArgs(argv: readonly string[], cwd: string): CliOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + scope: { type: 'string' }, + out: { type: 'string' }, + variant: { type: 'string', default: 'vitest5' }, + command: { type: 'string' }, + concurrency: { type: 'string' }, + 'max-test-runner-reuse': { type: 'string' }, + 'timeout-ms': { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }); + if (values.scope === undefined) throw new UsageError('--scope is required'); + if (values.out === undefined) throw new UsageError('--out is required'); + return { + scopeFile: path.resolve(cwd, values.scope), + outDir: path.resolve(cwd, values.out), + variant: parseVariant(values.variant), + concurrency: parseInteger(values.concurrency, 'concurrency', 2), + maxTestRunnerReuse: parseInteger(values['max-test-runner-reuse'], 'max-test-runner-reuse', 0), + timeoutMS: parseInteger(values['timeout-ms'], 'timeout-ms', 10000), + command: values.command, + }; +} + +/** + * The Stryker process environment: Stryker's own inherited variables removed, then `childEnv` + * (see {@link RunScopeOptions.childEnv}) applied, then the run's scope file named. + */ +export function strykerChildEnv( + env: Readonly>, + scopeFile: string, + childEnv: Readonly> = {}, +): Record { + const scrubbed = new Set([INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE, WORKER_ENV]); + const child: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined && !scrubbed.has(key)) child[key] = value; + } + Object.assign(child, childEnv); + child[MUTATION_SCOPE_ENV] = scopeFile; + return child; +} + +/** + * The pid that reaches the whole Stryker process tree: on POSIX the negative pid, which names the + * process group Stryker leads (it runs detached); on Windows, which has no process groups to + * signal, the Stryker process itself. + */ +export function signalTarget(pid: number, platform: NodeJS.Platform): number { + return platform === 'win32' ? pid : -pid; +} + +export function strykerCommand( + layout: RunLayout, + nodePath: string, +): { command: string; args: string[]; cwd: string } { + return { + command: nodePath, + args: ['--import', 'tsx', layout.strykerBin, 'run', layout.configFile], + cwd: layout.packageDir, + }; +} + +/** Kept Stryker sandboxes among the entries of its temp dir, sorted. */ +export function findSandboxes(tempDir: string, entries: readonly string[]): string[] { + return entries + .filter((entry) => entry.startsWith('sandbox-')) + .sort() + .map((entry) => path.join(tempDir, entry)); +} + +function resetOutDir(repoRoot: string, outDir: string): void { + if (isInside(repoRoot, outDir) || isInside(outDir, repoRoot)) { + throw new UsageError(`--out ${outDir} must lie outside the checkout ${repoRoot}`); + } + if (existsSync(outDir) && readdirSync(outDir).length > 0) { + if (!existsSync(path.join(outDir, RUN_MARKER_FILE))) { + throw new UsageError( + `${outDir} is not empty and was not created by this driver; choose another --out`, + ); + } + rmSync(outDir, { recursive: true, force: true }); + } + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, RUN_MARKER_FILE), ''); +} + +/** Every published sidecar record with its file path and modification time. */ +export function readSidecarInputs(sidecarDir: string): SidecarInput[] { + if (!existsSync(sidecarDir)) return []; + return readSidecars(sidecarDir).map((record) => { + const file = path.join( + sidecarDir, + sidecarFileName(record.kind, record.mutantId, record.worker, record.attempt), + ); + return { record, file, writtenAtMs: statSync(file).mtimeMs }; + }); +} + +function runStryker( + layout: RunLayout, + childEnv: Readonly> | undefined, +): Promise<{ exitCode: number; wallClockMs: number }> { + const { command, args, cwd } = strykerCommand(layout, process.execPath); + const log = createWriteStream(layout.logFile); + const started = performance.now(); + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: strykerChildEnv(process.env, layout.scopeFile, childEnv), + stdio: ['ignore', 'pipe', 'pipe'], + // Its own process group (POSIX), so one signal reaches every Stryker and Vitest worker. + detached: process.platform !== 'win32', + }); + const signalTree = (signal: NodeJS.Signals): void => { + if (child.pid === undefined) return; + try { + process.kill(signalTarget(child.pid, process.platform), signal); + } catch (error) { + // ESRCH: the whole group is gone. Anything else: at least reach Stryker itself. + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') child.kill(signal); + } + }; + // Detached workers no longer get the terminal's signals, so every signal is forwarded (a + // second SIGINT makes Stryker force its exit) until Stryker is gone. + process.on('SIGINT', signalTree); + process.on('SIGTERM', signalTree); + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + child.once('error', (error) => { + signalTree('SIGKILL'); + reject(error); + }); + // A cancelled or failed Stryker can leave workers behind: kill what is left of its group. + child.once('exit', (code, signal) => { + if (code !== 0 || signal !== null) signalTree('SIGKILL'); + }); + child.once('close', (code, signal) => { + process.off('SIGINT', signalTree); + process.off('SIGTERM', signalTree); + log.end(() => + resolve({ + exitCode: code ?? (signal === null ? 1 : 128), + wallClockMs: Math.round(performance.now() - started), + }), + ); + }); + }); +} + +const logTail = (file: string): string => + existsSync(file) ? readFileSync(file, 'utf8').split('\n').slice(-LOG_TAIL_LINES).join('\n') : ''; + +export class StrykerRunError extends Error { + override name = 'StrykerRunError'; +} + +/** Runs one scope end to end; returns the results and the exit code they imply. */ +export async function runScope( + options: RunScopeOptions, +): Promise<{ results: RunResults; exitCode: number }> { + const pathMap = buildPathMap(options.extraRoots); + const scope = parseDriverScope(readFileSync(options.scopeFile, 'utf8'), pathMap); + const ignoredDirs = options.ignoredDirs ?? DEFAULT_IGNORED_DIRS; + assertTestFilesInSandbox(scope.testFiles, ignoredDirs); + const layout = runLayout(options.outDir); + const config = buildStrykerConfig({ + variant: options.variant, + outDir: layout.outDir, + mutate: scope.mutate, + concurrency: options.concurrency, + maxTestRunnerReuse: options.maxTestRunnerReuse, + timeoutMS: options.timeoutMS, + command: options.command, + ignoredDirs, + pathMap, + vitest: options.vitest, + testIds: scope.testIds, + }); + + resetOutDir(options.repoRoot, layout.outDir); + const prepared = prepare({ + repoRoot: options.repoRoot, + outDir: layout.preparedRoot, + mutate: scope.mutate.map((entry) => entry.file), + pathMap, + }); + if (prepared.failed.length > 0) { + throw new UsageError( + `prepare rejected ${prepared.failed.map((f) => `${f.file} (${f.reason})`).join(', ')}`, + ); + } + const missing = scope.testFiles.filter((file) => !existsSync(path.join(layout.packageDir, file))); + if (missing.length > 0) throw new UsageError(`test files not found: ${missing.join(', ')}`); + const vitestConfig = options.vitest?.configFile; + if (vitestConfig !== undefined && !existsSync(path.join(layout.packageDir, vitestConfig))) { + throw new UsageError(`Vitest config not found in the prepared copy: ${vitestConfig}`); + } + + const scopeText = `${JSON.stringify({ testFiles: scope.testFiles }, null, 2)}\n`; + parseMutationScope(scopeText); + writeFileSync(layout.scopeFile, scopeText); + writeFileSync(layout.configFile, `${JSON.stringify(config, null, 2)}\n`); + if (options.variant === 'vitest5') { + mkdirSync(layout.runDir, { recursive: true }); + mkdirSync(layout.sidecarDir, { recursive: true }); + } + + console.log(`mutation run: Stryker ${options.variant} running; log ${layout.logFile}`); + const stryker = await runStryker(layout, options.childEnv); + if (stryker.exitCode !== 0 || !existsSync(layout.reportFile)) { + throw new StrykerRunError( + `Stryker exited with ${stryker.exitCode} after ${stryker.wallClockMs} ms` + + `${existsSync(layout.reportFile) ? '' : ' and wrote no report'}; last lines of ` + + `${layout.logFile}:\n${logTail(layout.logFile)}`, + ); + } + + const report = readMutationReport(layout.reportFile); + const identities = identifyMutants(report, { + packageDir: report.projectRoot ?? layout.packageDir, + readOriginal: readRepoFile(options.repoRoot), + pathMap, + }); + const spanMap = checkSpanMap(report, identities); + const isVitest5 = options.variant === 'vitest5'; + const normalised = normaliseRun({ + identities, + testFiles: parseReportTestFiles(readFileSync(layout.reportFile, 'utf8')), + manifest: isVitest5 ? readRunManifest(layout.runDir) : null, + sidecars: isVitest5 ? readSidecarInputs(layout.sidecarDir) : null, + }); + const results: RunResults = { + schemaVersion: RESULTS_SCHEMA_VERSION, + variant: options.variant, + scope: { + testFiles: scope.testFiles, + mutate: scope.mutate.map(formatMutateEntry), + testIds: scope.testIds ?? null, + }, + paths: { + outDir: layout.outDir, + preparedRoot: layout.preparedRoot, + packageDir: layout.packageDir, + configFile: layout.configFile, + reportFile: layout.reportFile, + logFile: layout.logFile, + runDir: isVitest5 ? layout.runDir : null, + sidecarDir: isVitest5 ? layout.sidecarDir : null, + sandboxDirs: existsSync(layout.tempDir) + ? findSandboxes(layout.tempDir, readdirSync(layout.tempDir)) + : [], + }, + stryker, + spanMap: { checked: spanMap.checked, failures: spanMap.failures }, + ...normalised, + }; + writeFileSync(layout.resultsFile, `${JSON.stringify(results, null, 2)}\n`); + const clean = spanMap.failures.length === 0 && normalised.flags.length === 0; + return { results, exitCode: clean ? 0 : 1 }; +} + +export const formatDuration = (ms: number): string => { + const seconds = Math.round(ms / 1000); + return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`; +}; + +const formatCounts = (counts: Readonly>): string => + Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([key, count]) => `${count} ${key}`) + .join(', '); + +/** A short human summary of a results document. */ +export function formatSummary(results: RunResults): string { + const { summary, spanMap, stryker, tests } = results; + const ran = tests.filter((test) => !test.skipped).length; + return [ + `mutation run (${results.variant}): ${summary.mutants} mutants: ${formatCounts(summary.byStatus)}`, + ` outcome classes: ${formatCounts(summary.byOutcome)}`, + ` tests with kills: ${summary.testsWithKills}/${ran} ` + + `(assertion kills: ${summary.testsWithAssertionKills}); zero-test runs: ${summary.zeroTestRuns}`, + ` mutants with a sidecar record: ${summary.mutantsWithSidecar}; with an outcome class: ` + + `${summary.mutantsWithOutcome}`, + ` span map: ${spanMap.checked} checked, ${spanMap.failures.length} failed; ` + + `flags: ${summary.flags}`, + ...results.flags + .slice(0, 10) + .map((flag) => ` ${flag.kind} ${flag.localId ?? '-'}: ${flag.detail}`), + ` Stryker: exit ${stryker.exitCode} in ${formatDuration(stryker.wallClockMs)}`, + ...results.paths.sandboxDirs.map((dir) => ` kept sandbox: ${dir}`), + ` results: ${path.join(results.paths.outDir, 'results.json')}`, + ].join('\n'); +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + const started = performance.now(); + try { + const options = parseCliArgs(process.argv.slice(2), process.cwd()); + const { results, exitCode } = await runScope({ ...options, repoRoot }); + console.log(formatSummary(results)); + console.log(` total wall clock: ${formatDuration(performance.now() - started)}`); + try { + assertSpanMap({ ...results.spanMap, mapped: [] }); + } catch (error) { + console.error(errorMessage(error)); + } + process.exitCode = exitCode; + } catch (error) { + console.error(errorMessage(error)); + if (error instanceof TypeError || error instanceof UsageError) { + console.error( + 'usage: npm run test:mutation:run -- --scope --out ' + + '[--variant vitest5|command] [--command ] [--concurrency ] ' + + '[--max-test-runner-reuse ] [--timeout-ms ]', + ); + } + process.exitCode = 2; + } +} + +if (isMainModule(import.meta.url)) { + void main(); +} diff --git a/gitnexus/tools/mutation/driver/stryker-config.ts b/gitnexus/tools/mutation/driver/stryker-config.ts new file mode 100644 index 000000000..db17b963e --- /dev/null +++ b/gitnexus/tools/mutation/driver/stryker-config.ts @@ -0,0 +1,423 @@ +/** + * Scope file, run layout and generated Stryker config of the scope driver (U10). + * + * Driver scope file (JSON; `testFiles` and `mutate` required, `testIds` optional, no other key + * allowed): + * { + * "testFiles": ["test/unit/shared-type-extractors.test.ts"], + * "mutate": ["gitnexus/src/core/ingestion/type-extractors/shared.ts:545-660", + * "gitnexus-shared/src/lbug/schema-constants.ts"], + * "testIds": ["test/unit/shared-type-extractors.test.ts#extractElementTypeFromString > ..."] + * } + * `testFiles` are package-relative POSIX paths, validated like the mutation scope file + * (scope-file.ts) that the driver writes for tools/mutation/vitest.mutation.config.ts. `mutate` + * entries are repo-relative `.ts` files under `gitnexus/src/` or `gitnexus-shared/src/`, each + * optionally followed by a Stryker range `:-` or + * `::-:`. Globs are not accepted: the prepare step must + * blank the `as` assertions of every mutated file (KTD7), so it needs the concrete files. + * + * `testIds` (Phase 1 KTD2) selects single tests: a non-empty list of distinct Stryker test ids + * (runner/test-ids.ts, KTD3) whose file part is one of `testFiles`. It reaches the vitest5 runner + * as its `vitest5.testIds` option: the dry run runs only those tests (it fails before any mutant + * run when one was not collected), so they are the run's whole test set. Without the key every + * test of `testFiles` runs; an empty list is rejected so that "every test" never happens by + * accident. The command variant cannot select tests and rejects it. + * + * Fixture projects (gate 2's conformance fixture) pass an extended path map (path-map.ts + * `buildPathMap`) so their `gitnexus/test/fixtures/mutation-/` files are accepted as mutate + * entries, and their own Vitest config file and project (`vitest` input). With the default map + * every other path keeps being rejected. + * + * Run layout: every artefact lives under one absolute output directory (see {@link RunLayout}). + * Stryker runs with its working directory in the prepared `gitnexus/` copy, so mutate patterns are + * relative to that directory and relocated `gitnexus-shared` files map through the path map (KTD8). + * + * The generated config never sets `testFiles` (KTD4): with it, static mutants would receive file + * paths as their filter and become false survivors. The `command` variant (KTD18) does not load + * the vitest5 runner plugin, so the oracle shares no runner code, and keeps its instrumented + * sandbox (`cleanTempDir: false`) for single-test launches. The `vitest5` variant cleans its + * sandbox after a successful run: its evidence (run manifest, sidecars, report) is written to + * absolute directories outside the sandbox, and Stryker keeps the sandbox of a failed run for + * debugging. + */ +import path from 'node:path'; +import { parseMutationScope } from '../scope-file.js'; +import { PACKAGE_DIR, PATH_MAP, toPreparedPath, type PathMapEntry } from '../prepare/path-map.js'; +import { parseTestId } from '../runner/test-ids.js'; +import { errorMessage } from '../shared/cli.js'; + +export const VARIANTS = ['vitest5', 'command'] as const; +export type Variant = (typeof VARIANTS)[number]; + +/** + * Package-relative directories kept out of the Stryker sandbox: native binaries and build output + * (prepare already leaves them out; this is the second line), and directories no pure-JS unit + * test scope needs. Each becomes an anchored ignore pattern `/`. + */ +export const DEFAULT_IGNORED_DIRS: readonly string[] = Object.freeze([ + 'vendor', + 'prebuilds', + 'dist', + 'coverage', + 'reports', + 'test/fixtures', + 'test/integration', + 'bench', + 'skills', + 'hooks', +]); + +/** The runner plugin and Stryker binary, relative to the prepared `gitnexus/` package. */ +const PLUGIN_FILE = 'tools/mutation/runner/plugin.ts'; +const STRYKER_BIN = 'node_modules/@stryker-mutator/core/bin/stryker.js'; +const TSX_NODE_ARGS: readonly string[] = ['--import', 'tsx']; + +export interface RunLayout { + /** Absolute output directory of the run; the driver owns everything inside it. */ + outDir: string; + /** The prepared root (prepare.ts `--out`). */ + preparedRoot: string; + /** The prepared `gitnexus/` package: Stryker's working directory. */ + packageDir: string; + pluginFile: string; + strykerBin: string; + /** Mutation scope file (`{testFiles}`) named by `GITNEXUS_MUTATION_SCOPE`. */ + scopeFile: string; + configFile: string; + /** Stryker JSON report (`mutation.json`). */ + reportFile: string; + /** vitest5 run directory (KTD16): holds `run-manifest.json`. */ + runDir: string; + /** vitest5 sidecar directory (KTD6). */ + sidecarDir: string; + /** Stryker's stdout and stderr. */ + logFile: string; + /** Normalised results (normalise-results.ts). */ + resultsFile: string; + /** Deliberately missing: Stryker then leaves tsconfig files untouched. */ + missingTsconfig: string; + /** Stryker's temp dir (default `tempDirName`); sandboxes are `sandbox-*` inside it. */ + tempDir: string; +} + +const fail = (detail: string): never => { + throw new Error(`mutation driver: ${detail}`); +}; + +/** The layout of a run under an absolute output directory. */ +export function runLayout(outDir: string): RunLayout { + if (!path.isAbsolute(outDir)) { + fail(`the output directory ${JSON.stringify(outDir)} must be an absolute path`); + } + const root = path.resolve(outDir); + const preparedRoot = path.join(root, 'prepared'); + const packageDir = path.join(preparedRoot, PACKAGE_DIR); + return { + outDir: root, + preparedRoot, + packageDir, + pluginFile: path.join(packageDir, PLUGIN_FILE), + strykerBin: path.join(packageDir, STRYKER_BIN), + scopeFile: path.join(root, 'scope.json'), + configFile: path.join(root, 'stryker.config.json'), + reportFile: path.join(root, 'mutation.json'), + runDir: path.join(root, 'run'), + sidecarDir: path.join(root, 'sidecars'), + logFile: path.join(root, 'stryker.log'), + resultsFile: path.join(root, 'results.json'), + missingTsconfig: path.join(root, 'no-such-tsconfig.json'), + tempDir: path.join(packageDir, '.stryker-tmp'), + }; +} + +export function parseVariant(value: string): Variant { + const variant = VARIANTS.find((known) => known === value); + return ( + variant ?? fail(`unknown variant ${JSON.stringify(value)} (expected ${VARIANTS.join(' or ')})`) + ); +} + +export interface MutateEntry { + /** Repo-relative POSIX path of the original file. */ + file: string; + /** Stryker range without the leading colon, e.g. `545-660`; null for the whole file. */ + range: string | null; +} + +const MUTATE_ENTRY = /^(.+?\.ts)(?::(\d+(?::\d+)?)-(\d+(?::\d+)?))?$/; + +/** Parses `[:]` and checks that the path maps into the prepared tree. */ +export function parseMutateEntry( + entry: string, + pathMap: readonly PathMapEntry[] = PATH_MAP, +): MutateEntry { + const match = MUTATE_ENTRY.exec(entry); + if (match === null || match[1].endsWith('.d.ts')) { + return fail(`mutate entry ${JSON.stringify(entry)} must name a .ts source file`); + } + const [, file, start, end] = match; + toPreparedPath(file, pathMap); // throws for a path outside the mapped roots + if (start === undefined || end === undefined) return { file, range: null }; + const startLine = Number(start.split(':')[0]); + const endLine = Number(end.split(':')[0]); + if (startLine < 1 || endLine < startLine) { + fail(`mutate entry ${JSON.stringify(entry)} has an invalid line range`); + } + return { file, range: `${start}-${end}` }; +} + +/** The entry as `[:]`, the form {@link parseMutateEntry} reads. */ +export const formatMutateEntry = ({ file, range }: MutateEntry): string => + range === null ? file : `${file}:${range}`; + +/** The entry as a Stryker mutate pattern, relative to the prepared `gitnexus/` package. */ +export function strykerMutatePattern( + entry: MutateEntry, + pathMap: readonly PathMapEntry[] = PATH_MAP, +): string { + const pattern = path.posix.relative(PACKAGE_DIR, toPreparedPath(entry.file, pathMap)); + return entry.range === null ? pattern : `${pattern}:${entry.range}`; +} + +export interface DriverScope { + testFiles: string[]; + mutate: MutateEntry[]; + /** Stryker ids of the only tests to run; absent when every test of `testFiles` runs. */ + testIds?: string[]; +} + +const SCOPE_KEYS: ReadonlySet = new Set(['testFiles', 'mutate', 'testIds']); + +/** Validates a scope's `testIds`: distinct test ids of the scope's test files. */ +function parseScopeTestIds(value: unknown, testFiles: readonly string[]): string[] { + if (!Array.isArray(value) || value.length === 0) { + return fail( + 'scope file: testIds must be a non-empty array; leave the key out to run every test', + ); + } + const files = new Set(testFiles); + const seen = new Set(); + return value.map((id: unknown): string => { + if (typeof id !== 'string') return fail('scope file: testIds entries must be strings'); + if (seen.has(id)) fail(`scope file: test id ${JSON.stringify(id)} is listed twice`); + seen.add(id); + let file: string; + try { + file = parseTestId(id).file; + } catch { + return fail(`scope file: ${JSON.stringify(id)} is not a test id (#)`); + } + if (!files.has(file)) + fail(`scope file: test id ${JSON.stringify(id)} names no test file of the scope`); + return id; + }); +} + +/** Parses and validates a driver scope file (see the module comment). */ +export function parseDriverScope( + text: string, + pathMap: readonly PathMapEntry[] = PATH_MAP, +): DriverScope { + let data: unknown; + try { + data = JSON.parse(text); + } catch (error) { + return fail(`scope file is not valid JSON (${errorMessage(error)})`); + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return fail('scope file must be a JSON object'); + } + const unknown = Object.keys(data).filter((key) => !SCOPE_KEYS.has(key)); + if (unknown.length > 0) fail(`scope file has unknown key ${JSON.stringify(unknown[0])}`); + const { testFiles } = parseMutationScope( + JSON.stringify({ testFiles: 'testFiles' in data ? data.testFiles : undefined }), + ); + const mutate: unknown = 'mutate' in data ? data.mutate : undefined; + if (!Array.isArray(mutate) || mutate.length === 0) { + return fail('scope file: mutate must be a non-empty array'); + } + const seen = new Set(); + const entries = mutate.map((entry: unknown): MutateEntry => { + if (typeof entry !== 'string') return fail('scope file: mutate entries must be strings'); + if (seen.has(entry)) fail(`scope file: mutate entry ${JSON.stringify(entry)} is listed twice`); + seen.add(entry); + return parseMutateEntry(entry, pathMap); + }); + if (!('testIds' in data)) return { testFiles, mutate: entries }; + return { testFiles, mutate: entries, testIds: parseScopeTestIds(data.testIds, testFiles) }; +} + +/** Throws when a scope test file lies under an ignored directory: the sandbox would lack it. */ +export function assertTestFilesInSandbox( + testFiles: readonly string[], + ignoredDirs: readonly string[], +): void { + for (const file of testFiles) { + const dir = ignoredDirs.find((ignored) => file.startsWith(`${ignored}/`)); + if (dir !== undefined) { + fail(`test file ${file} lies under the ignored directory ${dir}/, outside the sandbox`); + } + } +} + +/** A Vitest config of the vitest5 runner other than the default mutation config. */ +export interface VitestSelection { + /** Package-relative POSIX path of the Vitest config file. */ + configFile: string; + /** The single Vitest project to run. */ + project: string; +} + +export interface StrykerConfigInput { + variant: Variant; + /** Absolute output directory; see {@link runLayout}. */ + outDir: string; + mutate: readonly MutateEntry[]; + concurrency: number; + /** Restart each test-runner process after this many runs; 0 means never. */ + maxTestRunnerReuse: number; + /** Stryker's absolute timeout deviation per mutant run. */ + timeoutMS: number; + /** The command variant's `commandRunner.command`; must be absent for vitest5. */ + command?: string; + /** Defaults to {@link DEFAULT_IGNORED_DIRS}. */ + ignoredDirs?: readonly string[]; + /** Maps mutate entries into the prepared tree; defaults to {@link PATH_MAP}. */ + pathMap?: readonly PathMapEntry[]; + /** vitest5 only: the runner's Vitest config and project; the runner defaults apply without it. */ + vitest?: VitestSelection; + /** vitest5 only: the scope's `testIds`, passed as `vitest5.testIds`; every test runs without. */ + testIds?: readonly string[]; +} + +/** The Stryker options this driver sets (a `PartialStrykerOptions` subset). */ +export interface GeneratedStrykerConfig { + testRunner: Variant; + appendPlugins?: string[]; + commandRunner?: { command: string }; + vitest5?: { + runDir: string; + sidecarDir: string; + configFile?: string; + project?: string; + testIds?: string[]; + }; + /** vitest5 only: Stryker ignores (and warns about) test-runner node args for `command`. */ + testRunnerNodeArgs?: string[]; + checkerNodeArgs: string[]; + coverageAnalysis: 'perTest' | 'off'; + disableBail: true; + disableTypeChecks: false; + tsconfigFile: string; + mutate: string[]; + ignorePatterns: string[]; + concurrency: number; + maxTestRunnerReuse: number; + timeoutMS: number; + cleanTempDir: boolean; + reporters: string[]; + jsonReporter: { fileName: string }; + logLevel: 'info'; +} + +const requireInteger = (value: number, name: string, minimum: number): void => { + if (!Number.isInteger(value) || value < minimum) { + fail(`${name} must be a ${minimum === 0 ? 'non-negative' : 'positive'} integer, got ${value}`); + } +}; + +const isPackageRelative = (file: string): boolean => + file !== '' && + !file.includes('\\') && + !path.posix.isAbsolute(file) && + !file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'); + +function checkVitestSelection(vitest: VitestSelection): VitestSelection { + if (!isPackageRelative(vitest.configFile)) { + fail( + `the Vitest config file ${JSON.stringify(vitest.configFile)} must be a package-relative ` + + 'POSIX path', + ); + } + if (vitest.project === '') fail('the Vitest project name must not be empty'); + return { configFile: vitest.configFile, project: vitest.project }; +} + +/** The Stryker config of one run; pure. */ +export function buildStrykerConfig(input: StrykerConfigInput): GeneratedStrykerConfig { + const variant = parseVariant(input.variant); + const layout = runLayout(input.outDir); + if (input.mutate.length === 0) fail('the config needs at least one mutate entry'); + requireInteger(input.concurrency, 'concurrency', 1); + requireInteger(input.maxTestRunnerReuse, 'maxTestRunnerReuse', 0); + requireInteger(input.timeoutMS, 'timeoutMS', 1); + if (variant === 'vitest5' && input.command !== undefined) { + fail('a command is only for the command variant'); + } + if (variant === 'command' && (input.command === undefined || input.command === '')) { + fail('the command variant needs a command'); + } + if (variant === 'command' && input.vitest !== undefined) { + fail('a Vitest config and project are only for the vitest5 variant'); + } + if (variant === 'command' && input.testIds !== undefined) { + fail('testIds (single-test selection) are only for the vitest5 variant'); + } + if (input.testIds !== undefined && input.testIds.length === 0) { + fail('testIds must not be empty; leave them out to run every test'); + } + const ignoredDirs = input.ignoredDirs ?? DEFAULT_IGNORED_DIRS; + const mutate = input.mutate.map((entry) => strykerMutatePattern(entry, input.pathMap)); + for (const pattern of mutate) { + const dir = ignoredDirs.find((ignored) => pattern.startsWith(`${ignored}/`)); + if (dir !== undefined) { + fail(`mutate file ${pattern} lies under the ignored directory ${dir}/, outside the sandbox`); + } + } + const shared: Omit< + GeneratedStrykerConfig, + | 'testRunner' + | 'appendPlugins' + | 'commandRunner' + | 'vitest5' + | 'coverageAnalysis' + | 'cleanTempDir' + | 'testRunnerNodeArgs' + > = { + checkerNodeArgs: [...TSX_NODE_ARGS], + disableBail: true, + disableTypeChecks: false, + tsconfigFile: layout.missingTsconfig, + mutate, + ignorePatterns: ignoredDirs.map((dir) => `/${dir}`), + concurrency: input.concurrency, + maxTestRunnerReuse: input.maxTestRunnerReuse, + timeoutMS: input.timeoutMS, + reporters: ['json', 'clear-text'], + jsonReporter: { fileName: layout.reportFile }, + logLevel: 'info', + }; + if (variant === 'command') { + return { + testRunner: 'command', + commandRunner: { command: input.command ?? '' }, + coverageAnalysis: 'off', + cleanTempDir: false, + ...shared, + }; + } + return { + testRunner: 'vitest5', + appendPlugins: [layout.pluginFile], + vitest5: { + runDir: layout.runDir, + sidecarDir: layout.sidecarDir, + ...(input.vitest === undefined ? {} : checkVitestSelection(input.vitest)), + ...(input.testIds === undefined ? {} : { testIds: [...input.testIds] }), + }, + testRunnerNodeArgs: [...TSX_NODE_ARGS], + coverageAnalysis: 'perTest', + cleanTempDir: true, + ...shared, + }; +} diff --git a/gitnexus/tools/mutation/fidelity/allowed-config-diffs.ts b/gitnexus/tools/mutation/fidelity/allowed-config-diffs.ts new file mode 100644 index 000000000..10f99fb06 --- /dev/null +++ b/gitnexus/tools/mutation/fidelity/allowed-config-diffs.ts @@ -0,0 +1,337 @@ +/** + * Gate 1 config comparison (U6, KTD9): the effective config of the runner's unmutated run against + * a normal Vitest 5 run of `gitnexus/vitest.config.ts`, project `default`. + * + * Both sides are {@link ConfigView}s: the record.ts `ConfigSnapshot` sections (`project`: the run + * project's resolved test config, `global`: the root resolved config, `vite`: resolution, + * transform and cache settings) plus `env`, the compared environment variables as the test + * workers saw them. Absolute paths are normalised first ({@link normalisePaths}): the Stryker + * sandbox and the checkout's `gitnexus/` both become ``, the sandbox's relocated + * `gitnexus-shared` and the checkout's `gitnexus-shared` become ``, and the shared + * `node_modules` (a symlink in the prepared copy) becomes `/node_modules`. Any other + * prepared-copy or checkout path in the run keeps a distinct marker, so `root` and every other + * path value is compared after mapping sandbox paths to checkout paths and a run that points + * outside its sandbox still differs. + * + * Every leaf that differs must match an entry of {@link ALLOWED_CONFIG_DIFFS} (a dotted path + * pattern matches that path and everything below it) or a {@link VOLATILE_CONFIG_VALUES} entry + * whose condition holds. Anything else fails gate 1. + */ +import type { JsonValue } from './record.js'; + +export interface AllowedConfigDiff { + /** Name used in reports and in the envelope record. */ + name: string; + /** Dotted snapshot paths (`project.`, `global.`, `vite.`, `env.`); empty: no config key. */ + paths: readonly string[]; + /** Why the runner's run may differ here without changing what the tests execute. */ + reason: string; +} + +/** + * The plan's list (U6 step 4), one entry per item, then the additions this implementation found + * necessary, each marked as such in its reason. + */ +export const ALLOWED_CONFIG_DIFFS: readonly AllowedConfigDiff[] = Object.freeze([ + { + name: 'setupFiles', + paths: ['project.setupFiles', 'global.setupFiles'], + reason: + 'The runner prepends its own setup file (tools/mutation/runner/setup.ts, loaded from the ' + + 'prepared copy next to the plugin) for coverage and mutant activation (KTD4). The gate 1 ' + + 'recorder is installed in both runs the same way.', + }, + { + name: 'maxWorkers', + paths: ['project.maxWorkers', 'global.maxWorkers', 'global.cliOptions.maxWorkers'], + reason: + 'Worker count changes scheduling only: with forks and default isolation every test file ' + + 'runs in a fresh process whatever the pool size.', + }, + { + name: 'maxConcurrency', + paths: ['project.maxConcurrency', 'global.maxConcurrency', 'global.cliOptions.maxConcurrency'], + reason: 'Only `.concurrent` tests are affected; it limits how many run at once, not what runs.', + }, + { + name: 'bail', + paths: ['project.bail', 'global.bail', 'global.cliOptions.bail'], + reason: + 'Stops a failing run early; gate 1 compares passing runs, and Stryker sets its own bail ' + + 'policy (`disableBail`).', + }, + { + name: 'coverage', + paths: ['project.coverage', 'global.coverage', 'global.cliOptions.coverage'], + reason: + 'Vitest coverage is off in both runs; its resolved exclude list names the config file. ' + + 'Stryker records its own per-test coverage through the runner setup file.', + }, + { + name: 'KTD8 aliases', + paths: ['vite.resolve.alias', 'project.alias', 'global.alias'], + reason: + 'The mutation config resolves `gitnexus-shared` imports to the relocated sources inside ' + + 'the sandbox (KTD8); the base run resolves the package to its built `dist`. The module ' + + 'comparison checks both resolve to the same modules.', + }, + { + name: 'dangerouslyIgnoreUnhandledErrors', + paths: [ + 'project.dangerouslyIgnoreUnhandledErrors', + 'global.dangerouslyIgnoreUnhandledErrors', + 'global._rawTestConfig.dangerouslyIgnoreUnhandledErrors', + ], + reason: + 'Off in the mutation config so an unhandled error fails the run (KTD9); gate 1 reports ' + + 'any unhandled error of the base run separately.', + }, + { + name: 'cacheDir', + paths: [ + 'vite.cacheDir', + 'project.cache', + 'global.cache', + 'global.cliOptions.cache', + 'project.fsModuleCache', + 'global.fsModuleCache', + 'global.cliOptions.fsModuleCache', + 'global.providedOptions.fsModuleCache', + 'project.fsModuleCachePath', + 'global.fsModuleCachePath', + 'global.viteOverrides.cacheDir', + ], + reason: + 'The runner uses a per-process Vite cache directory inside the sandbox and turns the ' + + 'results and fs module caches off (KTD5), so no run reuses another run’s transforms.', + }, + { + name: 'NODE_ENV/VITEST', + paths: ['env.NODE_ENV', 'env.VITEST'], + reason: + 'Both runs set them the way `vitest run` does (`VITEST=true`, `NODE_ENV ??= test`); an ' + + 'inherited NODE_ENV may still differ between the gate process and Stryker’s.', + }, + { + name: 'disableTypeChecks', + paths: [], + reason: + 'A Stryker option, not a Vitest key. It is false, so Stryker prepends nothing to test ' + + 'files; the identity comparison (lines and columns) confirms it.', + }, + { + name: 'project name', + paths: ['project.name', 'global.project', 'global.cliOptions.project'], + reason: + 'The runner runs the single `mutation` project; Vitest task ids depend on the name, which ' + + 'is why tests are compared by file, titles and location instead.', + }, + { + name: 'include and exclude lists', + paths: ['project.include', 'project.exclude', 'env.GITNEXUS_MUTATION_SCOPE'], + reason: + 'The mutation project includes exactly the scope file’s test files (named by ' + + 'GITNEXUS_MUTATION_SCOPE); gate 1 runs the same file list on both sides.', + }, + { + name: 'projects array', + paths: ['global.projects', 'global.resolvedProjects', 'global._rawTestConfig.projects'], + reason: + 'The mutation config replaces the three repo projects with one (KTD9); the resolved ' + + 'project list follows it. The run project’s own config is compared key by key.', + }, + { + name: 'sequence.groupOrder', + paths: ['project.sequence.groupOrder', 'global.sequence.groupOrder'], + reason: 'Orders projects against each other; a single-project run has nothing to order.', + }, + { + name: 'root (after mapping sandbox paths to checkout paths)', + paths: [], + reason: + 'Not a raw difference: every path value, `root` included, is compared after the sandbox ' + + 'is mapped to the checkout, so a root outside the sandbox still fails.', + }, + { + name: 'config file (addition)', + paths: ['global.config', 'global.cliOptions.config'], + reason: + 'Addition to the plan’s list: the name of the config file. The Stryker run loads ' + + 'tools/mutation/vitest.mutation.config.ts (KTD9) through the gate 1 wrapper ' + + 'tools/mutation/fidelity/vitest.fidelity.config.ts; their settings are compared key by key.', + }, +]); + +export interface VolatileConfigValue { + name: string; + paths: readonly string[]; + /** The value is ignored only when this holds for both runs. */ + appliesTo: (view: ConfigView) => boolean; + reason: string; +} + +const lookup = (value: JsonValue | undefined, dotted: string): JsonValue | undefined => { + let current: JsonValue | undefined = value; + for (const key of dotted.split('.')) { + current = isJsonObject(current) ? current[key] : undefined; + } + return current; +}; + +/** `sequence.shuffle` is `false`, absent, or `{ files: false, tests: false }`. */ +const shuffleOff = (value: JsonValue | undefined): boolean => + value === undefined || + value === null || + value === false || + (isJsonObject(value) && value.files !== true && value.tests !== true); + +/** Values that differ between any two runs of one config, so they are not config differences. */ +export const VOLATILE_CONFIG_VALUES: readonly VolatileConfigValue[] = Object.freeze([ + { + name: 'sequence.seed', + paths: ['project.sequence.seed', 'global.sequence.seed'], + appliesTo: (view: ConfigView) => + shuffleOff(lookup(view.project, 'sequence.shuffle')) && + shuffleOff(lookup(view.global, 'sequence.shuffle')), + reason: + 'Defaults to the start time when unset and only drives shuffling; ignored only while ' + + 'shuffling is off in both runs.', + }, +]); + +export interface ConfigView { + project: JsonValue; + global: JsonValue; + vite: JsonValue; + env: Record; +} + +export interface ConfigDifference { + path: string; + /** Undefined when the key is absent on that side. */ + base: JsonValue | undefined; + run: JsonValue | undefined; +} + +export interface ConfigComparison { + /** Differences outside the allowed list: each fails gate 1. */ + failures: ConfigDifference[]; + /** Allowed entries that matched at least one difference, with the paths they matched. */ + allowed: { name: string; paths: string[] }[]; + /** Volatile values that differed and were ignored. */ + volatile: { name: string; paths: string[] }[]; +} + +export function isJsonObject(value: JsonValue | undefined): value is { [key: string]: JsonValue } { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function jsonEqual(a: JsonValue | undefined, b: JsonValue | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined || a === null || b === null) return false; + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((entry, index) => jsonEqual(entry, b[index])) + ); + } + if (isJsonObject(a) && isJsonObject(b)) { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].every((key) => jsonEqual(a[key], b[key])); + } + return false; +} + +/** Leaf differences between two JSON values; arrays are compared as whole values. */ +export function diffJson( + base: JsonValue | undefined, + run: JsonValue | undefined, + at: string, +): ConfigDifference[] { + if (isJsonObject(base) && isJsonObject(run)) { + const keys = [...new Set([...Object.keys(base), ...Object.keys(run)])].sort(); + return keys.flatMap((key) => diffJson(base[key], run[key], at === '' ? key : `${at}.${key}`)); + } + return jsonEqual(base, run) ? [] : [{ path: at, base, run }]; +} + +/** Whether a dotted pattern covers a path: the path itself or anything below it. */ +export const patternCovers = (pattern: string, dotted: string): boolean => + dotted === pattern || dotted.startsWith(`${pattern}.`); + +/** A path root replacement: `from` (absolute) becomes `to` wherever it starts a path. */ +export interface PathRoot { + from: string; + to: string; +} + +const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Replaces each root (longest first) wherever it appears as a whole path prefix inside a string: + * followed by `/`, `\`, the end of the string or a character that cannot continue a path segment. + */ +export function normalisePaths(value: JsonValue, roots: readonly PathRoot[]): JsonValue { + const ordered = [...roots].sort((a, b) => b.from.length - a.from.length); + const patterns = ordered.map(({ from, to }) => ({ + regex: new RegExp(`${escapeRegExp(from)}(?=[/\\\\]|$|[^\\w.@~-])`, 'g'), + to, + })); + const visit = (entry: JsonValue): JsonValue => { + if (typeof entry === 'string') { + return patterns.reduce((text, { regex, to }) => text.replace(regex, to), entry); + } + if (Array.isArray(entry)) return entry.map(visit); + if (isJsonObject(entry)) { + return Object.fromEntries(Object.entries(entry).map(([key, inner]) => [key, visit(inner)])); + } + return entry; + }; + return visit(value); +} + +const asJson = (env: Record): JsonValue => ({ ...env }); + +/** Compares two path-normalised config views against the allowed list. */ +export function compareConfigs( + base: ConfigView, + run: ConfigView, + allowedList: readonly AllowedConfigDiff[] = ALLOWED_CONFIG_DIFFS, + volatileList: readonly VolatileConfigValue[] = VOLATILE_CONFIG_VALUES, +): ConfigComparison { + const differences = (['project', 'global', 'vite'] as const) + .flatMap((section) => diffJson(base[section], run[section], section)) + .concat(diffJson(asJson(base.env), asJson(run.env), 'env')); + const allowedHits = new Map(); + const volatileHits = new Map(); + const failures: ConfigDifference[] = []; + const add = (hits: Map, name: string, dotted: string): void => { + hits.set(name, [...(hits.get(name) ?? []), dotted]); + }; + for (const difference of differences) { + const allowed = allowedList.find((entry) => + entry.paths.some((pattern) => patternCovers(pattern, difference.path)), + ); + const volatile = volatileList.find( + (entry) => + entry.paths.some((pattern) => patternCovers(pattern, difference.path)) && + entry.appliesTo(base) && + entry.appliesTo(run), + ); + if (allowed !== undefined) add(allowedHits, allowed.name, difference.path); + else if (volatile !== undefined) add(volatileHits, volatile.name, difference.path); + else failures.push(difference); + } + const listed = (hits: Map, order: readonly { name: string }[]) => + order.flatMap(({ name }) => { + const paths = hits.get(name); + return paths === undefined ? [] : [{ name, paths }]; + }); + return { + failures, + allowed: listed(allowedHits, allowedList), + volatile: listed(volatileHits, volatileList), + }; +} diff --git a/gitnexus/tools/mutation/fidelity/fidelity.ts b/gitnexus/tools/mutation/fidelity/fidelity.ts new file mode 100644 index 000000000..d828058e5 --- /dev/null +++ b/gitnexus/tools/mutation/fidelity/fidelity.ts @@ -0,0 +1,1326 @@ +/** + * Gate 1: execution-path fidelity and the validated envelope (U6, R6, KTD9, KTD19). + * + * Proves that the vitest5 runner's unmutated run behaves like a normal Vitest 5 run on the pure-JS + * category (the U5 classifier's accepted test files), and records the envelope it was validated + * in. Steps: + * 1. rebuild `gitnexus-shared/dist` (the base run imports `gitnexus-shared` through it) and + * check every source file has a current build; + * 2. classify `test/unit` (U5) for the accepted list; + * 3. Stryker run: the scope driver (U10, `vitest5`) runs the accepted files with + * tools/mutation/fidelity/vitest.fidelity.config.ts (the mutation config plus the gate 1 + * recorder, project `mutation`). Its only mutate target is {@link UNCOVERED_MUTATE_TARGET}, + * a production file outside every accepted file's import closure, so every mutant is + * NoCoverage and the run is the runner's dry run alone (checked afterwards); + * 4. base run: `gitnexus/vitest.config.ts`, project `default`, the same files, through the + * Vitest Node API with the environment `vitest run` sets, plus the same recorder; + * 5. compare: test identities `[file, titles, line, column]` and outcomes (base TestCase results + * against the runner's run manifest and dry-run sidecar), the effective config + * (allowed-config-diffs.ts), the KTD19 path rule (every local module of the Stryker run + * resolves inside the active sandbox; base `gitnexus-shared/dist` paths map to the relocated + * `src`), the local, package and builtin modules each file loads, and the runtime load check + * (no native binding, Worker construction, `child_process` or `cluster` load by the test, + * production or package code, in either run); + * 6. write `/gate1-report.json` and, for a full-category run, the envelope record + * (default gitnexus/test/fixtures/mutation-oracle/envelope.json). + * + * The Stryker run goes first: the base run sets `TEST`, `VITEST` and `NODE_ENV` in this process, + * and Stryker would inherit them. `--batch-size` splits the Stryker run into several scope-driver + * runs (each with its own sandbox) when one dry run would exceed Stryker's dry-run timeout. + * + * Usage (from gitnexus/): + * npm run test:mutation:fidelity -- [--out ] [--envelope ] [--file ]... + * [--batch-size ] [--concurrency ] [--max-test-runner-reuse ] [--compare-only] + * `--out` defaults to `/gitnexus-mutation-fidelity` and must lie outside the checkout. + * `--file` runs a subset of the accepted files and writes no envelope. `--compare-only` re-runs + * step 5 and 6 on the artefacts already in ``. + * Exit codes: 0 gate 1 passes; 1 a difference (each one named); 2 usage or setup error. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { isBuiltin } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { classify } from '../classify/classify.js'; +import { StrykerRunError, runScope } from '../driver/run-scope.js'; +import { readRunManifest, type RunManifest } from '../runner/run-manifest.js'; +import { readSidecars, type SidecarRecord } from '../runner/sidecar.js'; +import type { TestLocation } from '../runner/test-ids.js'; +import { resolveVitestInstall } from '../runner/version-guard.js'; +import { errorMessage, isMainModule } from '../shared/cli.js'; +import { readJsonFile } from '../shared/fs.js'; +import { + ALLOWED_CONFIG_DIFFS, + VOLATILE_CONFIG_VALUES, + compareConfigs, + isJsonObject, + normalisePaths, + type ConfigComparison, + type ConfigView, + type PathRoot, +} from './allowed-config-diffs.js'; +import { + CONFIG_RECORD_PREFIX, + FIDELITY_DIR_ENV, + WORKER_RECORD_PREFIX, + installRecorder, + isHarnessModule, + snapshotConfig, + type ConfigRecord, + type ConfigSnapshot, + type JsonValue, + type LoadEvent, + type WorkerRecord, +} from './record.js'; + +// ── Test identities and outcomes ────────────────────────────────────────── + +export type Outcome = 'passed' | 'failed' | 'skipped'; + +export interface ObservedTest { + /** Package-relative POSIX path. */ + file: string; + /** Suite and test titles joined with `' > '` (Vitest's full name). */ + fullName: string; + location: TestLocation | null; + outcome: Outcome; +} + +export type DifferenceKind = + | 'file-missing' + | 'file-extra' + | 'test-missing' + | 'test-extra' + | 'location' + | 'outcome' + | 'run-error' + | 'config' + | 'path-rule' + | 'module-set' + | 'runtime-load' + | 'record'; + +export interface GateDifference { + kind: DifferenceKind; + /** What differs: a file, ` > `, or a config path. */ + subject: string; + detail: string; +} + +const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +const formatLocation = (location: TestLocation | null): string => + location === null ? 'no location' : `${location.line}:${location.column}`; + +const byLocation = (a: ObservedTest, b: ObservedTest): number => + (a.location?.line ?? 0) - (b.location?.line ?? 0) || + (a.location?.column ?? 0) - (b.location?.column ?? 0); + +/** Tests per file, then per full name in first-seen order, each group sorted by location. */ +function groupTests(tests: readonly ObservedTest[]): Map> { + const files = new Map>(); + for (const test of tests) { + const names = files.get(test.file) ?? new Map(); + files.set(test.file, names); + names.set(test.fullName, [...(names.get(test.fullName) ?? []), test]); + } + for (const names of files.values()) { + for (const [name, group] of names) names.set(name, [...group].sort(byLocation)); + } + return files; +} + +const unionInOrder = (first: Iterable, second: Iterable): string[] => [ + ...new Set([...first, ...second]), +]; + +/** + * Compares two runs' tests as `[file, titles, line, column]` plus outcome. Tests that share a full + * name pair up in location order (then collection order); every unpaired test, moved location or + * changed outcome is a difference that names the test. + */ +export function compareTests( + base: readonly ObservedTest[], + run: readonly ObservedTest[], +): GateDifference[] { + const baseFiles = groupTests(base); + const runFiles = groupTests(run); + const differences: GateDifference[] = []; + for (const file of unionInOrder(baseFiles.keys(), runFiles.keys())) { + const baseNames = baseFiles.get(file); + const runNames = runFiles.get(file); + if (baseNames === undefined || runNames === undefined) { + const count = [...(baseNames ?? runNames ?? new Map()).values()].flat().length; + differences.push({ + kind: baseNames === undefined ? 'file-extra' : 'file-missing', + subject: file, + detail: `only the ${baseNames === undefined ? 'run' : 'base run'} has this file (${count} tests)`, + }); + continue; + } + for (const name of unionInOrder(baseNames.keys(), runNames.keys())) { + const left = baseNames.get(name) ?? []; + const right = runNames.get(name) ?? []; + const size = Math.max(left.length, right.length); + for (let index = 0; index < size; index += 1) { + const subject = + size > 1 ? `${file} > ${name} (${index + 1} of ${size})` : `${file} > ${name}`; + const b = left[index]; + const r = right[index]; + if (r === undefined) { + differences.push({ + kind: 'test-missing', + subject, + detail: `collected by the base run only (${formatLocation(b.location)})`, + }); + } else if (b === undefined) { + differences.push({ + kind: 'test-extra', + subject, + detail: `collected by the run only (${formatLocation(r.location)})`, + }); + } else { + if (formatLocation(b.location) !== formatLocation(r.location)) { + differences.push({ + kind: 'location', + subject, + detail: `base ${formatLocation(b.location)}, run ${formatLocation(r.location)}`, + }); + } + if (b.outcome !== r.outcome) { + differences.push({ + kind: 'outcome', + subject, + detail: `base ${b.outcome}, run ${r.outcome}`, + }); + } + } + } + } + } + return differences; +} + +// ── Module locations (KTD19) ────────────────────────────────────────────── + +/** Absolute roots of one comparison: the checkout and the Stryker run's prepared copy and sandbox. */ +export interface Roots { + repoRoot: string; + /** The scope driver's prepared root (`/prepared`). */ + preparedRoot: string; + /** The Stryker sandbox the run executed in. */ + sandbox: string; +} + +export type Side = 'base' | 'run'; +export type ModuleKind = + | 'production' + | 'test' + | 'tool' + | 'package' + | 'builtin' + | 'harness' + | 'other'; +export type ModuleOrigin = 'sandbox' | 'prepared-copy' | 'checkout' | 'none'; + +export interface LocatedModule { + /** + * Comparable name: the repo path for local modules (`gitnexus-shared/dist/X.js` and the + * relocated `src` both become `gitnexus-shared/src/X.ts`), `node_modules/...` for packages, + * `node:` for builtins. + */ + key: string; + kind: ModuleKind; + /** Where a local module was loaded from. */ + origin: ModuleOrigin; +} + +const RELOCATED_SHARED = 'relocated/gitnexus-shared/'; + +function sharedSource(relative: string): { key: string; kind: ModuleKind } | null { + if (relative.startsWith('src/')) + return { key: `gitnexus-shared/${relative}`, kind: 'production' }; + if (relative.startsWith('dist/') && relative.endsWith('.js')) { + return { key: `gitnexus-shared/src/${relative.slice(5, -3)}.ts`, kind: 'production' }; + } + return null; +} + +function packageSource(relative: string): { key: string; kind: ModuleKind } | null { + if (relative.startsWith(RELOCATED_SHARED)) { + return sharedSource(relative.slice(RELOCATED_SHARED.length)); + } + if (relative.startsWith('src/')) return { key: `gitnexus/${relative}`, kind: 'production' }; + if (relative.startsWith('test/')) return { key: `gitnexus/${relative}`, kind: 'test' }; + if (relative.startsWith('tools/')) return { key: `gitnexus/${relative}`, kind: 'tool' }; + return null; +} + +const isUnder = (root: string, file: string): boolean => file.startsWith(`${root}/`); + +const asBuiltin = (id: string): string | null => { + if (id.startsWith('node:')) return id; + return isBuiltin(id) ? `node:${id}` : null; +}; + +type Candidate = readonly [root: string, origin: ModuleOrigin, layout: 'package' | 'shared']; + +/** Where a recorded module comes from and how both runs name it. */ +export function locateModule(module: string, side: Side, roots: Roots): LocatedModule { + const builtin = asBuiltin(module); + if (builtin !== null) return { key: builtin, kind: 'builtin', origin: 'none' }; + const bare = module.replace(/[?#].*$/, ''); + const file = (bare.startsWith('file://') ? fileURLToPath(bare) : bare).replace(/\\/g, '/'); + if (!path.posix.isAbsolute(file)) return { key: module, kind: 'other', origin: 'none' }; + if (isHarnessModule(file)) return { key: file, kind: 'harness', origin: 'none' }; + // The runner plugin, and the setup file next to it, load from the prepared copy (KTD16 layout). + const pluginTools = path.posix.join(roots.preparedRoot, 'gitnexus', 'tools'); + if (side === 'run' && isUnder(pluginTools, file) && !isUnder(roots.sandbox, file)) { + return { key: file, kind: 'harness', origin: 'prepared-copy' }; + } + const marker = file.indexOf('/node_modules/'); + if (marker >= 0) { + const rest = file.slice(marker + '/node_modules/'.length); + const shared = rest.startsWith('gitnexus-shared/') + ? sharedSource(rest.slice('gitnexus-shared/'.length)) + : null; + return shared === null + ? { key: `node_modules/${rest}`, kind: 'package', origin: 'none' } + : { ...shared, origin: 'checkout' }; + } + const runCandidates: Candidate[] = + side === 'run' + ? [ + [roots.sandbox, 'sandbox', 'package'], + [path.posix.join(roots.preparedRoot, 'gitnexus'), 'prepared-copy', 'package'], + ] + : []; + const candidates: Candidate[] = [ + ...runCandidates, + [path.posix.join(roots.repoRoot, 'gitnexus-shared'), 'checkout', 'shared'], + [path.posix.join(roots.repoRoot, 'gitnexus'), 'checkout', 'package'], + ]; + for (const [root, origin, layout] of candidates) { + if (!isUnder(root, file)) continue; + const relative = file.slice(root.length + 1); + const local = layout === 'shared' ? sharedSource(relative) : packageSource(relative); + return local === null ? { key: file, kind: 'other', origin } : { ...local, origin }; + } + return { key: file, kind: 'other', origin: 'none' }; +} + +/** Path roots that make the base run's config comparable (allowed-config-diffs.ts). */ +export function basePathRoots(repoRoot: string): PathRoot[] { + return [ + { from: path.posix.join(repoRoot, 'gitnexus-shared'), to: '' }, + { from: path.posix.join(repoRoot, 'gitnexus'), to: '' }, + { from: repoRoot, to: '' }, + ]; +} + +/** + * Path roots of the Stryker run: the sandbox becomes `` (its relocated shared tree + * ``), the shared `node_modules` stays comparable, and any other prepared-copy or checkout + * path keeps a marker of its own, so it differs from the base run. + */ +export function runPathRoots(roots: Roots): PathRoot[] { + return [ + { from: path.posix.join(roots.sandbox, RELOCATED_SHARED.slice(0, -1)), to: '' }, + { from: roots.sandbox, to: '' }, + { + from: path.posix.join(roots.preparedRoot, 'gitnexus', 'node_modules'), + to: '/node_modules', + }, + { from: roots.preparedRoot, to: '' }, + { + from: path.posix.join(roots.repoRoot, 'gitnexus', 'node_modules'), + to: '/node_modules', + }, + { from: roots.repoRoot, to: '' }, + ]; +} + +/** Whether a load was caused by the test file, production or package code, not the harness. */ +function attributed(event: LoadEvent, side: Side, roots: Roots): boolean { + if (event.source === 'dlopen' || event.source === 'worker') return true; + if (event.parent === null) return event.source !== 'builtin-getter'; + return locateModule(event.parent, side, roots).kind !== 'harness'; +} + +const recordKey = (record: WorkerRecord, side: Side, roots: Roots): string => + locateModule(record.file, side, roots).key; + +const ORIGIN_LABEL: Record = { + sandbox: 'sandbox', + 'prepared-copy': 'prepared copy outside the sandbox', + checkout: 'original checkout', + none: 'unknown location', +}; + +const LOCAL_KINDS: ReadonlySet = new Set(['production', 'test', 'tool']); + +/** + * KTD19 path rule: every local module (production, test or tool code) of a Stryker-run file + * resolves inside the active sandbox; the runner's own files in the prepared copy are harness. + */ +export function checkPathRule(record: WorkerRecord, roots: Roots): GateDifference[] { + const subject = recordKey(record, 'run', roots); + return record.loads.flatMap((event) => { + if (event.source !== 'module-runner' && event.source !== 'node-resolve') return []; + const located = locateModule(event.module, 'run', roots); + const local = LOCAL_KINDS.has(located.kind); + if (!local || located.origin === 'sandbox') return []; + return [ + { + kind: 'path-rule' as const, + subject, + detail: `${located.key} loaded from the ${ORIGIN_LABEL[located.origin]} (${event.module})`, + }, + ]; + }); +} + +const COMPARED_KINDS: ReadonlySet = new Set([ + 'production', + 'test', + 'package', + 'builtin', +]); + +function loadedModules(record: WorkerRecord, side: Side, roots: Roots): Set { + return new Set( + record.loads + .filter( + (event) => + (event.source === 'module-runner' || event.source === 'node-resolve') && + attributed(event, side, roots), + ) + .map((event) => locateModule(event.module, side, roots)) + .filter((located) => COMPARED_KINDS.has(located.kind)) + .map((located) => located.key), + ); +} + +/** The local, package and builtin modules one test file loads must be the same in both runs. */ +export function compareModuleSets( + base: WorkerRecord, + run: WorkerRecord, + roots: Roots, +): GateDifference[] { + const left = loadedModules(base, 'base', roots); + const right = loadedModules(run, 'run', roots); + const onlyBase = [...left].filter((key) => !right.has(key)).sort(byText); + const onlyRun = [...right].filter((key) => !left.has(key)).sort(byText); + if (onlyBase.length === 0 && onlyRun.length === 0) return []; + return [ + { + kind: 'module-set', + subject: recordKey(base, 'base', roots), + detail: + `only in the base run: ${onlyBase.join(', ') || '-'}; ` + + `only in the run: ${onlyRun.join(', ') || '-'}`, + }, + ]; +} + +const FORBIDDEN_BUILTINS: ReadonlySet = new Set(['node:child_process', 'node:cluster']); + +export interface RuntimeLoadResult { + failures: GateDifference[]; + /** The file loaded `worker_threads` (allowed while no Worker is constructed). */ + workerThreadsLoaded: boolean; +} + +/** + * KTD19 runtime load check of one file: a native binding, a Worker construction, or a + * `child_process`/`cluster` load by the test, production or package code fails. Loads the harness + * makes (Vitest, the runner and the recorder) do not count. + */ +export function checkRuntimeLoads( + record: WorkerRecord, + side: Side, + roots: Roots, +): RuntimeLoadResult { + const file = recordKey(record, side, roots); + const failures: GateDifference[] = []; + let workerThreadsLoaded = false; + for (const event of record.loads) { + if (!attributed(event, side, roots)) continue; + const located = locateModule(event.module, side, roots); + const reason = + event.source === 'dlopen' || located.key.endsWith('.node') + ? 'native binding' + : event.source === 'worker' + ? 'worker' + : FORBIDDEN_BUILTINS.has(located.key) + ? located.key.slice('node:'.length) + : null; + if (located.key === 'node:worker_threads') workerThreadsLoaded = true; + if (reason === null) continue; + failures.push({ + kind: 'runtime-load', + subject: event.test === null ? file : `${file} > ${event.test}`, + detail: + `${reason}: ${event.module} (${event.source}, during ${event.phase}` + + `${event.parent === null ? '' : `, from ${event.parent}`})`, + }); + } + return { failures, workerThreadsLoaded }; +} + +// ── Envelope record ─────────────────────────────────────────────────────── + +export const ENVELOPE_SCHEMA_VERSION = 1; + +export interface StrykerSettings { + concurrency: number; + maxTestRunnerReuse: number; + testRunner?: string; + coverageAnalysis?: string; + disableTypeChecks?: boolean; + timeoutMS?: number; +} + +export interface EnvelopeEnvRead { + name: string; + set: boolean; + /** Kept for `GITNEXUS_*` names only. */ + value: string | null; + /** Repo paths (or package paths) of the modules that read it at load. */ + readers: string[]; +} + +export interface EnvelopeInput { + versions: { vitest: string; stryker: string; node: string }; + platform: { os: string; release: string; arch: string }; + stryker: StrykerSettings; + config: { + base: { file: string; project: string }; + run: { file: string; project: string }; + /** A summary of the effective settings (base run project config). */ + settings: JsonValue; + comparison: ConfigComparison; + }; + envReadsAtLoad: EnvelopeEnvRead[]; + /** The accepted pure-JS files that both runs executed. */ + files: string[]; + /** The base run's tests. */ + tests: ObservedTest[]; + differences: GateDifference[]; + runtimeNotes: { workerThreadsLoadedBy: string[] }; +} + +export interface Envelope { + schemaVersion: typeof ENVELOPE_SCHEMA_VERSION; + description: string; + versions: EnvelopeInput['versions']; + platform: EnvelopeInput['platform']; + stryker: StrykerSettings; + config: { + base: EnvelopeInput['config']['base']; + run: EnvelopeInput['config']['run']; + settings: JsonValue; + allowedDifferences: { name: string; paths: string[]; reason: string }[]; + ignoredVolatileValues: { name: string; paths: string[]; reason: string }[]; + }; + envReadsAtLoad: EnvelopeEnvRead[]; + accepted: { fileCount: number; testCount: number; ran: number; skipped: number; files: string[] }; + skippedOnThisPlatform: string[]; + runtimeLoads: { workerThreadsLoadedBy: string[] }; + gate: { result: 'pass' | 'fail'; differences: GateDifference[] }; +} + +const withReasons = ( + used: readonly { name: string; paths: string[] }[], + documented: readonly { name: string; reason: string }[], +) => + used.map(({ name, paths }) => ({ + name, + paths, + reason: documented.find((entry) => entry.name === name)?.reason ?? '', + })); + +/** The envelope record (gitnexus/test/fixtures/mutation-oracle/envelope.json). */ +export function buildEnvelope(input: EnvelopeInput): Envelope { + const skipped = input.tests.filter((test) => test.outcome === 'skipped'); + return { + schemaVersion: ENVELOPE_SCHEMA_VERSION, + description: + 'Gate 1 (U6): the vitest5 runner’s unmutated run matched a normal Vitest 5 run on the ' + + 'pure-JS category under these versions and settings. Results from other Vitest or Stryker ' + + 'versions are unvalidated until gates 2 and 3 pass again (KTD2). Regenerate with ' + + '`npm run test:mutation:fidelity` (from gitnexus/).', + versions: input.versions, + platform: input.platform, + stryker: input.stryker, + config: { + base: input.config.base, + run: input.config.run, + settings: input.config.settings, + allowedDifferences: withReasons(input.config.comparison.allowed, ALLOWED_CONFIG_DIFFS), + ignoredVolatileValues: withReasons(input.config.comparison.volatile, VOLATILE_CONFIG_VALUES), + }, + envReadsAtLoad: input.envReadsAtLoad, + accepted: { + fileCount: input.files.length, + testCount: input.tests.length, + ran: input.tests.length - skipped.length, + skipped: skipped.length, + files: input.files, + }, + skippedOnThisPlatform: skipped.map((test) => `${test.file} > ${test.fullName}`), + runtimeLoads: input.runtimeNotes, + gate: { + result: input.differences.length === 0 ? 'pass' : 'fail', + differences: input.differences, + }, + }; +} + +export const formatDifference = (difference: GateDifference): string => + `${difference.kind}: ${difference.subject}: ${difference.detail}`; + +// ── Runs ────────────────────────────────────────────────────────────────── + +/** A production file outside every accepted file's import closure (checked at run time). */ +export const UNCOVERED_MUTATE_TARGET = 'gitnexus/src/core/ingestion/utils/verbose.ts'; +export const FIDELITY_CONFIG = 'tools/mutation/fidelity/vitest.fidelity.config.ts'; +export const RUN_PROJECT = 'mutation'; +export const BASE_CONFIG = 'vitest.config.ts'; +export const BASE_PROJECT = 'default'; +export const DEFAULT_ENVELOPE = 'test/fixtures/mutation-oracle/envelope.json'; +const MARKER_FILE = '.gitnexus-mutation-fidelity'; + +export class UsageError extends Error { + override name = 'UsageError'; +} + +export interface GateOptions { + repoRoot: string; + outDir: string; + /** Absolute envelope path; null writes none. */ + envelopeFile: string | null; + /** A subset of the accepted files; undefined runs the whole category. */ + files: readonly string[] | undefined; + /** Files per Stryker run; 0 runs them all in one. */ + batchSize: number; + concurrency: number; + maxTestRunnerReuse: number; + timeoutMS: number; + compareOnly: boolean; +} + +interface Layout { + outDir: string; + filesFile: string; + baseRecords: string; + runRecords: string; + baseRunFile: string; + strykerRunFile: string; + reportFile: string; + batchDir: (index: number) => string; +} + +const layoutOf = (outDir: string): Layout => ({ + outDir, + filesFile: path.join(outDir, 'files.json'), + baseRecords: path.join(outDir, 'base-records'), + runRecords: path.join(outDir, 'stryker-records'), + baseRunFile: path.join(outDir, 'base-run.json'), + strykerRunFile: path.join(outDir, 'stryker-run.json'), + reportFile: path.join(outDir, 'gate1-report.json'), + batchDir: (index) => path.join(outDir, `stryker-${index}`), +}); + +const readJson = (file: string): T => readJsonFile(file) as T; +const writeJson = (file: string, value: unknown): void => + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); + +const isInside = (parent: string, child: string): boolean => { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +}; + +function resetOutDir(repoRoot: string, outDir: string): void { + if (isInside(repoRoot, outDir) || isInside(outDir, repoRoot)) { + throw new UsageError(`--out ${outDir} must lie outside the checkout ${repoRoot}`); + } + if (existsSync(outDir) && readdirSync(outDir).length > 0) { + if (!existsSync(path.join(outDir, MARKER_FILE))) { + throw new UsageError( + `${outDir} is not empty and was not created by gate 1; choose another --out`, + ); + } + rmSync(outDir, { recursive: true, force: true }); + } + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, MARKER_FILE), ''); +} + +/** Rebuilds gitnexus-shared/dist as gitnexus/scripts/build.js does, then checks it is current. */ +function buildShared(repoRoot: string): void { + const sharedRoot = path.join(repoRoot, 'gitnexus-shared'); + const tscJs = path.join(repoRoot, 'gitnexus', 'node_modules', 'typescript', 'lib', 'tsc.js'); + execFileSync(process.execPath, [tscJs], { cwd: sharedRoot, stdio: 'inherit' }); + const stale: string[] = []; + const visit = (relative: string): void => { + for (const entry of readdirSync(path.join(sharedRoot, 'src', relative), { + withFileTypes: true, + })) { + const child = path.join(relative, entry.name); + if (entry.isDirectory()) visit(child); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + const source = path.join(sharedRoot, 'src', child); + const built = path.join(sharedRoot, 'dist', child.replace(/\.ts$/, '.js')); + if (!existsSync(built) || statSync(built).mtimeMs < statSync(source).mtimeMs) + stale.push(child); + } + } + }; + visit(''); + if (stale.length > 0) { + throw new UsageError(`gitnexus-shared/dist is stale after the build: ${stale.join(', ')}`); + } +} + +interface FileSelection { + files: string[]; + /** Static closure union of the selected files (repo paths). */ + closure: string[]; + category: number; +} + +function selectFiles(repoRoot: string, subset: readonly string[] | undefined): FileSelection { + const result = classify({ repoRoot }); + const accepted = new Set(result.accepted); + const notAccepted = (subset ?? []).filter((file) => !accepted.has(file)); + if (notAccepted.length > 0) { + throw new UsageError(`not in the pure-JS category (U5): ${notAccepted.join(', ')}`); + } + const files = subset === undefined ? result.accepted : [...new Set(subset)].sort(byText); + const chosen = new Set(files); + const closure = [ + ...new Set(result.files.filter((v) => chosen.has(v.file)).flatMap((v) => v.closure)), + ].sort(byText); + if (closure.includes(UNCOVERED_MUTATE_TARGET)) { + throw new UsageError( + `${UNCOVERED_MUTATE_TARGET} is imported by an accepted file; choose another uncovered ` + + 'mutate target (UNCOVERED_MUTATE_TARGET)', + ); + } + return { files, closure, category: result.accepted.length }; +} + +interface StrykerBatch { + index: number; + outDir: string; + files: string[]; + wallClockMs: number; + /** Null when Stryker succeeded. */ + error: string | null; + mutants: { status: string }[]; + strykerConfig: Record; +} + +async function runStrykerBatches( + options: GateOptions, + layout: Layout, + files: string[], +): Promise { + mkdirSync(layout.runRecords, { recursive: true }); + process.env[FIDELITY_DIR_ENV] = layout.runRecords; + const size = options.batchSize > 0 ? options.batchSize : files.length; + const batches: StrykerBatch[] = []; + for (let start = 0, index = 0; start < files.length; start += size, index += 1) { + const batchFiles = files.slice(start, start + size); + const outDir = layout.batchDir(index); + const scopeFile = path.join(layout.outDir, `scope-${index}.json`); + writeJson(scopeFile, { testFiles: batchFiles, mutate: [UNCOVERED_MUTATE_TARGET] }); + console.log(`gate 1: Stryker run ${index + 1}: ${batchFiles.length} files`); + const started = performance.now(); + let error: string | null = null; + let mutants: { status: string }[] = []; + try { + const { results } = await runScope({ + repoRoot: options.repoRoot, + scopeFile, + outDir, + variant: 'vitest5', + concurrency: options.concurrency, + maxTestRunnerReuse: options.maxTestRunnerReuse, + timeoutMS: options.timeoutMS, + command: undefined, + vitest: { configFile: FIDELITY_CONFIG, project: RUN_PROJECT }, + }); + mutants = results.mutants.map((mutant) => ({ status: mutant.status })); + } catch (caught) { + if (!(caught instanceof StrykerRunError)) throw caught; + error = caught.message; + } + const configFile = path.join(outDir, 'stryker.config.json'); + batches.push({ + index, + outDir, + files: batchFiles, + wallClockMs: Math.round(performance.now() - started), + error, + mutants, + strykerConfig: existsSync(configFile) ? readJson>(configFile) : {}, + }); + } + delete process.env[FIDELITY_DIR_ENV]; + return batches; +} + +interface BaseRun { + wallClockMs: number; + tests: ObservedTest[]; + fileErrors: { file: string; errors: string[] }[]; + unhandledErrors: string[]; + config: ConfigSnapshot; +} + +const toOutcome = (state: string): Outcome => + state === 'passed' ? 'passed' : state === 'failed' ? 'failed' : 'skipped'; + +const errorText = (error: unknown): string => + isRecordLike(error) && typeof error.message === 'string' ? error.message : String(error); + +function isRecordLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** The base run: what `vitest run --project default ` does, plus the gate 1 recorder. */ +async function runBase(repoRoot: string, layout: Layout, files: string[]): Promise { + const packageDir = path.join(repoRoot, 'gitnexus'); + mkdirSync(layout.baseRecords, { recursive: true }); + process.env[FIDELITY_DIR_ENV] = layout.baseRecords; + // The environment startVitest/prepareVitest sets before creating Vitest. + process.env.TEST = 'true'; + process.env.VITEST = 'true'; + process.env.NODE_ENV ??= 'test'; + const started = performance.now(); + const { createVitest } = await import('vitest/node'); + const vitest = await createVitest({ + root: packageDir, + config: path.join(packageDir, BASE_CONFIG), + project: [BASE_PROJECT], + watch: false, + reporters: [], + }); + try { + const project = vitest.projects.find((candidate) => candidate.name === BASE_PROJECT); + if (project === undefined) throw new UsageError(`no Vitest project ${BASE_PROJECT}`); + installRecorder(project); + const specs = files.map((file) => project.createSpecification(path.join(packageDir, file))); + console.log(`gate 1: base run: ${files.length} files`); + const result = await vitest.runTestSpecifications(specs, true); + const tests: ObservedTest[] = []; + const fileErrors: BaseRun['fileErrors'] = []; + for (const module of result.testModules) { + const file = path.relative(packageDir, module.moduleId).split(path.sep).join('/'); + const errors = module.errors().map(errorText); + if (errors.length > 0) fileErrors.push({ file, errors }); + for (const test of module.children.allTests()) { + tests.push({ + file, + fullName: test.fullName, + location: test.location ?? null, + outcome: toOutcome(test.result().state), + }); + } + } + return { + wallClockMs: Math.round(performance.now() - started), + tests, + fileErrors, + unhandledErrors: result.unhandledErrors.map(errorText), + config: snapshotConfig(project, vitest), + }; + } finally { + await vitest.close(); + delete process.env[FIDELITY_DIR_ENV]; + } +} + +// ── Evaluation ──────────────────────────────────────────────────────────── + +const readRecords = (dir: string, prefix: string): T[] => + existsSync(dir) + ? readdirSync(dir) + .filter((name) => name.startsWith(prefix) && name.endsWith('.json')) + .sort(byText) + .map((name) => readJson(path.join(dir, name))) + : []; + +/** The run's tests: the manifest's collected tests with the dry-run sidecar's outcome classes. */ +function runTests(manifest: RunManifest, dryRun: SidecarRecord | undefined): ObservedTest[] { + const outcomeOf = new Map(dryRun?.result.tests.map((test) => [test.id, test.outcome]) ?? []); + return manifest.files.flatMap((file) => [ + ...file.tests.map((test) => ({ + file: file.file, + fullName: test.fullName, + location: test.location, + outcome: outcomeOf.get(test.id) === 'survived' ? ('passed' as const) : ('failed' as const), + })), + ...file.skipped.map((test) => ({ + file: file.file, + fullName: test.fullName, + location: test.location, + outcome: 'skipped' as const, + })), + ]); +} + +/** Sandbox and prepared root of a Stryker-run record: `/gitnexus/.stryker-tmp/sandbox-*`. */ +function rootsOf(repoRoot: string, sandbox: string): Roots | null { + const tempDir = path.posix.dirname(sandbox); + const packageDir = path.posix.dirname(tempDir); + const ok = + path.posix.basename(sandbox).startsWith('sandbox-') && + path.posix.basename(tempDir) === '.stryker-tmp' && + path.posix.basename(packageDir) === 'gitnexus'; + return ok ? { repoRoot, preparedRoot: path.posix.dirname(packageDir), sandbox } : null; +} + +const SETTINGS_KEYS = [ + 'pool', + 'isolate', + 'fileParallelism', + 'maxWorkers', + 'globals', + 'environment', + 'testTimeout', + 'hookTimeout', + 'teardownTimeout', + 'clearMocks', + 'mockReset', + 'restoreMocks', + 'unstubEnvs', + 'unstubGlobals', + 'includeTaskLocation', + 'retry', + 'env', + 'sequence', +]; + +function settingsSummary(project: JsonValue): JsonValue { + if (!isJsonObject(project)) return null; + const summary: { [key: string]: JsonValue } = {}; + for (const key of SETTINGS_KEYS) { + const value = project[key]; + if (key === 'sequence' && isJsonObject(value)) { + // seed is a start time; setupFiles: 'list' is set by the gate 1 recorder installation. + const { seed: _seed, setupFiles: _setupFiles, sequencer, ...rest } = value; + summary[key] = { ...rest, sequencer: sequencer ?? null }; + } else if (value !== undefined) { + summary[key] = value; + } + } + return summary; +} + +function sameEnv( + records: readonly WorkerRecord[], + side: string, +): { env: Record; differences: GateDifference[] } { + const env = records[0]?.env ?? {}; + const text = JSON.stringify(env); + const differences = records + .filter((record) => JSON.stringify(record.env) !== text) + .map((record) => ({ + kind: 'record' as const, + subject: record.file, + detail: `the ${side} worker environment differs from the first worker's`, + })); + return { env, differences }; +} + +function aggregateEnvReads(records: readonly WorkerRecord[], roots: Roots): EnvelopeEnvRead[] { + const reads = new Map(); + for (const record of records) { + for (const read of record.envReadsAtLoad) { + if (read.reader === null) continue; + const reader = locateModule(read.reader, 'base', roots).key; + const entry = reads.get(read.name) ?? { + name: read.name, + set: read.set, + value: read.value, + readers: [], + }; + if (!entry.readers.includes(reader)) entry.readers.push(reader); + reads.set(read.name, entry); + } + } + return [...reads.values()] + .map((entry) => ({ ...entry, readers: [...entry.readers].sort(byText) })) + .sort((a, b) => byText(a.name, b.name)); +} + +const packageVersion = (packageDir: string, name: string): string => + readJson<{ version: string }>(path.join(packageDir, 'node_modules', name, 'package.json')) + .version; + +export interface GateReport { + result: 'pass' | 'fail'; + differences: GateDifference[]; + envelope: Envelope; + wallClockMs: { base: number; stryker: number[] }; + counts: { files: number; baseTests: number; runTests: number; category: number }; + mutants: Record; +} + +function evaluate(options: GateOptions, layout: Layout): GateReport { + const { repoRoot } = options; + const packageDir = path.join(repoRoot, 'gitnexus'); + const selection = readJson(layout.filesFile); + const base = readJson(layout.baseRunFile); + const batches = readJson(layout.strykerRunFile); + const differences: GateDifference[] = []; + + // Run errors. + for (const batch of batches) { + if (batch.error !== null) { + differences.push({ + kind: 'run-error', + subject: `Stryker run ${batch.index + 1}`, + detail: batch.error, + }); + } + const covered = batch.mutants.filter((mutant) => mutant.status !== 'NoCoverage'); + if (covered.length > 0) { + differences.push({ + kind: 'run-error', + subject: `Stryker run ${batch.index + 1}`, + detail: `${covered.length} mutants of ${UNCOVERED_MUTATE_TARGET} were covered; the run was not a dry run alone`, + }); + } + } + for (const { file, errors } of base.fileErrors) { + differences.push({ + kind: 'run-error', + subject: file, + detail: `base run: ${errors.join('; ')}`, + }); + } + for (const error of base.unhandledErrors) { + differences.push({ + kind: 'run-error', + subject: 'base run', + detail: `unhandled error: ${error}`, + }); + } + + // Tests. + const run: ObservedTest[] = []; + for (const batch of batches) { + const runDir = path.join(batch.outDir, 'run'); + if (!existsSync(path.join(runDir, 'run-manifest.json'))) { + differences.push({ + kind: 'record', + subject: `Stryker run ${batch.index + 1}`, + detail: 'no run manifest', + }); + continue; + } + const sidecarDir = path.join(batch.outDir, 'sidecars'); + const dryRuns = existsSync(sidecarDir) + ? readSidecars(sidecarDir).filter((record) => record.kind === 'dry-run') + : []; + const dryRun = dryRuns.sort((a, b) => a.attempt - b.attempt).at(-1); + if (dryRun === undefined) { + differences.push({ + kind: 'record', + subject: `Stryker run ${batch.index + 1}`, + detail: 'no dry-run sidecar', + }); + } + for (const row of dryRun?.result.outsideTest ?? []) { + differences.push({ + kind: 'run-error', + subject: row.file, + detail: `run: ${row.outcome}: ${row.message}`, + }); + } + run.push(...runTests(readRunManifest(runDir), dryRun)); + } + differences.push(...compareTests(base.tests, run)); + + // Records. + const baseRoots: Roots = { + repoRoot, + preparedRoot: '/nonexistent-prepared', + sandbox: '/nonexistent-sandbox', + }; + const baseRecords = readRecords(layout.baseRecords, WORKER_RECORD_PREFIX); + const runRecords = readRecords(layout.runRecords, WORKER_RECORD_PREFIX); + const configRecords = readRecords(layout.runRecords, CONFIG_RECORD_PREFIX); + const baseByFile = new Map( + baseRecords.map((record) => [recordKey(record, 'base', baseRoots), record]), + ); + const runByFile = new Map(); + for (const record of runRecords) { + const roots = rootsOf(repoRoot, record.root); + if (roots === null) { + differences.push({ + kind: 'path-rule', + subject: record.file, + detail: `run root ${record.root} is not a Stryker sandbox`, + }); + continue; + } + runByFile.set(recordKey(record, 'run', roots), { record, roots }); + } + const workerThreadsLoadedBy: string[] = []; + for (const file of selection.files.map((entry) => `gitnexus/${entry}`)) { + const baseRecord = baseByFile.get(file); + const runEntry = runByFile.get(file); + if (baseRecord === undefined || runEntry === undefined) { + differences.push({ + kind: 'record', + subject: file, + detail: `no recorder output from the ${baseRecord === undefined ? 'base run' : 'Stryker run'} (collection failed?)`, + }); + continue; + } + differences.push(...checkPathRule(runEntry.record, runEntry.roots)); + differences.push(...compareModuleSets(baseRecord, runEntry.record, runEntry.roots)); + const baseLoads = checkRuntimeLoads(baseRecord, 'base', baseRoots); + const runLoads = checkRuntimeLoads(runEntry.record, 'run', runEntry.roots); + differences.push( + ...baseLoads.failures, + ...runLoads.failures.map((failure) => ({ + ...failure, + detail: `Stryker run: ${failure.detail}`, + })), + ); + if (baseLoads.workerThreadsLoaded) workerThreadsLoadedBy.push(file); + } + + // Config. + const baseEnv = sameEnv(baseRecords, 'base'); + const runEnv = sameEnv(runRecords, 'run'); + differences.push(...baseEnv.differences, ...runEnv.differences); + const comparisons: ConfigComparison[] = []; + const dryRunPids = new Set(runRecords.map((record) => record.ppid)); + for (const pid of dryRunPids) { + const configRecord = configRecords.find( + (candidate) => candidate.pid === pid && candidate.final, + ); + const sample = runRecords.find((record) => record.ppid === pid); + const roots = sample === undefined ? null : rootsOf(repoRoot, sample.root); + if (configRecord === undefined || roots === null) { + differences.push({ + kind: 'record', + subject: `runner process ${pid}`, + detail: 'no final config record', + }); + continue; + } + const baseView: ConfigView = normaliseView(base.config, baseEnv.env, basePathRoots(repoRoot)); + const runView: ConfigView = normaliseView( + configRecord.snapshot, + runEnv.env, + runPathRoots(roots), + ); + const comparison = compareConfigs(baseView, runView); + comparisons.push(comparison); + for (const failure of comparison.failures) { + differences.push({ + kind: 'config', + subject: failure.path, + detail: `base ${JSON.stringify(failure.base ?? '')}, run ${JSON.stringify(failure.run ?? '')}`, + }); + } + } + const comparison: ConfigComparison = mergeComparisons(comparisons); + + const strykerConfig = batches[0]?.strykerConfig ?? {}; + const envelope = buildEnvelope({ + versions: { + vitest: resolveVitestInstall(packageDir)?.version ?? 'unknown', + stryker: packageVersion(packageDir, '@stryker-mutator/core'), + node: process.version, + }, + platform: { os: os.platform(), release: os.release(), arch: os.arch() }, + stryker: { + concurrency: options.concurrency, + maxTestRunnerReuse: options.maxTestRunnerReuse, + testRunner: String(strykerConfig.testRunner ?? ''), + coverageAnalysis: String(strykerConfig.coverageAnalysis ?? ''), + disableTypeChecks: strykerConfig.disableTypeChecks === true, + timeoutMS: options.timeoutMS, + }, + config: { + base: { file: `gitnexus/${BASE_CONFIG}`, project: BASE_PROJECT }, + run: { file: 'gitnexus/tools/mutation/vitest.mutation.config.ts', project: RUN_PROJECT }, + settings: normalisePaths(settingsSummary(base.config.project), basePathRoots(repoRoot)), + comparison, + }, + envReadsAtLoad: aggregateEnvReads(baseRecords, baseRoots), + files: selection.files, + tests: base.tests, + differences, + runtimeNotes: { workerThreadsLoadedBy: workerThreadsLoadedBy.sort(byText) }, + }); + const mutants: Record = {}; + for (const mutant of batches.flatMap((batch) => batch.mutants)) { + mutants[mutant.status] = (mutants[mutant.status] ?? 0) + 1; + } + return { + result: envelope.gate.result, + differences, + envelope, + wallClockMs: { base: base.wallClockMs, stryker: batches.map((batch) => batch.wallClockMs) }, + counts: { + files: selection.files.length, + baseTests: base.tests.length, + runTests: run.length, + category: selection.category, + }, + mutants, + }; +} + +function normaliseView( + snapshot: ConfigSnapshot, + env: Record, + roots: PathRoot[], +): ConfigView { + const normalisedEnv = normalisePaths({ ...env }, roots); + return { + project: normalisePaths(snapshot.project, roots), + global: normalisePaths(snapshot.global, roots), + vite: normalisePaths(snapshot.vite, roots), + env: isJsonObject(normalisedEnv) + ? Object.fromEntries( + Object.entries(normalisedEnv).map(([key, value]) => [ + key, + typeof value === 'string' ? value : null, + ]), + ) + : {}, + }; +} + +function mergeComparisons(comparisons: readonly ConfigComparison[]): ConfigComparison { + const merge = (lists: { name: string; paths: string[] }[][]) => { + const byName = new Map>(); + for (const entry of lists.flat()) { + byName.set(entry.name, new Set([...(byName.get(entry.name) ?? []), ...entry.paths])); + } + return [...byName.entries()].map(([name, paths]) => ({ name, paths: [...paths].sort(byText) })); + }; + return { + failures: comparisons.flatMap((comparison) => comparison.failures), + allowed: merge(comparisons.map((comparison) => comparison.allowed)), + volatile: merge(comparisons.map((comparison) => comparison.volatile)), + }; +} + +/** Runs gate 1 end to end (or only its comparison) and writes the report and envelope. */ +export async function runGate(options: GateOptions): Promise { + const layout = layoutOf(options.outDir); + if (!options.compareOnly) { + resetOutDir(options.repoRoot, options.outDir); + buildShared(options.repoRoot); + const selection = selectFiles(options.repoRoot, options.files); + writeJson(layout.filesFile, selection); + const batches = await runStrykerBatches(options, layout, selection.files); + writeJson(layout.strykerRunFile, batches); + const base = await runBase(options.repoRoot, layout, selection.files); + writeJson(layout.baseRunFile, base); + } + const report = evaluate(options, layout); + writeJson(layout.reportFile, report); + if (options.envelopeFile !== null && options.files === undefined) { + mkdirSync(path.dirname(options.envelopeFile), { recursive: true }); + writeJson(options.envelopeFile, report.envelope); + } + return report; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +const parseInteger = (value: string | undefined, name: string, fallback: number): number => { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value)) throw new UsageError(`--${name} must be an integer, got ${value}`); + return Number(value); +}; + +export function parseCliArgs(argv: readonly string[], cwd: string, repoRoot: string): GateOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + out: { type: 'string' }, + envelope: { type: 'string' }, + file: { type: 'string', multiple: true }, + 'batch-size': { type: 'string' }, + concurrency: { type: 'string' }, + 'max-test-runner-reuse': { type: 'string' }, + 'timeout-ms': { type: 'string' }, + 'compare-only': { type: 'boolean', default: false }, + }, + strict: true, + allowPositionals: false, + }); + return { + repoRoot, + outDir: path.resolve(cwd, values.out ?? path.join(os.tmpdir(), 'gitnexus-mutation-fidelity')), + envelopeFile: path.resolve( + cwd, + values.envelope ?? path.join(repoRoot, 'gitnexus', DEFAULT_ENVELOPE), + ), + files: values.file === undefined || values.file.length === 0 ? undefined : values.file, + batchSize: parseInteger(values['batch-size'], 'batch-size', 0), + concurrency: parseInteger(values.concurrency, 'concurrency', 2), + maxTestRunnerReuse: parseInteger(values['max-test-runner-reuse'], 'max-test-runner-reuse', 0), + timeoutMS: parseInteger(values['timeout-ms'], 'timeout-ms', 10000), + compareOnly: values['compare-only'] === true, + }; +} + +const formatSeconds = (ms: number): string => `${Math.round(ms / 1000)}s`; + +async function main(): Promise { + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)).replace(/\/$/, ''); + const started = performance.now(); + try { + const options = parseCliArgs(process.argv.slice(2), process.cwd(), repoRoot); + const report = await runGate(options); + const { counts, envelope } = report; + console.log( + [ + `gate 1: ${report.result.toUpperCase()}: ${counts.files} of ${counts.category} pure-JS files, ` + + `${counts.baseTests} base tests, ${counts.runTests} run tests, ${report.differences.length} differences`, + ` allowed config differences: ${envelope.config.allowedDifferences.map((entry) => entry.name).join(', ') || '-'}`, + ` ignored volatile values: ${envelope.config.ignoredVolatileValues.map((entry) => entry.name).join(', ') || '-'}`, + ` mutants of the uncovered target: ${JSON.stringify(report.mutants)}`, + ` wall clock: base ${formatSeconds(report.wallClockMs.base)}, Stryker ` + + `${report.wallClockMs.stryker.map(formatSeconds).join(' + ')}, total ${formatSeconds(performance.now() - started)}`, + ...report.differences.slice(0, 40).map((difference) => ` ${formatDifference(difference)}`), + ` report: ${path.join(options.outDir, 'gate1-report.json')}`, + ...(options.files === undefined && options.envelopeFile !== null + ? [` envelope: ${options.envelopeFile}`] + : []), + ].join('\n'), + ); + process.exitCode = report.result === 'pass' ? 0 : 1; + } catch (error) { + console.error(errorMessage(error)); + if (error instanceof TypeError || error instanceof UsageError) { + console.error( + 'usage: npm run test:mutation:fidelity -- [--out ] [--envelope ] ' + + '[--file ]... [--batch-size ] [--concurrency ] [--max-test-runner-reuse ] ' + + '[--timeout-ms ] [--compare-only]', + ); + } + process.exitCode = 2; + } +} + +if (isMainModule(import.meta.url)) { + void main(); +} diff --git a/gitnexus/tools/mutation/fidelity/record.ts b/gitnexus/tools/mutation/fidelity/record.ts new file mode 100644 index 000000000..b57cdd5ec --- /dev/null +++ b/gitnexus/tools/mutation/fidelity/record.ts @@ -0,0 +1,236 @@ +/** + * Records shared by the gate 1 recorder (recorder.setup.ts, in Vitest workers), the Stryker-run + * config (vitest.fidelity.config.ts, in the runner's Vitest process) and the gate itself + * (fidelity.ts). Types, constants and pure helpers only: the setup file and the config import it, + * so it must stay free of side effects and heavy dependencies. + */ +import { renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { TestProject, Vitest } from 'vitest/node'; + +/** Absolute directory both gate 1 runs write their records to; inherited by every process. */ +export const FIDELITY_DIR_ENV = 'GITNEXUS_MUTATION_FIDELITY_DIR'; +export const RECORD_SCHEMA_VERSION = 1; +export const WORKER_RECORD_PREFIX = 'worker-'; +export const CONFIG_RECORD_PREFIX = 'config-'; +/** The recorder setup file, next to this module (in the checkout, or in the Stryker sandbox). */ +export const RECORDER_SETUP_FILE = fileURLToPath(new URL('./recorder.setup.ts', import.meta.url)); +/** Name of the Stryker-run plugin (vitest.fidelity.config.ts); config snapshots leave it out. */ +export const FIDELITY_PLUGIN_NAME = 'gitnexus-mutation-fidelity'; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +/** `load`: while the test file is imported and collected; `run`: once its file-level hooks start. */ +export type Phase = 'load' | 'run'; + +/** + * How a runtime load was seen: + * - `module-runner`: evaluated by Vitest's module runner (inlined modules and the externals they + * import directly), listed when the file ends; + * - `node-resolve`: resolved by Node after the recorder started (externalized packages and what + * their own code imports, builtins); + * - `dlopen`: a native addon loaded through `process.dlopen`; + * - `worker`: a `worker_threads` Worker was constructed; + * - `builtin-getter`: a `process.getBuiltinModule` lookup. + */ +export type LoadSource = 'module-runner' | 'node-resolve' | 'dlopen' | 'worker' | 'builtin-getter'; + +export interface LoadEvent { + source: LoadSource; + /** Absolute path, `file:` URL, `node:` builtin or other module id, as the source reports it. */ + module: string; + /** The importing module (absolute path or URL), when known. */ + parent: string | null; + phase: Phase; + /** Full name of the test that was running, or null outside a test. */ + test: string | null; +} + +export interface EnvRead { + name: string; + /** The value read, kept for `GITNEXUS_*` names only; null when unset or not kept. */ + value: string | null; + set: boolean; + /** First non-harness stack frame of the first read (absolute path), when one exists. */ + reader: string | null; +} + +/** One test file's record, written by the recorder in the file's fork. */ +export interface WorkerRecord { + schemaVersion: typeof RECORD_SCHEMA_VERSION; + pid: number; + /** The Vitest process that started the fork (the runner process in the Stryker run). */ + ppid: number; + /** Absolute test file path. */ + file: string; + /** Vitest root of the run (the Stryker sandbox in the Stryker run). */ + root: string; + project: string; + /** First occurrence of each (source, module) pair, in order of observation. */ + loads: LoadEvent[]; + /** Environment variables read by name while the file loaded and collected, sorted by name. */ + envReadsAtLoad: EnvRead[]; + /** The compared environment variables as the worker saw them at load (see COMPARED_ENV). */ + env: Record; +} + +/** The runner process's resolved Vitest config, captured by vitest.fidelity.config.ts. */ +export interface ConfigRecord { + schemaVersion: typeof RECORD_SCHEMA_VERSION; + pid: number; + /** False for the capture taken when the project was created, true for the one at close. */ + final: boolean; + snapshot: ConfigSnapshot; +} + +export interface ConfigSnapshot { + /** The run project's resolved test config. */ + project: JsonValue; + /** The root (global) resolved test config. */ + global: JsonValue; + /** Vite settings of the project that steer module resolution and caching. */ + vite: JsonValue; +} + +/** Environment variables whose worker-side values gate 1 compares between the two runs. */ +export const COMPARED_ENV_NAMES: readonly string[] = Object.freeze([ + 'CI', + 'LANG', + 'LC_ALL', + 'NODE_ENV', + 'NODE_OPTIONS', + 'TEST', + 'TZ', + 'VITEST', +]); + +/** Whether gate 1 compares (and the recorder snapshots) a variable. */ +export const isComparedEnv = (name: string): boolean => + name.startsWith('GITNEXUS_') || COMPARED_ENV_NAMES.includes(name); + +/** Values kept in env-read records: product settings only, never arbitrary machine values. */ +export const keepsEnvValue = (name: string): boolean => name.startsWith('GITNEXUS_'); + +/** + * Test-harness code wherever it is loaded from: Vitest and the packages of its runtime, Stryker, + * the TypeScript loader, Node internals, and this recorder (recorder.setup.ts and this module). + * The runner's own setup file is harness only where the plugin loads it, the prepared copy + * outside the sandbox; fidelity.ts `locateModule` adds that rule, because accepted tests import + * runner modules too. A load whose importer is harness code is attributed to the harness. + */ +const HARNESS = + /(?:^node:internal|\/node_modules\/(?:vitest|@vitest\/[^/]+|vite|tinypool|tinyspy|tinyrainbow|tinybench|chai|pathe|magic-string|es-module-lexer|@stryker-mutator\/[^/]+|tsx|esbuild|get-tsconfig)\/|\/tools\/mutation\/fidelity\/(?:recorder\.setup|record)\.ts$)/; + +/** Whether a module path or URL belongs to the test harness (see {@link HARNESS}). */ +export const isHarnessModule = (pathOrUrl: string): boolean => + HARNESS.test(pathOrUrl.replace(/\\/g, '/')); + +const FRAME_LOCATION = /\(?((?:file:\/\/|node:|\/|[A-Za-z]:[\\/])[^()]*?):\d+:\d+\)?$/; + +/** The location (path or URL, without line and column) of the first non-harness stack frame. */ +export function firstForeignFrame(stack: string | undefined): string | null { + for (const line of (stack ?? '').split('\n').slice(1)) { + const match = FRAME_LOCATION.exec(line.trim()); + if (match !== null && !isHarnessModule(match[1])) return match[1]; + } + return null; +} + +const MAX_DEPTH = 12; + +const isPlainObject = (value: object): boolean => { + const proto: unknown = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +/** + * A JSON form of a resolved config value: functions become `[function ]`, regular + * expressions `//`, maps and sets plain objects and arrays, other class instances + * `[]` with their own enumerable data, `undefined` entries are dropped, and cycles become + * `[circular]`. Object keys are sorted, so equal configs give equal text. + */ +export function toJsonValue(value: unknown, seen: Set = new Set(), depth = 0): JsonValue { + if (value === null || value === undefined) return null; + if (typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); + if (typeof value === 'bigint' || typeof value === 'symbol') return String(value); + if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`; + if (value instanceof RegExp) return `/${value.source}/${value.flags}`; + if (typeof value !== 'object') return String(value); + if (seen.has(value)) return '[circular]'; + if (depth >= MAX_DEPTH) return '[too deep]'; + seen.add(value); + try { + if (Array.isArray(value)) return value.map((entry) => toJsonValue(entry, seen, depth + 1)); + if (value instanceof Set) { + return [...value].map((entry) => toJsonValue(entry, seen, depth + 1)); + } + const entries: [string, unknown][] = + value instanceof Map + ? [...value.entries()].map(([key, entry]): [string, unknown] => [String(key), entry]) + : Object.entries(value); + const out: { [key: string]: JsonValue } = {}; + if (!(value instanceof Map) && !isPlainObject(value)) { + out['[class]'] = value.constructor?.name ?? 'anonymous'; + } + for (const [key, entry] of entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + if (entry !== undefined) out[key] = toJsonValue(entry, seen, depth + 1); + } + return out; + } finally { + seen.delete(value); + } +} + +/** Writes JSON atomically (temporary dot-file, then rename) into a directory. */ +export function writeRecordFile(dir: string, name: string, value: unknown): void { + const target = path.join(dir, name); + const temp = path.join(dir, `.${name}.${process.pid}.tmp`); + writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`); + renameSync(temp, target); +} + +/** + * Puts the recorder first among a project's setup files, after the config is resolved. Both runs + * do it the same way (the base run after `createVitest`, the Stryker run from its config plugin), + * so it adds no config difference beyond `setupFiles` itself. `sequence.setupFiles: 'list'` makes + * the setup files run one after another, which the runner requires once a project has any. + */ +export function installRecorder(project: TestProject): void { + const { config } = project; + if (!config.setupFiles.includes(RECORDER_SETUP_FILE)) { + config.setupFiles = [RECORDER_SETUP_FILE, ...config.setupFiles]; + } + config.sequence.setupFiles = 'list'; +} + +/** + * The effective config of a project as JSON (see {@link toJsonValue}): the project's and the root + * resolved test config, and the Vite settings that steer module resolution, transforms and + * caching. The gate 1 plugin is left out of the plugin list. + */ +export function snapshotConfig(project: TestProject, vitest: Vitest): ConfigSnapshot { + const vite = project.vite.config; + return { + project: toJsonValue(project.config), + global: toJsonValue(vitest.config), + vite: toJsonValue({ + root: vite.root, + mode: vite.mode, + cacheDir: vite.cacheDir, + define: vite.define, + resolve: vite.resolve, + ssr: vite.ssr, + plugins: vite.plugins + .map((plugin) => plugin.name) + .filter((name) => name !== FIDELITY_PLUGIN_NAME), + }), + }; +} diff --git a/gitnexus/tools/mutation/fidelity/recorder.setup.ts b/gitnexus/tools/mutation/fidelity/recorder.setup.ts new file mode 100644 index 000000000..9b69734d0 --- /dev/null +++ b/gitnexus/tools/mutation/fidelity/recorder.setup.ts @@ -0,0 +1,193 @@ +/** + * Gate 1 recorder (U6, KTD19): a Vitest setup file that both gate 1 runs load. The base run adds + * it to the `default` project's setup files; the Stryker run gets it from + * vitest.fidelity.config.ts. With the forks pool and default isolation every test file runs in a + * fresh fork, so this file starts before the test file (and anything it imports) loads, and the + * record covers exactly one test file. It observes and never changes what the file does: + * + * - every Node resolution after it starts (`module.registerHooks`): builtins and externalized + * packages, including what package code imports (the U5 classifier does not walk packages); + * - native addons (`process.dlopen`), `worker_threads` Worker construction and + * `process.getBuiltinModule` lookups, through pass-through wrappers; + * - when the file ends, every module the Vite module runner evaluated (inlined test and + * production modules, and the externals they import directly) with its first importer; + * - environment variables read by name while the file loads and collects, through a + * pass-through proxy on `process.env` that is removed when collection ends; + * - the values of the compared environment variables (record.ts `isComparedEnv`) at load. + * + * Each load carries its phase (`load` until the file-level `aroundAll` starts, then `run`) and the + * running test's full name. The record is written atomically to the directory named by + * GITNEXUS_MUTATION_FIDELITY_DIR when the file's run ends (record.ts `WorkerRecord`). A file whose + * collection fails writes no record; gate 1 reports it as missing. Vitest parses the second + * parameter of the `aroundAll` hook as a fixture pattern, so it must be written `{}`. + */ +import { createHash } from 'node:crypto'; +import { registerHooks, syncBuiltinESMExports } from 'node:module'; +import type { WorkerOptions } from 'node:worker_threads'; +import { TestRunner, aroundAll, type RunnerTestCase, type WorkerGlobalState } from 'vitest'; +import { + FIDELITY_DIR_ENV, + RECORD_SCHEMA_VERSION, + WORKER_RECORD_PREFIX, + firstForeignFrame, + isComparedEnv, + keepsEnvValue, + writeRecordFile, + type EnvRead, + type LoadEvent, + type LoadSource, + type Phase, + type WorkerRecord, +} from './record.js'; + +const recordDir = process.env[FIDELITY_DIR_ENV]; +if (recordDir === undefined || recordDir === '') { + throw new Error(`gate 1 recorder: ${FIDELITY_DIR_ENV} is not set`); +} +if (typeof registerHooks !== 'function') { + throw new Error('gate 1 recorder: module.registerHooks is missing (Node >= 22.15 required)'); +} + +let phase: Phase = 'load'; +const loads: LoadEvent[] = []; +const seen = new Set(); + +const currentTest = (): string | null => + TestRunner.getCurrentTest()?.fullName ?? null; + +function note(source: LoadSource, module: string, parent: string | null, at?: Phase): void { + const key = `${source}\u0000${module}`; + if (seen.has(key)) return; + seen.add(key); + loads.push({ + source, + module, + parent, + phase: at ?? phase, + test: at === undefined ? currentTest() : null, + }); +} + +// ── Environment ─────────────────────────────────────────────────────────── + +const originalEnv = process.env; +const env: Record = Object.fromEntries( + Object.keys(originalEnv) + .filter((name) => isComparedEnv(name) && name !== FIDELITY_DIR_ENV) + .sort() + .map((name) => [name, originalEnv[name] ?? null]), +); +const envReads = new Map(); + +function noteEnvRead(name: string): void { + if (envReads.has(name)) return; + const value = originalEnv[name]; + envReads.set(name, { + name, + value: keepsEnvValue(name) ? (value ?? null) : null, + set: value !== undefined, + reader: firstForeignFrame(new Error().stack), + }); +} + +const envProxy = new Proxy(originalEnv, { + get(target, key) { + if (typeof key === 'string') noteEnvRead(key); + return Reflect.get(target, key); + }, + has(target, key) { + if (typeof key === 'string') noteEnvRead(key); + return Reflect.has(target, key); + }, + set(target, key, value) { + return Reflect.set(target, key, value); + }, +}); + +// ── Native addons, workers, builtin lookups ─────────────────────────────── + +const workerThreads = process.getBuiltinModule('node:worker_threads'); +const OriginalWorker = workerThreads.Worker; +class RecordedWorker extends OriginalWorker { + constructor(filename: string | URL, options?: WorkerOptions) { + note('worker', String(filename), firstForeignFrame(new Error().stack)); + super(filename, options); + } +} +Object.defineProperty(workerThreads, 'Worker', { + value: RecordedWorker, + writable: true, + configurable: true, + enumerable: true, +}); +syncBuiltinESMExports(); + +const originalDlopen = process.dlopen.bind(process); +process.dlopen = (module: object, filename: string, flags?: number): void => { + note('dlopen', filename, firstForeignFrame(new Error().stack)); + if (flags === undefined) originalDlopen(module, filename); + else originalDlopen(module, filename, flags); +}; + +const originalGetBuiltinModule = process.getBuiltinModule.bind(process); +Object.defineProperty(process, 'getBuiltinModule', { + value: (id: string): unknown => { + note('builtin-getter', id, firstForeignFrame(new Error().stack)); + return originalGetBuiltinModule(id); + }, + writable: true, + configurable: true, +}); + +registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + note('node-resolve', result.url, context.parentURL ?? null); + return result; + }, +}); + +process.env = envProxy; + +// ── The file's record ───────────────────────────────────────────────────── + +const workerState = (): WorkerGlobalState => { + const state = (globalThis as { __vitest_worker__?: WorkerGlobalState }).__vitest_worker__; + if (state === undefined) throw new Error('gate 1 recorder: no Vitest worker state'); + return state; +}; + +function writeRecord(file: string, loadedAtStart: ReadonlySet): void { + const state = workerState(); + for (const [id, node] of state.evaluatedModules.idToModuleMap) { + const importer: string | undefined = node.importers.values().next().value; + note('module-runner', id, importer ?? null, loadedAtStart.has(id) ? 'load' : 'run'); + } + const record: WorkerRecord = { + schemaVersion: RECORD_SCHEMA_VERSION, + pid: process.pid, + ppid: process.ppid, + file, + root: state.config.root, + project: state.config.name ?? '', + loads, + envReadsAtLoad: [...envReads.values()].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ), + env, + }; + const digest = createHash('sha256').update(file).digest('hex').slice(0, 16); + writeRecordFile(recordDir ?? '', `${WORKER_RECORD_PREFIX}${process.pid}-${digest}.json`, record); +} + +// The `{}` is required: Vitest parses this parameter as a fixture pattern. +aroundAll(async (runSuite, {}, suite) => { + phase = 'run'; + if (process.env === envProxy) process.env = originalEnv; + const loadedAtStart = new Set(workerState().evaluatedModules.idToModuleMap.keys()); + try { + await runSuite(); + } finally { + writeRecord(suite.file.filepath, loadedAtStart); + } +}); diff --git a/gitnexus/tools/mutation/fidelity/vitest.fidelity.config.ts b/gitnexus/tools/mutation/fidelity/vitest.fidelity.config.ts new file mode 100644 index 000000000..0e2728353 --- /dev/null +++ b/gitnexus/tools/mutation/fidelity/vitest.fidelity.config.ts @@ -0,0 +1,56 @@ +/** + * Vitest config of gate 1's Stryker run (U6): tools/mutation/vitest.mutation.config.ts unchanged, + * plus one observation plugin. The runner's unmutated run uses it through the scope driver's + * `vitest` option (project `mutation`, so task ids match real mutation runs). + * + * The plugin's `configureVitest` hook, for the `mutation` project: + * - installs the gate 1 recorder setup file exactly as the base run does (record.ts + * `installRecorder`); + * - captures the resolved config into GITNEXUS_MUTATION_FIDELITY_DIR when the project is created + * and again when Vitest closes (after the runner has added its own setup file), one file per + * runner process (record.ts `ConfigRecord`). + * Config snapshots leave this plugin out, so it is not a config difference itself. + */ +import { defineConfig } from 'vitest/config'; +import type { Vite, VitestPluginContext } from 'vitest/node'; +import mutationConfig from '../vitest.mutation.config.js'; +import { + CONFIG_RECORD_PREFIX, + FIDELITY_DIR_ENV, + FIDELITY_PLUGIN_NAME, + RECORD_SCHEMA_VERSION, + installRecorder, + snapshotConfig, + writeRecordFile, + type ConfigRecord, +} from './record.js'; + +const recordDir = process.env[FIDELITY_DIR_ENV]; +if (recordDir === undefined || recordDir === '') { + throw new Error(`vitest.fidelity.config: ${FIDELITY_DIR_ENV} is not set`); +} + +function fidelityPlugin(dir: string): Vite.Plugin { + return { + name: FIDELITY_PLUGIN_NAME, + configureVitest({ project, vitest }: VitestPluginContext) { + installRecorder(project); + const write = (final: boolean): void => { + const record: ConfigRecord = { + schemaVersion: RECORD_SCHEMA_VERSION, + pid: process.pid, + final, + snapshot: snapshotConfig(project, vitest), + }; + writeRecordFile(dir, `${CONFIG_RECORD_PREFIX}${process.pid}-${project.name}.json`, record); + }; + write(false); + vitest.onClose(() => write(true)); + }, + }; +} + +export default defineConfig({ + ...mutationConfig, + plugins: [...(mutationConfig.plugins ?? []), fidelityPlugin(recordDir)], +}); diff --git a/gitnexus/tools/mutation/oracle/command-run.ts b/gitnexus/tools/mutation/oracle/command-run.ts new file mode 100644 index 000000000..c37995e68 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/command-run.ts @@ -0,0 +1,436 @@ +/** + * Oracle command-runner run (U13, KTD17, KTD18): the oracle's own Stryker run over one scope. + * + * 1. The scope driver (U10, driver/run-scope.ts) prepares the copy and runs Stryker with the + * `command` test runner (`coverageAnalysis: off`, `cleanTempDir: false`, no runner plugin). + * The command is command-wrapper.ts from the prepared copy, so every run, the dry run + * included, runs every scope test file in the sandbox and leaves a record in + * `/oracle-reports/` (record schema in oracle-classify.ts). + * 2. The dry run's latest record is the baseline: it must classify as survived, and its + * executed tests are the expected test set of every mutant run (KTD17 invalid row). + * 3. Every mutant of `mutation.json` gets an oracle class from its raw Stryker status and its + * latest record (oracle-classify.ts `classifyMutant`). + * 4. `/oracle-command-run.json` ({@link OracleCommandRun}) records the classes, the + * per-test rows and the kept, instrumented sandbox for single-test launches (U12). + * + * `/oracle-command-run.json`, schemaVersion 1: + * scope {testFiles, mutate} what ran (`mutate` as given in the scope file) + * paths output dir, prepared package, report dir, Stryker's + * mutation.json, the driver's results.json and log, + * the mutation scope file, the kept `sandboxDir` (null + * unless exactly one exists) and every `sandboxDirs` entry + * command the wrapper command Stryker ran + * stryker {exitCode, wallClockMs} the driver's Stryker run + * spanMapFailures span-map failures of the driver (U2) + * baseline the dry run: record path, class, reason and executed + * test ids (KTD3) + * mutants[] {@link OracleMutantResult}, in report order + * problems[] anything that makes the run unusable for gate 3 + * summary counts by Stryker status and oracle class, records + * found, mean wrapper wall clock per mutant run + * Per-test rows: `tests` lists every test whose outcome is not `survived` (failed tests, tests a + * failed `beforeAll` skipped); every other test in `executed` passed. + * + * Usage (from gitnexus/): + * npm run test:mutation:oracle:command -- --scope --out + * [--concurrency ] [--timeout-ms ] + * Exit codes: 0 every mutant classified with a clean baseline and one kept sandbox; 1 results + * written with problems; 2 usage, setup or Stryker failure. + */ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { RESULTS_SCHEMA_VERSION } from '../driver/normalise-results.js'; +import { StrykerRunError, UsageError, formatDuration, runScope } from '../driver/run-scope.js'; +import { runLayout } from '../driver/stryker-config.js'; +import { + MUTANT_STATUSES, + readMutationReport, + type MutantStatus, + type ReportLocation, +} from '../prepare/mutant-identity.js'; +import { + ORACLE_CLASSES, + classifyMutant, + classifyRecord, + parseOracleRunRecord, + parseRecordFileName, + strykerStatuses, + type OracleClass, + type OracleRunRecord, + type OutcomeSource, + type OutsideTestOutcome, +} from './oracle-classify.js'; + +export const ORACLE_RUN_SCHEMA_VERSION = 1; +export const REPORT_DIR_NAME = 'oracle-reports'; +export const RESULT_FILE_NAME = 'oracle-command-run.json'; +/** The wrapper, relative to the prepared `gitnexus/` package. */ +export const WRAPPER_FILE = 'tools/mutation/oracle/command-wrapper.ts'; +const ROW_MESSAGE_LIMIT = 300; + +export interface CommandRunCliOptions { + scopeFile: string; + outDir: string; + concurrency: number; + timeoutMS: number; +} + +export interface OracleTestRow { + id: string; + outcome: OracleClass; + message: string | null; +} + +export interface OracleMutantResult { + globalId: string; + localId: string; + file: string; + location: ReportLocation; + mutatorName: string; + replacement: string; + static: boolean | null; + /** Stryker's raw status (`mutation.json`). */ + strykerStatus: MutantStatus; + statusReason: string | null; + oracleClass: OracleClass; + source: OutcomeSource; + reason: string | null; + /** The latest record file, or null. */ + record: string | null; + attempts: number; + exitCode: number | null; + wallClockMs: number | null; + executed: number; + killedBy: string[]; + assertionKilledBy: string[]; + tests: OracleTestRow[]; + outsideTest: OutsideTestOutcome[]; +} + +export interface OracleCommandRun { + schemaVersion: typeof ORACLE_RUN_SCHEMA_VERSION; + kind: 'oracle-command-run'; + scope: { testFiles: string[]; mutate: string[] }; + paths: { + outDir: string; + packageDir: string; + reportDir: string; + mutationReport: string; + driverResults: string; + logFile: string; + scopeFile: string; + sandboxDir: string | null; + sandboxDirs: string[]; + }; + command: string; + stryker: { exitCode: number; wallClockMs: number }; + spanMapFailures: number; + baseline: { + record: string | null; + outcome: OracleClass | null; + reason: string | null; + executed: string[]; + }; + mutants: OracleMutantResult[]; + problems: string[]; + summary: { + mutants: number; + byStrykerStatus: Record; + byClass: Record; + withRecord: number; + meanWrapperWallClockMs: number | null; + }; +} + +const parseInteger = (value: string | undefined, name: string, fallback: number): number => { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value) || Number(value) < 1) { + throw new UsageError(`--${name} must be a positive integer, got ${value}`); + } + return Number(value); +}; + +export function parseCommandRunArgs(argv: readonly string[], cwd: string): CommandRunCliOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + scope: { type: 'string' }, + out: { type: 'string' }, + concurrency: { type: 'string' }, + 'timeout-ms': { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }); + if (values.scope === undefined) throw new UsageError('--scope is required'); + if (values.out === undefined) throw new UsageError('--out is required'); + return { + scopeFile: path.resolve(cwd, values.scope), + outDir: path.resolve(cwd, values.out), + concurrency: parseInteger(values.concurrency, 'concurrency', 2), + timeoutMS: parseInteger(values['timeout-ms'], 'timeout-ms', 10000), + }; +} + +/** Quotes one word for `/bin/sh`, which Stryker's command runner uses on POSIX systems. */ +export function shellQuote(value: string): string { + return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** The `commandRunner.command`: the wrapper of the prepared copy, under `node --import tsx`. */ +export function wrapperCommand(nodePath: string, packageDir: string, reportDir: string): string { + return [ + shellQuote(nodePath), + '--import', + 'tsx', + shellQuote(path.join(packageDir, WRAPPER_FILE)), + '--report-dir', + shellQuote(reportDir), + ].join(' '); +} + +export interface RecordEntry { + record: OracleRunRecord; + file: string; + attempts: number; +} + +/** The latest record per key (`dry-run` or the local mutant id) in the report dir. */ +export function readRecords(reportDir: string): { + latest: Map; + problems: string[]; +} { + const latest = new Map(); + const problems: string[] = []; + const entries = existsSync(reportDir) ? readdirSync(reportDir).sort() : []; + for (const name of entries) { + const parsed = parseRecordFileName(name); + if (parsed === null) continue; + const file = path.join(reportDir, name); + let record: OracleRunRecord; + try { + record = parseOracleRunRecord(readFileSync(file, 'utf8')); + } catch (error) { + problems.push(`${file}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } + if ( + record.kind !== parsed.kind || + record.mutantId !== parsed.mutantId || + record.attempt !== parsed.attempt + ) { + problems.push(`${file}: kind, mutant id or attempt differ from the file name`); + continue; + } + const key = parsed.mutantId ?? 'dry-run'; + const previous = latest.get(key); + const attempts = (previous?.attempts ?? 0) + 1; + if (previous === undefined || previous.record.attempt < record.attempt) { + latest.set(key, { record, file, attempts }); + } else { + latest.set(key, { ...previous, attempts }); + } + } + return { latest, problems }; +} + +const firstLine = (message: string | null): string | null => { + if (message === null) return null; + const line = message.split('\n', 1)[0] ?? ''; + return line.length > ROW_MESSAGE_LIMIT ? `${line.slice(0, ROW_MESSAGE_LIMIT)}...` : line; +}; + +const zeroCounts = (keys: readonly T[]): Record => + Object.fromEntries(keys.map((key) => [key, 0])) as Record; + +/** Runs the oracle's Stryker run for one scope and classifies every mutant. */ +export async function runOracleCommand( + options: CommandRunCliOptions & { repoRoot: string }, +): Promise<{ result: OracleCommandRun; exitCode: number }> { + const layout = runLayout(options.outDir); + const reportDir = path.join(layout.outDir, REPORT_DIR_NAME); + const command = wrapperCommand(process.execPath, layout.packageDir, reportDir); + const { results } = await runScope({ + repoRoot: options.repoRoot, + scopeFile: options.scopeFile, + outDir: options.outDir, + variant: 'command', + command, + concurrency: options.concurrency, + maxTestRunnerReuse: 0, + timeoutMS: options.timeoutMS, + }); + if (results.schemaVersion !== RESULTS_SCHEMA_VERSION) { + throw new Error(`driver results schemaVersion ${results.schemaVersion} is not supported`); + } + + const problems: string[] = []; + const statuses = strykerStatuses(readMutationReport(layout.reportFile)); + const { latest, problems: recordProblems } = readRecords(reportDir); + problems.push(...recordProblems); + + const dryRun = latest.get('dry-run'); + const baseline = dryRun === undefined ? null : classifyRecord(dryRun.record, null); + if (baseline === null) problems.push('the dry run left no record'); + else if (baseline.outcome !== 'survived' || baseline.executed.length === 0) { + problems.push(`the dry run classifies as ${baseline.outcome}: ${baseline.reason ?? ''}`); + } + const expected = baseline === null ? null : baseline.executed; + + const known = new Set(results.mutants.map((mutant) => mutant.localId)); + for (const key of latest.keys()) { + if (key !== 'dry-run' && !known.has(key)) { + problems.push(`a record exists for local id ${key}, which mutation.json lacks`); + } + } + + const mutants = results.mutants.map((mutant): OracleMutantResult => { + const strykerStatus = statuses.get(mutant.localId); + if (strykerStatus === undefined) { + problems.push(`mutation.json lacks local id ${mutant.localId}`); + } + const entry = latest.get(mutant.localId); + const classified = classifyMutant({ + strykerStatus: strykerStatus ?? mutant.status, + record: entry?.record ?? null, + expected, + }); + return { + globalId: mutant.globalId, + localId: mutant.localId, + file: mutant.file, + location: mutant.location, + mutatorName: mutant.mutatorName, + replacement: mutant.replacement, + static: mutant.static, + strykerStatus: strykerStatus ?? mutant.status, + statusReason: mutant.statusReason, + oracleClass: classified.outcome, + source: classified.source, + reason: classified.reason, + record: entry?.file ?? null, + attempts: entry?.attempts ?? 0, + exitCode: entry?.record.exitCode ?? null, + wallClockMs: entry?.record.wallClockMs ?? null, + executed: classified.executed.length, + killedBy: classified.killedBy, + assertionKilledBy: classified.assertionKilledBy, + tests: classified.tests + .filter((test) => test.outcome !== 'survived') + .map((test) => ({ id: test.id, outcome: test.outcome, message: firstLine(test.message) })), + outsideTest: classified.outsideTest, + }; + }); + + const sandboxDirs = results.paths.sandboxDirs; + const sandboxDir = + sandboxDirs.length === 1 && existsSync(sandboxDirs[0] ?? '') ? (sandboxDirs[0] ?? null) : null; + if (sandboxDir === null) { + problems.push( + `expected one kept Stryker sandbox, found ${JSON.stringify(sandboxDirs)} (cleanTempDir false)`, + ); + } + if (results.spanMap.failures.length > 0) { + problems.push(`${results.spanMap.failures.length} span-map failures (see results.json)`); + } + + const byStrykerStatus = zeroCounts(MUTANT_STATUSES); + const byClass = zeroCounts(ORACLE_CLASSES); + for (const mutant of mutants) { + byStrykerStatus[mutant.strykerStatus]++; + byClass[mutant.oracleClass]++; + } + const wallClocks = mutants.flatMap((mutant) => mutant.wallClockMs ?? []); + const result: OracleCommandRun = { + schemaVersion: ORACLE_RUN_SCHEMA_VERSION, + kind: 'oracle-command-run', + scope: results.scope, + paths: { + outDir: layout.outDir, + packageDir: layout.packageDir, + reportDir, + mutationReport: layout.reportFile, + driverResults: layout.resultsFile, + logFile: layout.logFile, + scopeFile: layout.scopeFile, + sandboxDir, + sandboxDirs, + }, + command, + stryker: results.stryker, + spanMapFailures: results.spanMap.failures.length, + baseline: { + record: dryRun?.file ?? null, + outcome: baseline?.outcome ?? null, + reason: baseline?.reason ?? null, + executed: baseline?.executed ?? [], + }, + mutants, + problems, + summary: { + mutants: mutants.length, + byStrykerStatus, + byClass, + withRecord: mutants.filter((mutant) => mutant.record !== null).length, + meanWrapperWallClockMs: + wallClocks.length === 0 + ? null + : Math.round(wallClocks.reduce((sum, ms) => sum + ms, 0) / wallClocks.length), + }, + }; + writeFileSync(path.join(layout.outDir, RESULT_FILE_NAME), `${JSON.stringify(result, null, 2)}\n`); + return { result, exitCode: problems.length === 0 ? 0 : 1 }; +} + +const formatCounts = (counts: Readonly>): string => + Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([key, count]) => `${count} ${key}`) + .join(', '); + +export function formatOracleSummary(result: OracleCommandRun): string { + const { summary } = result; + return [ + `oracle command run: ${summary.mutants} mutants; Stryker: ${formatCounts(summary.byStrykerStatus)}`, + ` oracle classes: ${formatCounts(summary.byClass)}`, + ` records: ${summary.withRecord}/${summary.mutants}; mean wrapper run ` + + `${summary.meanWrapperWallClockMs ?? '-'} ms; baseline ${result.baseline.outcome ?? 'missing'} ` + + `(${result.baseline.executed.length} tests)`, + ` Stryker: exit ${result.stryker.exitCode} in ${formatDuration(result.stryker.wallClockMs)}`, + ` kept sandbox: ${result.paths.sandboxDir ?? 'none'}`, + ...result.problems.slice(0, 10).map((problem) => ` problem: ${problem}`), + ` results: ${path.join(result.paths.outDir, RESULT_FILE_NAME)}`, + ].join('\n'); +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + const started = performance.now(); + try { + const options = parseCommandRunArgs(process.argv.slice(2), process.cwd()); + const { result, exitCode } = await runOracleCommand({ ...options, repoRoot }); + console.log(formatOracleSummary(result)); + console.log(` total wall clock: ${formatDuration(performance.now() - started)}`); + process.exitCode = exitCode; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof UsageError || error instanceof TypeError) { + console.error( + 'usage: npm run test:mutation:oracle:command -- --scope --out ' + + '[--concurrency ] [--timeout-ms ]', + ); + } + if (!(error instanceof StrykerRunError || error instanceof UsageError)) { + console.error(error instanceof Error ? error.stack : ''); + } + process.exitCode = 2; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + void main(); +} diff --git a/gitnexus/tools/mutation/oracle/command-wrapper.ts b/gitnexus/tools/mutation/oracle/command-wrapper.ts new file mode 100644 index 000000000..2cb374cd5 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/command-wrapper.ts @@ -0,0 +1,226 @@ +/** + * Oracle command wrapper (U13, KTD17, KTD18): the `commandRunner.command` of the oracle's own + * Stryker run (command-run.ts). Stryker's command runner sees only an exit code, so the wrapper + * runs Vitest itself and leaves one record per run for the oracle's classifier. + * + * Stryker runs the command with its working directory in the instrumented sandbox and, for a + * mutant run, `__STRYKER_ACTIVE_MUTANT__` set to the local mutant id; the dry run has no active + * mutant. The wrapper: + * 1. runs Vitest in its own process through `startVitest`, the code path of `vitest run`, with + * the sandbox as root, the sandbox's copy of the mutation config (`--config`), no filter (every + * test file of the scope named by GITNEXUS_MUTATION_SCOPE), no results cache, no fs module + * cache and a Vite cacheDir of its own inside the sandbox (KTD5). The prepared copy's + * node_modules is a symlink to the checkout's, so Vitest's default caches would be shared. + * The active mutant stays in the environment: Vitest's fresh fork workers inherit it and the + * instrumented code activates it; + * 2. reports with Vitest's JSON reporter and the oracle reporter (oracle-reporter.ts); + * 3. writes the record (schema in oracle-classify.ts) to `/.attempt-.json` + * through a temporary name and a rename, next to Vitest's `.attempt-.vitest.json`; + * 4. exits with Vitest's exit code, so Stryker sees Killed (non-zero) or Survived (0) as usual + * and the dry run passes only when every test passes. + * When Vitest cannot run (missing config, startup error) the record carries `wrapperError` and + * the wrapper exits 1. + * + * Usage (Stryker runs it from the sandbox): + * node --import tsx /gitnexus/tools/mutation/oracle/command-wrapper.ts \ + * --report-dir [--config ] + */ +import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import type { Vite } from 'vitest/node'; +import { + ORACLE_RECORD_SCHEMA_VERSION, + isMutantId, + parseRecordFileName, + recordFileName, + vitestJsonFileName, + type OracleRunRecord, + type RecordKind, + type VitestJsonTotals, +} from './oracle-classify.js'; +import { OracleReporter } from './oracle-reporter.js'; +import { writeFileAtomicSync } from './util.js'; + +export const DEFAULT_VITEST_CONFIG = 'tools/mutation/vitest.mutation.config.ts'; +/** Per-process Vite cache directories live under this sandbox directory (KTD5). */ +export const ORACLE_CACHE_DIR = '.oracle-vitest'; + +export interface WrapperArgs { + /** Absolute directory for records and Vitest JSON reports, outside the sandbox. */ + reportDir: string; + /** Vitest config file, relative to the working directory (the sandbox). */ + configFile: string; +} + +export function parseWrapperArgs(argv: readonly string[]): WrapperArgs { + const { values } = parseArgs({ + args: [...argv], + options: { + 'report-dir': { type: 'string' }, + config: { type: 'string', default: DEFAULT_VITEST_CONFIG }, + }, + strict: true, + allowPositionals: false, + }); + const reportDir = values['report-dir']; + if (reportDir === undefined || !path.isAbsolute(reportDir)) { + throw new Error('oracle wrapper: --report-dir must be an absolute directory'); + } + return { reportDir, configFile: values.config }; +} + +/** The run's kind and mutant id from the environment Stryker gives the command. */ +export function activeRun(env: Readonly>): { + kind: RecordKind; + mutantId: string | null; +} { + const value = env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]; + if (value === undefined || value === '') return { kind: 'dry-run', mutantId: null }; + if (!isMutantId(value)) { + throw new Error(`oracle wrapper: active mutant id ${JSON.stringify(value)} is not numeric`); + } + return { kind: 'mutant', mutantId: value }; +} + +/** One more than the highest attempt of the run's key among the report dir's entries. */ +export function nextAttempt( + entries: readonly string[], + kind: RecordKind, + mutantId: string | null, +): number { + const attempts = entries + .map(parseRecordFileName) + .filter((entry) => entry !== null && entry.kind === kind && entry.mutantId === mutantId) + .map((entry) => entry?.attempt ?? 0); + return Math.max(0, ...attempts) + 1; +} + +/** The totals the classifier cross-checks, from Vitest's JSON report; null when unreadable. */ +export function readVitestJsonTotals(file: string): VitestJsonTotals | null { + if (!existsSync(file)) return null; + try { + const data: unknown = JSON.parse(readFileSync(file, 'utf8')); + if (typeof data !== 'object' || data === null) return null; + const pick = (key: string): number | null => { + const value: unknown = key in data ? Reflect.get(data, key) : undefined; + return typeof value === 'number' ? value : null; + }; + const [numTotalTests, numPassedTests, numFailedTests] = [ + pick('numTotalTests'), + pick('numPassedTests'), + pick('numFailedTests'), + ]; + if (numTotalTests === null || numPassedTests === null || numFailedTests === null) return null; + return { numTotalTests, numPassedTests, numFailedTests }; + } catch { + return null; + } +} + +export interface OracleVitestOptions { + /** Vitest root: the Stryker sandbox (or any prepared `gitnexus/` copy). */ + root: string; + /** Absolute Vitest config file. */ + configFile: string; + reportDir: string; + kind: RecordKind; + mutantId: string | null; + /** CLI filters, e.g. `:` for a single-test launch (launch-single.ts); none here. */ + filters?: readonly string[]; + /** Vitest's `-t` pattern for a single-test launch. */ + testNamePattern?: string; + /** Extra Vite plugins, e.g. one whose `configureVitest` hook adds the oracle setup file. */ + plugins?: readonly Vite.Plugin[]; +} + +/** Runs Vitest once as described in the module comment; returns the record it wrote. */ +export async function runOracleVitest(options: OracleVitestOptions): Promise { + const { root, configFile, reportDir, kind, mutantId } = options; + mkdirSync(reportDir, { recursive: true }); + const attempt = nextAttempt(readdirSync(reportDir), kind, mutantId); + const recordFile = path.join(reportDir, recordFileName(kind, mutantId, attempt)); + const vitestJsonFile = path.join(reportDir, vitestJsonFileName(kind, mutantId, attempt)); + const reporter = new OracleReporter(root); + const started = performance.now(); + let wrapperError: string | null = null; + let vitestVersion = ''; + let exitCode = 1; + + if (!existsSync(configFile)) { + wrapperError = `the Vitest config ${configFile} does not exist`; + } else { + process.exitCode = undefined; + try { + const { startVitest } = await import('vitest/node'); + const vitest = await startVitest( + [...(options.filters ?? [])], + { + root, + config: configFile, + run: true, + watch: false, + cache: false, + fsModuleCache: false, + reporters: [['json', { outputFile: vitestJsonFile }], reporter], + ...(options.testNamePattern === undefined + ? {} + : { testNamePattern: options.testNamePattern }), + }, + { + cacheDir: path.join(root, ORACLE_CACHE_DIR, `cache-${process.pid}`), + ...(options.plugins === undefined ? {} : { plugins: [...options.plugins] }), + }, + ); + vitestVersion = vitest.version; + exitCode = Number(process.exitCode ?? 0); + } catch (error) { + wrapperError = `Vitest failed to run: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`; + } + } + + const record: OracleRunRecord = { + schemaVersion: ORACLE_RECORD_SCHEMA_VERSION, + kind, + mutantId, + attempt, + pid: process.pid, + root, + configFile, + vitestVersion, + exitCode: wrapperError === null ? exitCode : 1, + wallClockMs: Math.round(performance.now() - started), + wrapperError, + run: wrapperError === null ? reporter.run : null, + vitestJson: readVitestJsonTotals(vitestJsonFile), + vitestJsonFile: existsSync(vitestJsonFile) ? vitestJsonFile : null, + }; + writeFileAtomicSync(recordFile, `${JSON.stringify(record)}\n`); + return record; +} + +async function main(): Promise { + let code = 1; + try { + const args = parseWrapperArgs(process.argv.slice(2)); + const root = process.cwd(); + const record = await runOracleVitest({ + root, + configFile: path.resolve(root, args.configFile), + reportDir: args.reportDir, + ...activeRun(process.env), + }); + if (record.wrapperError !== null) console.error(`oracle wrapper: ${record.wrapperError}`); + code = record.exitCode; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + } + // Vitest is closed; leftover handles must not keep Stryker waiting. + process.exit(code); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + void main(); +} diff --git a/gitnexus/tools/mutation/oracle/compare.ts b/gitnexus/tools/mutation/oracle/compare.ts new file mode 100644 index 000000000..925e83f53 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/compare.ts @@ -0,0 +1,705 @@ +/** + * Gate 3 (U7; KTD11, KTD12, KTD13, KTD17, KTD18): compares vitest5 runner runs (U10 `results.json`) + * with the oracle's frozen manifests (freeze.ts). + * + * Per scope, in this order: + * 1. Freshness (KTD12): the input hashes of the checkout, the Stryker and Vitest versions, the + * Node major version and the runner run's Stryker `mutator` option must equal the manifest's. + * Any mismatch is "re-freeze required" (exit 3); nothing else is compared. The runner run must + * also have run the manifest's scope, on the manifest's Vitest version, from a prepared copy + * whose input files hash like the manifest's; otherwise the runner run is stale (exit 2). + * 2. Runner run: its flags and span-map failures are differences. + * 3. Inventory (KTD18): the oracle's test inventory (the manifest's tests) must equal the runner's + * run manifest, and the global mutant ids of both runs must be identical sets. Any difference + * blocks the scope before outcomes are compared. + * 4. Classes: every mutant's runner class must equal its oracle class. + * 5. Per-test outcomes: for every sampled mutant and every oracle-validated test, the runner's + * per-test class (from the mutant's latest sidecar record) must equal the frozen one. + * Then every stratum is counted across the compared manifests (freeze.ts `combineStrata`); an + * unmet stratum is a difference. + * + * Normalisations (KTD13), exactly four; everything else must agree, whatever its known cause: + * timeout every timeout row of KTD17 (Vitest's test or hook timeout, the hit limit, Stryker's + * timer, the oracle's wall clock) is the one class `timeout` + * no-coverage NoCoverage and survived are one class; the raw Stryker status stays in the report, + * and a test the runner did not run because it does not cover the mutant is a + * survived observation of that test + * titles title escaping in test ids (freeze.ts `normaliseTestId`) + * paths path separators in test ids + * + * The runner's per-test class of a test: its row in the sidecar record when it ran; else the class + * of an outside-test row that attributes to it (a failed `beforeAll`); else collection when its + * file failed to collect; else survived (not run). A mutant the runner timed out with Stryker's + * timer has no record: its per-test observations are listed as unsupported (the runner claims no + * per-test outcome; step 4 compares its class). Any other mutant without a record is a difference. + * + * Report (`--out`, {@link Gate3Report}, schemaVersion 1): + * result pass | differences | refreeze-required | stale-runner-run + * exitCode, normalisations[] + * scopes[] {@link ScopeReport}: name, manifest, results, result, freshness + * {fresh, mismatches[], refreeze}, staleness[], runnerRun {flags[], + * spanMapFailures}, inventory {blocked, tests, mutants} (counts and the ids + * only one side has), classes {compared, agree, normalised[], + * differences[]}, perTest {sampledMutants, compared, agree, notRunAsSurvived, + * notOracleValidated, unsupported[], differences[]}; classes and perTest + * are null when an earlier step blocked + * strata[] freeze.ts `CombinedStratum` per stratum + * summary[] the printed lines + * Class rows keep the runner's raw Stryker status and outcome source. + * + * Usage (from gitnexus/): + * npm run test:mutation:oracle -- --scope , [--scope ...] + * [--out ] + * Exit codes: 0 pass; 1 differences; 2 usage or setup error, a manifest with problems, or a stale + * runner run; 3 re-freeze required (the manifest's `refreeze` command says how). + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { PACKAGE_DIR } from '../prepare/path-map.js'; +import { + FreezeUsageError, + combineStrata, + currentVersions, + diffInputs, + diffInventory, + hashInputs, + inputFiles, + inventoryDiffers, + normaliseTestId, + preparedInputs, + readManifest, + readRunManifest, + readRunnerResults, + readStrykerMutator, + toOracleClass, + type CombinedStratum, + type InputHashes, + type InventoryDiff, + type JsonValue, + type OracleManifest, + type RunnerMutantView, + type RunnerRunView, + type ToolVersions, +} from './freeze.js'; +import type { OracleClass } from './oracle-classify.js'; +import { isObject } from './util.js'; + +export const REPORT_SCHEMA_VERSION = 1; +export const EXIT_PASS = 0; +export const EXIT_DIFFERENCES = 1; +export const EXIT_USAGE = 2; +export const EXIT_REFREEZE = 3; + +export const NORMALISATIONS = [ + 'timeout: every KTD17 timeout row is the one class timeout', + 'no-coverage: NoCoverage and survived are one class (raw status kept); a test the runner did ' + + 'not run is a survived observation', + 'titles: title escaping in test ids', + 'paths: path separators in test ids', +] as const; + +// ── freshness (KTD12) ────────────────────────────────────────────────────── + +export interface FreshnessMismatch { + kind: 'input' | 'version' | 'operators'; + subject: string; + frozen: string; + current: string; +} + +export interface Freshness { + fresh: boolean; + mismatches: FreshnessMismatch[]; + refreeze: string; +} + +export interface CurrentState { + inputs: InputHashes; + versions: ToolVersions; + /** The runner run's Stryker `mutator` option. */ + mutator: JsonValue; +} + +/** Whether a manifest still describes the checkout and toolchain; pure. */ +export function checkFreshness(manifest: OracleManifest, current: CurrentState): Freshness { + const mismatches: FreshnessMismatch[] = diffInputs(manifest.inputs, current.inputs).map( + (entry) => ({ + kind: 'input', + subject: `${entry.group}: ${entry.path}`, + frozen: entry.expected, + current: entry.actual, + }), + ); + const versions: [string, string, string][] = [ + ['stryker', manifest.versions.stryker, current.versions.stryker], + ['vitest', manifest.versions.vitest, current.versions.vitest], + ['node major', String(manifest.versions.nodeMajor), String(current.versions.nodeMajor)], + ]; + for (const [subject, frozen, now] of versions) { + if (frozen !== now) mismatches.push({ kind: 'version', subject, frozen, current: now }); + } + const frozenMutator = JSON.stringify(manifest.operators.mutator); + const currentMutator = JSON.stringify(current.mutator); + if (frozenMutator !== currentMutator) { + mismatches.push({ + kind: 'operators', + subject: 'Stryker mutator option', + frozen: frozenMutator, + current: currentMutator, + }); + } + return { fresh: mismatches.length === 0, mismatches, refreeze: manifest.refreeze }; +} + +// ── runner evidence ──────────────────────────────────────────────────────── + +export interface SidecarView { + tests: { id: string; outcome: OracleClass }[]; + outsideTest: { + file: string; + suiteTaskId: string | null; + outcome: OracleClass; + attributedTo: string[]; + }[]; +} + +/** Reads the parts of a runner sidecar record (KTD6) gate 3 needs; own parser, no runner code. */ +export function readSidecarView(file: string): SidecarView { + const data: unknown = JSON.parse(readFileSync(file, 'utf8')); + const result = isObject(data) ? data.result : undefined; + if (!isObject(result) || !Array.isArray(result.tests) || !Array.isArray(result.outsideTest)) { + throw new FreezeUsageError(`${file} is not a sidecar record`); + } + const text = (value: unknown, at: string): string => { + if (typeof value !== 'string') throw new FreezeUsageError(`${file}: ${at} must be a string`); + return value; + }; + return { + tests: result.tests.map((row: unknown, index) => { + const at = `result.tests[${index}]`; + if (!isObject(row)) throw new FreezeUsageError(`${file}: ${at} must be an object`); + return { id: text(row.id, `${at}.id`), outcome: toOracleClass(row.outcome, at) }; + }), + outsideTest: result.outsideTest.map((row: unknown, index) => { + const at = `result.outsideTest[${index}]`; + if (!isObject(row) || !Array.isArray(row.attributedTo)) { + throw new FreezeUsageError(`${file}: ${at} must be an outside-test row`); + } + return { + file: text(row.file, `${at}.file`), + suiteTaskId: typeof row.suiteTaskId === 'string' ? row.suiteTaskId : null, + outcome: toOracleClass(row.outcome, at), + attributedTo: row.attributedTo.map((id, i) => text(id, `${at}.attributedTo[${i}]`)), + }; + }), + }; +} + +/** What the runner says about a mutant's tests. */ +export type RunnerPerTest = + | { kind: 'record'; sidecar: SidecarView } + | { kind: 'no-coverage' } + | { kind: 'unsupported'; reason: string } + | { kind: 'missing'; reason: string }; + +/** The runner's per-test evidence for one mutant (see the module comment). */ +export function runnerPerTest( + mutant: RunnerMutantView, + readSidecar: (file: string) => SidecarView = readSidecarView, +): RunnerPerTest { + if (mutant.sidecarFile !== null) + return { kind: 'record', sidecar: readSidecar(mutant.sidecarFile) }; + if (mutant.status === 'NoCoverage') return { kind: 'no-coverage' }; + if (mutant.outcomeSource === 'stryker-timeout') { + return { kind: 'unsupported', reason: "Stryker's timer killed the run; no record" }; + } + return { kind: 'missing', reason: `the runner left no record (status ${mutant.status})` }; +} + +export interface RunnerTestClass { + outcome: OracleClass; + /** How the class was found: row, attributed, collection, not-run. */ + source: 'row' | 'attributed' | 'collection' | 'not-run'; +} + +/** + * Indexes a sidecar record once and returns the runner's class of one test (normalised id) in it: + * its row, else the first outside-test row attributed to it, else the first collection failure + * of its file, else survived (not run); each lookup takes the first match in record order. Pure. + */ +function sidecarTestClasses(sidecar: SidecarView): (testId: string) => RunnerTestClass { + const rows = new Map(); + const attributed = new Map(); + const collections = new Map(); + const first = (index: Map, key: string, outcome: OracleClass): void => { + if (!index.has(key)) index.set(key, outcome); + }; + for (const entry of sidecar.tests) first(rows, normaliseTestId(entry.id), entry.outcome); + for (const entry of sidecar.outsideTest) { + for (const id of entry.attributedTo) first(attributed, normaliseTestId(id), entry.outcome); + if (entry.outcome === 'killed-outside-test:collection') { + first(collections, entry.file.replaceAll('\\', '/'), entry.outcome); + } + } + return (testId) => { + const key = normaliseTestId(testId); + const row = rows.get(key); + if (row !== undefined) return { outcome: row, source: 'row' }; + const outside = attributed.get(key); + if (outside !== undefined) return { outcome: outside, source: 'attributed' }; + const collection = collections.get(key.slice(0, key.indexOf('#'))); + if (collection !== undefined) return { outcome: collection, source: 'collection' }; + return { outcome: 'survived', source: 'not-run' }; + }; +} + +/** The runner's class of one test (normalised id) in a sidecar record; pure. */ +export function runnerTestClass(sidecar: SidecarView, testId: string): RunnerTestClass { + return sidecarTestClasses(sidecar)(testId); +} + +// ── comparison ───────────────────────────────────────────────────────────── + +export interface ClassRow { + globalId: string; + oracle: OracleClass; + runner: OracleClass | null; + /** The runner's raw Stryker status. */ + runnerStatus: string; + runnerSource: string; + /** The KTD13 rule that made the row agree; null for an identical or differing row. */ + rule: 'no-coverage' | 'timeout' | null; +} + +export interface PerTestRow { + globalId: string; + testId: string; + oracle: OracleClass; + runner: OracleClass; + runnerSource: RunnerTestClass['source'] | 'missing'; +} + +export interface InventoryReport extends InventoryDiff { + oracle: number; + runner: number; +} + +export interface ScopeReport { + name: string; + manifest: string; + results: string; + result: 'pass' | 'differences' | 'refreeze-required' | 'stale-runner-run'; + freshness: Freshness; + staleness: string[]; + runnerRun: { flags: string[]; spanMapFailures: number }; + inventory: { blocked: boolean; tests: InventoryReport; mutants: InventoryReport } | null; + classes: { + compared: number; + agree: number; + normalised: ClassRow[]; + differences: ClassRow[]; + } | null; + perTest: { + sampledMutants: number; + compared: number; + agree: number; + notRunAsSurvived: number; + notOracleValidated: number; + unsupported: { globalId: string; reason: string; observations: number }[]; + differences: PerTestRow[]; + } | null; +} + +export interface ScopeInput { + manifest: OracleManifest; + manifestFile: string; + freshness: Freshness; + /** Why the runner run does not belong to this manifest; empty when it does. */ + staleness: string[]; + runner: RunnerRunView; + /** Test ids of the runner's run manifest. */ + runnerTests: readonly string[]; + /** The runner's per-test evidence of a mutant, by global id. */ + perTest: (mutant: RunnerMutantView) => RunnerPerTest; +} + +const inventoryReport = ( + oracle: readonly string[], + runner: readonly string[], + normalise?: (id: string) => string, +): InventoryReport => ({ + oracle: oracle.length, + runner: runner.length, + ...diffInventory(oracle, runner, normalise), +}); + +/** Compares one runner run with one manifest (steps 1-5 of the module comment); pure. */ +export function compareScope(input: ScopeInput): ScopeReport { + const { manifest, runner } = input; + const base = { + name: manifest.name, + manifest: input.manifestFile, + results: runner.file, + freshness: input.freshness, + staleness: input.staleness, + runnerRun: { flags: runner.flags, spanMapFailures: runner.spanMapFailures }, + }; + if (!input.freshness.fresh) { + return { ...base, result: 'refreeze-required', inventory: null, classes: null, perTest: null }; + } + if (input.staleness.length > 0) { + return { ...base, result: 'stale-runner-run', inventory: null, classes: null, perTest: null }; + } + const runnerRunDiffers = runner.flags.length > 0 || runner.spanMapFailures > 0; + const oracleMutants = manifest.mutants.map(([globalId]) => globalId); + const inventory = { + tests: inventoryReport(manifest.tests, input.runnerTests, normaliseTestId), + mutants: inventoryReport( + oracleMutants, + runner.mutants.map((mutant) => mutant.globalId), + ), + blocked: false, + }; + inventory.blocked = inventoryDiffers(inventory.tests) || inventoryDiffers(inventory.mutants); + if (inventory.blocked) { + return { ...base, result: 'differences', inventory, classes: null, perTest: null }; + } + + const runnerById = new Map(runner.mutants.map((mutant) => [mutant.globalId, mutant])); + const normalised: ClassRow[] = []; + const classDifferences: ClassRow[] = []; + for (const [globalId, oracle] of manifest.mutants) { + const mutant = runnerById.get(globalId); + if (mutant === undefined) continue; + const row: ClassRow = { + globalId, + oracle, + runner: mutant.outcome, + runnerStatus: mutant.status, + runnerSource: mutant.outcomeSource, + rule: null, + }; + if (mutant.outcome !== oracle) classDifferences.push(row); + else if (mutant.status === 'NoCoverage') normalised.push({ ...row, rule: 'no-coverage' }); + else if (oracle === 'timeout') normalised.push({ ...row, rule: 'timeout' }); + } + + const notOracleValidated = new Set(manifest.notOracleValidated); + const perTest: NonNullable = { + sampledMutants: manifest.sampled.length, + compared: 0, + agree: 0, + notRunAsSurvived: 0, + notOracleValidated: 0, + unsupported: [], + differences: [], + }; + for (const sampled of manifest.sampled) { + const mutant = runnerById.get(sampled.id); + if (mutant === undefined) continue; + const frozen = new Map(sampled.outcomes); + const invalid = new Set(sampled.invalid.map(([position]) => position)); + const evidence = input.perTest(mutant); + const validated = manifest.tests.flatMap((testId, position) => + notOracleValidated.has(testId) || invalid.has(position) ? [] : [{ testId, position }], + ); + perTest.notOracleValidated += manifest.tests.filter((id) => notOracleValidated.has(id)).length; + if (evidence.kind === 'unsupported') { + perTest.unsupported.push({ + globalId: sampled.id, + reason: evidence.reason, + observations: validated.length, + }); + continue; + } + const recordClass = evidence.kind === 'record' ? sidecarTestClasses(evidence.sidecar) : null; + for (const { testId, position } of validated) { + const oracle = frozen.get(position) ?? 'survived'; + const found: RunnerTestClass | null = + recordClass !== null + ? recordClass(testId) + : evidence.kind === 'no-coverage' + ? { outcome: 'survived', source: 'not-run' } + : null; + perTest.compared++; + if (found !== null && found.outcome === oracle) { + perTest.agree++; + if (found.source === 'not-run') perTest.notRunAsSurvived++; + continue; + } + perTest.differences.push({ + globalId: sampled.id, + testId, + oracle, + runner: found?.outcome ?? 'invalid', + runnerSource: found?.source ?? 'missing', + }); + } + } + + const differs = runnerRunDiffers || classDifferences.length > 0 || perTest.differences.length > 0; + return { + ...base, + result: differs ? 'differences' : 'pass', + inventory, + classes: { + compared: manifest.mutants.length, + agree: manifest.mutants.length - classDifferences.length, + normalised, + differences: classDifferences, + }, + perTest, + }; +} + +export interface Gate3Report { + schemaVersion: typeof REPORT_SCHEMA_VERSION; + kind: 'gitnexus-mutation-gate3-report'; + result: 'pass' | 'differences' | 'refreeze-required' | 'stale-runner-run'; + exitCode: number; + normalisations: readonly string[]; + scopes: ScopeReport[]; + strata: CombinedStratum[]; + summary: string[]; +} + +/** The gate's exit code: re-freeze first, then a stale runner run, then differences; pure. */ +export function gateExitCode( + scopes: readonly ScopeReport[], + strata: readonly CombinedStratum[], +): number { + if (scopes.some((scope) => scope.result === 'refreeze-required')) return EXIT_REFREEZE; + if (scopes.some((scope) => scope.result === 'stale-runner-run')) return EXIT_USAGE; + const differs = + scopes.some((scope) => scope.result === 'differences') || + strata.some((stratum) => stratum.status === 'unmet'); + return differs ? EXIT_DIFFERENCES : EXIT_PASS; +} + +const RESULT_BY_EXIT: Record = { + [EXIT_PASS]: 'pass', + [EXIT_DIFFERENCES]: 'differences', + [EXIT_USAGE]: 'stale-runner-run', + [EXIT_REFREEZE]: 'refreeze-required', +}; + +export function summarise( + scopes: readonly ScopeReport[], + strata: readonly CombinedStratum[], +): string[] { + const lines: string[] = []; + for (const scope of scopes) { + lines.push(`gate 3 ${scope.name}: ${scope.result}`); + if (!scope.freshness.fresh) { + lines.push(` re-freeze required: ${scope.freshness.mismatches.length} mismatches`); + for (const mismatch of scope.freshness.mismatches.slice(0, 10)) { + lines.push( + ` ${mismatch.kind} ${mismatch.subject}: ${mismatch.frozen} -> ${mismatch.current}`, + ); + } + lines.push(` ${scope.freshness.refreeze}`); + continue; + } + for (const reason of scope.staleness.slice(0, 10)) lines.push(` stale runner run: ${reason}`); + if (scope.runnerRun.flags.length > 0 || scope.runnerRun.spanMapFailures > 0) { + lines.push( + ` runner run: ${scope.runnerRun.flags.length} flags, ` + + `${scope.runnerRun.spanMapFailures} span-map failures`, + ); + } + const { inventory } = scope; + if (inventory !== null) { + const describe = (label: string, entry: InventoryReport): string => + ` ${label}: oracle ${entry.oracle}, runner ${entry.runner}; only oracle ` + + `${entry.onlyA.length}, only runner ${entry.onlyB.length}, ambiguous ` + + `${entry.ambiguousA.length + entry.ambiguousB.length}`; + lines.push(describe('tests', inventory.tests), describe('mutants', inventory.mutants)); + for (const id of [...inventory.tests.onlyA, ...inventory.mutants.onlyA].slice(0, 5)) { + lines.push(` only oracle: ${id}`); + } + for (const id of [...inventory.tests.onlyB, ...inventory.mutants.onlyB].slice(0, 5)) { + lines.push(` only runner: ${id}`); + } + if (inventory.blocked) lines.push(' inventory differs: outcomes not compared'); + } + if (scope.classes !== null) { + const rules = (rule: ClassRow['rule']): number => + scope.classes?.normalised.filter((row) => row.rule === rule).length ?? 0; + lines.push( + ` classes: ${scope.classes.agree}/${scope.classes.compared} agree (NoCoverage as survived ` + + `${rules('no-coverage')}, timeout ${rules('timeout')}); ` + + `${scope.classes.differences.length} differences`, + ); + for (const row of scope.classes.differences.slice(0, 10)) { + lines.push( + ` ${row.globalId}: oracle ${row.oracle}, runner ${row.runner ?? 'none'} (${row.runnerStatus})`, + ); + } + } + if (scope.perTest !== null) { + lines.push( + ` per-test: ${scope.perTest.agree}/${scope.perTest.compared} agree over ` + + `${scope.perTest.sampledMutants} sampled mutants (not run as survived ` + + `${scope.perTest.notRunAsSurvived}); not oracle-validated ${scope.perTest.notOracleValidated}; ` + + `unsupported ${scope.perTest.unsupported.length} mutants; ` + + `${scope.perTest.differences.length} differences`, + ); + for (const row of scope.perTest.differences.slice(0, 10)) { + lines.push( + ` ${row.globalId} / ${row.testId}: oracle ${row.oracle}, runner ${row.runner} (${row.runnerSource})`, + ); + } + } + } + lines.push( + `strata across ${scopes.length} scopes: ${strata + .map( + (stratum) => `${stratum.name} ${stratum.sampled}/${stratum.population} ${stratum.status}`, + ) + .join(', ')}`, + ); + return lines; +} + +// ── I/O ──────────────────────────────────────────────────────────────────── + +export interface ComparePair { + manifest: string; + results: string; +} + +/** The runner run's inputs and versions that must match the manifest; empty when they do. */ +function runnerStaleness( + manifest: OracleManifest, + runner: RunnerRunView, + runnerVitest: string, +): string[] { + const reasons: string[] = []; + const sorted = (values: readonly string[]): string => JSON.stringify([...values].sort()); + if ( + sorted(runner.scope.testFiles) !== sorted(manifest.scope.testFiles) || + sorted(runner.scope.mutate) !== sorted(manifest.scope.mutate) + ) { + reasons.push('the runner run ran another scope than the manifest'); + } + if (runnerVitest !== manifest.versions.vitest) { + reasons.push( + `the runner run used Vitest ${runnerVitest}, the manifest ${manifest.versions.vitest}`, + ); + } + const mutated = new Set(manifest.scope.mutate.map((entry) => entry.split(':')[0])); + const prepared = preparedInputs(runner.preparedRoot, manifest.inputs); + for (const mismatch of diffInputs(manifest.inputs, prepared, mutated)) { + reasons.push(`the runner run was prepared from another ${mismatch.path} (${mismatch.group})`); + } + return reasons; +} + +/** Runs gate 3 over manifest and results pairs (see the module comment). */ +export function runGate3(repoRoot: string, pairs: readonly ComparePair[]): Gate3Report { + const packageDir = path.join(repoRoot, PACKAGE_DIR); + const versions = currentVersions(packageDir); + const scopes: ScopeReport[] = []; + const manifests: OracleManifest[] = []; + for (const pair of pairs) { + const manifest = readManifest(pair.manifest); + if (manifest.problems.length > 0) { + throw new FreezeUsageError( + `${pair.manifest} records ${manifest.problems.length} freeze problems; re-freeze it`, + ); + } + manifests.push(manifest); + const runner = readRunnerResults(pair.results); + const runManifest = readRunManifest(runner.runDir); + const freshness = checkFreshness(manifest, { + inputs: hashInputs(inputFiles(repoRoot, manifest.scope.testFiles), (repoPath) => + path.join(repoRoot, repoPath), + ), + versions, + mutator: existsSync(runner.configFile) ? readStrykerMutator(runner.configFile) : null, + }); + scopes.push( + compareScope({ + manifest, + manifestFile: pair.manifest, + freshness, + staleness: freshness.fresh + ? runnerStaleness(manifest, runner, runManifest.vitestVersion) + : [], + runner, + runnerTests: runManifest.tests.map((test) => test.id), + perTest: (mutant) => runnerPerTest(mutant), + }), + ); + } + const strata = combineStrata( + manifests.map((manifest) => ({ name: manifest.name, strata: manifest.strata })), + ); + const exitCode = gateExitCode(scopes, strata); + return { + schemaVersion: REPORT_SCHEMA_VERSION, + kind: 'gitnexus-mutation-gate3-report', + result: RESULT_BY_EXIT[exitCode] ?? 'differences', + exitCode, + normalisations: NORMALISATIONS, + scopes, + strata, + summary: summarise(scopes, strata), + }; +} + +export function parseCompareArgs( + argv: readonly string[], + cwd: string, +): { pairs: ComparePair[]; out: string | null } { + const { values } = parseArgs({ + args: [...argv], + options: { scope: { type: 'string', multiple: true }, out: { type: 'string' } }, + strict: true, + allowPositionals: false, + }); + if (values.scope === undefined || values.scope.length === 0) { + throw new FreezeUsageError('--scope , is required'); + } + return { + pairs: values.scope.map((entry) => { + const [manifest, results, ...rest] = entry.split(','); + if (!manifest || !results || rest.length > 0) { + throw new FreezeUsageError(`--scope ${entry}: expected ,`); + } + return { manifest: path.resolve(cwd, manifest), results: path.resolve(cwd, results) }; + }), + out: values.out === undefined ? null : path.resolve(cwd, values.out), + }; +} + +function main(): void { + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + try { + const { pairs, out } = parseCompareArgs(process.argv.slice(2), process.cwd()); + const report = runGate3(repoRoot, pairs); + if (out !== null) writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`); + console.log(report.summary.join('\n')); + console.log( + `gate 3: ${report.result} (exit ${report.exitCode})${out === null ? '' : `; report ${out}`}`, + ); + process.exitCode = report.exitCode; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof FreezeUsageError || error instanceof TypeError) { + console.error( + 'usage: npm run test:mutation:oracle -- --scope , ' + + '[--scope ...] [--out ]', + ); + } else { + console.error(error instanceof Error ? error.stack : ''); + } + process.exitCode = EXIT_USAGE; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main(); +} diff --git a/gitnexus/tools/mutation/oracle/freeze.ts b/gitnexus/tools/mutation/oracle/freeze.ts new file mode 100644 index 000000000..85ddb437e --- /dev/null +++ b/gitnexus/tools/mutation/oracle/freeze.ts @@ -0,0 +1,1879 @@ +/** + * Oracle freeze (U7, gate 3; KTD11, KTD12, KTD13, KTD17, KTD18): freezes the oracle's expected + * outcomes for one KTD11 scope into a manifest that compare.ts (gate 3) checks runner runs against. + * + * A freeze of one scope: + * 1. Oracle command run (U13, command-run.ts): the oracle class of every mutant and the kept, + * instrumented sandbox. Reused from `--command-run`, or from `/command/` once it exists. + * 2. Runner run (U10, vitest5 variant): only its run manifest is used, to check that the oracle's + * own test inventory (the command run's dry-run record) equals it (KTD18). Reused from + * `--runner-results`, or from `/runner/` once it exists. + * 3. Baseline: every test of the inventory is launched alone without a mutant (U12, + * launch-single.ts) and must pass. The oracle setup file also lists which mutant sites ran + * during load and collection, in hooks and in the test (probe.ts `coverage`); their union over + * the baseline launches gives the strata populations. + * 4. Sample and strata (below). A required stratum the sample misses fails the freeze before any + * mutant is launched. + * 5. Per-test outcomes: one launch per (sampled mutant, inventory test), at most + * `--launch-concurrency` at a time. + * 6. The manifest goes to `/manifest.json`, and to `--manifest` only when the freeze has no + * problems. + * Before launching, the inputs (KTD12 hashes) of the checkout are compared with the prepared + * copies both runs used, so a freeze never records hashes of code other than what ran. + * + * Sampling. A mutant's rank is the SHA-256 hex digest of `\n` (ties broken by the + * global id); the random sample is the `--sample-size` lowest ranks, all mutants when the scope has + * fewer. That is a seeded random permutation that does not depend on input order or on a PRNG + * implementation: the same seed always gives the same sample. Top-ups then add, per stratum in the + * order of {@link STRATA}, the lowest-ranked population members not yet selected until the sample + * holds `min(--top-up-min, population)` of them (`--top-up-min 0` disables top-ups). + * + * Strata (populations are mutants of the whole scope): + * static its site ran during module load or collection (baseline coverage) + * hook-hit its site ran in a hook with no current test (baseline coverage); construct: a + * `beforeAll` in a scope test file (U5 classifier hook counts) + * multi-killer the command run kills it with two or more tests ("several killers") + * survived the command run classifies it survived and some baseline launch covers it + * no-coverage no baseline launch ran its site (Stryker's NoCoverage) + * each-row its site ran in an `it.each`/`test.for` row, i.e. a test that shares its file and + * location with another test (KTD3); construct: an `.each`/`.for` call in a scope + * test file. A single-row call is not recognised as a row. + * Within one scope only construct strata can be required: when the scope's tests contain the + * construct and the population is not empty (KTD11). A required stratum without a sampled member + * fails the freeze unless every affected test (the tests of files with `beforeAll`; the rows) is + * marked not oracle-validated. Empty populations are recorded, never required. Every stratum is + * also counted across the scopes combined ({@link combineStrata}); gate 3 checks that count. + * + * Not oracle-validated tests: the runner's run-manifest flags, tests whose oracle id needed the row + * ordinal (KTD3), and `--not-oracle-validated `. They are launched and recorded like any other + * test; gate 3 does not compare their per-test outcomes. + * + * Work dir (absolute, outside the checkout); a freeze is resumable: + * freeze-work.json what this work dir freezes (scope, seed, sample options, wall clock, + * reused runs); a restart with other values is refused + * scope.json the driver scope file of steps 1 and 2 + * command/, runner/ the runs of steps 1 and 2 when they are not given + * launches/baseline//attempt-/, launches/m//attempt-/ + * launch dirs (launch-single.ts); `` is the first 16 hex digits of the + * SHA-256 of the test id + * manifest.json the manifest, also when the freeze has problems + * A launch is done once its `launch.json` exists (launch-single writes it last, atomically). A + * restart skips done launches, deletes unfinished attempt dirs and launches those again; + * `--retry-invalid` gives a done launch with an invalid observation a new attempt. SIGINT and + * SIGTERM kill the process group of every running launch and exit 130 once they are gone. A run of + * Stryker (steps 1 and 2) cannot be interrupted that way; the driver restarts it from scratch. + * + * Manifest ({@link OracleManifest}, schemaVersion 1, JSON): + * name, description, refreeze the scope's name, what the file is, the re-freeze command + * scope {testFiles, mutate} the driver scope (driver/stryker-config.ts) + * versions {stryker, vitest, nodeMajor} + * operators {mutator, names} Stryker's `mutator` option (null: Stryker's default set, + * every mutator) and the mutator names of the scope's mutants + * inputs {closure, vitestConfig, mutationConfig, prepareTool} + * repo-relative path to the SHA-256 of the file (KTD12): the + * U5 static import closure of the scope's test files; of + * gitnexus/vitest.config.ts; of tools/mutation/ + * vitest.mutation.config.ts plus gitnexus-shared/package.json + * (its alias source); and of every tools/mutation/prepare/*.ts + * module (the prepare tool: copy, blanking, path map, mutant + * identity and span map) + * sample {seed, method, size, topUpMin, random, topUps[]} + * strata[] {@link StratumRecord} per stratum, populations included + * baseline {tests, passedAlone} + * tests[] the oracle's test inventory: KTD3 ids in collection order + * notOracleValidated[] test ids + * mutants[] [global id, oracle class] of every mutant, by global id + * sampled[] {@link SampledMutant}: per-test outcomes of the sample + * launches {total, valid, invalid, wallClockLimitMs} + * total counts baseline and mutant launches; valid and + * invalid count the sampled mutants' observations + * warnings[], problems[] a manifest with problems is never written to `--manifest` + * Per-test outcomes are compact: `outcomes` lists [test index, class] for every valid observation + * whose class is not `survived`, `invalid` lists [test index, class, reason] for every invalid one, + * and every other test of `tests` is a valid `survived` observation. A per-test class is the test's + * own: its row when it ran, the class of a failed `beforeAll` that skipped it, collection when its + * file failed to load, and an oracle wall-clock kill is `timeout`; an `afterAll` failure that + * attributes to no test leaves the test's class alone (KTD17). + * + * Usage (from gitnexus/): + * npm run test:mutation:oracle:freeze -- (--scope | --from-manifest ) + * --seed --work [--manifest ] [--name ] + * [--command-run ] [--runner-results ] + * [--sample-size (100)] [--top-up-min (3)] [--launch-concurrency (4)] + * [--concurrency (4, Stryker runs)] [--timeout-ms (10000)] + * [--wall-clock-ms (180000)] [--not-oracle-validated ]... [--retry-invalid] + * `--from-manifest` re-freezes a frozen manifest's scope (name and `--manifest` default to it). + * Exit codes: 0 manifest written; 1 freeze problems (see `/manifest.json`); 2 usage or setup + * error; 130 interrupted. + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { Classifier } from '../classify/classify.js'; +import { runScope } from '../driver/run-scope.js'; +import { formatMutateEntry, parseDriverScope } from '../driver/stryker-config.js'; +import { PACKAGE_DIR, RELOCATED_SHARED_DIR } from '../prepare/path-map.js'; +import { RESULT_FILE_NAME, runOracleCommand } from './command-run.js'; +import { + DEFAULT_WALL_CLOCK_MS, + LaunchAbortedError, + findPackageDir, + launchSingle, + readCommandRun, + readTestInventory, + type CommandRunView, + type LaunchOptions, + type LaunchTarget, +} from './launch-single.js'; +import { + ORACLE_CLASSES, + classifyRun, + isKillClass, + isUnusableClass, + parseOracleRunRecord, + recordFileName, + type OracleClass, +} from './oracle-classify.js'; +import { parseOracleProbe, type ProbeCoverage } from './probe.js'; +import { isObject, writeFileAtomicSync } from './util.js'; + +export const MANIFEST_SCHEMA_VERSION = 1; +export const MANIFEST_KIND = 'gitnexus-mutation-oracle-manifest'; +export const WORK_FILE = 'freeze-work.json'; +export const WORK_MANIFEST_FILE = 'manifest.json'; +export const DEFAULT_SAMPLE_SIZE = 100; +export const DEFAULT_TOP_UP_MIN = 3; +export const DEFAULT_LAUNCH_CONCURRENCY = 4; +export const SAMPLE_METHOD = + 'sha256(seed + "\\n" + globalId) ascending; the size lowest ranks, then strata top-ups'; +/** Exit code of an interrupted freeze (128 + SIGINT). */ +export const EXIT_INTERRUPTED = 130; + +export class FreezeUsageError extends Error { + override name = 'FreezeUsageError'; +} + +const sha256 = (data: string | Buffer): string => createHash('sha256').update(data).digest('hex'); + +const isInside = (dir: string, file: string): boolean => { + const relative = path.relative(dir, file); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +}; + +// ── test ids (KTD3, KTD13) ───────────────────────────────────────────────── + +const TITLE_ESCAPE = /\\([|()[\]?.*+^${}\\'"`])/g; + +/** + * A test id under gate 3's id normalisations (KTD13): path separators of the file part become `/`; + * in the title part a backslash before a regex metacharacter, a backslash or a quote is dropped + * (title escaping), and the title compares in Unicode NFC. + */ +export function normaliseTestId(id: string): string { + const hash = id.indexOf('#'); + if (hash === -1) return id.replaceAll('\\', '/'); + const file = id.slice(0, hash).replaceAll('\\', '/'); + return `${file}#${id + .slice(hash + 1) + .replace(TITLE_ESCAPE, '$1') + .normalize('NFC')}`; +} + +export interface InventoryDiff { + /** Ids only the first inventory has (original spelling). */ + onlyA: string[]; + onlyB: string[]; + /** Ids that collapse onto one normalised id within the same inventory. */ + ambiguousA: string[]; + ambiguousB: string[]; +} + +/** The difference of two id inventories after `normalise`; pure. */ +export function diffInventory( + a: readonly string[], + b: readonly string[], + normalise: (id: string) => string = (id) => id, +): InventoryDiff { + const index = (ids: readonly string[]): { keys: Map; ambiguous: string[] } => { + const keys = new Map(); + const ambiguous: string[] = []; + for (const id of ids) { + const key = normalise(id); + if (keys.has(key)) ambiguous.push(id); + else keys.set(key, id); + } + return { keys, ambiguous }; + }; + const left = index(a); + const right = index(b); + const only = (from: Map, other: Map): string[] => + [...from].flatMap(([key, id]) => (other.has(key) ? [] : [id])).sort(); + return { + onlyA: only(left.keys, right.keys), + onlyB: only(right.keys, left.keys), + ambiguousA: left.ambiguous.sort(), + ambiguousB: right.ambiguous.sort(), + }; +} + +export const inventoryDiffers = (diff: InventoryDiff): boolean => + diff.onlyA.length + diff.onlyB.length + diff.ambiguousA.length + diff.ambiguousB.length > 0; + +/** Tests whose KTD3 id needed the row ordinal (`:::`). */ +export function ordinalTests(inventory: readonly LaunchTarget[]): string[] { + return inventory.flatMap((target) => { + const suffix = target.id.slice(`${target.file}#${target.fullName}`.length); + return /^:(?:\d+|\?):(?:\d+|\?):\d+$/.test(suffix) ? [target.id] : []; + }); +} + +/** `it.each`/`test.for` rows: tests that share their file and location with another test. */ +export function eachRowTests(inventory: readonly LaunchTarget[]): string[] { + const place = (target: LaunchTarget): string | null => + target.location === null + ? null + : `${target.file}\0${target.location.line}:${target.location.column}`; + const counts = new Map(); + for (const target of inventory) { + const key = place(target); + if (key !== null) counts.set(key, (counts.get(key) ?? 0) + 1); + } + return inventory.flatMap((target) => { + const key = place(target); + return key !== null && (counts.get(key) ?? 0) > 1 ? [target.id] : []; + }); +} + +// ── sampling ─────────────────────────────────────────────────────────────── + +export const STRATA = [ + 'static', + 'hook-hit', + 'multi-killer', + 'survived', + 'no-coverage', + 'each-row', +] as const; +export type StratumName = (typeof STRATA)[number]; +export type Construct = 'beforeAll' | 'each'; + +export const STRATUM_CONSTRUCT: Readonly> = { + static: null, + 'hook-hit': 'beforeAll', + 'multi-killer': null, + survived: null, + 'no-coverage': null, + 'each-row': 'each', +}; + +export type Populations = ReadonlyMap>; + +/** A mutant's sampling rank: the SHA-256 hex digest of `\n`. */ +export const sampleRank = (seed: string, globalId: string): string => + sha256(`${seed}\n${globalId}`); + +export interface TopUp { + globalId: string; + stratum: StratumName; +} + +export interface SampleInput { + /** Global ids of every mutant of the scope. */ + mutants: readonly string[]; + seed: string; + sampleSize: number; + topUpMin: number; + populations: Populations; +} + +export interface SampleSelection { + /** The random sample, lowest rank first. */ + random: string[]; + topUps: TopUp[]; + /** The random sample, then the top-ups. */ + selected: string[]; +} + +/** The seeded sample with strata top-ups (see the module comment); pure. */ +export function selectSample(input: SampleInput): SampleSelection { + const ranked = [...new Set(input.mutants)] + .map((globalId) => ({ globalId, rank: sampleRank(input.seed, globalId) })) + .sort((a, b) => + a.rank === b.rank ? a.globalId.localeCompare(b.globalId) : a.rank < b.rank ? -1 : 1, + ) + .map((entry) => entry.globalId); + const random = ranked.slice(0, input.sampleSize); + const selected = new Set(random); + const topUps: TopUp[] = []; + for (const stratum of STRATA) { + const population = input.populations.get(stratum) ?? new Set(); + const quota = Math.min(input.topUpMin, population.size); + let have = [...selected].filter((id) => population.has(id)).length; + for (const globalId of ranked) { + if (have >= quota) break; + if (population.has(globalId) && !selected.has(globalId)) { + selected.add(globalId); + topUps.push({ globalId, stratum }); + have++; + } + } + } + return { random, topUps, selected: [...random, ...topUps.map((entry) => entry.globalId)] }; +} + +// ── strata ───────────────────────────────────────────────────────────────── + +export interface PopulationMutant { + globalId: string; + localId: string; + oracleClass: OracleClass; + killedBy: readonly string[]; +} + +export interface BaselineCoverage { + testId: string; + coverage: ProbeCoverage; +} + +/** The strata populations of one scope, as global ids (see the module comment); pure. */ +export function computePopulations( + mutants: readonly PopulationMutant[], + baseline: readonly BaselineCoverage[], + eachRows: ReadonlySet, +): Map> { + const toGlobal = new Map(mutants.map((mutant) => [mutant.localId, mutant.globalId])); + const collect = (pick: (entry: BaselineCoverage) => readonly string[]): Set => + new Set( + baseline.flatMap((entry) => pick(entry).flatMap((localId) => toGlobal.get(localId) ?? [])), + ); + const loaded = collect((entry) => entry.coverage.loadAndCollection); + const hooked = collect((entry) => entry.coverage.hook); + const tested = collect((entry) => entry.coverage.test); + const rows = collect((entry) => (eachRows.has(entry.testId) ? entry.coverage.test : [])); + const covered = (id: string): boolean => loaded.has(id) || hooked.has(id) || tested.has(id); + const ids = (keep: (mutant: PopulationMutant) => boolean): Set => + new Set(mutants.filter(keep).map((mutant) => mutant.globalId)); + return new Map>([ + ['static', loaded], + ['hook-hit', hooked], + [ + 'multi-killer', + ids((mutant) => isKillClass(mutant.oracleClass) && new Set(mutant.killedBy).size >= 2), + ], + ['survived', ids((mutant) => mutant.oracleClass === 'survived' && covered(mutant.globalId))], + ['no-coverage', ids((mutant) => !covered(mutant.globalId))], + ['each-row', rows], + ]); +} + +export type StratumStatus = + | 'met' + | 'unmet' + | 'waived' + | 'empty-population' + | 'construct-absent' + | 'combined'; + +export interface StratumRecord { + name: StratumName; + /** The test construct a per-scope requirement depends on; null for the other strata. */ + construct: Construct | null; + /** Whether the scope's tests contain the construct; null without a construct. */ + constructPresent: boolean | null; + population: number; + /** Members in the final sample (random sample and top-ups). */ + sampled: number; + toppedUp: number; + /** Required within this scope (construct strata only). */ + required: boolean; + /** + * met / unmet / waived: a required stratum with, without (failing) and without but excused + * (every affected test not oracle-validated) a sampled member; empty-population; construct-absent; + * combined: required only across the scopes combined. + */ + status: StratumStatus; + /** Tests a construct stratum stands for: tests of files with `beforeAll`, or the rows. */ + affectedTests: number; +} + +export interface StrataInput { + populations: Populations; + selected: readonly string[]; + topUps: readonly TopUp[]; + constructs: Readonly>; + affectedTests: Readonly>; + notOracleValidated: ReadonlySet; +} + +/** The per-scope stratum records (KTD11 rules in the module comment); pure. */ +export function evaluateStrata(input: StrataInput): StratumRecord[] { + const selected = new Set(input.selected); + return STRATA.map((name): StratumRecord => { + const population = input.populations.get(name) ?? new Set(); + const construct = STRATUM_CONSTRUCT[name]; + const sampled = [...selected].filter((id) => population.has(id)).length; + const affected = construct === null ? [] : input.affectedTests[construct]; + const base = { + name, + construct, + constructPresent: construct === null ? null : input.constructs[construct], + population: population.size, + sampled, + toppedUp: input.topUps.filter((entry) => entry.stratum === name).length, + affectedTests: affected.length, + }; + if (construct !== null && !input.constructs[construct]) { + return { ...base, required: false, status: 'construct-absent' }; + } + if (population.size === 0) return { ...base, required: false, status: 'empty-population' }; + if (construct === null) return { ...base, required: false, status: 'combined' }; + if (sampled > 0) return { ...base, required: true, status: 'met' }; + const waived = + affected.length > 0 && affected.every((testId) => input.notOracleValidated.has(testId)); + return { ...base, required: true, status: waived ? 'waived' : 'unmet' }; + }); +} + +export interface CombinedStratum { + name: StratumName; + /** Population and sampled members over the scopes where the stratum counts. */ + population: number; + sampled: number; + status: 'met' | 'unmet' | 'empty-population'; + scopes: { scope: string; population: number; sampled: number; status: StratumStatus }[]; +} + +/** + * Every stratum counted across the scopes combined (KTD11); pure. A scope counts unless the + * stratum's construct is absent there or the stratum is waived there. The combined stratum is + * unmet when any scope leaves it unmet, or when its combined population has no sampled member. + */ +export function combineStrata( + scopes: readonly { name: string; strata: readonly StratumRecord[] }[], +): CombinedStratum[] { + return STRATA.map((name): CombinedStratum => { + const entries = scopes.flatMap((scope) => + scope.strata + .filter((record) => record.name === name) + .map((record) => ({ + scope: scope.name, + population: record.population, + sampled: record.sampled, + status: record.status, + })), + ); + const counted = entries.filter( + (entry) => entry.status !== 'construct-absent' && entry.status !== 'waived', + ); + const population = counted.reduce((sum, entry) => sum + entry.population, 0); + const sampled = counted.reduce((sum, entry) => sum + entry.sampled, 0); + const status = entries.some((entry) => entry.status === 'unmet') + ? 'unmet' + : population === 0 + ? 'empty-population' + : sampled > 0 + ? 'met' + : 'unmet'; + return { name, population, sampled, status, scopes: entries }; + }); +} + +// ── inputs and versions (KTD12) ──────────────────────────────────────────── + +export const INPUT_GROUPS = ['closure', 'vitestConfig', 'mutationConfig', 'prepareTool'] as const; +export type InputGroup = (typeof INPUT_GROUPS)[number]; +/** Per group: repo-relative POSIX path to the SHA-256 of the file (`absent` when missing). */ +export type InputHashes = Record>; +export const ABSENT = 'absent'; + +/** Package-relative entry points of the config and tool groups. */ +export const VITEST_CONFIG = 'vitest.config.ts'; +export const MUTATION_CONFIG = 'tools/mutation/vitest.mutation.config.ts'; +export const PREPARE_DIR = 'tools/mutation/prepare'; +/** Repo-relative: the mutation config derives its aliases from this file's `exports`. */ +export const SHARED_MANIFEST = 'gitnexus-shared/package.json'; + +/** + * The repo-relative files of each input group in the checkout (U5 classifier closures). Pass + * `classifier` to share its parse and resolution caches with later classifications. + */ +export function inputFiles( + repoRoot: string, + testFiles: readonly string[], + classifier: Classifier = new Classifier(repoRoot), +): Record { + const closure = (files: readonly string[]): string[] => + [...new Set(files.flatMap((file) => classifier.classifyFile(file).closure))].sort(); + const prepareModules = readdirSync(path.join(repoRoot, PACKAGE_DIR, PREPARE_DIR)) + .filter((name) => name.endsWith('.ts') && !name.endsWith('.d.ts')) + .map((name) => `${PREPARE_DIR}/${name}`); + return { + closure: closure(testFiles), + vitestConfig: closure([VITEST_CONFIG]), + mutationConfig: [...closure([MUTATION_CONFIG]), SHARED_MANIFEST].sort(), + prepareTool: closure(prepareModules), + }; +} + +/** Hashes every listed file through `locate` (repo path to absolute path). */ +export function hashInputs( + files: Readonly>, + locate: (repoPath: string) => string, +): InputHashes { + const hashGroup = (group: InputGroup): Record => + Object.fromEntries( + [...files[group]].sort().map((repoPath) => { + const file = locate(repoPath); + return [repoPath, existsSync(file) ? sha256(readFileSync(file)) : ABSENT]; + }), + ); + return { + closure: hashGroup('closure'), + vitestConfig: hashGroup('vitestConfig'), + mutationConfig: hashGroup('mutationConfig'), + prepareTool: hashGroup('prepareTool'), + }; +} + +const SHARED_PREFIX = 'gitnexus-shared/'; + +/** + * Where a repo file lives in a prepared copy (prepare.ts layout, KTD8): `gitnexus/` files keep + * their path, `gitnexus-shared/` files live under the relocated copy inside the package. + */ +export function preparedLocation(preparedRoot: string, repoPath: string): string { + if (repoPath.startsWith(`${PACKAGE_DIR}/`)) return path.join(preparedRoot, repoPath); + if (repoPath.startsWith(SHARED_PREFIX)) { + return path.join( + preparedRoot, + PACKAGE_DIR, + RELOCATED_SHARED_DIR, + repoPath.slice(SHARED_PREFIX.length), + ); + } + throw new FreezeUsageError(`${repoPath} has no place in a prepared copy`); +} + +export interface InputMismatch { + group: InputGroup; + path: string; + expected: string; + actual: string; +} + +/** Entries of `actual` that differ from `expected`, including paths only one side lists; pure. */ +export function diffInputs( + expected: InputHashes, + actual: InputHashes, + skip: ReadonlySet = new Set(), +): InputMismatch[] { + return INPUT_GROUPS.flatMap((group) => { + const paths = [...new Set([...Object.keys(expected[group]), ...Object.keys(actual[group])])]; + return paths.sort().flatMap((repoPath): InputMismatch[] => { + if (skip.has(repoPath)) return []; + const want = expected[group][repoPath] ?? ABSENT; + const have = actual[group][repoPath] ?? ABSENT; + return want === have ? [] : [{ group, path: repoPath, expected: want, actual: have }]; + }); + }); +} + +/** The input hashes a prepared copy holds for the files `inputs` lists. */ +export function preparedInputs(preparedRoot: string, inputs: InputHashes): InputHashes { + const files: Record = { + closure: Object.keys(inputs.closure), + vitestConfig: Object.keys(inputs.vitestConfig), + mutationConfig: Object.keys(inputs.mutationConfig), + prepareTool: Object.keys(inputs.prepareTool), + }; + return hashInputs(files, (repoPath) => preparedLocation(preparedRoot, repoPath)); +} + +export interface ToolVersions { + stryker: string; + vitest: string; + nodeMajor: number; +} + +function packageVersion(fromDir: string, name: string): string { + const dir = findPackageDir(fromDir, name); + if (dir === null) throw new FreezeUsageError(`${name} is not installed under ${fromDir}`); + const data: unknown = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + const version = isObject(data) ? data.version : undefined; + if (typeof version !== 'string') throw new FreezeUsageError(`${name} has no version`); + return version; +} + +/** Stryker, Vitest and Node versions as the checkout's package resolves them. */ +export function currentVersions(packageDir: string): ToolVersions { + return { + stryker: packageVersion(packageDir, '@stryker-mutator/core'), + vitest: packageVersion(packageDir, 'vitest'), + nodeMajor: Number(process.versions.node.split('.')[0]), + }; +} + +// ── JSON readers ─────────────────────────────────────────────────────────── + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; +function field(data: unknown, key: string, at: string): unknown { + if (!isObject(data) || !(key in data)) throw new FreezeUsageError(`${at}.${key} is missing`); + return data[key]; +} + +function stringField(data: unknown, key: string, at: string): string { + const value = field(data, key, at); + if (typeof value !== 'string') throw new FreezeUsageError(`${at}.${key} must be a string`); + return value; +} + +function nullableStringField(data: unknown, key: string, at: string): string | null { + const value = field(data, key, at); + if (value === null) return null; + if (typeof value !== 'string') + throw new FreezeUsageError(`${at}.${key} must be a string or null`); + return value; +} + +function numberField(data: unknown, key: string, at: string): number { + const value = field(data, key, at); + if (typeof value !== 'number') throw new FreezeUsageError(`${at}.${key} must be a number`); + return value; +} + +function stringsField(data: unknown, key: string, at: string): string[] { + const value = field(data, key, at); + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new FreezeUsageError(`${at}.${key} must be an array of strings`); + } + return value.map(String); +} + +function arrayField(data: unknown, key: string, at: string): unknown[] { + const value = field(data, key, at); + if (!Array.isArray(value)) throw new FreezeUsageError(`${at}.${key} must be an array`); + return value; +} + +export function toOracleClass(value: unknown, at: string): OracleClass { + const found = ORACLE_CLASSES.find((entry) => entry === value); + if (found === undefined) { + throw new FreezeUsageError(`${at}: ${JSON.stringify(value)} is not an outcome class`); + } + return found; +} + +const toJsonValue = (value: unknown): JsonValue => JSON.parse(JSON.stringify(value ?? null)); + +const readJson = (file: string): unknown => JSON.parse(readFileSync(file, 'utf8')); + +export interface ScopeSpec { + testFiles: string[]; + mutate: string[]; +} + +const sameScope = (a: ScopeSpec, b: ScopeSpec): boolean => + JSON.stringify([[...a.testFiles].sort(), [...a.mutate].sort()]) === + JSON.stringify([[...b.testFiles].sort(), [...b.mutate].sort()]); + +function readScopeSpec(data: unknown, at: string): ScopeSpec { + const scope = field(data, 'scope', at); + return { + testFiles: stringsField(scope, 'testFiles', `${at}.scope`), + mutate: stringsField(scope, 'mutate', `${at}.scope`), + }; +} + +/** The `mutator` option of a generated Stryker config; null when Stryker's defaults apply. */ +export function readStrykerMutator(configFile: string): JsonValue { + const data = readJson(configFile); + return isObject(data) && 'mutator' in data ? toJsonValue(data.mutator) : null; +} + +/** The parts of a vitest5 driver run (U10 `results.json`) that freeze and gate 3 read. */ +export interface RunnerMutantView { + globalId: string; + localId: string; + /** Stryker's raw status. */ + status: string; + outcome: OracleClass | null; + outcomeSource: string; + /** The latest sidecar record, or null. */ + sidecarFile: string | null; +} + +export interface RunnerRunView { + file: string; + scope: ScopeSpec; + runDir: string; + preparedRoot: string; + configFile: string; + mutants: RunnerMutantView[]; + flags: string[]; + spanMapFailures: number; +} + +/** Reads a vitest5 driver run's `results.json` with this module's own minimal parser. */ +export function readRunnerResults(file: string): RunnerRunView { + const data = readJson(file); + const at = path.basename(file); + if (field(data, 'variant', at) !== 'vitest5') { + throw new FreezeUsageError(`${file} is not the results.json of a vitest5 run`); + } + const paths = field(data, 'paths', at); + const runDir = nullableStringField(paths, 'runDir', `${at}.paths`); + if (runDir === null) throw new FreezeUsageError(`${file} has no run directory`); + const spanMap = field(data, 'spanMap', at); + return { + file, + scope: readScopeSpec(data, at), + runDir, + preparedRoot: stringField(paths, 'preparedRoot', `${at}.paths`), + configFile: stringField(paths, 'configFile', `${at}.paths`), + mutants: arrayField(data, 'mutants', at).map((entry, index): RunnerMutantView => { + const where = `${at}.mutants[${index}]`; + const outcome = field(entry, 'outcome', where); + const sidecar = field(entry, 'sidecar', where); + return { + globalId: stringField(entry, 'globalId', where), + localId: stringField(entry, 'localId', where), + status: stringField(entry, 'status', where), + outcome: outcome === null ? null : toOracleClass(outcome, `${where}.outcome`), + outcomeSource: stringField(entry, 'outcomeSource', where), + sidecarFile: sidecar === null ? null : stringField(sidecar, 'file', `${where}.sidecar`), + }; + }), + flags: arrayField(data, 'flags', at).map( + (flag, index) => + `${stringField(flag, 'kind', `${at}.flags[${index}]`)}: ` + + `${stringField(flag, 'detail', `${at}.flags[${index}]`)}`, + ), + spanMapFailures: arrayField(spanMap, 'failures', `${at}.spanMap`).length, + }; +} + +export interface RunManifestView { + vitestVersion: string; + tests: { id: string; notOracleValidated: boolean }[]; +} + +/** Reads the runner's `/run-manifest.json` (KTD16) with this module's own parser. */ +export function readRunManifest(runDir: string): RunManifestView { + const file = path.join(runDir, 'run-manifest.json'); + if (!existsSync(file)) throw new FreezeUsageError(`${file} is missing`); + const data = readJson(file); + const at = 'run-manifest.json'; + return { + vitestVersion: stringField(data, 'vitestVersion', at), + tests: arrayField(data, 'files', at).flatMap((entry, fileIndex) => + arrayField(entry, 'tests', `${at}.files[${fileIndex}]`).map((test, index) => { + const where = `${at}.files[${fileIndex}].tests[${index}]`; + return { + id: stringField(test, 'id', where), + notOracleValidated: field(test, 'notOracleValidated', where) === true, + }; + }), + ), + }; +} + +/** The parts of an oracle command run's JSON that launch-single.ts `readCommandRun` skips. */ +interface CommandRunExtras { + scope: ScopeSpec; + problems: string[]; + mutatorNames: string[]; + configFile: string; + killedBy: Map; +} + +function readCommandRunExtras(run: CommandRunView): CommandRunExtras { + const data = readJson(run.file); + const at = path.basename(run.file); + const mutants = arrayField(data, 'mutants', at); + return { + scope: readScopeSpec(data, at), + problems: stringsField(data, 'problems', at), + mutatorNames: [ + ...new Set( + mutants.map((entry, index) => stringField(entry, 'mutatorName', `${at}.mutants[${index}]`)), + ), + ].sort(), + configFile: path.join(run.outDir, 'stryker.config.json'), + killedBy: new Map(run.mutants.map((mutant) => [mutant.globalId, mutant.killedBy])), + }; +} + +// ── resumable launches ───────────────────────────────────────────────────── + +export interface PlannedLaunch { + /** Directory of the launch's attempts, relative to the launch root. */ + key: string; + target: LaunchTarget; + /** Local mutant id of the oracle's command run; null for a baseline launch. */ + mutantId: string | null; +} + +export const launchKey = (mutantId: string | null, testId: string): string => + `${mutantId === null ? 'baseline' : `m${mutantId}`}/${sha256(testId).slice(0, 16)}`; + +export interface CompletedLaunch { + key: string; + /** The attempt dir holding launch.json. */ + dir: string; + attempt: number; + testId: string; + mutantId: string | null; + validity: 'valid' | 'invalid'; + outcome: OracleClass; + reason: string | null; + covering: boolean | null; + /** Found done in the work dir rather than launched by this run. */ + reused: boolean; +} + +export interface LaunchProgress { + done: number; + total: number; + launched: number; + reused: number; + invalid: number; +} + +export interface LaunchRunOptions { + launchRoot: string; + sandboxDir: string; + scopeFile: string; + wallClockMs: number; + concurrency: number; + retryInvalid: boolean; + signal?: AbortSignal; + /** Defaults to launch-single.ts `launchSingle`; it must leave launch.json in `launchDir`. */ + launch?: (options: LaunchOptions) => Promise; + onDone?: (launch: CompletedLaunch, progress: LaunchProgress) => void; +} + +export interface LaunchRunResult { + completed: Map; + launched: number; + reused: number; + aborted: boolean; +} + +const ATTEMPT_DIR = /^attempt-([1-9]\d*)$/; +const LAUNCH_FILE = 'launch.json'; + +/** Reads a done attempt's launch.json (launch-single.ts `LaunchResult`). */ +export function readCompletedLaunch(key: string, dir: string, attempt: number): CompletedLaunch { + const at = path.join(dir, LAUNCH_FILE); + const data = readJson(at); + const target = field(data, 'target', at); + const observation = field(data, 'observation', at); + const validity = field(observation, 'validity', `${at}.observation`); + const covering = field(observation, 'covering', `${at}.observation`); + if (validity !== 'valid' && validity !== 'invalid') { + throw new FreezeUsageError(`${at}: observation.validity must be valid or invalid`); + } + return { + key, + dir, + attempt, + testId: stringField(target, 'id', `${at}.target`), + mutantId: nullableStringField(data, 'mutantId', at), + validity, + outcome: toOracleClass(field(observation, 'outcome', `${at}.observation`), at), + reason: nullableStringField(observation, 'reason', `${at}.observation`), + covering: typeof covering === 'boolean' ? covering : null, + reused: false, + }; +} + +/** The done attempt of a launch to reuse, or the attempt number to launch next. */ +function scanAttempts( + plan: PlannedLaunch, + keyDir: string, + retryInvalid: boolean, +): { done: CompletedLaunch } | { next: number } { + const numbers = existsSync(keyDir) + ? readdirSync(keyDir) + .flatMap((name) => ATTEMPT_DIR.exec(name)?.[1] ?? []) + .map(Number) + : []; + let latest = 0; + for (const attempt of numbers.sort((a, b) => a - b)) { + const dir = path.join(keyDir, `attempt-${attempt}`); + if (existsSync(path.join(dir, LAUNCH_FILE))) latest = attempt; + else rmSync(dir, { recursive: true, force: true }); + } + if (latest === 0) return { next: 1 }; + const done = readCompletedLaunch(plan.key, path.join(keyDir, `attempt-${latest}`), latest); + if (done.testId !== plan.target.id || done.mutantId !== plan.mutantId) { + throw new FreezeUsageError( + `${done.dir} holds test ${done.testId} with mutant ${done.mutantId ?? 'none'}, not ` + + `${plan.target.id} with ${plan.mutantId ?? 'none'}; use a new work dir`, + ); + } + if (retryInvalid && done.validity === 'invalid') return { next: latest + 1 }; + return { done: { ...done, reused: true } }; +} + +/** + * Runs the planned launches at most `concurrency` at a time, skipping launches already done in + * the launch root (see the module comment). An abort stops scheduling, kills running launches and + * returns with `aborted: true`; any other launch error aborts the rest and is rethrown. + */ +export async function runLaunches( + plans: readonly PlannedLaunch[], + options: LaunchRunOptions, +): Promise { + const launch = options.launch ?? launchSingle; + const completed = new Map(); + const progress: LaunchProgress = { + done: 0, + total: plans.length, + launched: 0, + reused: 0, + invalid: 0, + }; + const finish = (entry: CompletedLaunch): void => { + completed.set(entry.key, entry); + progress.done++; + if (entry.reused) progress.reused++; + else progress.launched++; + if (entry.validity === 'invalid') progress.invalid++; + options.onDone?.(entry, { ...progress }); + }; + const pending: { plan: PlannedLaunch; attempt: number }[] = []; + for (const plan of plans) { + const scanned = scanAttempts( + plan, + path.join(options.launchRoot, plan.key), + options.retryInvalid, + ); + if ('done' in scanned) finish(scanned.done); + else pending.push({ plan, attempt: scanned.next }); + } + + const controller = new AbortController(); + const forward = (): void => controller.abort(); + options.signal?.addEventListener('abort', forward, { once: true }); + if (options.signal?.aborted === true) controller.abort(); + let failure: unknown = null; + let next = 0; + const worker = async (): Promise => { + while (!controller.signal.aborted && next < pending.length) { + const { plan, attempt } = pending[next++]; + const dir = path.join(options.launchRoot, plan.key, `attempt-${attempt}`); + try { + await launch({ + sandboxDir: options.sandboxDir, + scopeFile: options.scopeFile, + target: plan.target, + mutantId: plan.mutantId, + wallClockMs: options.wallClockMs, + launchDir: dir, + signal: controller.signal, + }); + } catch (error) { + if (!(error instanceof LaunchAbortedError)) { + failure ??= error; + controller.abort(); + } + return; + } + finish(readCompletedLaunch(plan.key, dir, attempt)); + } + }; + try { + await Promise.all( + Array.from({ length: Math.min(options.concurrency, pending.length) }, worker), + ); + } finally { + options.signal?.removeEventListener('abort', forward); + } + if (failure !== null) throw failure; + return { + completed, + launched: progress.launched, + reused: progress.reused, + aborted: completed.size < plans.length, + }; +} + +/** + * The test's own class in a launch (see "Per-test outcomes" in the module comment): the oracle + * classifier's row of the target in the launch's record; the launch's outcome when the launch + * left no observed run (a wall-clock kill) or is invalid. + */ +export function launchTestClass(launch: CompletedLaunch): OracleClass { + if (launch.validity === 'invalid') return launch.outcome; + const kind = launch.mutantId === null ? 'dry-run' : 'mutant'; + const file = path.join(launch.dir, 'reports', recordFileName(kind, launch.mutantId, 1)); + if (!existsSync(file)) return launch.outcome; + const record = parseOracleRunRecord(readFileSync(file, 'utf8')); + if (record.run === null) return launch.outcome; + const { tests } = classifyRun(record.run, { + exitCode: record.exitCode, + expected: [launch.testId], + vitestJson: record.vitestJson, + }); + return tests.find((test) => test.id === launch.testId)?.outcome ?? launch.outcome; +} + +/** The baseline launch's coverage from its probe; null when the probe recorded none. */ +export function launchCoverage(launch: CompletedLaunch): ProbeCoverage | null { + const file = path.join(launch.dir, 'probe.json'); + if (!existsSync(file)) return null; + return parseOracleProbe(readFileSync(file, 'utf8')).coverage ?? null; +} + +// ── manifest ─────────────────────────────────────────────────────────────── + +export interface SampledMutant { + id: string; + strata: StratumName[]; + /** Tests whose launch hit the mutant. */ + covering: number; + /** [test index, class] of every valid observation that is not `survived`. */ + outcomes: [number, OracleClass][]; + /** [test index, class, reason] of every invalid observation. */ + invalid: [number, OracleClass, string][]; +} + +export interface OracleManifest { + schemaVersion: typeof MANIFEST_SCHEMA_VERSION; + kind: typeof MANIFEST_KIND; + name: string; + description: string; + refreeze: string; + scope: ScopeSpec; + versions: ToolVersions; + operators: { mutator: JsonValue; names: string[] }; + inputs: InputHashes; + sample: { + seed: string; + method: string; + size: number; + topUpMin: number; + /** Size of the random part of the sample. */ + random: number; + topUps: TopUp[]; + }; + strata: StratumRecord[]; + baseline: { tests: number; passedAlone: number }; + tests: string[]; + notOracleValidated: string[]; + mutants: [string, OracleClass][]; + sampled: SampledMutant[]; + launches: { total: number; valid: number; invalid: number; wallClockLimitMs: number }; + warnings: string[]; + problems: string[]; +} + +/** + * Formats JSON with two-space indentation, keeping arrays of primitives and of primitive rows + * on one line when they fit 100 columns, and putting each row of a longer one on its own line. + */ +export function formatJson(value: unknown, indent = ''): string { + const inner = `${indent} `; + const isPrimitive = (entry: unknown): boolean => entry === null || typeof entry !== 'object'; + const isRow = (entry: unknown): boolean => + isPrimitive(entry) || (Array.isArray(entry) && entry.every(isPrimitive)); + if (isPrimitive(value)) return JSON.stringify(value); + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + if (value.every(isRow)) { + const inline = JSON.stringify(value); + if (indent.length + inline.length <= 100) return inline; + return `[\n${value.map((entry) => `${inner}${JSON.stringify(entry)}`).join(',\n')}\n${indent}]`; + } + return `[\n${value.map((entry) => `${inner}${formatJson(entry, inner)}`).join(',\n')}\n${indent}]`; + } + if (!isObject(value)) return JSON.stringify(value); + const entries = Object.entries(value).filter(([, entry]) => entry !== undefined); + if (entries.length === 0) return '{}'; + return `{\n${entries + .map(([key, entry]) => `${inner}${JSON.stringify(key)}: ${formatJson(entry, inner)}`) + .join(',\n')}\n${indent}}`; +} + +function parseInputs(data: unknown, at: string): InputHashes { + const group = (name: InputGroup): Record => { + const value = field(data, name, at); + if (!isObject(value) || Object.values(value).some((hash) => typeof hash !== 'string')) { + throw new FreezeUsageError(`${at}.${name} must map paths to hashes`); + } + return Object.fromEntries(Object.entries(value).map(([key, hash]) => [key, String(hash)])); + }; + return { + closure: group('closure'), + vitestConfig: group('vitestConfig'), + mutationConfig: group('mutationConfig'), + prepareTool: group('prepareTool'), + }; +} + +const toStratum = (value: unknown, at: string): StratumName => { + const found = STRATA.find((name) => name === value); + if (found === undefined) throw new FreezeUsageError(`${at}: unknown stratum ${String(value)}`); + return found; +}; + +const STRATUM_STATUSES: readonly StratumStatus[] = [ + 'met', + 'unmet', + 'waived', + 'empty-population', + 'construct-absent', + 'combined', +]; + +function parseStratum(data: unknown, at: string): StratumRecord { + const name = toStratum(field(data, 'name', at), at); + const status = STRATUM_STATUSES.find((entry) => entry === field(data, 'status', at)); + if (status === undefined) throw new FreezeUsageError(`${at}.status is unknown`); + const constructPresent = field(data, 'constructPresent', at); + return { + name, + construct: STRATUM_CONSTRUCT[name], + constructPresent: typeof constructPresent === 'boolean' ? constructPresent : null, + population: numberField(data, 'population', at), + sampled: numberField(data, 'sampled', at), + toppedUp: numberField(data, 'toppedUp', at), + required: field(data, 'required', at) === true, + status, + affectedTests: numberField(data, 'affectedTests', at), + }; +} + +function tuple(value: unknown, length: number, at: string): unknown[] { + if (!Array.isArray(value) || value.length !== length) { + throw new FreezeUsageError(`${at} must be an array of ${length} entries`); + } + return value; +} + +const index = (value: unknown, at: string): number => { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new FreezeUsageError(`${at} must be a test index`); + } + return value; +}; + +/** Parses and validates a frozen manifest's text. */ +export function parseManifest(text: string): OracleManifest { + const data: unknown = JSON.parse(text); + const at = 'manifest'; + if (field(data, 'kind', at) !== MANIFEST_KIND) { + throw new FreezeUsageError('not an oracle manifest (kind)'); + } + if (field(data, 'schemaVersion', at) !== MANIFEST_SCHEMA_VERSION) { + throw new FreezeUsageError(`manifest schemaVersion must be ${MANIFEST_SCHEMA_VERSION}`); + } + const versions = field(data, 'versions', at); + const operators = field(data, 'operators', at); + const sample = field(data, 'sample', at); + const baseline = field(data, 'baseline', at); + const launches = field(data, 'launches', at); + const tests = stringsField(data, 'tests', at); + const testIndex = (value: unknown, where: string): number => { + const found = index(value, where); + if (found >= tests.length) throw new FreezeUsageError(`${where}: no test ${found}`); + return found; + }; + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + kind: MANIFEST_KIND, + name: stringField(data, 'name', at), + description: stringField(data, 'description', at), + refreeze: stringField(data, 'refreeze', at), + scope: readScopeSpec(data, at), + versions: { + stryker: stringField(versions, 'stryker', `${at}.versions`), + vitest: stringField(versions, 'vitest', `${at}.versions`), + nodeMajor: numberField(versions, 'nodeMajor', `${at}.versions`), + }, + operators: { + mutator: toJsonValue(field(operators, 'mutator', `${at}.operators`)), + names: stringsField(operators, 'names', `${at}.operators`), + }, + inputs: parseInputs(field(data, 'inputs', at), `${at}.inputs`), + sample: { + seed: stringField(sample, 'seed', `${at}.sample`), + method: stringField(sample, 'method', `${at}.sample`), + size: numberField(sample, 'size', `${at}.sample`), + topUpMin: numberField(sample, 'topUpMin', `${at}.sample`), + random: numberField(sample, 'random', `${at}.sample`), + topUps: arrayField(sample, 'topUps', `${at}.sample`).map((entry, i) => ({ + globalId: stringField(entry, 'globalId', `${at}.sample.topUps[${i}]`), + stratum: toStratum(field(entry, 'stratum', `${at}.sample.topUps[${i}]`), at), + })), + }, + strata: arrayField(data, 'strata', at).map((entry, i) => + parseStratum(entry, `${at}.strata[${i}]`), + ), + baseline: { + tests: numberField(baseline, 'tests', `${at}.baseline`), + passedAlone: numberField(baseline, 'passedAlone', `${at}.baseline`), + }, + tests, + notOracleValidated: stringsField(data, 'notOracleValidated', at), + mutants: arrayField(data, 'mutants', at).map((entry, i): [string, OracleClass] => { + const [globalId, outcome] = tuple(entry, 2, `${at}.mutants[${i}]`); + if (typeof globalId !== 'string') { + throw new FreezeUsageError(`${at}.mutants[${i}][0] must be a global id`); + } + return [globalId, toOracleClass(outcome, `${at}.mutants[${i}]`)]; + }), + sampled: arrayField(data, 'sampled', at).map((entry, i): SampledMutant => { + const where = `${at}.sampled[${i}]`; + return { + id: stringField(entry, 'id', where), + strata: arrayField(entry, 'strata', where).map((name) => toStratum(name, where)), + covering: numberField(entry, 'covering', where), + outcomes: arrayField(entry, 'outcomes', where).map((row, j): [number, OracleClass] => { + const [test, outcome] = tuple(row, 2, `${where}.outcomes[${j}]`); + return [testIndex(test, `${where}.outcomes[${j}]`), toOracleClass(outcome, where)]; + }), + invalid: arrayField(entry, 'invalid', where).map( + (row, j): [number, OracleClass, string] => { + const [test, outcome, reason] = tuple(row, 3, `${where}.invalid[${j}]`); + return [ + testIndex(test, `${where}.invalid[${j}]`), + toOracleClass(outcome, where), + String(reason), + ]; + }, + ), + }; + }), + launches: { + total: numberField(launches, 'total', `${at}.launches`), + valid: numberField(launches, 'valid', `${at}.launches`), + invalid: numberField(launches, 'invalid', `${at}.launches`), + wallClockLimitMs: numberField(launches, 'wallClockLimitMs', `${at}.launches`), + }, + warnings: stringsField(data, 'warnings', at), + problems: stringsField(data, 'problems', at), + }; +} + +export function readManifest(file: string): OracleManifest { + try { + return parseManifest(readFileSync(file, 'utf8')); + } catch (error) { + throw new FreezeUsageError( + `${file}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +const writeAtomic = (file: string, text: string): void => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileAtomicSync(file, text); +}; + +export const writeManifest = (file: string, manifest: OracleManifest): void => + writeAtomic(file, `${formatJson(manifest)}\n`); + +/** The re-freeze command recorded in a manifest (KTD12). */ +export const refreezeCommand = (manifestPath: string): string => + `npm run test:mutation:oracle:freeze -- --from-manifest ${manifestPath} --seed ` + + '--work '; + +const describeManifest = (name: string, manifestPath: string): string => + `Gate 3 (U7): the oracle's frozen outcomes for the ${name} scope: a seeded sample of mutants ` + + 'with strata top-ups, the oracle class of every mutant (U13 command run) and the per-test ' + + 'outcome of every sampled mutant with every test, each test launched alone (U12). Check a ' + + `vitest5 runner run with \`npm run test:mutation:oracle -- --scope ${manifestPath},\` ` + + '(from gitnexus/). When gate 3 reports "re-freeze required", run the refreeze command.'; + +// ── freeze ───────────────────────────────────────────────────────────────── + +export interface FreezeOptions { + repoRoot: string; + /** Driver scope file; exactly one of this and `fromManifest`. */ + scopeFile: string | null; + fromManifest: string | null; + name: string | null; + workDir: string; + /** Where a clean manifest goes; null writes only `/manifest.json`. */ + manifestFile: string | null; + seed: string; + sampleSize: number; + topUpMin: number; + launchConcurrency: number; + /** Stryker concurrency of the command and runner runs. */ + concurrency: number; + timeoutMS: number; + wallClockMs: number; + commandRun: string | null; + runnerResults: string | null; + notOracleValidated: string[]; + retryInvalid: boolean; + signal?: AbortSignal; + log?: (line: string) => void; +} + +export interface FreezeResult { + manifest: OracleManifest | null; + /** The file the manifest went to (`--manifest` when clean, else the work dir's). */ + manifestFile: string | null; + exitCode: number; +} + +interface WorkIdentity { + schemaVersion: 1; + name: string; + scope: ScopeSpec; + seed: string; + sampleSize: number; + topUpMin: number; + wallClockMs: number; + commandRun: string | null; + runnerResults: string | null; + notOracleValidated: string[]; +} + +function claimWorkDir(repoRoot: string, workDir: string, identity: WorkIdentity): void { + if (!path.isAbsolute(workDir)) throw new FreezeUsageError('--work must be an absolute path'); + if (isInside(repoRoot, workDir) || isInside(workDir, repoRoot)) { + throw new FreezeUsageError(`--work ${workDir} must lie outside the checkout ${repoRoot}`); + } + mkdirSync(workDir, { recursive: true }); + const file = path.join(workDir, WORK_FILE); + const text = `${JSON.stringify(identity, null, 2)}\n`; + if (existsSync(file)) { + const previous = readFileSync(file, 'utf8'); + if (previous !== text) { + throw new FreezeUsageError( + `${workDir} belongs to another freeze (${WORK_FILE} differs: scope, seed, sample ` + + 'options, wall clock, reused runs or marked tests); use a new --work dir', + ); + } + return; + } + if (readdirSync(workDir).length > 0) { + throw new FreezeUsageError(`${workDir} is not empty and holds no ${WORK_FILE}`); + } + writeAtomic(file, text); +} + +const formatDuration = (ms: number): string => { + const minutes = Math.round(ms / 60_000); + return minutes < 60 + ? `${minutes}m` + : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`; +}; + +const defaultName = (mutate: readonly string[]): string => + path.posix.basename((mutate[0] ?? 'scope').split(':')[0]).replace(/\.ts$/, ''); + +/** Runs one freeze (see the module comment). */ +export async function runFreeze(options: FreezeOptions): Promise { + const log = options.log ?? ((line: string): void => console.log(line)); + const packageDir = path.join(options.repoRoot, PACKAGE_DIR); + const source = options.fromManifest === null ? null : readManifest(options.fromManifest); + let scope: ScopeSpec; + if (source !== null) scope = source.scope; + else if (options.scopeFile !== null) { + const parsed = parseDriverScope(readFileSync(options.scopeFile, 'utf8')); + scope = { testFiles: parsed.testFiles, mutate: parsed.mutate.map(formatMutateEntry) }; + } else throw new FreezeUsageError('give --scope or --from-manifest '); + const name = options.name ?? source?.name ?? defaultName(scope.mutate); + const manifestFile = options.manifestFile ?? options.fromManifest; + const manifestPath = + manifestFile !== null && isInside(packageDir, manifestFile) + ? path.relative(packageDir, manifestFile).split(path.sep).join('/') + : `test/fixtures/mutation-oracle/${name}.json`; + + claimWorkDir(options.repoRoot, options.workDir, { + schemaVersion: 1, + name, + scope, + seed: options.seed, + sampleSize: options.sampleSize, + topUpMin: options.topUpMin, + wallClockMs: options.wallClockMs, + commandRun: options.commandRun, + runnerResults: options.runnerResults, + notOracleValidated: [...options.notOracleValidated].sort(), + }); + const scopeFile = path.join(options.workDir, 'scope.json'); + writeAtomic(scopeFile, `${JSON.stringify(scope, null, 2)}\n`); + const started = performance.now(); + const problems: string[] = []; + const warnings: string[] = []; + + // 1-2. Runs. + let commandRunFile = options.commandRun; + if (commandRunFile === null) { + commandRunFile = path.join(options.workDir, 'command', RESULT_FILE_NAME); + if (!existsSync(commandRunFile)) { + log(`freeze ${name}: oracle command run in ${path.dirname(commandRunFile)}`); + await runOracleCommand({ + repoRoot: options.repoRoot, + scopeFile, + outDir: path.dirname(commandRunFile), + concurrency: options.concurrency, + timeoutMS: options.timeoutMS, + }); + } + } + let runnerFile = options.runnerResults; + if (runnerFile === null) { + runnerFile = path.join(options.workDir, 'runner', 'results.json'); + if (!existsSync(runnerFile)) { + log(`freeze ${name}: vitest5 runner run in ${path.dirname(runnerFile)}`); + await runScope({ + repoRoot: options.repoRoot, + scopeFile, + outDir: path.dirname(runnerFile), + variant: 'vitest5', + concurrency: options.concurrency, + maxTestRunnerReuse: 0, + timeoutMS: options.timeoutMS, + command: undefined, + }); + } + } + if (options.signal?.aborted === true) { + return { manifest: null, manifestFile: null, exitCode: EXIT_INTERRUPTED }; + } + const run = readCommandRun(commandRunFile); + const extras = readCommandRunExtras(run); + const runner = readRunnerResults(runnerFile); + const runManifest = readRunManifest(runner.runDir); + problems.push(...extras.problems.map((problem) => `command run: ${problem}`)); + if (!sameScope(extras.scope, scope)) problems.push('the command run ran another scope'); + if (!sameScope(runner.scope, scope)) problems.push('the runner run ran another scope'); + const unusable = run.mutants.filter((mutant) => isUnusableClass(mutant.oracleClass)); + for (const mutant of unusable) { + problems.push(`command run: ${mutant.globalId} classifies as ${mutant.oracleClass}`); + } + if (runner.flags.length > 0) { + warnings.push(`the runner run has ${runner.flags.length} flags (gate 3 blocks on them)`); + } + + // Versions, operators and inputs (KTD12). + const versions = currentVersions(packageDir); + const dryRun = parseOracleRunRecord(readFileSync(run.baselineRecord, 'utf8')); + if (dryRun.vitestVersion !== versions.vitest) { + problems.push( + `the command run used Vitest ${dryRun.vitestVersion}, the checkout ${versions.vitest}`, + ); + } + if (runManifest.vitestVersion !== versions.vitest) { + problems.push( + `the runner run used Vitest ${runManifest.vitestVersion}, the checkout ${versions.vitest}`, + ); + } + const mutator = readStrykerMutator(extras.configFile); + if (JSON.stringify(readStrykerMutator(runner.configFile)) !== JSON.stringify(mutator)) { + problems.push('the runner and command runs used different Stryker mutator options'); + } + const classifier = new Classifier(options.repoRoot); + const inputs = hashInputs(inputFiles(options.repoRoot, scope.testFiles, classifier), (repoPath) => + path.join(options.repoRoot, repoPath), + ); + const mutated = new Set(scope.mutate.map((entry) => entry.split(':')[0])); + for (const [label, preparedRoot] of [ + ['command run', path.join(run.outDir, 'prepared')], + ['runner run', runner.preparedRoot], + ] as const) { + for (const mismatch of diffInputs(inputs, preparedInputs(preparedRoot, inputs), mutated)) { + problems.push( + `the ${label} was prepared from another ${mismatch.path} than the checkout's (${mismatch.group})`, + ); + } + } + for (const mutant of run.mutants) { + const at = mutant.globalId.indexOf('@'); + const file = mutant.globalId.slice(0, at); + const hash = mutant.globalId.slice(at + 1, at + 17); + const current = inputs.closure[file]; + if (current !== undefined && !current.startsWith(hash)) { + problems.push(`${file} changed since the command run (mutant ${mutant.globalId})`); + break; + } + } + + // Test inventory (KTD18) and not oracle-validated tests. + const inventory = readTestInventory(run); + const testIds = inventory.map((target) => target.id); + const inventoryDiff = diffInventory( + testIds, + runManifest.tests.map((test) => test.id), + normaliseTestId, + ); + for (const id of inventoryDiff.onlyA) + problems.push(`test ${id} is missing from the run manifest`); + for (const id of inventoryDiff.onlyB) + problems.push(`run-manifest test ${id} is missing from the oracle inventory`); + for (const id of [...inventoryDiff.ambiguousA, ...inventoryDiff.ambiguousB]) { + problems.push(`test id ${id} is ambiguous after normalisation`); + } + const known = new Map(testIds.map((id) => [normaliseTestId(id), id])); + const notOracleValidated = new Set([ + ...ordinalTests(inventory), + ...runManifest.tests + .filter((test) => test.notOracleValidated) + .flatMap((test) => known.get(normaliseTestId(test.id)) ?? []), + ]); + for (const id of options.notOracleValidated) { + const found = known.get(normaliseTestId(id)); + if (found === undefined) + throw new FreezeUsageError(`--not-oracle-validated ${id} is not a test of the scope`); + notOracleValidated.add(found); + } + + // Constructs (U5 classifier) and each rows. + const verdicts = scope.testFiles.map((file) => classifier.classifyFile(file)); + const beforeAllFiles = new Set( + verdicts.filter((verdict) => verdict.hooks.beforeAll > 0).map((verdict) => verdict.file), + ); + const eachRows = eachRowTests(inventory); + const constructs: Record = { + beforeAll: beforeAllFiles.size > 0, + each: verdicts.some((verdict) => verdict.eachCalls > 0), + }; + const affectedTests: Record = { + beforeAll: inventory.filter((target) => beforeAllFiles.has(target.file)).map((t) => t.id), + each: eachRows, + }; + + const launchRoot = path.join(options.workDir, 'launches'); + const launchOptions = { + launchRoot, + sandboxDir: run.sandboxDir, + scopeFile: run.scopeFile, + wallClockMs: options.wallClockMs, + concurrency: options.launchConcurrency, + retryInvalid: options.retryInvalid, + signal: options.signal, + }; + const progressLogger = (phase: string): LaunchRunOptions['onDone'] => { + let lastLog = Number.NEGATIVE_INFINITY; + const phaseStarted = performance.now(); + return (_launch, progress) => { + const now = performance.now(); + if (progress.done !== progress.total && now - lastLog < 30_000) return; + lastLog = now; + const rate = progress.launched === 0 ? 0 : (now - phaseStarted) / progress.launched; + const eta = rate * (progress.total - progress.done); + log( + `freeze ${name}: ${phase} ${progress.done}/${progress.total} (reused ${progress.reused}, ` + + `invalid ${progress.invalid}); elapsed ${formatDuration(now - started)}` + + (progress.done === progress.total ? '' : `, eta ${formatDuration(eta)}`), + ); + }; + }; + + // 3. Baseline launches. + const baselinePlans = inventory.map((target) => ({ + key: launchKey(null, target.id), + target, + mutantId: null, + })); + log(`freeze ${name}: ${baselinePlans.length} baseline launches`); + const baselineRun = await runLaunches(baselinePlans, { + ...launchOptions, + onDone: progressLogger('baseline'), + }); + if (baselineRun.aborted) + return { manifest: null, manifestFile: null, exitCode: EXIT_INTERRUPTED }; + const baseline: BaselineCoverage[] = []; + let passedAlone = 0; + for (const plan of baselinePlans) { + const launch = baselineRun.completed.get(plan.key); + const outcome = launch === undefined ? null : launchTestClass(launch); + if (launch === undefined || launch.validity !== 'valid' || outcome !== 'survived') { + problems.push( + `baseline: ${plan.target.id} does not pass alone (${launch?.validity ?? 'missing'} ` + + `${outcome ?? ''}: ${launch?.reason ?? ''})`, + ); + continue; + } + passedAlone++; + const coverage = launchCoverage(launch); + if (coverage === null) problems.push(`baseline: ${launch.dir} recorded no coverage`); + else baseline.push({ testId: plan.target.id, coverage }); + } + + // 4. Sample and strata. + const populationMutants = run.mutants.map((mutant) => ({ + globalId: mutant.globalId, + localId: mutant.localId, + oracleClass: mutant.oracleClass, + killedBy: extras.killedBy.get(mutant.globalId) ?? [], + })); + const populations = computePopulations(populationMutants, baseline, new Set(eachRows)); + const selection = selectSample({ + mutants: run.mutants.map((mutant) => mutant.globalId), + seed: options.seed, + sampleSize: options.sampleSize, + topUpMin: options.topUpMin, + populations, + }); + const strata = evaluateStrata({ + populations, + selected: selection.selected, + topUps: selection.topUps, + constructs, + affectedTests, + notOracleValidated, + }); + for (const stratum of strata.filter((record) => record.status === 'unmet')) { + problems.push( + `stratum ${stratum.name}: the scope's tests contain ${stratum.construct ?? 'it'} and its ` + + `population has ${stratum.population} mutants, but the sample has none; mark the ` + + `${stratum.affectedTests} affected tests not oracle-validated or raise --top-up-min`, + ); + } + log( + `freeze ${name}: sample ${selection.random.length} + ${selection.topUps.length} top-ups of ` + + `${run.mutants.length} mutants; strata ${strata + .map((record) => `${record.name} ${record.sampled}/${record.population} ${record.status}`) + .join(', ')}`, + ); + + // 5. Per-test launches of the sampled mutants. + const byGlobal = new Map(run.mutants.map((mutant) => [mutant.globalId, mutant])); + const sampledPlans: PlannedLaunch[] = + problems.length > 0 + ? [] + : selection.selected.flatMap((globalId) => { + const mutant = byGlobal.get(globalId); + return mutant === undefined + ? [] + : inventory.map((target) => ({ + key: launchKey(mutant.localId, target.id), + target, + mutantId: mutant.localId, + })); + }); + if (problems.length > 0) log(`freeze ${name}: ${problems.length} problems; no mutant launched`); + else log(`freeze ${name}: ${sampledPlans.length} mutant launches`); + const sampledRun = await runLaunches(sampledPlans, { + ...launchOptions, + onDone: progressLogger('mutant launches'), + }); + if (sampledRun.aborted) return { manifest: null, manifestFile: null, exitCode: EXIT_INTERRUPTED }; + + // 6. Manifest. + const testIndex = new Map(testIds.map((id, position) => [id, position])); + const strataOf = (globalId: string): StratumName[] => + STRATA.filter((stratum) => populations.get(stratum)?.has(globalId) ?? false); + let valid = 0; + let invalid = 0; + const sampled = + sampledPlans.length === 0 + ? [] + : selection.selected.map((globalId): SampledMutant => { + const mutant = byGlobal.get(globalId); + const entry: SampledMutant = { + id: globalId, + strata: strataOf(globalId), + covering: 0, + outcomes: [], + invalid: [], + }; + for (const target of inventory) { + const launch = sampledRun.completed.get(launchKey(mutant?.localId ?? null, target.id)); + const position = testIndex.get(target.id) ?? -1; + if (launch === undefined) { + entry.invalid.push([position, 'invalid', 'no launch result']); + invalid++; + continue; + } + if (launch.covering === true) entry.covering++; + if (launch.validity === 'invalid') { + entry.invalid.push([position, launch.outcome, launch.reason ?? '']); + invalid++; + continue; + } + valid++; + const outcome = launchTestClass(launch); + if (outcome !== 'survived') entry.outcomes.push([position, outcome]); + } + if (entry.invalid.length > 0) { + problems.push(`${globalId}: ${entry.invalid.length} invalid observations`); + } + const commandKills = new Set(mutant?.assertionKilledBy ?? []); + const aloneKills = new Set( + entry.outcomes + .filter(([, outcome]) => outcome === 'killed-by-test:assertion') + .map(([position]) => testIds[position]), + ); + const differ = + commandKills.size !== aloneKills.size || + [...commandKills].some((id) => !aloneKills.has(id)); + if (differ) { + warnings.push( + `${globalId}: assertion kills of the tests alone (${aloneKills.size}) differ from the ` + + `command run's (${commandKills.size})`, + ); + } + return entry; + }); + + const manifest: OracleManifest = { + schemaVersion: MANIFEST_SCHEMA_VERSION, + kind: MANIFEST_KIND, + name, + description: describeManifest(name, manifestPath), + refreeze: refreezeCommand(manifestPath), + scope, + versions, + operators: { mutator, names: extras.mutatorNames }, + inputs, + sample: { + seed: options.seed, + method: SAMPLE_METHOD, + size: options.sampleSize, + topUpMin: options.topUpMin, + random: selection.random.length, + topUps: selection.topUps, + }, + strata, + baseline: { tests: inventory.length, passedAlone }, + tests: testIds, + notOracleValidated: testIds.filter((id) => notOracleValidated.has(id)), + mutants: run.mutants + .map((mutant): [string, OracleClass] => [mutant.globalId, mutant.oracleClass]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + sampled, + launches: { + total: baselinePlans.length + sampledPlans.length, + valid, + invalid, + wallClockLimitMs: options.wallClockMs, + }, + warnings, + problems, + }; + const workManifest = path.join(options.workDir, WORK_MANIFEST_FILE); + writeManifest(workManifest, manifest); + const target = problems.length === 0 && manifestFile !== null ? manifestFile : workManifest; + if (target !== workManifest) writeManifest(target, manifest); + log( + `freeze ${name}: ${problems.length === 0 ? 'clean' : `${problems.length} problems`}; ` + + `${sampled.length} sampled mutants, ${valid} valid and ${invalid} invalid observations, ` + + `${warnings.length} warnings; manifest ${target}; ${formatDuration(performance.now() - started)}`, + ); + for (const problem of problems.slice(0, 20)) log(` problem: ${problem}`); + for (const warning of warnings.slice(0, 10)) log(` warning: ${warning}`); + return { manifest, manifestFile: target, exitCode: problems.length === 0 ? 0 : 1 }; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +const positive = (value: string | undefined, name: string, fallback: number, min = 1): number => { + if (value === undefined) return fallback; + if (!/^\d+$/.test(value) || Number(value) < min) { + throw new FreezeUsageError(`--${name} must be an integer of at least ${min}, got ${value}`); + } + return Number(value); +}; + +export function parseFreezeArgs( + argv: readonly string[], + cwd: string, +): Omit { + const { values } = parseArgs({ + args: [...argv], + options: { + scope: { type: 'string' }, + 'from-manifest': { type: 'string' }, + name: { type: 'string' }, + work: { type: 'string' }, + manifest: { type: 'string' }, + seed: { type: 'string' }, + 'sample-size': { type: 'string' }, + 'top-up-min': { type: 'string' }, + 'launch-concurrency': { type: 'string' }, + concurrency: { type: 'string' }, + 'timeout-ms': { type: 'string' }, + 'wall-clock-ms': { type: 'string' }, + 'command-run': { type: 'string' }, + 'runner-results': { type: 'string' }, + 'not-oracle-validated': { type: 'string', multiple: true }, + 'retry-invalid': { type: 'boolean', default: false }, + }, + strict: true, + allowPositionals: false, + }); + if ((values.scope === undefined) === (values['from-manifest'] === undefined)) { + throw new FreezeUsageError('give exactly one of --scope and --from-manifest'); + } + if (values.seed === undefined || values.seed === '') + throw new FreezeUsageError('--seed is required'); + if (values.work === undefined) throw new FreezeUsageError('--work is required'); + const resolve = (value: string | undefined): string | null => + value === undefined ? null : path.resolve(cwd, value); + return { + scopeFile: resolve(values.scope), + fromManifest: resolve(values['from-manifest']), + name: values.name ?? null, + workDir: path.resolve(cwd, values.work), + manifestFile: resolve(values.manifest), + seed: values.seed, + sampleSize: positive(values['sample-size'], 'sample-size', DEFAULT_SAMPLE_SIZE), + topUpMin: positive(values['top-up-min'], 'top-up-min', DEFAULT_TOP_UP_MIN, 0), + launchConcurrency: positive( + values['launch-concurrency'], + 'launch-concurrency', + DEFAULT_LAUNCH_CONCURRENCY, + ), + concurrency: positive(values.concurrency, 'concurrency', 4), + timeoutMS: positive(values['timeout-ms'], 'timeout-ms', 10000), + wallClockMs: positive(values['wall-clock-ms'], 'wall-clock-ms', DEFAULT_WALL_CLOCK_MS), + commandRun: resolve(values['command-run']), + runnerResults: resolve(values['runner-results']), + notOracleValidated: values['not-oracle-validated'] ?? [], + retryInvalid: values['retry-invalid'], + }; +} + +async function main(): Promise { + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + const controller = new AbortController(); + const interrupt = (signal: string): void => { + console.error(`freeze: ${signal}; stopping the running launches`); + controller.abort(); + }; + process.once('SIGINT', () => interrupt('SIGINT')); + process.once('SIGTERM', () => interrupt('SIGTERM')); + try { + const options = parseFreezeArgs(process.argv.slice(2), process.cwd()); + const { exitCode } = await runFreeze({ ...options, repoRoot, signal: controller.signal }); + if (exitCode === EXIT_INTERRUPTED) console.error('freeze: interrupted; rerun to resume'); + process.exitCode = exitCode; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof FreezeUsageError || error instanceof TypeError) { + console.error( + 'usage: npm run test:mutation:oracle:freeze -- (--scope | --from-manifest ' + + ') --seed --work [--manifest ] [--name ] ' + + '[--command-run ] [--runner-results ] ' + + '[--sample-size ] [--top-up-min ] [--launch-concurrency ] [--concurrency ] ' + + '[--timeout-ms ] [--wall-clock-ms ] [--not-oracle-validated ]... ' + + '[--retry-invalid]', + ); + } else { + console.error(error instanceof Error ? error.stack : ''); + } + process.exitCode = controller.signal.aborted ? EXIT_INTERRUPTED : 2; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + void main(); +} diff --git a/gitnexus/tools/mutation/oracle/launch-single.ts b/gitnexus/tools/mutation/oracle/launch-single.ts new file mode 100644 index 000000000..cd1c822de --- /dev/null +++ b/gitnexus/tools/mutation/oracle/launch-single.ts @@ -0,0 +1,961 @@ +/** + * Oracle single-test launcher (U12, KTD13, KTD15, KTD17): runs one test with one mutant (or none) + * in a fresh process inside the kept, instrumented sandbox of an oracle command run (U13, + * command-run.ts) and classifies what it observed. + * + * A launch: + * 1. selects the test by `:` plus `-t "^$"` (the full name is the + * suite and test titles joined with `' > '`, Vitest's `fullTestName`; every regex + * metacharacter is escaped); + * 2. spawns `node --import tsx launch-single.ts --child ` detached (its own process + * group), with the sandbox as working directory and Vitest root, the run's scope file in + * GITNEXUS_MUTATION_SCOPE, the sandbox's tools/mutation/vitest.mutation.config.ts, and the + * mutant installed through `__STRYKER_ACTIVE_MUTANT__` before anything is imported. The child + * runs Vitest once through command-wrapper.ts `runOracleVitest` (the `vitest run` code path, + * no results cache, no fs module cache, its own Vite cacheDir; KTD5) with the oracle reporter, + * and puts oracle-setup.ts first among the mutation project's setup files; + * 3. enforces a wall-clock limit: when it expires, the whole process group gets SIGKILL. The + * group is killed after a normal exit too, so no Vitest worker outlives its launch. The + * default, {@link DEFAULT_WALL_CLOCK_MS}, is the base config's testTimeout plus hookTimeout + * plus a startup margin, so a hang inside Vitest surfaces as Vitest's own timeout first; + * 4. classifies the record (oracle-classify.ts) and the probe (probe.ts) with + * {@link classifyLaunch}. + * + * Validity (KTD17 classes; `validity` is `valid` only for a usable observation): + * probe's installed id differs from the intended one, or the probe names + * another intended id invalid + * wall-clock kill after the setup file confirmed the installed id timeout + * wall-clock kill before the setup file ran infrastructure + * no record, a wrapper error, an unhandled error or exit-code mismatch infrastructure + * the location filter matched no test invalid + * two or more tests executed, or one test other than the intended one invalid + * zero tests executed, unless a failed `beforeAll` skipped the intended + * test (killed-outside-test:hook, or timeout for a hookTimeout) or the + * file failed to load (killed-outside-test:collection) invalid + * exactly the intended test executed: the oracle classifier's class + * (assertion, runtime, hook, timeout, survived); `retry` makes it + * inconclusive. A survivor that never hit the mutant is a valid + * non-covering observation (`covering: false`; KTD13 merges NoCoverage + * and survived) that class + * An executed launch needs a complete probe; without one it is infrastructure. + * + * Launch directory (absolute, empty or new): request.json, the child's stdout.log/stderr.log, + * probe.json (probe.ts), child.json ({@link ChildInfo}), reports/ (the oracle record and Vitest + * JSON report) and launch.json ({@link LaunchResult}). + * + * Usage (from gitnexus/): + * node --import tsx tools/mutation/oracle/launch-single.ts --run + * --test (--mutant | --baseline) [--wall-clock-ms ] + * [--out ] [--install-mutant ] + * `--install-mutant` installs another id than `--mutant` intends; it exists to check that such a + * launch comes out invalid. Exit codes: 0 valid observation, 1 invalid, 2 usage or setup error. + */ +import { spawn } from 'node:child_process'; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import type { Vite } from 'vitest/node'; +import { DEFAULT_VITEST_CONFIG, runOracleVitest } from './command-wrapper.js'; +import { + ORACLE_CLASSES, + assignTestIds, + classifyRun, + isMutantId, + isUnusableClass, + parseOracleRunRecord, + recordFileName, + type OracleClass, + type OracleRunRecord, + type OracleTestLocation, +} from './oracle-classify.js'; +import { + INTENDED_MUTANT_ENV, + PROBE_FILE_ENV, + parseOracleProbe, + type OracleProbe, +} from './probe.js'; +import { isObject, writeFileAtomicSync } from './util.js'; + +/** `testTimeout` and `hookTimeout` of gitnexus/vitest.config.ts. */ +export const BASE_TEST_TIMEOUT_MS = 30_000; +export const BASE_HOOK_TIMEOUT_MS = 120_000; +/** Room for process start, Vite and the transform of the test file's imports. */ +export const STARTUP_MARGIN_MS = 30_000; +/** Default wall-clock limit per launch: 180 000 ms. */ +export const DEFAULT_WALL_CLOCK_MS = + BASE_TEST_TIMEOUT_MS + BASE_HOOK_TIMEOUT_MS + STARTUP_MARGIN_MS; + +const MUTATION_SCOPE_ENV = 'GITNEXUS_MUTATION_SCOPE'; +const WORKER_ENV = 'STRYKER_MUTATOR_WORKER'; +const ORACLE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const LAUNCH_FILE = fileURLToPath(import.meta.url); +/** The worker setup file, next to this module. */ +export const SETUP_FILE = path.join(ORACLE_DIR, 'oracle-setup.ts'); +export const MUTATION_PROJECT = 'mutation'; +const NO_TEST_AT_LINE = /^No test found in .+ in line \d+/; +const LAUNCH_SCHEMA_VERSION = 1; + +export class LaunchUsageError extends Error { + override name = 'LaunchUsageError'; +} + +/** The launch's `signal` aborted it: its process group was killed and no launch.json written. */ +export class LaunchAbortedError extends Error { + override name = 'LaunchAbortedError'; +} + +// ── test selection ───────────────────────────────────────────────────────── + +export interface LaunchTarget { + /** KTD3 id: `#`, with the oracle's own suffixes for shared names. */ + id: string; + /** Package-relative POSIX path of the test file. */ + file: string; + /** Suite and test titles joined with `' > '`. */ + fullName: string; + location: OracleTestLocation | null; +} + +/** Escapes every regex metacharacter: `| ( ) [ ] ? . * + ^ $ { } \`. */ +export const escapeRegExp = (text: string): string => text.replace(/[|()[\]?.*+^${}\\]/g, '\\$&'); + +/** Vitest's `-t` value that matches exactly one full name. */ +export const testNamePattern = (fullName: string): string => `^${escapeRegExp(fullName)}$`; + +/** Vitest's `:` filter for the target. */ +export function locationFilter(target: LaunchTarget): string { + if (target.location === null) { + throw new LaunchUsageError(`test ${target.id} has no location; a launch needs its line`); + } + return `${target.file}:${target.location.line}`; +} + +// ── environment ──────────────────────────────────────────────────────────── + +export interface LaunchEnvOptions { + scopeFile: string; + probeFile: string; + intendedMutantId: string | null; + installedMutantId: string | null; +} + +/** The child's environment: no stale Stryker variables, the mutant and probe settings set. */ +export function buildLaunchEnv( + base: Readonly>, + options: LaunchEnvOptions, +): Record { + const dropped = new Set([ + INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE, + WORKER_ENV, + MUTATION_SCOPE_ENV, + PROBE_FILE_ENV, + INTENDED_MUTANT_ENV, + ]); + const env: Record = {}; + for (const [key, value] of Object.entries(base)) { + if (value !== undefined && !dropped.has(key)) env[key] = value; + } + if (options.installedMutantId !== null) { + env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE] = options.installedMutantId; + } + env[MUTATION_SCOPE_ENV] = options.scopeFile; + env[PROBE_FILE_ENV] = options.probeFile; + env[INTENDED_MUTANT_ENV] = options.intendedMutantId ?? ''; + return env; +} + +// ── classification ───────────────────────────────────────────────────────── + +export interface LaunchEvidence { + target: LaunchTarget; + /** Null for an unmutated baseline launch. */ + intendedMutantId: string | null; + wallClockLimitMs: number; + killedByWallClock: boolean; + /** The child's exit code; null when a signal ended it. */ + exitCode: number | null; + signal: string | null; + record: OracleRunRecord | null; + probe: OracleProbe | null; +} + +export interface LaunchHits { + loadAndCollection: number; + hook: number; + /** Hits while the intended test was current. */ + test: number; + /** Hits while another test was current (none in a valid launch). */ + otherTests: number; +} + +export interface LaunchObservation { + testId: string; + mutantId: string | null; + validity: 'valid' | 'invalid'; + outcome: OracleClass; + reason: string | null; + /** KTD3 ids of the tests that ran. */ + executed: string[]; + /** The id the worker had installed, from the probe; null when unknown or none. */ + installedMutantId: string | null; + /** Whether the mutant was hit during the launch; null when the probe has no hits. */ + covering: boolean | null; + hits: LaunchHits | null; + killedBy: string[]; + assertionKilledBy: string[]; +} + +/** Classifies one launch; pure. See the module comment for the rules. */ +export function classifyLaunch(evidence: LaunchEvidence): LaunchObservation { + const { target, probe, record } = evidence; + const base = { + testId: target.id, + mutantId: evidence.intendedMutantId, + installedMutantId: probe?.activation.installed ?? null, + }; + const reject = ( + outcome: OracleClass, + reason: string, + executed: string[] = [], + ): LaunchObservation => ({ + ...base, + validity: 'invalid', + outcome, + reason, + executed, + covering: null, + hits: null, + killedBy: [], + assertionKilledBy: [], + }); + + if (probe !== null && probe.activation.intended !== evidence.intendedMutantId) { + return reject( + 'invalid', + `the probe's intended mutant ${JSON.stringify(probe.activation.intended)} is not the ` + + `launch's ${JSON.stringify(evidence.intendedMutantId)}`, + ); + } + if (probe !== null && !probe.activation.matches) { + const { installed, intended, instrumentedBeforeSetup } = probe.activation; + return reject( + 'invalid', + instrumentedBeforeSetup + ? 'instrumented code ran before the oracle setup file' + : `installed mutant ${JSON.stringify(installed)} differs from the intended ` + + `${JSON.stringify(intended)}`, + ); + } + if (evidence.killedByWallClock) { + if (probe === null) { + return reject( + 'infrastructure', + `the wall-clock limit of ${evidence.wallClockLimitMs} ms expired before the oracle setup ` + + 'file ran', + ); + } + return { + ...base, + validity: 'valid', + outcome: 'timeout', + reason: `the wall-clock limit of ${evidence.wallClockLimitMs} ms expired; the process group was killed`, + executed: [], + covering: null, + hits: null, + killedBy: [], + assertionKilledBy: [], + }; + } + if (record === null) { + return reject( + 'infrastructure', + `the launch left no record (exit ${evidence.exitCode ?? '-'}, signal ${evidence.signal ?? '-'})`, + ); + } + if (record.wrapperError !== null || record.run === null) { + return reject( + 'infrastructure', + record.wrapperError ?? 'the oracle reporter saw no end of the Vitest run', + ); + } + const filterMiss = record.run.modules + .flatMap((module) => module.errors) + .find((error) => NO_TEST_AT_LINE.test(error.message)); + if (filterMiss !== undefined) { + return reject('invalid', `the location filter matched no test: ${filterMiss.message}`); + } + + const classified = classifyRun(record.run, { + exitCode: record.exitCode, + expected: [target.id], + vitestJson: record.vitestJson, + }); + const { executed } = classified; + if (classified.outcome === 'infrastructure') { + return reject('infrastructure', classified.reason ?? 'infrastructure', executed); + } + if (executed.length > 1) { + return reject('invalid', `${executed.length} tests executed: ${executed.join(', ')}`, executed); + } + if (executed.length === 1 && executed[0] !== target.id) { + return reject('invalid', `${executed[0]} executed, which is not the intended test`, executed); + } + if (executed.length === 0) { + const skippedByHook = classified.tests.some( + (test) => + test.id === target.id && + (test.outcome === 'killed-outside-test:hook' || test.outcome === 'timeout'), + ); + const loadFailed = + classified.outcome === 'killed-outside-test:collection' && + classified.outsideTest.some((row) => row.file === target.file && row.suite === null); + if (loadFailed && probe === null) { + return reject( + 'infrastructure', + 'the test file failed to load and the oracle setup file left no probe', + ); + } + if (!skippedByHook && !loadFailed) { + return reject( + 'invalid', + `zero tests executed${classified.reason === null ? '' : ` (${classified.reason})`}`, + ); + } + } + if (isUnusableClass(classified.outcome)) { + return reject(classified.outcome, classified.reason ?? classified.outcome, executed); + } + + const hits = probe?.hits ?? null; + if (executed.length === 1 && hits === null) { + return reject( + 'infrastructure', + probe === null + ? 'the oracle setup file left no probe' + : 'the probe is incomplete: the setup file did not see the end of the run', + executed, + ); + } + const isTarget = (entry: { fullName: string; location: OracleTestLocation | null }): boolean => + entry.fullName === target.fullName && + entry.location?.line === target.location?.line && + entry.location?.column === target.location?.column; + const launchHits: LaunchHits | null = + hits === null + ? null + : { + loadAndCollection: hits.loadAndCollection, + hook: hits.hook, + test: hits.perTest.filter(isTarget).reduce((sum, entry) => sum + entry.count, 0), + otherTests: hits.perTest + .filter((entry) => !isTarget(entry)) + .reduce((sum, entry) => sum + entry.count, 0), + }; + const covering = + launchHits === null + ? null + : launchHits.loadAndCollection + launchHits.hook + launchHits.test + launchHits.otherTests > + 0; + return { + ...base, + validity: 'valid', + outcome: classified.outcome, + reason: + classified.outcome !== 'survived' + ? classified.reason + : evidence.intendedMutantId === null + ? 'the unmutated test passed' + : covering === false + ? 'the test passed and never hit the mutant' + : 'the test passed and hit the mutant', + executed, + covering, + hits: launchHits, + killedBy: classified.killedBy, + assertionKilledBy: classified.assertionKilledBy, + }; +} + +// ── oracle command run and test inventory ────────────────────────────────── + +/** The parts of an `oracle-command-run.json` (command-run.ts) a launch needs. */ +export interface CommandRunView { + file: string; + outDir: string; + sandboxDir: string; + scopeFile: string; + baselineRecord: string; + baselineExecuted: string[]; + mutants: CommandRunMutant[]; +} + +export interface CommandRunMutant { + localId: string; + globalId: string; + oracleClass: OracleClass; + killedBy: string[]; + assertionKilledBy: string[]; + wallClockMs: number | null; +} + +function member(data: unknown, key: string, at: string): unknown { + if (!isObject(data) || !(key in data)) { + throw new LaunchUsageError(`oracle command run: ${at}.${key} is missing`); + } + return data[key]; +} + +function text(data: unknown, key: string, at: string): string { + const value = member(data, key, at); + if (typeof value !== 'string') { + throw new LaunchUsageError(`oracle command run: ${at}.${key} must be a string`); + } + return value; +} + +function texts(data: unknown, key: string, at: string): string[] { + const value = member(data, key, at); + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new LaunchUsageError(`oracle command run: ${at}.${key} must be an array of strings`); + } + return value.map(String); +} + +function oracleClass(value: string, at: string): OracleClass { + const found = ORACLE_CLASSES.find((entry) => entry === value); + if (found === undefined) { + throw new LaunchUsageError(`oracle command run: ${at}.oracleClass ${value} is unknown`); + } + return found; +} + +/** Reads the fields of an oracle command run a launch needs. */ +export function readCommandRun(file: string): CommandRunView { + const data: unknown = JSON.parse(readFileSync(file, 'utf8')); + if (member(data, 'kind', 'root') !== 'oracle-command-run') { + throw new LaunchUsageError(`${file} is not an oracle command run`); + } + const paths = member(data, 'paths', 'root'); + const baseline = member(data, 'baseline', 'root'); + const sandboxDir = member(paths, 'sandboxDir', 'paths'); + const baselineRecord = member(baseline, 'record', 'baseline'); + if (typeof sandboxDir !== 'string' || !existsSync(sandboxDir)) { + throw new LaunchUsageError(`${file}: the run kept no usable sandbox (paths.sandboxDir)`); + } + if (typeof baselineRecord !== 'string') { + throw new LaunchUsageError(`${file}: the run has no baseline record`); + } + const mutants = member(data, 'mutants', 'root'); + if (!Array.isArray(mutants)) throw new LaunchUsageError(`${file}: mutants must be an array`); + return { + file, + outDir: text(paths, 'outDir', 'paths'), + sandboxDir, + scopeFile: text(paths, 'scopeFile', 'paths'), + baselineRecord, + baselineExecuted: texts(baseline, 'executed', 'baseline'), + mutants: mutants.map((entry: unknown, index): CommandRunMutant => { + const at = `mutants[${index}]`; + const wallClockMs = member(entry, 'wallClockMs', at); + return { + localId: text(entry, 'localId', at), + globalId: text(entry, 'globalId', at), + oracleClass: oracleClass(text(entry, 'oracleClass', at), at), + killedBy: texts(entry, 'killedBy', at), + assertionKilledBy: texts(entry, 'assertionKilledBy', at), + wallClockMs: typeof wallClockMs === 'number' ? wallClockMs : null, + }; + }), + }; +} + +/** + * The tests of the run's baseline (dry-run) record in collection order, with the oracle's own + * KTD3 ids; the executed ones must equal the run's recorded baseline. + */ +export function readTestInventory(run: CommandRunView): LaunchTarget[] { + const record = parseOracleRunRecord(readFileSync(run.baselineRecord, 'utf8')); + if (record.run === null) throw new LaunchUsageError(`${run.baselineRecord} has no observed run`); + const targets = record.run.modules.flatMap((module) => { + const ids = assignTestIds( + module.tests.map((test) => ({ + file: module.file, + fullName: test.fullName, + location: test.location, + })), + ); + return module.tests.flatMap((test, index): LaunchTarget[] => + test.state === 'passed' + ? [{ id: ids[index], file: module.file, fullName: test.fullName, location: test.location }] + : [], + ); + }); + const expected = [...run.baselineExecuted].sort(); + const found = targets.map((target) => target.id).sort(); + if (expected.length !== found.length || expected.some((id, index) => id !== found[index])) { + throw new LaunchUsageError( + `${run.baselineRecord}: its passing tests differ from the run's baseline.executed`, + ); + } + return targets; +} + +// ── launching ────────────────────────────────────────────────────────────── + +export interface LaunchOptions { + /** The kept, instrumented Stryker sandbox (Vitest root and working directory). */ + sandboxDir: string; + /** The run's mutation scope file (GITNEXUS_MUTATION_SCOPE). */ + scopeFile: string; + /** Vitest config, relative to the sandbox. */ + configFile?: string; + target: LaunchTarget; + /** Local mutant id to install; null for an unmutated baseline launch. */ + mutantId: string | null; + /** Self-check only: install this id instead of `mutantId`; the launch must come out invalid. */ + installMutantId?: string | null; + wallClockMs?: number; + /** Absolute launch directory; created, and must be empty. */ + launchDir: string; + /** + * Aborting kills the launch's process group; the launch then rejects with + * {@link LaunchAbortedError} and writes no launch.json, so the directory stays unfinished. + */ + signal?: AbortSignal; +} + +/** What the child reports about its own Vitest run (`child.json`). */ +export interface ChildInfo { + schemaVersion: 1; + pid: number; + /** Resolved `testTimeout`/`hookTimeout` of the mutation project; null if it was not seen. */ + testTimeout: number | null; + hookTimeout: number | null; + /** The mutation project's setup files after the oracle setup file was added. */ + setupFiles: string[]; + mutationProjects: number; + /** CPU time of the child and its reaped children (Vitest's workers); null off Linux. */ + cpuMs: number | null; +} + +export interface LaunchResult { + schemaVersion: typeof LAUNCH_SCHEMA_VERSION; + launchDir: string; + target: LaunchTarget; + mutantId: string | null; + installedMutantId: string | null; + wallClockLimitMs: number; + /** Spawn to exit, measured by the launcher. */ + wallClockMs: number; + /** Vitest's run inside the child (the record's wall clock). */ + vitestWallClockMs: number | null; + cpuMs: number | null; + exitCode: number | null; + signal: string | null; + killedByWallClock: boolean; + child: ChildInfo | null; + /** Launch-level problems that do not change the observation (e.g. a too-small limit). */ + problems: string[]; + observation: LaunchObservation; +} + +interface ChildRequest { + root: string; + configFile: string; + reportDir: string; + childInfoFile: string; + mutantId: string | null; + filter: string; + testNamePattern: string; + setupFile: string; +} + +const packageDirs = new Map(); + +/** + * The real directory of `node_modules/` as Node resolves it from `fromDir`, or null; + * memoised per resolved `fromDir` and name for the life of the process. + */ +export function findPackageDir(fromDir: string, name: string): string | null { + const start = path.resolve(fromDir); + const key = `${start}\0${name}`; + const cached = packageDirs.get(key); + if (cached !== undefined) return cached; + let found: string | null = null; + for (let dir = start; ; dir = path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', name, 'package.json'); + if (existsSync(candidate)) { + found = realpathSync(path.dirname(candidate)); + break; + } + if (path.dirname(dir) === dir) break; + } + packageDirs.set(key, found); + return found; +} + +const writeJson = (file: string, value: unknown): void => + writeFileAtomicSync(file, `${JSON.stringify(value, null, 2)}\n`); + +const readOptional = (file: string, parse: (text: string) => T): T | null => + existsSync(file) ? parse(readFileSync(file, 'utf8')) : null; + +function killGroup(pid: number | undefined): void { + if (pid === undefined) return; + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // The group is gone already. + } +} + +function parseChildInfo(text: string): ChildInfo { + const data: unknown = JSON.parse(text); + const number = (key: string): number | null => { + const value = member(data, key, 'child'); + return typeof value === 'number' ? value : null; + }; + return { + schemaVersion: 1, + pid: number('pid') ?? 0, + testTimeout: number('testTimeout'), + hookTimeout: number('hookTimeout'), + setupFiles: texts(data, 'setupFiles', 'child'), + mutationProjects: number('mutationProjects') ?? 0, + cpuMs: number('cpuMs'), + }; +} + +/** Runs one launch as described in the module comment. */ +export async function launchSingle(options: LaunchOptions): Promise { + const { sandboxDir, target, mutantId, launchDir } = options; + const wallClockLimitMs = options.wallClockMs ?? DEFAULT_WALL_CLOCK_MS; + const installedMutantId = + options.installMutantId === undefined ? mutantId : options.installMutantId; + for (const id of [mutantId, installedMutantId]) { + if (id !== null && !isMutantId(id)) { + throw new LaunchUsageError(`mutant id ${JSON.stringify(id)} is not numeric`); + } + } + if (!path.isAbsolute(launchDir)) throw new LaunchUsageError('the launch dir must be absolute'); + if (options.signal?.aborted === true) throw new LaunchAbortedError('the launch was aborted'); + mkdirSync(launchDir, { recursive: true }); + if (readdirSync(launchDir).length > 0) { + throw new LaunchUsageError(`the launch dir ${launchDir} is not empty`); + } + const ownVitest = findPackageDir(ORACLE_DIR, 'vitest'); + const sandboxVitest = findPackageDir(sandboxDir, 'vitest'); + if (ownVitest === null || ownVitest !== sandboxVitest) { + throw new LaunchUsageError( + `the oracle setup file would load Vitest from ${ownVitest ?? 'nowhere'}, the sandbox from ` + + `${sandboxVitest ?? 'nowhere'}; both must be one copy`, + ); + } + + const files = { + request: path.join(launchDir, 'request.json'), + probe: path.join(launchDir, 'probe.json'), + child: path.join(launchDir, 'child.json'), + stdout: path.join(launchDir, 'stdout.log'), + stderr: path.join(launchDir, 'stderr.log'), + reports: path.join(launchDir, 'reports'), + result: path.join(launchDir, 'launch.json'), + }; + const request: ChildRequest = { + root: sandboxDir, + configFile: path.resolve(sandboxDir, options.configFile ?? DEFAULT_VITEST_CONFIG), + reportDir: files.reports, + childInfoFile: files.child, + mutantId, + filter: locationFilter(target), + testNamePattern: testNamePattern(target.fullName), + setupFile: SETUP_FILE, + }; + writeJson(files.request, request); + const env = buildLaunchEnv(process.env, { + scopeFile: options.scopeFile, + probeFile: files.probe, + intendedMutantId: mutantId, + installedMutantId, + }); + + const out = openSync(files.stdout, 'w'); + const err = openSync(files.stderr, 'w'); + const started = performance.now(); + let killedByWallClock = false; + let aborted = false; + const { exitCode, signal } = await new Promise<{ + exitCode: number | null; + signal: string | null; + }>((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', 'tsx', LAUNCH_FILE, '--child', files.request], + { cwd: sandboxDir, env, detached: true, stdio: ['ignore', out, err] }, + ); + const timer = setTimeout(() => { + killedByWallClock = true; + killGroup(child.pid); + }, wallClockLimitMs); + const onAbort = (): void => { + aborted = true; + killGroup(child.pid); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + child.once('error', (error) => { + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + reject(error); + }); + child.once('exit', (code, exitSignal) => { + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + killGroup(child.pid); + resolve({ exitCode: code, signal: exitSignal }); + }); + }).finally(() => { + closeSync(out); + closeSync(err); + }); + if (aborted) throw new LaunchAbortedError(`the launch in ${launchDir} was aborted`); + const wallClockMs = Math.round(performance.now() - started); + + const kind = mutantId === null ? 'dry-run' : 'mutant'; + const record = readOptional( + path.join(files.reports, recordFileName(kind, mutantId, 1)), + parseOracleRunRecord, + ); + const probe = readOptional(files.probe, parseOracleProbe); + const child = readOptional(files.child, parseChildInfo); + const observation = classifyLaunch({ + target, + intendedMutantId: mutantId, + wallClockLimitMs, + killedByWallClock, + exitCode, + signal, + record, + probe, + }); + const problems: string[] = []; + if (child !== null && child.testTimeout !== null && child.hookTimeout !== null) { + if (wallClockLimitMs <= child.testTimeout + child.hookTimeout) { + problems.push( + `the wall-clock limit ${wallClockLimitMs} ms does not exceed testTimeout ` + + `${child.testTimeout} + hookTimeout ${child.hookTimeout}`, + ); + } + } + if (child !== null && child.mutationProjects !== 1) { + problems.push(`the oracle setup file was added to ${child.mutationProjects} mutation projects`); + } + const result: LaunchResult = { + schemaVersion: LAUNCH_SCHEMA_VERSION, + launchDir, + target, + mutantId, + installedMutantId, + wallClockLimitMs, + wallClockMs, + vitestWallClockMs: record?.wallClockMs ?? null, + cpuMs: child?.cpuMs ?? null, + exitCode, + signal, + killedByWallClock, + child, + problems, + observation, + }; + writeJson(files.result, result); + return result; +} + +// ── child ────────────────────────────────────────────────────────────────── + +/** CPU time of this process and its reaped children from /proc/self/stat; null off Linux. */ +function selfAndChildrenCpuMs(): number | null { + try { + const stat = readFileSync('/proc/self/stat', 'utf8'); + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + // Fields 14-17 of proc(5) (utime, stime, cutime, cstime) in clock ticks of 10 ms (USER_HZ). + const ticks = [11, 12, 13, 14].reduce((sum, index) => sum + Number(fields[index] ?? 0), 0); + return ticks * 10; + } catch { + return null; + } +} + +function parseChildRequest(textValue: string): ChildRequest { + const data: unknown = JSON.parse(textValue); + const mutantId = member(data, 'mutantId', 'request'); + return { + root: text(data, 'root', 'request'), + configFile: text(data, 'configFile', 'request'), + reportDir: text(data, 'reportDir', 'request'), + childInfoFile: text(data, 'childInfoFile', 'request'), + mutantId: typeof mutantId === 'string' ? mutantId : null, + filter: text(data, 'filter', 'request'), + testNamePattern: text(data, 'testNamePattern', 'request'), + setupFile: text(data, 'setupFile', 'request'), + }; +} + +/** The child: one Vitest run with the oracle setup file; returns Vitest's exit code. */ +async function runChild(requestFile: string): Promise { + const request = parseChildRequest(readFileSync(requestFile, 'utf8')); + const info: ChildInfo = { + schemaVersion: 1, + pid: process.pid, + testTimeout: null, + hookTimeout: null, + setupFiles: [], + mutationProjects: 0, + cpuMs: null, + }; + const plugin: Vite.Plugin = { + name: 'gitnexus-oracle-setup', + configureVitest({ project }) { + if (project.name !== MUTATION_PROJECT) return; + const { config } = project; + if (config.setupFiles.length > 0) config.sequence.setupFiles = 'list'; + config.setupFiles = [request.setupFile, ...config.setupFiles]; + info.mutationProjects++; + info.testTimeout = config.testTimeout; + info.hookTimeout = config.hookTimeout; + info.setupFiles = [...config.setupFiles]; + }, + }; + const record = await runOracleVitest({ + root: request.root, + configFile: request.configFile, + reportDir: request.reportDir, + kind: request.mutantId === null ? 'dry-run' : 'mutant', + mutantId: request.mutantId, + filters: [request.filter], + testNamePattern: request.testNamePattern, + plugins: [plugin], + }); + info.cpuMs = selfAndChildrenCpuMs(); + writeJson(request.childInfoFile, info); + if (record.wrapperError !== null) console.error(`oracle launch: ${record.wrapperError}`); + return record.exitCode; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +interface LaunchCliOptions { + runFile: string; + testId: string; + mutantId: string | null; + installMutantId: string | null | undefined; + wallClockMs: number; + outDir: string | null; +} + +export function parseLaunchArgs(argv: readonly string[], cwd: string): LaunchCliOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + run: { type: 'string' }, + test: { type: 'string' }, + mutant: { type: 'string' }, + baseline: { type: 'boolean', default: false }, + 'install-mutant': { type: 'string' }, + 'wall-clock-ms': { type: 'string' }, + out: { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }); + if (values.run === undefined) throw new LaunchUsageError('--run is required'); + if (values.test === undefined) throw new LaunchUsageError('--test is required'); + if ((values.mutant === undefined) === !values.baseline) { + throw new LaunchUsageError('give exactly one of --mutant and --baseline'); + } + const limit = values['wall-clock-ms']; + if (limit !== undefined && (!/^\d+$/.test(limit) || Number(limit) < 1)) { + throw new LaunchUsageError(`--wall-clock-ms must be a positive integer, got ${limit}`); + } + return { + runFile: path.resolve(cwd, values.run), + testId: values.test, + mutantId: values.mutant ?? null, + installMutantId: values['install-mutant'], + wallClockMs: limit === undefined ? DEFAULT_WALL_CLOCK_MS : Number(limit), + outDir: values.out === undefined ? null : path.resolve(cwd, values.out), + }; +} + +/** `/oracle-launches/launch-`, the first that does not exist. */ +export function nextLaunchDir(runOutDir: string): string { + const root = path.join(runOutDir, 'oracle-launches'); + const used = existsSync(root) ? readdirSync(root) : []; + const numbers = used.flatMap((name) => /^launch-(\d+)$/.exec(name)?.[1] ?? []).map(Number); + return path.join(root, `launch-${String(Math.max(0, ...numbers) + 1).padStart(4, '0')}`); +} + +async function main(argv: readonly string[]): Promise { + if (argv[0] === '--child' && argv[1] !== undefined) { + let code = 1; + try { + code = await runChild(argv[1]); + } catch (error) { + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + } + // Vitest is closed; leftover handles must not keep the launch alive. + process.exit(code); + } + try { + const options = parseLaunchArgs(argv, process.cwd()); + const run = readCommandRun(options.runFile); + const target = readTestInventory(run).find((entry) => entry.id === options.testId); + if (target === undefined) { + throw new LaunchUsageError(`test ${options.testId} is not in the run's baseline`); + } + const result = await launchSingle({ + sandboxDir: run.sandboxDir, + scopeFile: run.scopeFile, + target, + mutantId: options.mutantId, + ...(options.installMutantId === undefined + ? {} + : { installMutantId: options.installMutantId }), + wallClockMs: options.wallClockMs, + launchDir: options.outDir ?? nextLaunchDir(run.outDir), + }); + const { observation } = result; + console.log( + `${observation.validity} ${observation.outcome}: ${target.id} with ` + + `${options.mutantId === null ? 'no mutant' : `mutant ${options.mutantId}`} in ` + + `${result.wallClockMs} ms` + + (observation.reason === null ? '' : `\n ${observation.reason}`), + ); + for (const problem of result.problems) console.log(` problem: ${problem}`); + console.log(` launch: ${path.join(result.launchDir, 'launch.json')}`); + process.exitCode = observation.validity === 'valid' ? 0 : 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + if (error instanceof LaunchUsageError || error instanceof TypeError) { + console.error( + 'usage: node --import tsx tools/mutation/oracle/launch-single.ts --run ' + + '--test (--mutant | --baseline) [--wall-clock-ms ] [--out ] ' + + '[--install-mutant ]', + ); + } + process.exitCode = 2; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + void main(process.argv.slice(2)); +} diff --git a/gitnexus/tools/mutation/oracle/oracle-classify.ts b/gitnexus/tools/mutation/oracle/oracle-classify.ts new file mode 100644 index 000000000..813a82669 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/oracle-classify.ts @@ -0,0 +1,842 @@ +/** + * The oracle's own outcome classifier (U13, KTD15, KTD17) and the record format it reads. + * + * The oracle shares no code with the vitest5 runner (tools/mutation/runner/); this module applies + * the KTD17 table to the oracle's own observations so gate 3 can compare two independent + * classifications. It may use the prepare step's report types (KTD18). + * + * Record file: command-wrapper.ts writes one {@link OracleRunRecord} (schemaVersion 1) per Vitest + * run to `/.attempt-.json`, where `` is `dry-run` or `mutant-` + * and `` counts the runs of that key from 1 (Stryker reruns a mutant only after a rejected run). + * Vitest's own JSON report of the same run sits next to it as `.attempt-.vitest.json`. + * kind, mutantId `dry-run` (no active mutant) or `mutant` with Stryker's local id + * attempt, pid, root attempt number, wrapper pid, the Vitest root (the Stryker sandbox) + * configFile absolute Vitest config file + * vitestVersion Vitest version that ran ('' when Vitest did not start) + * exitCode, wallClockMs the wrapper's exit code (Vitest's) and its run time + * wrapperError why Vitest could not run, or null + * run {@link OracleObservedRun} from oracle-reporter.ts, or null when the + * reporter saw no run end + * vitestJson totals of Vitest's JSON report, or null when it is missing + * vitestJsonFile path of that JSON report + * + * KTD17 rows as the oracle observes them: + * failed test, first error `AssertionError` killed-by-test:assertion + * failed test or hook, Vitest's testTimeout/hookTimeout error timeout + * failed test, another error killed-by-test:runtime + * failed `beforeEach`, or failed `afterEach` after a passing body killed-by-test:hook + * failed `beforeAll` (its suite's tests skipped) killed-outside-test:hook, + * attributed to the suite's + * baseline tests + * failed `afterAll`, or another suite-level error killed-outside-test:hook, + * no attribution + * file error and no test ran (import or collection) killed-outside-test:collection + * unhandled error, interrupted run, reporter or exit-code mismatch infrastructure + * an executed test with `retry` above zero inconclusive + * zero tests ran, a test set different from the baseline, a test + * or file that did not finish invalid + * every executed test passed survived + * A failed hook is one whose start Vitest reported without an end (oracle-reporter.ts). + * + * Mutant-level precedence: infrastructure, invalid, inconclusive, then the strongest kill + * (assertion, runtime, test hook, outside-test hook, collection), then timeout, then survived. + * Before the record is read, Stryker's raw status decides: Timeout is timeout (Stryker's timer, + * KTD17), NoCoverage is survived (KTD13), a missing record of a run Stryker reports as Killed, + * Survived or RuntimeError is infrastructure, and a mutant Stryker never ran (Ignored, Pending, + * CompileError) is invalid (zero tests). A Killed status with exit code 0, or Survived with a + * non-zero exit, is infrastructure. Kill sets (`killedBy`, `assertionKilledBy`) are filled only for + * kill classes; gates 3 and 4 count `assertionKilledBy` alone. + * + * Test ids follow KTD3: `#`, a `::` suffix for full names shared + * inside a file, and `:` (the 0-based index among all tests at that location) when the + * location is shared too. + */ +import type { MutantStatus, MutationReport } from '../prepare/mutant-identity.js'; +import { isObject, type JsonObject } from './util.js'; + +export const ORACLE_RECORD_SCHEMA_VERSION = 1; + +export const ORACLE_CLASSES = [ + 'killed-by-test:assertion', + 'killed-by-test:runtime', + 'killed-by-test:hook', + 'killed-outside-test:hook', + 'killed-outside-test:collection', + 'timeout', + 'infrastructure', + 'inconclusive', + 'invalid', + 'survived', +] as const; + +export type OracleClass = (typeof ORACLE_CLASSES)[number]; + +/** Kill classes, strongest first. */ +export const KILL_ORDER: readonly OracleClass[] = ORACLE_CLASSES.slice(0, 5); + +export const isKillClass = (outcome: OracleClass): boolean => KILL_ORDER.includes(outcome); + +/** Classes that carry no verdict on the mutant: the run was invalid, broken or undecided. */ +const UNUSABLE_CLASSES: ReadonlySet = new Set([ + 'invalid', + 'infrastructure', + 'inconclusive', +]); + +export const isUnusableClass = (outcome: OracleClass): boolean => UNUSABLE_CLASSES.has(outcome); + +export type TestHookName = 'beforeEach' | 'afterEach'; +export type SuiteHookName = 'beforeAll' | 'afterAll'; +export type OracleTestState = 'passed' | 'failed' | 'skipped' | 'pending'; + +export interface OracleError { + name: string; + message: string; +} + +export interface OracleTestLocation { + line: number; + column: number; +} + +export interface OracleTestRecord { + /** Vitest's full name: suite and test titles joined with `' > '`. */ + fullName: string; + location: OracleTestLocation | null; + state: OracleTestState; + /** In Vitest's order: the body or `beforeEach` error first. */ + errors: OracleError[]; + failedHooks: TestHookName[]; + /** The test's configured `retry` count. */ + retry: number; + /** Retries Vitest made. */ + retryCount: number; +} + +export interface OracleSuiteRecord { + fullName: string; + errors: OracleError[]; + failedHooks: SuiteHookName[]; + /** Indexes into the module's tests: every test below the suite, at any depth. */ + tests: number[]; +} + +export interface OracleModuleRecord { + /** Package-relative POSIX path. */ + file: string; + /** Vitest's module state (`passed`, `failed`, `skipped`, `pending` or `queued`). */ + state: string; + /** Import, collection and file-level hook errors. */ + errors: OracleError[]; + failedHooks: SuiteHookName[]; + suites: OracleSuiteRecord[]; + /** Every test of the file in collection order. */ + tests: OracleTestRecord[]; +} + +export interface OracleObservedRun { + reason: 'passed' | 'interrupted' | 'failed'; + unhandledErrors: OracleError[]; + modules: OracleModuleRecord[]; +} + +/** The totals of Vitest's own JSON report that the classifier cross-checks. */ +export interface VitestJsonTotals { + numTotalTests: number; + numPassedTests: number; + numFailedTests: number; +} + +export type RecordKind = 'dry-run' | 'mutant'; + +export interface OracleRunRecord { + schemaVersion: typeof ORACLE_RECORD_SCHEMA_VERSION; + kind: RecordKind; + mutantId: string | null; + attempt: number; + pid: number; + root: string; + configFile: string; + vitestVersion: string; + exitCode: number; + wallClockMs: number; + wrapperError: string | null; + run: OracleObservedRun | null; + vitestJson: VitestJsonTotals | null; + vitestJsonFile: string | null; +} + +// ── record files ───────────────────────────────────────────────────────────── + +const MUTANT_ID = /^\d+$/; +const RECORD_FILE = /^(?:dry-run|mutant-(\d+))\.attempt-([1-9]\d*)\.json$/; + +export function isMutantId(value: string): boolean { + return MUTANT_ID.test(value); +} + +const recordKey = (kind: RecordKind, mutantId: string | null): string => { + if (kind === 'dry-run') return 'dry-run'; + if (mutantId === null || !isMutantId(mutantId)) { + throw new Error(`oracle record: invalid mutant id ${JSON.stringify(mutantId)}`); + } + return `mutant-${mutantId}`; +}; + +export function recordFileName(kind: RecordKind, mutantId: string | null, attempt: number): string { + return `${recordKey(kind, mutantId)}.attempt-${attempt}.json`; +} + +export function vitestJsonFileName( + kind: RecordKind, + mutantId: string | null, + attempt: number, +): string { + return `${recordKey(kind, mutantId)}.attempt-${attempt}.vitest.json`; +} + +/** The kind, mutant id and attempt of a record file name; null for any other file. */ +export function parseRecordFileName( + name: string, +): { kind: RecordKind; mutantId: string | null; attempt: number } | null { + const match = RECORD_FILE.exec(name); + if (match === null) return null; + const [, mutantId, attempt] = match; + return mutantId === undefined + ? { kind: 'dry-run', mutantId: null, attempt: Number(attempt) } + : { kind: 'mutant', mutantId, attempt: Number(attempt) }; +} + +// ── record parsing ─────────────────────────────────────────────────────────── + +function fail(at: string, detail: string): never { + throw new Error(`oracle record: ${at} ${detail}`); +} + +function field(data: JsonObject, key: string, at: string): unknown { + if (!(key in data)) fail(`${at}.${key}`, 'is missing'); + return data[key]; +} + +function str(data: JsonObject, key: string, at: string): string { + const value = field(data, key, at); + return typeof value === 'string' ? value : fail(`${at}.${key}`, 'must be a string'); +} + +function nullableStr(data: JsonObject, key: string, at: string): string | null { + const value = field(data, key, at); + if (value === null) return null; + return typeof value === 'string' ? value : fail(`${at}.${key}`, 'must be a string or null'); +} + +function int(data: JsonObject, key: string, at: string): number { + const value = field(data, key, at); + return typeof value === 'number' && Number.isInteger(value) + ? value + : fail(`${at}.${key}`, 'must be an integer'); +} + +function list(data: JsonObject, key: string, at: string): unknown[] { + const value = field(data, key, at); + return Array.isArray(value) ? value : fail(`${at}.${key}`, 'must be an array'); +} + +function object(value: unknown, at: string): JsonObject { + return isObject(value) ? value : fail(at, 'must be an object'); +} + +function oneOf(value: string, allowed: readonly T[], at: string): T { + const found = allowed.find((entry) => entry === value); + return found ?? fail(at, `must be one of ${allowed.join(', ')}`); +} + +const TEST_STATES: readonly OracleTestState[] = ['passed', 'failed', 'skipped', 'pending']; +const TEST_HOOKS: readonly TestHookName[] = ['beforeEach', 'afterEach']; +const SUITE_HOOKS: readonly SuiteHookName[] = ['beforeAll', 'afterAll']; +const RUN_REASONS: readonly OracleObservedRun['reason'][] = ['passed', 'interrupted', 'failed']; + +const parseErrors = (data: JsonObject, key: string, at: string): OracleError[] => + list(data, key, at).map((value, index) => { + const entry = object(value, `${at}.${key}[${index}]`); + return { + name: str(entry, 'name', `${at}.${key}[${index}]`), + message: str(entry, 'message', `${at}.${key}[${index}]`), + }; + }); + +const parseHooks = (data: JsonObject, at: string, allowed: readonly T[]): T[] => + list(data, 'failedHooks', at).map((value, index) => + oneOf(typeof value === 'string' ? value : '', allowed, `${at}.failedHooks[${index}]`), + ); + +function parseTest(value: unknown, at: string): OracleTestRecord { + const data = object(value, at); + const location = field(data, 'location', at); + return { + fullName: str(data, 'fullName', at), + location: + location === null + ? null + : { + line: int(object(location, `${at}.location`), 'line', `${at}.location`), + column: int(object(location, `${at}.location`), 'column', `${at}.location`), + }, + state: oneOf(str(data, 'state', at), TEST_STATES, `${at}.state`), + errors: parseErrors(data, 'errors', at), + failedHooks: parseHooks(data, at, TEST_HOOKS), + retry: int(data, 'retry', at), + retryCount: int(data, 'retryCount', at), + }; +} + +function parseSuite(value: unknown, at: string, testCount: number): OracleSuiteRecord { + const data = object(value, at); + return { + fullName: str(data, 'fullName', at), + errors: parseErrors(data, 'errors', at), + failedHooks: parseHooks(data, at, SUITE_HOOKS), + tests: list(data, 'tests', at).map((index, i) => + typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < testCount + ? index + : fail(`${at}.tests[${i}]`, 'must index a test of the module'), + ), + }; +} + +function parseModule(value: unknown, at: string): OracleModuleRecord { + const data = object(value, at); + const tests = list(data, 'tests', at).map((test, i) => parseTest(test, `${at}.tests[${i}]`)); + return { + file: str(data, 'file', at), + state: str(data, 'state', at), + errors: parseErrors(data, 'errors', at), + failedHooks: parseHooks(data, at, SUITE_HOOKS), + suites: list(data, 'suites', at).map((suite, i) => + parseSuite(suite, `${at}.suites[${i}]`, tests.length), + ), + tests, + }; +} + +function parseRun(value: unknown): OracleObservedRun | null { + if (value === null) return null; + const data = object(value, 'run'); + return { + reason: oneOf(str(data, 'reason', 'run'), RUN_REASONS, 'run.reason'), + unhandledErrors: parseErrors(data, 'unhandledErrors', 'run'), + modules: list(data, 'modules', 'run').map((entry, i) => + parseModule(entry, `run.modules[${i}]`), + ), + }; +} + +function parseTotals(value: unknown): VitestJsonTotals | null { + if (value === null) return null; + const data = object(value, 'vitestJson'); + return { + numTotalTests: int(data, 'numTotalTests', 'vitestJson'), + numPassedTests: int(data, 'numPassedTests', 'vitestJson'), + numFailedTests: int(data, 'numFailedTests', 'vitestJson'), + }; +} + +/** Parses and validates the text of a record file. */ +export function parseOracleRunRecord(text: string): OracleRunRecord { + const data = object(JSON.parse(text), 'root'); + if (field(data, 'schemaVersion', 'root') !== ORACLE_RECORD_SCHEMA_VERSION) { + fail('root.schemaVersion', `must be ${ORACLE_RECORD_SCHEMA_VERSION}`); + } + const kind = oneOf(str(data, 'kind', 'root'), ['dry-run', 'mutant'] as const, 'root.kind'); + const mutantId = nullableStr(data, 'mutantId', 'root'); + if ((kind === 'mutant') !== (mutantId !== null && isMutantId(mutantId))) { + fail('root.mutantId', `does not fit kind ${kind}`); + } + return { + schemaVersion: ORACLE_RECORD_SCHEMA_VERSION, + kind, + mutantId, + attempt: int(data, 'attempt', 'root'), + pid: int(data, 'pid', 'root'), + root: str(data, 'root', 'root'), + configFile: str(data, 'configFile', 'root'), + vitestVersion: str(data, 'vitestVersion', 'root'), + exitCode: int(data, 'exitCode', 'root'), + wallClockMs: int(data, 'wallClockMs', 'root'), + wrapperError: nullableStr(data, 'wrapperError', 'root'), + run: parseRun(field(data, 'run', 'root')), + vitestJson: parseTotals(field(data, 'vitestJson', 'root')), + vitestJsonFile: nullableStr(data, 'vitestJsonFile', 'root'), + }; +} + +/** Stryker's raw status per local mutant id, from `mutation.json`. */ +export function strykerStatuses(report: MutationReport): Map { + const statuses = new Map(); + for (const [file, entry] of Object.entries(report.files)) { + for (const mutant of entry.mutants) { + if (statuses.has(mutant.id)) { + throw new Error(`mutation report: local id ${mutant.id} appears twice (${file})`); + } + statuses.set(mutant.id, mutant.status); + } + } + return statuses; +} + +// ── errors and test identity ───────────────────────────────────────────────── + +const MESSAGE_LIMIT = 2000; +const STACK_ERROR_NAME = /^\s*([A-Za-z_$][\w$]*)(?:\s*\[[^\]\n]*\])?:/; +const VITEST_TIMEOUT = + /^(?:(?:Test|Hook) timed out in \d+ms|The (?:setup|teardown) phase of "around(?:Each|All)" hook timed out after \d+ms)/; +const NO_TESTS = /^No test found in suite/; + +const clip = (text: string): string => + text.length > MESSAGE_LIMIT ? `${text.slice(0, MESSAGE_LIMIT)}...` : text; + +/** + * A Vitest error as the oracle records it. Without a `name`, the name comes from the first word + * of the stack (`AssertionError: ...`, `AssertionError [ERR_ASSERTION]: ...`), else `Error`. + */ +export function toOracleError(error: { + name?: unknown; + message?: unknown; + stack?: unknown; +}): OracleError { + const fromStack = + typeof error.stack === 'string' ? STACK_ERROR_NAME.exec(error.stack)?.[1] : undefined; + const name = + typeof error.name === 'string' && error.name !== '' ? error.name : (fromStack ?? 'Error'); + const message = typeof error.message === 'string' ? error.message : String(error.message ?? ''); + return { name, message: clip(message) }; +} + +export const isVitestTimeout = (error: OracleError): boolean => VITEST_TIMEOUT.test(error.message); + +export interface OracleTestKey { + file: string; + fullName: string; + location: OracleTestLocation | null; +} + +/** KTD3 test ids for tests in collection order (see the module comment). */ +export function assignTestIds(tests: readonly OracleTestKey[]): string[] { + const at = (test: OracleTestKey): string => + test.location === null ? '?:?' : `${test.location.line}:${test.location.column}`; + const count = (keys: string[]): Map => { + const counts = new Map(); + for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1); + return counts; + }; + const byName = count(tests.map((test) => `${test.file}#${test.fullName}`)); + const byNameAndPlace = count(tests.map((test) => `${test.file}#${test.fullName}:${at(test)}`)); + const nextRow = new Map(); + return tests.map((test) => { + const place = `${test.file}\0${at(test)}`; + const row = nextRow.get(place) ?? 0; + nextRow.set(place, row + 1); + const base = `${test.file}#${test.fullName}`; + if ((byName.get(base) ?? 0) < 2) return base; + const placed = `${base}:${at(test)}`; + return (byNameAndPlace.get(placed) ?? 0) < 2 ? placed : `${placed}:${row}`; + }); +} + +const fileOfId = (id: string): string => id.slice(0, Math.max(0, id.indexOf('#'))); + +// ── classification ─────────────────────────────────────────────────────────── + +export interface TestOutcome { + id: string; + file: string; + fullName: string; + outcome: OracleClass; + message: string | null; +} + +export interface OutsideTestOutcome { + file: string; + /** Full name of the suite; null for the file itself. */ + suite: string | null; + outcome: OracleClass; + /** Test ids the row kills; empty when the row has no test attribution. */ + attributedTo: string[]; + message: string; +} + +export interface RunClassification { + outcome: OracleClass; + reason: string | null; + /** Ids of the tests that ran (passed or failed), in collection order. */ + executed: string[]; + /** Every executed test, and every test a failed `beforeAll` skipped. */ + tests: TestOutcome[]; + outsideTest: OutsideTestOutcome[]; + /** Kill classes only: tests with a kill row and the tests outside-test rows attribute to. */ + killedBy: string[]; + /** Kill classes only: tests killed with an assertion. */ + assertionKilledBy: string[]; +} + +export interface ClassifyRunOptions { + /** The wrapper's (Vitest's) exit code. */ + exitCode: number; + /** Executed test ids of the baseline (dry) run; null when classifying the baseline itself. */ + expected: readonly string[] | null; + vitestJson: VitestJsonTotals | null; +} + +const describeErrors = (errors: readonly OracleError[]): string => + errors.map((error) => `${error.name}: ${error.message}`).join('\n'); + +function classifyFailedTest(test: OracleTestRecord): OracleClass { + const [first] = test.errors; + if (first === undefined) return 'killed-by-test:runtime'; + if (isVitestTimeout(first)) return 'timeout'; + const hookFailed = + test.failedHooks.includes('beforeEach') || + (test.failedHooks.includes('afterEach') && test.errors.length === 1); + if (hookFailed) return 'killed-by-test:hook'; + return first.name === 'AssertionError' ? 'killed-by-test:assertion' : 'killed-by-test:runtime'; +} + +const hookRowOutcome = (error: OracleError | undefined): OracleClass => + error !== undefined && isVitestTimeout(error) ? 'timeout' : 'killed-outside-test:hook'; + +const ran = (test: OracleTestRecord): boolean => test.state === 'passed' || test.state === 'failed'; + +interface Scope { + suite: string | null; + errors: OracleError[]; + failedHooks: SuiteHookName[]; + tests: number[]; +} + +/** Classifies one observed Vitest run; pure. See the module comment for the rules. */ +export function classifyRun( + run: OracleObservedRun, + options: ClassifyRunOptions, +): RunClassification { + const infrastructure: string[] = []; + const invalid: string[] = []; + const inconclusive: string[] = []; + const tests: TestOutcome[] = []; + const outsideTest: OutsideTestOutcome[] = []; + const executed: string[] = []; + const explained = new Set(); + const collectionFailed = new Set(); + const expected = options.expected === null ? null : new Set(options.expected); + + if (run.unhandledErrors.length > 0) { + infrastructure.push(`unhandled errors during the run: ${describeErrors(run.unhandledErrors)}`); + } + if (run.reason === 'interrupted') infrastructure.push('the Vitest run was interrupted'); + + const all = run.modules.flatMap((module) => module.tests); + const observedTotals: VitestJsonTotals = { + numTotalTests: all.length, + numPassedTests: all.filter((test) => test.state === 'passed').length, + numFailedTests: all.filter((test) => test.state === 'failed').length, + }; + if (options.vitestJson === null) { + infrastructure.push('the Vitest JSON report is missing'); + } else { + const keys = ['numTotalTests', 'numPassedTests', 'numFailedTests'] as const; + const differing = keys.filter((key) => options.vitestJson?.[key] !== observedTotals[key]); + if (differing.length > 0) { + infrastructure.push( + `the Vitest JSON report disagrees with the oracle reporter on ${differing + .map((key) => `${key} (${options.vitestJson?.[key]} vs ${observedTotals[key]})`) + .join(', ')}`, + ); + } + } + + for (const module of run.modules) { + const ids = assignTestIds( + module.tests.map((test) => ({ + file: module.file, + fullName: test.fullName, + location: test.location, + })), + ); + const noTests = [...module.errors, ...module.suites.flatMap((suite) => suite.errors)].filter( + (error) => NO_TESTS.test(error.message), + ); + for (const error of noTests) invalid.push(`${module.file}: ${error.message}`); + const loadErrors = module.errors.filter((error) => !NO_TESTS.test(error.message)); + if (loadErrors.length > 0 && module.failedHooks.length === 0 && !module.tests.some(ran)) { + collectionFailed.add(module.file); + outsideTest.push({ + file: module.file, + suite: null, + outcome: 'killed-outside-test:collection', + attributedTo: [], + message: describeErrors(loadErrors), + }); + continue; + } + if (module.state === 'pending' || module.state === 'queued') { + invalid.push(`${module.file} did not finish (state ${module.state})`); + } + + const scopes: Scope[] = [ + { + suite: null, + errors: loadErrors, + failedHooks: module.failedHooks, + tests: module.tests.map((_, index) => index), + }, + ...module.suites.map((suite) => ({ + suite: suite.fullName, + errors: suite.errors.filter((error) => !NO_TESTS.test(error.message)), + failedHooks: suite.failedHooks, + tests: suite.tests, + })), + ]; + const skippedByHook = new Set(); + for (const scope of scopes) { + if (scope.failedHooks.includes('beforeAll')) { + const error = scope.errors[0]; + const outcome = hookRowOutcome(error); + const message = error === undefined ? 'beforeAll failed' : describeErrors([error]); + const attributed = scope.tests.filter( + (index) => + !ran(module.tests[index]) && (expected === null || expected.has(ids[index] ?? '')), + ); + for (const index of attributed.filter((entry) => !skippedByHook.has(entry))) { + skippedByHook.add(index); + explained.add(ids[index]); + tests.push({ + id: ids[index], + file: module.file, + fullName: module.tests[index].fullName, + outcome, + message, + }); + } + outsideTest.push({ + file: module.file, + suite: scope.suite, + outcome, + attributedTo: outcome === 'timeout' ? [] : attributed.map((index) => ids[index]), + message, + }); + } + if (scope.failedHooks.includes('afterAll')) { + const error = scope.errors.at(-1); + outsideTest.push({ + file: module.file, + suite: scope.suite, + outcome: hookRowOutcome(error), + attributedTo: [], + message: error === undefined ? 'afterAll failed' : describeErrors([error]), + }); + } + if (scope.failedHooks.length === 0 && scope.errors.length > 0) { + outsideTest.push({ + file: module.file, + suite: scope.suite, + outcome: hookRowOutcome(scope.errors[0]), + attributedTo: [], + message: describeErrors(scope.errors), + }); + } + } + + module.tests.forEach((test, index) => { + const id = ids[index]; + if (skippedByHook.has(index)) return; + if (test.state === 'pending') { + invalid.push(`test ${id} did not finish`); + return; + } + if (!ran(test)) return; + executed.push(id); + if (test.retry > 0) inconclusive.push(`${id} has retry ${test.retry}`); + tests.push({ + id, + file: module.file, + fullName: test.fullName, + outcome: test.state === 'passed' ? 'survived' : classifyFailedTest(test), + message: test.state === 'passed' ? null : (test.errors[0]?.message ?? null), + }); + }); + } + + if (expected !== null) { + const ranSet = new Set(executed); + for (const id of expected) { + const accounted = ranSet.has(id) || explained.has(id) || collectionFailed.has(fileOfId(id)); + if (!accounted) invalid.push(`baseline test ${id} did not run`); + } + for (const id of executed.filter((entry) => !expected.has(entry))) { + invalid.push(`test ${id} ran but is not in the baseline`); + } + } + const outsideDecides = outsideTest.some( + (row) => isKillClass(row.outcome) || row.outcome === 'timeout', + ); + if (executed.length === 0 && !outsideDecides) invalid.push('zero tests ran'); + + const failureObserved = + run.unhandledErrors.length > 0 || + all.some((test) => test.state === 'failed') || + run.modules.some( + (module) => + module.errors.length > 0 || module.suites.some((suite) => suite.errors.length > 0), + ); + if (options.exitCode === 0 && failureObserved) { + infrastructure.push('Vitest exited 0 although the run failed'); + } + if (options.exitCode !== 0 && !failureObserved && invalid.length === 0) { + infrastructure.push(`Vitest exited ${options.exitCode} but the oracle reporter saw no failure`); + } + + const rows = [...tests.map((test) => test.outcome), ...outsideTest.map((row) => row.outcome)]; + const decide = (outcome: OracleClass, reason: string | null): RunClassification => { + const kill = isKillClass(outcome); + return { + outcome, + reason, + executed, + tests, + outsideTest, + killedBy: kill + ? [ + ...new Set([ + ...tests.filter((test) => isKillClass(test.outcome)).map((test) => test.id), + ...outsideTest.flatMap((row) => row.attributedTo), + ]), + ] + : [], + assertionKilledBy: kill + ? tests.filter((test) => test.outcome === 'killed-by-test:assertion').map((test) => test.id) + : [], + }; + }; + if (infrastructure.length > 0) return decide('infrastructure', infrastructure.join('; ')); + if (invalid.length > 0) return decide('invalid', invalid.join('; ')); + if (inconclusive.length > 0) { + return decide('inconclusive', `executed tests use retry: ${inconclusive.join('; ')}`); + } + const kill = KILL_ORDER.find((outcome) => rows.includes(outcome)); + if (kill !== undefined) return decide(kill, null); + if (rows.includes('timeout')) { + const timedOut = [...tests, ...outsideTest].find((row) => row.outcome === 'timeout'); + return decide('timeout', timedOut?.message ?? 'a Vitest timeout'); + } + return decide('survived', null); +} + +/** How a mutant's class was decided. */ +export type OutcomeSource = + | 'report' + | 'stryker-timeout' + | 'no-coverage' + | 'missing-report' + | 'not-run'; + +export interface ClassifyMutantInput { + /** Stryker's raw status (`mutation.json`). */ + strykerStatus: MutantStatus; + /** The latest record of the mutant, or null when there is none. */ + record: OracleRunRecord | null; + /** Executed test ids of the baseline run; null when no baseline is known. */ + expected: readonly string[] | null; +} + +export interface MutantClassification extends RunClassification { + source: OutcomeSource; +} + +/** Statuses of runs the command runner finished: each one must have left a record. */ +const RAN_STATUSES: ReadonlySet = new Set(['Killed', 'Survived', 'RuntimeError']); + +const noRun = ( + outcome: OracleClass, + source: OutcomeSource, + reason: string, +): MutantClassification => ({ + outcome, + source, + reason, + executed: [], + tests: [], + outsideTest: [], + killedBy: [], + assertionKilledBy: [], +}); + +/** Classifies a record; a record without an observed run is infrastructure. */ +export function classifyRecord( + record: OracleRunRecord, + expected: readonly string[] | null, +): RunClassification { + if (record.wrapperError !== null || record.run === null) { + return noRun( + 'infrastructure', + 'report', + record.wrapperError ?? 'the oracle reporter saw no end of the Vitest run', + ); + } + return classifyRun(record.run, { + exitCode: record.exitCode, + expected, + vitestJson: record.vitestJson, + }); +} + +function statusProblem(status: MutantStatus, exitCode: number): string | null { + if (status === 'Killed') { + return exitCode === 0 ? 'Stryker reports Killed but the wrapper exited 0' : null; + } + if (status === 'Survived') { + return exitCode === 0 ? null : `Stryker reports Survived but the wrapper exited ${exitCode}`; + } + return `Stryker reports ${status} for a run that left a record`; +} + +/** Classifies one mutant from its Stryker status and latest record; pure. */ +export function classifyMutant(input: ClassifyMutantInput): MutantClassification { + const { strykerStatus, record, expected } = input; + if (strykerStatus === 'Timeout') { + const observed = record === null ? null : classifyRecord(record, expected); + return { + ...(observed ?? noRun('timeout', 'stryker-timeout', '')), + outcome: 'timeout', + source: 'stryker-timeout', + reason: + observed === null + ? 'Stryker timed out the run; no record was written' + : `Stryker timed out the run; its record classifies as ${observed.outcome}`, + killedBy: [], + assertionKilledBy: [], + }; + } + if (strykerStatus === 'NoCoverage') { + return noRun('survived', 'no-coverage', 'Stryker found no test covering the mutant'); + } + if (record === null) { + return RAN_STATUSES.has(strykerStatus) + ? noRun( + 'infrastructure', + 'missing-report', + `no record for a run Stryker reports as ${strykerStatus}`, + ) + : noRun('invalid', 'not-run', `Stryker did not run the mutant (status ${strykerStatus})`); + } + const classified = classifyRecord(record, expected); + const problem = statusProblem(strykerStatus, record.exitCode); + if (problem !== null) { + return { + ...classified, + outcome: 'infrastructure', + source: 'report', + reason: problem, + killedBy: [], + assertionKilledBy: [], + }; + } + return { ...classified, source: 'report' }; +} diff --git a/gitnexus/tools/mutation/oracle/oracle-reporter.ts b/gitnexus/tools/mutation/oracle/oracle-reporter.ts new file mode 100644 index 000000000..d62a82c1a --- /dev/null +++ b/gitnexus/tools/mutation/oracle/oracle-reporter.ts @@ -0,0 +1,123 @@ +/** + * The oracle's Vitest reporter (U13, KTD15): records what one Vitest run observed, through the + * public reporter API only, as an {@link OracleObservedRun} for oracle-classify.ts. + * + * Vitest's JSON reporter keeps no error names (only stack text), no hook failures and no + * unhandled errors, so this reporter adds them: + * - every error keeps its name (oracle-classify.ts `toOracleError`); + * - a hook failed when Vitest reported its start without an end: Vitest marks a hook's end only + * after every hook of that kind at that level passed, so `onHookStart` without `onHookEnd` + * leaves the hook open on its test, suite or module; + * - unhandled errors and the run's end reason come from `onTestRunEnd`; + * - each test keeps its configured `retry` and the retries Vitest made. + * + * The reporter runs in the wrapper's process (command-wrapper.ts passes an instance), never in a + * Vitest worker, and imports no runner code. + */ +import path from 'node:path'; +import type { + Reporter, + ReportedHookContext, + TestCase, + TestModule, + TestRunEndReason, +} from 'vitest/node'; +import { + toOracleError, + type OracleError, + type OracleModuleRecord, + type OracleObservedRun, + type OracleTestRecord, + type SuiteHookName, + type TestHookName, +} from './oracle-classify.js'; + +type HookName = ReportedHookContext['name']; + +const retryCount = (retry: TestCase['options']['retry']): number => { + if (retry === undefined) return 0; + return typeof retry === 'number' ? retry : (retry.count ?? 0); +}; + +const errorsOf = (errors: readonly object[] | undefined): OracleError[] => + (errors ?? []).map((error) => toOracleError(error)); + +export class OracleReporter implements Reporter { + readonly #root: string; + /** Hooks started without an end, per reported entity id. */ + readonly #openHooks = new Map>(); + #run: OracleObservedRun | null = null; + + /** @param root the Vitest root; module paths are recorded relative to it. */ + constructor(root: string) { + this.#root = root; + } + + /** The observed run, or null until Vitest reports the end of a run. */ + get run(): OracleObservedRun | null { + return this.#run; + } + + onHookStart(hook: ReportedHookContext): void { + const open = this.#openHooks.get(hook.entity.id) ?? new Set(); + open.add(hook.name); + this.#openHooks.set(hook.entity.id, open); + } + + onHookEnd(hook: ReportedHookContext): void { + this.#openHooks.get(hook.entity.id)?.delete(hook.name); + } + + onTestRunEnd( + testModules: ReadonlyArray, + unhandledErrors: ReadonlyArray, + reason: TestRunEndReason, + ): void { + this.#run = { + reason, + unhandledErrors: errorsOf(unhandledErrors), + modules: testModules.map((testModule) => this.#module(testModule)), + }; + } + + #failed(id: string, names: readonly T[]): T[] { + const open = this.#openHooks.get(id); + return names.filter((name) => open?.has(name) === true); + } + + #module(testModule: TestModule): OracleModuleRecord { + const cases = [...testModule.children.allTests()]; + const indexOf = new Map(cases.map((testCase, index) => [testCase.id, index])); + const suiteHooks: readonly SuiteHookName[] = ['beforeAll', 'afterAll']; + return { + file: path.relative(this.#root, testModule.moduleId).split(path.sep).join('/'), + state: testModule.state(), + errors: errorsOf(testModule.errors()), + failedHooks: this.#failed(testModule.id, suiteHooks), + suites: [...testModule.children.allSuites()].map((suite) => ({ + fullName: suite.fullName, + errors: errorsOf(suite.errors()), + failedHooks: this.#failed(suite.id, suiteHooks), + tests: [...suite.children.allTests()].flatMap((testCase) => indexOf.get(testCase.id) ?? []), + })), + tests: cases.map((testCase) => this.#test(testCase)), + }; + } + + #test(testCase: TestCase): OracleTestRecord { + const result = testCase.result(); + const testHooks: readonly TestHookName[] = ['beforeEach', 'afterEach']; + return { + fullName: testCase.fullName, + location: + testCase.location === undefined + ? null + : { line: testCase.location.line, column: testCase.location.column }, + state: result.state, + errors: errorsOf(result.errors), + failedHooks: this.#failed(testCase.id, testHooks), + retry: retryCount(testCase.options.retry), + retryCount: testCase.diagnostic()?.retryCount ?? 0, + }; + } +} diff --git a/gitnexus/tools/mutation/oracle/oracle-setup.ts b/gitnexus/tools/mutation/oracle/oracle-setup.ts new file mode 100644 index 000000000..73f852119 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/oracle-setup.ts @@ -0,0 +1,176 @@ +/** + * The oracle's worker setup file (U12, KTD15). launch-single.ts puts it first among the mutation + * project's setup files, so with the forks pool and default isolation it runs in the launch's + * fresh worker before the test file, and anything the test file imports, is loaded. + * + * 1. Activation: before any test-file import it compares the mutant id installed through + * `__STRYKER_ACTIVE_MUTANT__` with the id the launcher intended ({@link INTENDED_MUTANT_ENV}) + * and checks that no instrumented code ran yet. It writes the probe file at once; on a + * mismatch it throws, so no test runs and the launcher classifies the launch invalid. + * 2. It installs the id in Stryker's namespace itself, the value the instrumented code would read + * from the environment, and counts the installed mutant's hits through an accessor on the + * namespace's `hitCount` (the instrumenter increments it on every hit of the active mutant; no + * hit limit is set, the launch's wall clock bounds a hang). + * 3. Each hit lands in one bucket (probe.ts): load and collection until the file-level + * `aroundAll` starts (it runs before every `beforeAll`), per test while + * `TestRunner.getCurrentTest()` names one, and hook otherwise. + * 4. The `aroundAll` teardown, which runs after every `afterAll` hook and `beforeAll` cleanup, + * failing ones included, rewrites the probe file with the hits. + * 5. Coverage: it gives Stryker's namespace its own `mutantCoverage` before any instrumented code + * runs. The instrumenter's coverage counter reads `mutantCoverage.static` on every call (this + * file never sets `currentTestId`), and a getter returns the record of the current bucket + * (the same three as the hits), so the probe lists which mutant sites ran where. + * + * It imports nothing from the vitest5 runner. Vitest parses the second parameter of `aroundAll` + * as a fixture pattern, so it must be written `{}`. + */ +import path from 'node:path'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import { TestRunner, aroundAll, type RunnerTask, type RunnerTestCase } from 'vitest'; +import { + INTENDED_MUTANT_ENV, + PROBE_FILE_ENV, + PROBE_SCHEMA_VERSION, + checkActivation, + mutantIdFromEnv, + type OracleProbe, + type ProbeHits, + type ProbeTestHits, +} from './probe.js'; +import { writeFileAtomicSync } from './util.js'; + +/** Stryker's coverage record: counts per mutant id, read by the instrumenter's `stryCov`. */ +type CoverageCounts = Record; + +interface CoverageView { + static: CoverageCounts; + perTest: Record; +} + +interface Namespace { + activeMutant?: string; + hitCount?: number; + hitLimit?: number; + mutantCoverage?: CoverageView; +} + +const probeFile = process.env[PROBE_FILE_ENV]; +if (probeFile === undefined || !path.isAbsolute(probeFile)) { + throw new Error(`oracle setup: ${PROBE_FILE_ENV} must name an absolute probe file`); +} + +const writeProbe = (probe: OracleProbe): void => + writeFileAtomicSync(probeFile, `${JSON.stringify(probe)}\n`); + +const globals = globalThis as unknown as Record; +const existing = globals[INSTRUMENTER_CONSTANTS.NAMESPACE]; +const activation = checkActivation({ + intended: mutantIdFromEnv(process.env[INTENDED_MUTANT_ENV]), + installed: mutantIdFromEnv(process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]), + namespaceExists: existing !== undefined, + namespaceActiveMutant: existing?.activeMutant ?? null, +}); +writeProbe({ + schemaVersion: PROBE_SCHEMA_VERSION, + pid: process.pid, + activation, + hits: null, + complete: false, +}); +if (!activation.matches) { + throw new Error( + `oracle setup: installed mutant ${JSON.stringify(activation.installed)} differs from the ` + + `intended ${JSON.stringify(activation.intended)}` + + (activation.instrumentedBeforeSetup ? ' or instrumented code ran before the setup file' : ''), + ); +} + +const ns: Namespace = existing ?? {}; +globals[INSTRUMENTER_CONSTANTS.NAMESPACE] = ns; +ns.activeMutant = activation.installed ?? undefined; +ns.hitLimit = undefined; + +let runStarted = false; +let total = 0; +let loadAndCollection = 0; +let hook = 0; +const perTest = new Map(); +const covered: Record<'loadAndCollection' | 'hook' | 'test', CoverageCounts> = { + loadAndCollection: {}, + hook: {}, + test: {}, +}; + +ns.mutantCoverage = { + perTest: {}, + get static(): CoverageCounts { + if (!runStarted) return covered.loadAndCollection; + return TestRunner.getCurrentTest() === undefined + ? covered.hook + : covered.test; + }, +}; + +const recordHit = (): void => { + if (!runStarted) { + loadAndCollection++; + return; + } + const test = TestRunner.getCurrentTest(); + if (test === undefined) { + hook++; + return; + } + const entry = perTest.get(test.id) ?? { task: test, count: 0 }; + entry.count++; + perTest.set(test.id, entry); +}; + +// The instrumenter runs `++ns.hitCount` for every hit of the active mutant. +Object.defineProperty(ns, INSTRUMENTER_CONSTANTS.HIT_COUNT, { + configurable: true, + enumerable: true, + get: (): number => total, + set: (value: number): void => { + recordHit(); + total = value; + }, +}); + +const testHits = (task: RunnerTask, count: number): ProbeTestHits => ({ + taskId: task.id, + fullName: task.fullTestName ?? task.name, + location: + task.location === undefined ? null : { line: task.location.line, column: task.location.column }, + count, +}); + +const coveredIds = (counts: CoverageCounts): string[] => + Object.keys(counts).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b)); + +const snapshot = (): ProbeHits => ({ + loadAndCollection, + hook, + perTest: [...perTest.values()].map((entry) => testHits(entry.task, entry.count)), +}); + +// The `{}` is required: Vitest parses this parameter as a fixture pattern. +aroundAll(async (runSuite, {}) => { + runStarted = true; + try { + await runSuite(); + } finally { + writeProbe({ + schemaVersion: PROBE_SCHEMA_VERSION, + pid: process.pid, + activation, + hits: snapshot(), + complete: true, + coverage: { + loadAndCollection: coveredIds(covered.loadAndCollection), + hook: coveredIds(covered.hook), + test: coveredIds(covered.test), + }, + }); + } +}); diff --git a/gitnexus/tools/mutation/oracle/probe.ts b/gitnexus/tools/mutation/oracle/probe.ts new file mode 100644 index 000000000..2b9c79e92 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/probe.ts @@ -0,0 +1,205 @@ +/** + * The oracle probe (U12, KTD15): what oracle-setup.ts observes inside the Vitest worker of one + * single-test launch (launch-single.ts), written as JSON to the file named by + * {@link PROBE_FILE_ENV}. Both sides import this module, so it holds no side effects and no + * Vitest import. + * + * Probe file, schemaVersion 1: + * pid the worker's pid + * activation checked by the setup file before the test file is imported: + * intended the mutant id the launcher meant to install ({@link INTENDED_MUTANT_ENV}; + * null for an unmutated baseline launch) + * installed `__STRYKER_ACTIVE_MUTANT__` in the worker's environment (null when + * unset or empty); the instrumented code activates this id + * instrumentedBeforeSetup whether Stryker's namespace already existed, i.e. instrumented code + * ran before the setup file + * namespaceActiveMutant the namespace's active mutant at that point, or null + * matches installed equals intended and no instrumented code ran before + * hits null until the file's run ends (the setup file's file-level + * `aroundAll` teardown); then the installed mutant's hits, counted + * apart from activation, in three buckets: + * loadAndCollection before the file's run started: module load and test collection + * hook during the run with no current test: `beforeAll`/`afterAll` hooks + * (and `aroundAll`) of a suite + * perTest[] {taskId, fullName, location, count} while a test is current: its + * body, its `beforeEach`/`afterEach` hooks and fixtures + * complete true once `hits` is written + * coverage written with `hits` (absent in older probes): the ids of the mutants + * whose coverage counter (the instrumenter's `stryCov`) ran, sorted + * numerically, in the same three buckets: `loadAndCollection`, `hook` + * and `test` (while any test was current). The instrumenter calls the + * counter on every executed mutant site except the active mutant's own, + * so an unmutated baseline launch records the test's full coverage; + * the oracle freeze (freeze.ts) derives its strata populations from it. + * A hit is one evaluation of the installed mutant's switch that selected the mutated code + * (Stryker's instrumenter counts it in `hitCount`). The baseline launch has no hits. + */ +import { isObject, type JsonObject } from './util.js'; + +export const PROBE_SCHEMA_VERSION = 1; + +/** Absolute probe file for the worker's setup file. */ +export const PROBE_FILE_ENV = 'GITNEXUS_ORACLE_PROBE_FILE'; +/** The mutant id the launcher means to install; empty for an unmutated baseline launch. */ +export const INTENDED_MUTANT_ENV = 'GITNEXUS_ORACLE_INTENDED_MUTANT'; + +export interface ProbeActivation { + intended: string | null; + installed: string | null; + instrumentedBeforeSetup: boolean; + namespaceActiveMutant: string | null; + matches: boolean; +} + +export interface ProbeTestHits { + /** Vitest task id of the test. */ + taskId: string; + /** Suite and test titles joined with `' > '`. */ + fullName: string; + location: { line: number; column: number } | null; + count: number; +} + +export interface ProbeHits { + loadAndCollection: number; + hook: number; + perTest: ProbeTestHits[]; +} + +/** Mutant ids whose coverage counter ran, per bucket; sorted numerically. */ +export interface ProbeCoverage { + loadAndCollection: string[]; + hook: string[]; + test: string[]; +} + +export interface OracleProbe { + schemaVersion: typeof PROBE_SCHEMA_VERSION; + pid: number; + activation: ProbeActivation; + hits: ProbeHits | null; + complete: boolean; + /** Absent until the run ends, and in probes written before coverage was recorded. */ + coverage?: ProbeCoverage; +} + +/** An environment value as a mutant id: unset and empty mean no mutant. */ +export const mutantIdFromEnv = (value: string | undefined): string | null => + value === undefined || value === '' ? null : value; + +/** The activation check of the setup file; pure. */ +export function checkActivation(input: { + intended: string | null; + installed: string | null; + namespaceExists: boolean; + namespaceActiveMutant: string | null; +}): ProbeActivation { + return { + intended: input.intended, + installed: input.installed, + instrumentedBeforeSetup: input.namespaceExists, + namespaceActiveMutant: input.namespaceActiveMutant, + matches: input.installed === input.intended && !input.namespaceExists, + }; +} + +function fail(at: string, detail: string): never { + throw new Error(`oracle probe: ${at} ${detail}`); +} + +function get(data: JsonObject, key: string, at: string): unknown { + if (!(key in data)) fail(`${at}.${key}`, 'is missing'); + return data[key]; +} + +function count(data: JsonObject, key: string, at: string): number { + const value = get(data, key, at); + return typeof value === 'number' && Number.isInteger(value) && value >= 0 + ? value + : fail(`${at}.${key}`, 'must be a non-negative integer'); +} + +function nullableString(data: JsonObject, key: string, at: string): string | null { + const value = get(data, key, at); + if (value === null) return null; + return typeof value === 'string' ? value : fail(`${at}.${key}`, 'must be a string or null'); +} + +function bool(data: JsonObject, key: string, at: string): boolean { + const value = get(data, key, at); + return typeof value === 'boolean' ? value : fail(`${at}.${key}`, 'must be a boolean'); +} + +function object(value: unknown, at: string): JsonObject { + return isObject(value) ? value : fail(at, 'must be an object'); +} + +function parseTestHits(value: unknown, at: string): ProbeTestHits { + const data = object(value, at); + const taskId = get(data, 'taskId', at); + const fullName = get(data, 'fullName', at); + const location = get(data, 'location', at); + return { + taskId: typeof taskId === 'string' ? taskId : fail(`${at}.taskId`, 'must be a string'), + fullName: typeof fullName === 'string' ? fullName : fail(`${at}.fullName`, 'must be a string'), + location: + location === null + ? null + : { + line: count(object(location, `${at}.location`), 'line', `${at}.location`), + column: count(object(location, `${at}.location`), 'column', `${at}.location`), + }, + count: count(data, 'count', at), + }; +} + +function ids(data: JsonObject, key: string, at: string): string[] { + const value = get(data, key, at); + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? value.map(String) + : fail(`${at}.${key}`, 'must be an array of strings'); +} + +function parseCoverage(value: unknown, at: string): ProbeCoverage { + const data = object(value, at); + return { + loadAndCollection: ids(data, 'loadAndCollection', at), + hook: ids(data, 'hook', at), + test: ids(data, 'test', at), + }; +} + +/** Parses and validates the text of a probe file. */ +export function parseOracleProbe(text: string): OracleProbe { + const data = object(JSON.parse(text), 'root'); + if (get(data, 'schemaVersion', 'root') !== PROBE_SCHEMA_VERSION) { + fail('root.schemaVersion', `must be ${PROBE_SCHEMA_VERSION}`); + } + const activation = object(get(data, 'activation', 'root'), 'root.activation'); + const hitsValue = get(data, 'hits', 'root'); + const hits = hitsValue === null ? null : object(hitsValue, 'root.hits'); + const perTest = hits === null ? [] : get(hits, 'perTest', 'root.hits'); + return { + schemaVersion: PROBE_SCHEMA_VERSION, + pid: count(data, 'pid', 'root'), + activation: { + intended: nullableString(activation, 'intended', 'root.activation'), + installed: nullableString(activation, 'installed', 'root.activation'), + instrumentedBeforeSetup: bool(activation, 'instrumentedBeforeSetup', 'root.activation'), + namespaceActiveMutant: nullableString(activation, 'namespaceActiveMutant', 'root.activation'), + matches: bool(activation, 'matches', 'root.activation'), + }, + hits: + hits === null + ? null + : { + loadAndCollection: count(hits, 'loadAndCollection', 'root.hits'), + hook: count(hits, 'hook', 'root.hits'), + perTest: Array.isArray(perTest) + ? perTest.map((entry, i) => parseTestHits(entry, `root.hits.perTest[${i}]`)) + : fail('root.hits.perTest', 'must be an array'), + }, + complete: bool(data, 'complete', 'root'), + ...('coverage' in data ? { coverage: parseCoverage(data.coverage, 'root.coverage') } : {}), + }; +} diff --git a/gitnexus/tools/mutation/oracle/util.ts b/gitnexus/tools/mutation/oracle/util.ts new file mode 100644 index 000000000..0db948d56 --- /dev/null +++ b/gitnexus/tools/mutation/oracle/util.ts @@ -0,0 +1,19 @@ +/** + * Helpers of the oracle's own modules (KTD15: nothing here comes from the vitest5 runner). Node + * built-ins only and no side effects, because the modules that run inside Vitest workers + * (oracle-setup.ts, probe.ts, command-wrapper.ts) import it too. + */ +import { renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export type JsonObject = Record; + +export const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Writes `text` to `...tmp` beside `file`, then renames it over `file`. */ +export function writeFileAtomicSync(file: string, text: string): void { + const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`); + writeFileSync(temporary, text); + renameSync(temporary, file); +} diff --git a/gitnexus/tools/mutation/prepare/blank-assertions.ts b/gitnexus/tools/mutation/prepare/blank-assertions.ts new file mode 100644 index 000000000..b37455782 --- /dev/null +++ b/gitnexus/tools/mutation/prepare/blank-assertions.ts @@ -0,0 +1,237 @@ +/** + * Blanks TypeScript `as` assertions for mutation testing (KTD7). + * + * Stryker's instrumenter skips every `TSAsExpression` subtree, so code inside `x as T` + * (including every `as const` table) is never mutated. This step replaces the ` as ` + * text of each assertion with spaces of the same length and keeps line terminators, so every + * line and column of the file is unchanged and the span map is an identity on columns. + * + * "Still parses" is not enough: ASI before `(`, `[` or a template literal, and precedence + * (`a + b as T * c`), can turn the blanked text into a different tree. A file is accepted only + * when the AST of its blanked text equals the original AST with every `TSAsExpression` + * replaced by its operand, compared without positions, comments or `extra`. `satisfies` and + * the non-null `!` are not on Stryker's skip list and stay. + */ +import { parse, type ParserOptions } from '@babel/parser'; + +export type BlankOutcome = + | { ok: true; text: string; assertions: number } + | { ok: false; reason: string; assertions: number }; + +export interface ScopeFile { + path: string; + source: string; +} + +export interface BlankedScope { + written: { path: string; text: string; assertions: number }[]; + failed: { path: string; reason: string }[]; +} + +const PARSER_OPTIONS: ParserOptions = { + sourceType: 'module', + plugins: ['typescript', 'decorators-legacy'], +}; + +/** Keys that carry positions, comments or raw text; they never change what the tree means. */ +const IGNORED_KEYS = new Set([ + 'start', + 'end', + 'loc', + 'range', + 'extra', + 'leadingComments', + 'trailingComments', + 'innerComments', + 'comments', + 'tokens', + 'errors', +]); + +/** Line terminators stay in place so line numbers do not move. */ +const LINE_TERMINATOR = /[\n\r\u2028\u2029]/; + +interface Positioned { + type: string; + start: number; + end: number; +} + +interface AsExpression extends Positioned { + expression: Positioned; + typeAnnotation: Positioned; +} + +interface Token { + type: unknown; + value?: unknown; + start: number; + end: number; +} + +type Range = readonly [start: number, end: number]; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isAsExpression = (value: unknown): value is AsExpression => + isRecord(value) && value.type === 'TSAsExpression'; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Every `TSAsExpression` node in the tree, outer before inner. */ +function collectAsExpressions(root: unknown): AsExpression[] { + const found: AsExpression[] = []; + const stack: unknown[] = [root]; + while (stack.length > 0) { + const node = stack.pop(); + if (Array.isArray(node)) { + stack.push(...node); + continue; + } + if (!isRecord(node)) continue; + if (isAsExpression(node)) found.push(node); + for (const [key, value] of Object.entries(node)) { + if (!IGNORED_KEYS.has(key) && typeof value === 'object') stack.push(value); + } + } + return found; +} + +/** Index of the first token that starts at or after `offset`. */ +function firstTokenAt(tokens: readonly Token[], offset: number): number { + let low = 0; + let high = tokens.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (tokens[mid].start < offset) low = mid + 1; + else high = mid; + } + return low; +} + +/** + * The text range to blank for one assertion: from its `as` keyword to the end of the node. + * Only closing parens of the operand, the `as` keyword and opening parens of the type sit + * between the operand and the type, so the one `as` name token there is the keyword. + */ +function assertionRange(node: AsExpression, tokens: readonly Token[], source: string): Range { + for (let i = firstTokenAt(tokens, node.expression.end); i < tokens.length; i++) { + const token = tokens[i]; + if (token.start >= node.typeAnnotation.start) break; + // Comments are tokens too, with a string `type`; real tokens carry a token-type object. + if (typeof token.type === 'object' && token.value === 'as') { + if (source.slice(token.start, token.end) !== 'as') break; + return [token.start, node.end]; + } + } + throw new Error(`no \`as\` keyword found for the assertion at offset ${node.start}`); +} + +/** Replaces every non-line-terminator code unit inside `ranges` with a space. */ +function blankRanges(source: string, ranges: readonly Range[]): string { + const sorted = [...ranges].sort((a, b) => a[0] - b[0]); + let text = ''; + let cursor = 0; + for (const [start, end] of sorted) { + const from = Math.max(start, cursor); + if (from >= end) continue; + text += source.slice(cursor, from); + for (const char of source.slice(from, end)) { + text += LINE_TERMINATOR.test(char) ? char : ' '.repeat(char.length); + } + cursor = end; + } + return text + source.slice(cursor); +} + +/** + * The first place where `original`, with every `TSAsExpression` unwrapped, and `blanked` + * differ, as a property path; `null` when the trees are equal. + */ +function firstDifference(original: unknown, blanked: unknown, at: string): string | null { + let left = original; + while (isAsExpression(left)) left = left.expression; + if (Array.isArray(left) || Array.isArray(blanked)) { + if (!Array.isArray(left) || !Array.isArray(blanked) || left.length !== blanked.length) { + return at; + } + for (let i = 0; i < left.length; i++) { + const difference = firstDifference(left[i], blanked[i], `${at}[${i}]`); + if (difference !== null) return difference; + } + return null; + } + if (isRecord(left) && isRecord(blanked)) { + const keys = new Set([...Object.keys(left), ...Object.keys(blanked)]); + for (const key of [...keys].sort()) { + if (IGNORED_KEYS.has(key)) continue; + const difference = firstDifference(left[key], blanked[key], `${at}.${key}`); + if (difference !== null) return difference; + } + return null; + } + return Object.is(left, blanked) ? null : at; +} + +/** Blanks every `as` assertion in one TypeScript source and checks the result (KTD7). */ +export function blankAssertions(source: string): BlankOutcome { + let original: ReturnType; + try { + original = parse(source, { ...PARSER_OPTIONS, tokens: true }); + } catch (error) { + return { ok: false, assertions: 0, reason: `original does not parse: ${errorMessage(error)}` }; + } + const assertions = collectAsExpressions(original.program); + if (assertions.length === 0) return { ok: true, assertions: 0, text: source }; + + const tokens: readonly Token[] = original.tokens ?? []; + let text: string; + try { + text = blankRanges( + source, + assertions.map((node) => assertionRange(node, tokens, source)), + ); + } catch (error) { + return { ok: false, assertions: assertions.length, reason: errorMessage(error) }; + } + + let blanked: ReturnType; + try { + blanked = parse(text, PARSER_OPTIONS); + } catch (error) { + return { + ok: false, + assertions: assertions.length, + reason: `blanked text does not parse: ${errorMessage(error)}`, + }; + } + const difference = firstDifference(original.program, blanked.program, 'program'); + if (difference !== null) { + return { + ok: false, + assertions: assertions.length, + reason: `AST differs from the unwrapped original at ${difference}`, + }; + } + return { ok: true, assertions: assertions.length, text }; +} + +/** + * Blanks a mutate scope. A file that fails the check is reported in `failed` and never + * appears in `written`, so the caller keeps its original text. + */ +export function blankScope(files: readonly ScopeFile[]): BlankedScope { + const scope: BlankedScope = { written: [], failed: [] }; + for (const file of files) { + const outcome = blankAssertions(file.source); + // `in` narrows the union under tsconfig's `strict: false`, where `ok` alone does not. + if ('reason' in outcome) { + scope.failed.push({ path: file.path, reason: outcome.reason }); + } else { + scope.written.push({ path: file.path, text: outcome.text, assertions: outcome.assertions }); + } + } + return scope; +} diff --git a/gitnexus/tools/mutation/prepare/mutant-identity.ts b/gitnexus/tools/mutation/prepare/mutant-identity.ts new file mode 100644 index 000000000..31567b4db --- /dev/null +++ b/gitnexus/tools/mutation/prepare/mutant-identity.ts @@ -0,0 +1,305 @@ +/** + * Global mutant identity (origin §2, KTD12). + * + * Stryker numbers mutants per run, so a local id means nothing across runs (the runner's run + * and the oracle's run, KTD18) or across freezes. The global identity names a mutant by its + * original source instead: the repo path of the original file (prepared paths translate through + * the path map), the sha256 content hash of the original file, the span, the mutator name and + * the replacement. Content hashes survive squash-merges and shallow checkouts, which git SHAs + * do not. + * + * The span is the report's location as-is: 1-based lines and columns, end exclusive. The + * prepared copy differs from the original only by `as` assertions blanked to spaces of the same + * length (KTD7), so the map is an identity on lines and columns; span-map-check.ts proves that + * for every mutant. + * + * Global id format, one line, fields in this order: + * @::-::: + * where each hash is the first 16 hex digits of a sha256 digest (the replacement's over its + * UTF-8 text). The full file hash stays in {@link MutantIdentity.fileHash}. + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { toRepoPathFromPackageDir, type PathMapEntry } from './path-map.js'; + +/** A 1-based line and column, as in Stryker's mutation-testing report. */ +export interface ReportPosition { + line: number; + column: number; +} + +/** A report span: 1-based positions, `end` exclusive. */ +export interface ReportLocation { + start: ReportPosition; + end: ReportPosition; +} + +export const MUTANT_STATUSES = [ + 'Killed', + 'Survived', + 'NoCoverage', + 'CompileError', + 'RuntimeError', + 'Timeout', + 'Ignored', + 'Pending', +] as const; + +export type MutantStatus = (typeof MUTANT_STATUSES)[number]; + +/** The mutant fields this tooling consumes from `files[path].mutants[]`. */ +export interface ReportMutant { + /** Stryker's local id, unique within one run. */ + id: string; + mutatorName: string; + replacement: string; + location: ReportLocation; + status: MutantStatus; + statusReason?: string; + static?: boolean; + testsCompleted?: number; + coveredBy?: string[]; + killedBy?: string[]; +} + +export interface ReportFile { + language: string; + /** The file text Stryker mutated (the prepared copy's text). */ + source: string; + mutants: ReportMutant[]; +} + +/** The parts of Stryker's `mutation.json` (mutation-testing-report-schema 1.x) used here. */ +export interface MutationReport { + schemaVersion: string; + /** The directory Stryker ran in; report file keys are relative to it. */ + projectRoot?: string; + files: Record; +} + +export interface MutantIdentity { + /** Repo-relative POSIX path of the original source file. */ + file: string; + /** sha256 hex digest of the original file's UTF-8 text. */ + fileHash: string; + location: ReportLocation; + mutatorName: string; + replacement: string; +} + +export interface IdentifiedMutant { + localId: string; + globalId: string; + identity: MutantIdentity; + /** The report's file key, relative to the directory Stryker ran in. */ + reportFile: string; + mutant: ReportMutant; +} + +export interface RunIdentities { + /** Every mutant of the run, in report order. */ + mutants: IdentifiedMutant[]; + localToGlobal: ReadonlyMap; + /** Original file text by repo path, as read for the content hashes. */ + originals: ReadonlyMap; +} + +export interface IdentifyOptions { + /** Absolute directory that ran Stryker: the prepared `gitnexus/` copy or its sandbox. */ + packageDir: string; + /** Reads an original source file by repo-relative path. */ + readOriginal: (repoPath: string) => string; + /** Translates report paths; defaults to path-map.ts `PATH_MAP`. */ + pathMap?: readonly PathMapEntry[]; +} + +const SCHEMA_VERSION = /^1(\.\d+)*$/; +/** Mutator names are identifiers, so the global id stays parseable from the right. */ +const MUTATOR_NAME = /^[A-Za-z][A-Za-z0-9]*$/; +const STATUSES: ReadonlySet = new Set(MUTANT_STATUSES); +const SHORT_HASH = 16; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isPositiveInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 1; + +const isStatus = (value: string): value is MutantStatus => STATUSES.has(value); + +// A function declaration, so a call in statement position narrows like a `throw`. +function fail(at: string, detail: string): never { + throw new Error(`mutation report: ${at} ${detail}`); +} + +function requireString(value: unknown, at: string, pattern?: RegExp): string { + if (typeof value !== 'string' || (pattern !== undefined && !pattern.test(value))) { + return fail(at, `must be a string${pattern === undefined ? '' : ` matching ${pattern}`}`); + } + return value; +} + +function optionalStrings(value: unknown, at: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + return fail(at, 'must be an array of strings'); + } + return [...value]; +} + +function parsePosition(value: unknown, at: string): ReportPosition { + if (!isRecord(value) || !isPositiveInteger(value.line) || !isPositiveInteger(value.column)) { + return fail(at, 'must have a positive integer line and column'); + } + return { line: value.line, column: value.column }; +} + +function parseLocation(value: unknown, at: string): ReportLocation { + if (!isRecord(value)) return fail(at, 'must be an object'); + const start = parsePosition(value.start, `${at}.start`); + const end = parsePosition(value.end, `${at}.end`); + if (end.line < start.line || (end.line === start.line && end.column < start.column)) { + fail(at, 'ends before it starts'); + } + return { start, end }; +} + +function parseMutant(value: unknown, at: string): ReportMutant { + if (!isRecord(value)) return fail(at, 'must be an object'); + const status = requireString(value.status, `${at}.status`); + if (!isStatus(status)) fail(`${at}.status`, `is not a known status: ${status}`); + const mutant: ReportMutant = { + id: requireString(value.id, `${at}.id`), + mutatorName: requireString(value.mutatorName, `${at}.mutatorName`, MUTATOR_NAME), + replacement: requireString(value.replacement, `${at}.replacement`), + location: parseLocation(value.location, `${at}.location`), + status, + }; + if (value.statusReason !== undefined) { + mutant.statusReason = requireString(value.statusReason, `${at}.statusReason`); + } + const { static: isStatic, testsCompleted } = value; + if (isStatic !== undefined) { + if (typeof isStatic !== 'boolean') fail(`${at}.static`, 'must be a boolean'); + mutant.static = isStatic; + } + if (testsCompleted !== undefined) { + if (typeof testsCompleted !== 'number') fail(`${at}.testsCompleted`, 'must be a number'); + mutant.testsCompleted = testsCompleted; + } + const coveredBy = optionalStrings(value.coveredBy, `${at}.coveredBy`); + if (coveredBy !== undefined) mutant.coveredBy = coveredBy; + const killedBy = optionalStrings(value.killedBy, `${at}.killedBy`); + if (killedBy !== undefined) mutant.killedBy = killedBy; + return mutant; +} + +function parseFile(value: unknown, at: string): ReportFile { + if (!isRecord(value)) return fail(at, 'must be an object'); + const list = value.mutants; + if (!Array.isArray(list)) fail(`${at}.mutants`, 'must be an array'); + const mutants: readonly unknown[] = list; + return { + language: requireString(value.language, `${at}.language`), + source: requireString(value.source, `${at}.source`), + mutants: mutants.map((mutant, i) => parseMutant(mutant, `${at}.mutants[${i}]`)), + }; +} + +/** Parses and validates Stryker's `mutation.json` text. */ +export function parseMutationReport(text: string): MutationReport { + const data: unknown = JSON.parse(text); + if (!isRecord(data)) return fail('root', 'must be an object'); + const schemaVersion = requireString(data.schemaVersion, 'schemaVersion', SCHEMA_VERSION); + if (!isRecord(data.files)) return fail('files', 'must be an object keyed by file path'); + // fromEntries defines own properties, so no file key can reach the prototype. + const files = Object.fromEntries( + Object.entries(data.files).map(([file, value]) => [ + file, + parseFile(value, `files[${JSON.stringify(file)}]`), + ]), + ); + const report: MutationReport = { schemaVersion, files }; + if (data.projectRoot !== undefined) { + report.projectRoot = requireString(data.projectRoot, 'projectRoot'); + } + return report; +} + +export function readMutationReport(file: string): MutationReport { + return parseMutationReport(readFileSync(file, 'utf8')); +} + +/** Reads original sources from a repo checkout. */ +export function readRepoFile(repoRoot: string): (repoPath: string) => string { + return (repoPath) => readFileSync(path.join(repoRoot, repoPath), 'utf8'); +} + +/** sha256 hex digest of a text's UTF-8 encoding. */ +export function contentHash(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +/** The global id of an identity; see the module comment for the format. */ +export function formatGlobalId(identity: MutantIdentity): string { + const { start, end } = identity.location; + return ( + `${identity.file}@${identity.fileHash.slice(0, SHORT_HASH)}` + + `:${start.line}:${start.column}-${end.line}:${end.column}` + + `:${identity.mutatorName}:${contentHash(identity.replacement).slice(0, SHORT_HASH)}` + ); +} + +/** + * Gives every mutant of one run its global identity and returns the local-id map. Throws for a + * file outside the mapped roots, a repeated local id, or two local mutants with the same global + * identity (the map would not be one-to-one, so cross-run comparisons could not use it). + */ +export function identifyMutants(report: MutationReport, options: IdentifyOptions): RunIdentities { + const originals = new Map(); + const hashes = new Map(); + const localToGlobal = new Map(); + const globalToLocal = new Map(); + const mutants: IdentifiedMutant[] = []; + for (const [reportFile, fileResult] of Object.entries(report.files)) { + const file = toRepoPathFromPackageDir( + options.packageDir, + path.resolve(options.packageDir, reportFile), + options.pathMap, + ); + let fileHash = hashes.get(file); + if (fileHash === undefined) { + const original = options.readOriginal(file); + fileHash = contentHash(original); + originals.set(file, original); + hashes.set(file, fileHash); + } + for (const mutant of fileResult.mutants) { + const identity: MutantIdentity = { + file, + fileHash, + location: { + start: { ...mutant.location.start }, + end: { ...mutant.location.end }, + }, + mutatorName: mutant.mutatorName, + replacement: mutant.replacement, + }; + const globalId = formatGlobalId(identity); + if (localToGlobal.has(mutant.id)) { + throw new Error(`mutant identity: local id ${mutant.id} appears twice in the report`); + } + const twin = globalToLocal.get(globalId); + if (twin !== undefined) { + throw new Error( + `mutant identity: local ids ${twin} and ${mutant.id} have the same global identity ${globalId}`, + ); + } + localToGlobal.set(mutant.id, globalId); + globalToLocal.set(globalId, mutant.id); + mutants.push({ localId: mutant.id, globalId, identity, reportFile, mutant }); + } + } + return { mutants, localToGlobal, originals }; +} diff --git a/gitnexus/tools/mutation/prepare/path-map.ts b/gitnexus/tools/mutation/prepare/path-map.ts new file mode 100644 index 000000000..846bdfce8 --- /dev/null +++ b/gitnexus/tools/mutation/prepare/path-map.ts @@ -0,0 +1,188 @@ +/** + * Prepared-copy layout, path map and `gitnexus-shared` aliases (KTD8). + * + * Stryker cannot mutate files outside its working directory, so the prepare step copies + * `gitnexus-shared/src` into the prepared `gitnexus/` tree at {@link RELOCATED_SHARED_DIR}. + * The relocation path never contains `node_modules`. Paths in this module are POSIX paths + * relative to the prepared root, which mirrors the repo root: `gitnexus/src/...` is the + * identity and the relocated shared tree translates back to `gitnexus-shared/src/...`. + * Any other path is rejected rather than passed through. + * + * Fixture projects of the mutation tooling (the gate 2 conformance fixture) mutate their own + * production modules where they live, under `gitnexus/test/fixtures/mutation-/`. A caller + * that needs one builds an extended map with {@link buildPathMap} and passes it explicitly; the + * translation functions default to {@link PATH_MAP}, which never maps a fixture path. + * + * This module is imported by tools/mutation/vitest.mutation.config.ts, so it must stay free + * of heavy dependencies. + */ +import path from 'node:path'; + +/** The prepared root's copy of the repo's `gitnexus/` package. */ +export const PACKAGE_DIR = 'gitnexus'; + +/** Where `gitnexus-shared` (its `src/` and `package.json`) lives, relative to the package dir. */ +export const RELOCATED_SHARED_DIR = 'relocated/gitnexus-shared'; + +export interface PathMapEntry { + /** Directory prefix under the prepared root, with a trailing `/`. */ + prepared: string; + /** The same directory in the repo, with a trailing `/`. */ + repo: string; +} + +export const PATH_MAP: readonly PathMapEntry[] = Object.freeze([ + { prepared: `${PACKAGE_DIR}/${RELOCATED_SHARED_DIR}/src/`, repo: 'gitnexus-shared/src/' }, + { prepared: `${PACKAGE_DIR}/src/`, repo: `${PACKAGE_DIR}/src/` }, +]); + +/** The only directories {@link buildPathMap} accepts as extra roots. */ +const EXTRA_ROOT = /^gitnexus\/test\/fixtures\/mutation-[a-z0-9]+(?:-[a-z0-9]+)*\/$/; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Normalises to a relative POSIX path, rejecting absolute paths and `..` escapes. */ +function normalizeRelative(input: string): string { + const posix = input.replace(/\\/g, '/'); + const normalized = path.posix.normalize(posix); + const escapes = normalized === '..' || normalized.startsWith('../'); + if (path.posix.isAbsolute(posix) || /^[A-Za-z]:\//.test(posix) || escapes) { + throw new Error(`path map: ${JSON.stringify(input)} is not a relative path inside the root`); + } + return normalized; +} + +/** + * {@link PATH_MAP} plus one identity entry per extra root. An extra root is a mutation fixture + * project directory, `gitnexus/test/fixtures/mutation-` (repo-relative, `/` or `\` + * separators, trailing slash optional); its files keep their path in the prepared copy, which + * mirrors the package. Anything else throws. A root listed twice is mapped once. + */ +export function buildPathMap(extraRoots: readonly string[] = []): readonly PathMapEntry[] { + const roots = new Set(); + for (const root of extraRoots) { + const directory = `${normalizeRelative(root).replace(/\/+$/, '')}/`; + if (!EXTRA_ROOT.test(directory)) { + throw new Error( + `path map: extra root ${JSON.stringify(root)} is not a mutation fixture directory ` + + '(gitnexus/test/fixtures/mutation-)', + ); + } + roots.add(directory); + } + if (roots.size === 0) return PATH_MAP; + return Object.freeze([ + ...PATH_MAP, + ...[...roots].map((root): PathMapEntry => ({ prepared: root, repo: root })), + ]); +} + +function translate( + input: string, + from: 'prepared' | 'repo', + to: 'prepared' | 'repo', + map: readonly PathMapEntry[], +): string { + const relative = normalizeRelative(input); + for (const entry of map) { + const prefix = entry[from]; + if (relative.startsWith(prefix) && relative.length > prefix.length) { + return entry[to] + relative.slice(prefix.length); + } + } + throw new Error( + `path map: ${JSON.stringify(input)} is outside the mapped roots (${map.map((e) => e[from]).join(', ')})`, + ); +} + +/** A path relative to the prepared root, translated to its repo-relative source path. */ +export function toRepoPath(preparedPath: string, map: readonly PathMapEntry[] = PATH_MAP): string { + return translate(preparedPath, 'prepared', 'repo', map); +} + +/** A repo-relative source path, translated to its location under the prepared root. */ +export function toPreparedPath(repoPath: string, map: readonly PathMapEntry[] = PATH_MAP): string { + return translate(repoPath, 'repo', 'prepared', map); +} + +/** + * An absolute path under a directory that plays the role of the prepared `gitnexus/` package + * (the prepared copy itself or a Stryker sandbox of it), translated to its repo path. + */ +export function toRepoPathFromPackageDir( + packageDir: string, + absolutePath: string, + map: readonly PathMapEntry[] = PATH_MAP, +): string { + const relative = path.relative(packageDir, absolutePath); + if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) { + throw new Error( + `path map: ${JSON.stringify(absolutePath)} is not a relative path inside the root`, + ); + } + return toRepoPath(`${PACKAGE_DIR}/${relative}`, map); +} + +export interface SharedPackageJson { + name: string; + exports: unknown; +} + +/** Parses the `gitnexus-shared` package.json fields the aliases need. */ +export function parseSharedPackageJson(text: string): SharedPackageJson { + const data: unknown = JSON.parse(text); + if (!isPlainObject(data) || typeof data.name !== 'string' || data.name === '') { + throw new Error('gitnexus-shared exports: package.json has no package name'); + } + return { name: data.name, exports: data.exports }; +} + +export interface SharedAlias { + /** The bare import specifier, e.g. `gitnexus-shared/test-helpers`. */ + specifier: string; + /** Anchored regex for Vite's `resolve.alias`. */ + find: RegExp; + /** Absolute path of the source file inside the relocated copy. */ + replacement: string; +} + +const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&'); + +/** The export target a runtime import resolves to: a string, or the `default`/`import` condition. */ +function exportTarget(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!isPlainObject(value)) return undefined; + const target = value.default ?? value.import; + return typeof target === 'string' ? target : undefined; +} + +/** + * One regex alias per key of the package `exports` map, pointing at the relocated source file + * the built `dist/.js` comes from (`src/.ts`). A shape this cannot map exactly + * (wildcards, targets outside `dist/`, no runtime condition) throws instead of being skipped, + * so a new export cannot silently fall back to the unrelocated package. + */ +export function deriveSharedAliases(pkg: SharedPackageJson, sharedDir: string): SharedAlias[] { + const fail = (detail: string): never => { + throw new Error(`gitnexus-shared exports: ${detail}`); + }; + const exportsMap = pkg.exports; + if (!isPlainObject(exportsMap)) return fail('expected an object of subpath exports'); + return Object.entries(exportsMap).map(([key, value]): SharedAlias => { + if (key !== '.' && !/^\.\/[^*]+$/.test(key)) { + return fail(`unsupported key ${JSON.stringify(key)}`); + } + const target = exportTarget(value); + const match = target === undefined ? null : /^\.\/dist\/([^*]+)\.js$/.exec(target); + if (match === null) { + return fail(`cannot map the target of ${JSON.stringify(key)} to a source file`); + } + const specifier = key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`; + return { + specifier, + find: new RegExp(`^${escapeRegExp(specifier)}$`), + replacement: path.join(sharedDir, 'src', `${match[1]}.ts`), + }; + }); +} diff --git a/gitnexus/tools/mutation/prepare/prepare.ts b/gitnexus/tools/mutation/prepare/prepare.ts new file mode 100644 index 000000000..54bccfe5f --- /dev/null +++ b/gitnexus/tools/mutation/prepare/prepare.ts @@ -0,0 +1,249 @@ +/** + * Builds the prepared copy that mutation runs execute in (KTD7, KTD8). + * + * /prepare-report.json what was copied, blanked and rejected, plus the path map + * /gitnexus/ the package sources (tracked and untracked, not ignored) + * /gitnexus/node_modules symlink to the checkout's gitnexus/node_modules + * /gitnexus/relocated/gitnexus-shared/{package.json,src/} + * gitnexus-shared, relocated inside the package (KTD8) + * + * `vendor/` and `prebuilds/` (native binaries), `dist/` and other build output are left out: + * the pure-JS slice does not load them. Each `--mutate` file has its `as` assertions blanked + * (blank-assertions.ts). A file that fails the AST-equality check keeps its original text, is + * listed under `failed` in the report, and makes the command exit 1. + * + * The output directory must lie outside the checkout. An existing non-empty directory is + * replaced only when it holds a previous prepare's marker file. Exit codes: 0 prepared, + * 1 prepared but some `--mutate` file was rejected, 2 usage or setup error. + * + * Usage (from gitnexus/): + * node --import tsx tools/mutation/prepare/prepare.ts --out [--mutate ]... + * `--mutate` takes repo-relative paths under gitnexus/src/ or gitnexus-shared/src/. + */ +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { blankScope } from './blank-assertions.js'; +import { + PACKAGE_DIR, + PATH_MAP, + RELOCATED_SHARED_DIR, + toPreparedPath, + toRepoPath, + type PathMapEntry, +} from './path-map.js'; + +/** Marks a directory as created by this tool, so a re-run may delete it. */ +export const MARKER_FILE = '.gitnexus-mutation-prepared'; +export const REPORT_FILE = 'prepare-report.json'; + +/** Top-level package entries never copied: native binaries, build output, local state. */ +const EXCLUDED_TOP_LEVEL = new Set([ + 'vendor', + 'prebuilds', + 'dist', + 'node_modules', + 'coverage', + '.stryker-tmp', + 'reports', +]); + +export interface PrepareOptions { + /** Absolute path of the repo checkout. */ + repoRoot: string; + /** Prepared root to create; must lie outside `repoRoot`. */ + outDir: string; + /** Repo-relative `.ts` files whose `as` assertions are blanked. */ + mutate: readonly string[]; + /** + * Maps `mutate` into the prepared tree; defaults to {@link PATH_MAP}. A fixture project's + * extended map (`buildPathMap`) lets its files be blanked in place. + */ + pathMap?: readonly PathMapEntry[]; +} + +export interface PrepareReport { + version: 1; + repoRoot: string; + outDir: string; + packageDir: string; + relocatedShared: string; + pathMap: readonly PathMapEntry[]; + copiedFiles: number; + /** Every `--mutate` file that passed the AST check, with its assertion count (0 = unchanged). */ + blanked: { file: string; assertions: number }[]; + /** `--mutate` files that failed the AST check; their prepared copy keeps the original text. */ + failed: { file: string; reason: string }[]; +} + +/** Package-relative paths to copy, without excluded top-level entries; sorted. */ +export function selectPackageFiles(files: readonly string[]): string[] { + return [...new Set(files)].filter((file) => !EXCLUDED_TOP_LEVEL.has(file.split('/')[0])).sort(); +} + +/** True when `child` is `parent` or lies inside it. */ +const isInside = (parent: string, child: string): boolean => { + const relative = path.relative(parent, child); + const escapes = relative === '..' || relative.startsWith(`..${path.sep}`); + return !escapes && !path.isAbsolute(relative); +}; + +/** Tracked and untracked, not ignored files under `dir`, relative to it. */ +function listSourceFiles(dir: string): string[] { + const out = execFileSync( + 'git', + ['-c', 'core.quotePath=false', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], + { cwd: dir, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }, + ); + return out.split('\0').filter((file) => file !== ''); +} + +/** Copies one file or symlink; returns false when the source no longer exists. */ +function copyEntry(from: string, to: string): boolean { + const stat = lstatSync(from, { throwIfNoEntry: false }); + if (stat === undefined) return false; // deleted in the working tree but still in the index + mkdirSync(path.dirname(to), { recursive: true }); + if (stat.isSymbolicLink()) symlinkSync(readlinkSync(from), to); + else copyFileSync(from, to); + return true; +} + +/** Creates `outDir`, replacing it only if a previous prepare left its marker. */ +function resetOutDir(repoRoot: string, outDir: string): void { + if (isInside(repoRoot, outDir) || isInside(outDir, repoRoot)) { + throw new Error(`prepare: --out ${outDir} must lie outside the checkout ${repoRoot}`); + } + if (existsSync(outDir) && readdirSync(outDir).length > 0) { + if (!existsSync(path.join(outDir, MARKER_FILE))) { + throw new Error( + `prepare: ${outDir} is not empty and was not created by this tool; choose another --out`, + ); + } + rmSync(outDir, { recursive: true, force: true }); + } + mkdirSync(outDir, { recursive: true }); + writeFileSync(path.join(outDir, MARKER_FILE), ''); +} + +function checkMutatePath(file: string): void { + if (!file.endsWith('.ts') || file.endsWith('.d.ts')) { + throw new Error(`prepare: --mutate ${file} is not a .ts source file`); + } +} + +/** + * The `--mutate` files as canonical repo paths (separators, `./`), each once, in first-seen + * order. Throws for a non-`.ts` file or a path outside the map's roots. + */ +export function canonicalMutatePaths( + mutate: readonly string[], + pathMap: readonly PathMapEntry[] = PATH_MAP, +): string[] { + mutate.forEach(checkMutatePath); + return [...new Set(mutate.map((file) => toRepoPath(toPreparedPath(file, pathMap), pathMap)))]; +} + +/** Builds the prepared copy and returns its report (also written to `/prepare-report.json`). */ +export function prepare(options: PrepareOptions): PrepareReport { + const repoRoot = path.resolve(options.repoRoot); + const outDir = path.resolve(options.outDir); + const sourcePackage = path.join(repoRoot, PACKAGE_DIR); + const sourceModules = path.join(sourcePackage, 'node_modules'); + const pathMap = options.pathMap ?? PATH_MAP; + const mutate = canonicalMutatePaths(options.mutate, pathMap); + if (!existsSync(sourceModules)) { + throw new Error(`prepare: ${sourceModules} is missing; run npm ci in ${PACKAGE_DIR}/ first`); + } + + resetOutDir(repoRoot, outDir); + const preparedPackage = path.join(outDir, PACKAGE_DIR); + let copiedFiles = 0; + for (const file of selectPackageFiles(listSourceFiles(sourcePackage))) { + if (copyEntry(path.join(sourcePackage, file), path.join(preparedPackage, file))) copiedFiles++; + } + symlinkSync(sourceModules, path.join(preparedPackage, 'node_modules'), 'dir'); + + const sharedRoot = path.join(repoRoot, 'gitnexus-shared'); + const relocatedRoot = path.join(preparedPackage, RELOCATED_SHARED_DIR); + for (const file of listSourceFiles(path.join(sharedRoot, 'src'))) { + const from = path.join(sharedRoot, 'src', file); + if (copyEntry(from, path.join(outDir, toPreparedPath(`gitnexus-shared/src/${file}`)))) { + copiedFiles++; + } + } + copyFileSync(path.join(sharedRoot, 'package.json'), path.join(relocatedRoot, 'package.json')); + copiedFiles++; + + const scope = blankScope( + mutate.map((file) => { + const absolute = path.join(outDir, toPreparedPath(file, pathMap)); + if (!existsSync(absolute)) throw new Error(`prepare: --mutate ${file} not found`); + return { path: file, source: readFileSync(absolute, 'utf8') }; + }), + ); + for (const file of scope.written) { + writeFileSync(path.join(outDir, toPreparedPath(file.path, pathMap)), file.text); + } + + const report: PrepareReport = { + version: 1, + repoRoot, + outDir, + packageDir: PACKAGE_DIR, + relocatedShared: `${PACKAGE_DIR}/${RELOCATED_SHARED_DIR}`, + pathMap, + copiedFiles, + blanked: scope.written.map(({ path: file, assertions }) => ({ file, assertions })), + failed: scope.failed.map(({ path: file, reason }) => ({ file, reason })), + }; + writeFileSync(path.join(outDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`); + return report; +} + +// ── CLI ──────────────────────────────────────────────────────────────────── + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const { values } = parseArgs({ + options: { + out: { type: 'string' }, + mutate: { type: 'string', multiple: true, default: [] }, + }, + strict: true, + }); + if (!values.out) { + console.error( + 'usage: node --import tsx tools/mutation/prepare/prepare.ts --out [--mutate ]...', + ); + process.exit(2); + } + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + try { + const report = prepare({ repoRoot, outDir: path.resolve(values.out), mutate: values.mutate }); + const assertions = report.blanked.reduce((sum, file) => sum + file.assertions, 0); + console.log( + `prepare: ${report.copiedFiles} files into ${report.outDir}; ` + + `${report.blanked.length} mutate file(s) processed (${assertions} assertions blanked), ` + + `${report.failed.length} rejected`, + ); + for (const failure of report.failed) { + console.error(` rejected ${failure.file}: ${failure.reason}`); + } + process.exitCode = report.failed.length > 0 ? 1 : 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } +} diff --git a/gitnexus/tools/mutation/prepare/span-map-check.ts b/gitnexus/tools/mutation/prepare/span-map-check.ts new file mode 100644 index 000000000..506b817f8 --- /dev/null +++ b/gitnexus/tools/mutation/prepare/span-map-check.ts @@ -0,0 +1,297 @@ +/** + * Span-map check (KTD7): proves, mutant by mutant, that a span Stryker reports on the prepared + * file means the same change on the original file, so global identities (mutant-identity.ts) + * can name mutants by original spans. + * + * Stryker's locations refer to the text it mutated, the report's `files[path].source` (the + * prepared copy's text). The prepare step changes that text only by blanking `as` assertions to + * spaces of the same length, keeping line terminators, so a location must convert to the same + * offsets in both files. + * + * Blanked ranges come from a code-unit diff of the two texts: a position is blanked when the + * prepared text has a space there and the original has a different character that is not a + * line terminator (blank-assertions.ts replaces every such character, tabs and both halves of a + * surrogate pair included, with one space per code unit). Any other difference, and any length + * difference, is foreign: the prepare step never makes it. Deriving the ranges from the texts + * needs no parse and no record from the prepare step, and a foreign difference cannot hide in + * them because only "space where the original had a non-terminator" is masked. + * + * Applying a mutant's replacement at the same offsets to two texts of equal length gives two + * mutated texts that differ exactly where the inputs differ outside the span; inside the span + * both hold the replacement. The check therefore compares, with blanked positions masked, the + * text around the span (the mutated texts) and the span text itself (what the mutant removes, + * which is what the global identity claims it removes from the original). A mutant fails when: + * - the files differ in length, or the location converts to different offsets in each; + * - the location lies outside either file; + * - a span boundary falls strictly inside a blanked run (it would cut an assertion); + * - the span text, or the text around it, differs outside blanked positions. + * Every failure message names the mutant's global identity. + */ +import type { + MutationReport, + ReportLocation, + ReportPosition, + RunIdentities, +} from './mutant-identity.js'; + +/** Line boundaries of a text, using the line terminators Babel (and so Stryker) counts. */ +export interface SourceIndex { + /** How messages name the text, e.g. `prepared file`. */ + name: string; + text: string; + /** Offset of the first character of each line. */ + lineStarts: readonly number[]; + /** Offset just past the last non-terminator character of each line. */ + lineEnds: readonly number[]; +} + +/** 0-based offsets, `end` exclusive. */ +export interface Offsets { + start: number; + end: number; +} + +export interface SpanMapEntry { + localId: string; + globalId: string; + /** Offsets of the span, the same in both files. */ + start: number; + end: number; + originalText: string; + preparedText: string; +} + +export interface SpanMapFailure { + localId: string; + globalId: string; + message: string; +} + +export interface SpanMapResult { + checked: number; + mapped: SpanMapEntry[]; + failures: SpanMapFailure[]; +} + +const CR = 0x0d; +const LF = 0x0a; +const LINE_SEPARATOR = 0x2028; +const PARAGRAPH_SEPARATOR = 0x2029; +const SPACE = 0x20; +/** Failures listed in an {@link assertSpanMap} error; the count covers the rest. */ +const LISTED_FAILURES = 20; +/** Code units quoted on each side of a span-text difference. */ +const EXCERPT_RADIUS = 24; + +const isLineTerminator = (code: number): boolean => + code === LF || code === CR || code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR; + +/** Indexes a text's lines: `\r\n`, `\r`, `\n`, U+2028 and U+2029 each end a line. */ +export function indexSource(text: string, name = 'file'): SourceIndex { + const lineStarts = [0]; + const lineEnds: number[] = []; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (!isLineTerminator(code)) continue; + lineEnds.push(i); + if (code === CR && text.charCodeAt(i + 1) === LF) i++; + lineStarts.push(i + 1); + } + lineEnds.push(text.length); + return { name, text, lineStarts, lineEnds }; +} + +function positionToOffset(index: SourceIndex, { line, column }: ReportPosition): number { + const lines = index.lineStarts.length; + if (!Number.isInteger(line) || line < 1 || line > lines) { + throw new RangeError(`line ${line} is outside the ${index.name} (${lines} lines)`); + } + const lineStart = index.lineStarts[line - 1]; + const length = index.lineEnds[line - 1] - lineStart; + // An exclusive end may sit just past the line's last character, on its terminator. + if (!Number.isInteger(column) || column < 1 || column > length + 1) { + throw new RangeError( + `column ${column} is outside line ${line} of the ${index.name} (${length} characters)`, + ); + } + return lineStart + column - 1; +} + +/** Converts a 1-based, end-exclusive report location to 0-based offsets. */ +export function locationToOffsets(index: SourceIndex, location: ReportLocation): Offsets { + const start = positionToOffset(index, location.start); + const end = positionToOffset(index, location.end); + if (end < start) throw new RangeError(`the location ends before it starts in the ${index.name}`); + return { start, end }; +} + +/** The 1-based `line:column` of an offset. */ +function describeOffset(index: SourceIndex, offset: number): string { + let low = 0; + let high = index.lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >>> 1; + if (index.lineStarts[mid] <= offset) low = mid; + else high = mid - 1; + } + return `${low + 1}:${offset - index.lineStarts[low] + 1}`; +} + +/** Index of the first value `>= target` in a sorted array. */ +function lowerBound(values: readonly number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (values[mid] < target) low = mid + 1; + else high = mid; + } + return low; +} + +interface FileComparison { + original: SourceIndex; + prepared: SourceIndex; + sameLength: boolean; + /** 1 where the prepared text holds a blanking space. */ + blanked: Uint8Array; + /** Sorted offsets where the texts differ and the difference is not a blank. */ + foreign: number[]; +} + +function compareFiles(original: string, prepared: string): FileComparison { + const comparison: FileComparison = { + original: indexSource(original, 'original file'), + prepared: indexSource(prepared, 'prepared file'), + sameLength: original.length === prepared.length, + blanked: new Uint8Array(prepared.length), + foreign: [], + }; + if (!comparison.sameLength) return comparison; + for (let i = 0; i < original.length; i++) { + const before = original.charCodeAt(i); + const after = prepared.charCodeAt(i); + if (before === after) continue; + if (after === SPACE && !isLineTerminator(before)) comparison.blanked[i] = 1; + else comparison.foreign.push(i); + } + return comparison; +} + +/** True when `offset` falls strictly inside a run of blanked positions. */ +const cutsBlankedRun = (blanked: Uint8Array, offset: number): boolean => + offset > 0 && blanked[offset - 1] === 1 && blanked[offset] === 1; + +/** The reason one mutant's span does not map, or `null` when it maps. */ +function spanProblem(comparison: FileComparison, location: ReportLocation): string | null { + const { original, prepared, blanked, foreign } = comparison; + if (!comparison.sameLength) { + return ( + `the prepared file has ${prepared.text.length} characters and the original ` + + `${original.text.length}, so columns do not map` + ); + } + let span: Offsets; + let originalSpan: Offsets; + try { + span = locationToOffsets(prepared, location); + originalSpan = locationToOffsets(original, location); + } catch (error) { + if (error instanceof RangeError) return error.message; + throw error; + } + if (span.start !== originalSpan.start || span.end !== originalSpan.end) { + return ( + `the location is at offsets ${span.start}-${span.end} in the prepared file but ` + + `${originalSpan.start}-${originalSpan.end} in the original` + ); + } + for (const [side, offset] of [ + ['start', span.start], + ['end', span.end], + ] as const) { + if (cutsBlankedRun(blanked, offset)) { + return `the span ${side} cuts a blanked assertion at ${describeOffset(original, offset)}`; + } + } + const inside = foreign[lowerBound(foreign, span.start)]; + if (inside !== undefined && inside < span.end) { + // Quote a window of the span around the difference, so a long span stays readable. + const from = Math.max(span.start, inside - EXCERPT_RADIUS); + const to = Math.min(span.end, inside + EXCERPT_RADIUS); + const quote = (text: string): string => + (from > span.start ? '…' : '') + + JSON.stringify(text.slice(from, to)) + + (to < span.end ? '…' : ''); + return ( + `the original span text differs from the prepared span text at ` + + `${describeOffset(original, inside)} outside blanked assertions ` + + `(original ${quote(original.text)}, prepared ${quote(prepared.text)})` + ); + } + // No foreign offset lies inside the span, so the first one, if any, lies around it. + const outside = foreign[0]; + if (outside !== undefined) { + return ( + `the original file differs from the prepared file at ` + + `${describeOffset(original, outside)} outside blanked assertions` + ); + } + return null; +} + +/** + * Checks every mutant of a run (see the module comment). `run` must come from + * `identifyMutants(report, ...)`: it supplies the original texts and the global ids. + */ +export function checkSpanMap(report: MutationReport, run: RunIdentities): SpanMapResult { + const comparisons = new Map(); + const result: SpanMapResult = { checked: 0, mapped: [], failures: [] }; + for (const { localId, globalId, identity, reportFile } of run.mutants) { + const fileResult = report.files[reportFile]; + const original = run.originals.get(identity.file); + if (fileResult === undefined || original === undefined) { + throw new Error(`span map: mutant ${globalId} does not belong to this report`); + } + let comparison = comparisons.get(reportFile); + if (comparison === undefined) { + comparison = compareFiles(original, fileResult.source); + comparisons.set(reportFile, comparison); + } + result.checked++; + const problem = spanProblem(comparison, identity.location); + if (problem !== null) { + result.failures.push({ + localId, + globalId, + message: `span map: mutant ${globalId} (local id ${localId}): ${problem}`, + }); + continue; + } + const { start, end } = locationToOffsets(comparison.original, identity.location); + result.mapped.push({ + localId, + globalId, + start, + end, + originalText: original.slice(start, end), + preparedText: fileResult.source.slice(start, end), + }); + } + return result; +} + +/** Throws when any mutant failed the check, listing the first failures by global id. */ +export function assertSpanMap(result: SpanMapResult): void { + const { failures, checked } = result; + if (failures.length === 0) return; + const listed = failures.slice(0, LISTED_FAILURES).map((failure) => ` ${failure.message}`); + const rest = failures.length - listed.length; + throw new Error( + [ + `span map: ${failures.length} of ${checked} mutants failed:`, + ...listed, + ...(rest > 0 ? [` ... and ${rest} more`] : []), + ].join('\n'), + ); +} diff --git a/gitnexus/tools/mutation/runner/classify-outcome.ts b/gitnexus/tools/mutation/runner/classify-outcome.ts new file mode 100644 index 000000000..7730da564 --- /dev/null +++ b/gitnexus/tools/mutation/runner/classify-outcome.ts @@ -0,0 +1,335 @@ +/** + * The KTD17 outcome-class table, applied by the vitest5 runner to one Vitest run. + * + * | Observation | Class | Status | + * | ------------------------------------------------------- | ------------------------------- | -------- | + * | Test fails with `AssertionError` | killed-by-test:assertion | killed | + * | Test or hook fails with Vitest testTimeout/hookTimeout | timeout | timeout | + * | Test fails with another error | killed-by-test:runtime | killed | + * | `beforeEach`/`afterEach` fails for a test | killed-by-test:hook | killed | + * | `beforeAll` fails; suite tests skipped | killed-outside-test:hook | killed, attributed to the suite's tests | + * | `afterAll` fails; tests stay passing | killed-outside-test:hook | killed, no attribution | + * | Test file fails to import or collect | killed-outside-test:collection | killed, no attribution | + * | Unhandled error during the run | infrastructure | error | + * | A selected test has `retry` above zero | inconclusive | error | + * | Hit limit reached | timeout | timeout | + * | Zero tests, or a test set different from the request | invalid | error | + * | No selected test fails | survived | survived | + * + * Stryker's own timer is not seen here: Stryker kills the process and no sidecar is written. + * Precedence for the run status: hit limit, then infrastructure, invalid, inconclusive, then any + * kill, then a Vitest timeout, then survived. Only `killed-by-test:assertion` rows count for the + * gate 3 and gate 4 kill sets; Stryker sees Killed for every killed row. + * + * This module is pure: the plugin turns Vitest's results into an {@link ObservedRun}. + */ + +export type OutcomeClass = + | 'killed-by-test:assertion' + | 'killed-by-test:runtime' + | 'killed-by-test:hook' + | 'killed-outside-test:hook' + | 'killed-outside-test:collection' + | 'timeout' + | 'infrastructure' + | 'inconclusive' + | 'invalid' + | 'survived'; + +export type RunnerStatus = 'killed' | 'survived' | 'timeout' | 'error'; + +export interface ObservedError { + name: string; + message: string; +} + +export type TestHook = 'beforeEach' | 'afterEach'; +export type SuiteHook = 'beforeAll' | 'afterAll'; + +export interface ObservedTest { + taskId: string; + fullName: string; + state: 'passed' | 'failed' | 'skipped' | 'pending'; + /** In Vitest's order: the body or `beforeEach` error first, an `afterEach` error last. */ + errors: readonly ObservedError[]; + /** Hooks that started for this test and did not finish (the setup file reads `result.hooks`). */ + failedHooks: readonly TestHook[]; + retry: number; +} + +export interface ObservedSuite { + taskId: string; + failedHooks: readonly SuiteHook[]; + errors: readonly ObservedError[]; + /** Every test below the suite, at any depth. */ + testTaskIds: readonly string[]; +} + +export interface ObservedModule { + /** Package-relative POSIX path. */ + file: string; + /** The runner's setup file finished its file-level hook, so collection succeeded. */ + collected: boolean; + /** Collection errors, or errors of file-level `beforeAll`/`afterAll` hooks. */ + errors: readonly ObservedError[]; + failedHooks: readonly SuiteHook[]; + suites: readonly ObservedSuite[]; + tests: readonly ObservedTest[]; + hitCount: number | undefined; +} + +export interface RequestedTest { + /** Stryker id (KTD3). */ + id: string; + taskId: string; + fullName: string; + file: string; +} + +export interface ObservedRun { + requested: readonly RequestedTest[]; + modules: readonly ObservedModule[]; + unhandledErrors: readonly ObservedError[]; + hitLimit: number | undefined; +} + +export interface TestOutcome { + id: string; + outcome: OutcomeClass; + message: string | undefined; +} + +export interface OutsideTestOutcome { + file: string; + /** Undefined for the file itself. */ + suiteTaskId: string | undefined; + outcome: OutcomeClass; + /** Stryker ids that Stryker sees as killers; empty when the row has no test attribution. */ + attributedTo: string[]; + message: string; +} + +export interface RunClassification { + status: RunnerStatus; + outcome: OutcomeClass; + reason: string | undefined; + killedBy: string[]; + failureMessage: string | undefined; + /** Requested tests that ran (passed or failed). */ + nrOfTests: number; + hitCount: number; + tests: TestOutcome[]; + outsideTest: OutsideTestOutcome[]; +} + +const VITEST_TIMEOUT = + /^(?:(?:Test|Hook) timed out in \d+ms|The (?:setup|teardown) phase of "around(?:Each|All)" hook timed out after \d+ms)/; +const HIT_LIMIT = /Stryker: Hit count limit reached/; + +export const isVitestTimeoutError = (error: ObservedError): boolean => + VITEST_TIMEOUT.test(error.message); + +export const isHitLimitError = (error: ObservedError): boolean => HIT_LIMIT.test(error.message); + +const KILL_PRIORITY: readonly OutcomeClass[] = [ + 'killed-by-test:assertion', + 'killed-by-test:runtime', + 'killed-by-test:hook', + 'killed-outside-test:hook', + 'killed-outside-test:collection', +]; + +const isKill = (outcome: OutcomeClass): boolean => KILL_PRIORITY.includes(outcome); + +function classifyFailedTest(test: ObservedTest): OutcomeClass { + const first = test.errors[0]; + if (first === undefined) return 'killed-by-test:runtime'; + if (isVitestTimeoutError(first) || isHitLimitError(first)) return 'timeout'; + const hookOnly = + test.failedHooks.includes('beforeEach') || + (test.failedHooks.includes('afterEach') && test.errors.length === 1); + if (hookOnly) return 'killed-by-test:hook'; + return first.name === 'AssertionError' ? 'killed-by-test:assertion' : 'killed-by-test:runtime'; +} + +const hookOutcome = (error: ObservedError | undefined): OutcomeClass => + error !== undefined && (isVitestTimeoutError(error) || isHitLimitError(error)) + ? 'timeout' + : 'killed-outside-test:hook'; + +const describeErrors = (errors: readonly ObservedError[]): string => + errors.map((error) => `${error.name}: ${error.message}`).join('\n'); + +export function classifyRun(run: ObservedRun): RunClassification { + const hitCount = run.modules.reduce((sum, module) => sum + (module.hitCount ?? 0), 0); + const requestedByTaskId = new Map(run.requested.map((test) => [test.taskId, test])); + const tests: TestOutcome[] = []; + const outsideTest: OutsideTestOutcome[] = []; + const invalid: string[] = []; + const inconclusive: string[] = []; + const accounted = new Set(); + const flagged = new Set(); + const collectionFailedFiles = new Set(); + const allErrors: ObservedError[] = [...run.unhandledErrors]; + let nrOfTests = 0; + + for (const module of run.modules) { + allErrors.push(...module.errors); + if (!module.collected) { + if (module.errors.length > 0) { + collectionFailedFiles.add(module.file); + outsideTest.push({ + file: module.file, + suiteTaskId: undefined, + outcome: module.errors.some(isHitLimitError) + ? 'timeout' + : 'killed-outside-test:collection', + attributedTo: [], + message: describeErrors(module.errors), + }); + } + continue; + } + + const requestedOf = (taskIds: readonly string[]): RequestedTest[] => + taskIds.flatMap((taskId) => requestedByTaskId.get(taskId) ?? []); + const scopes = [ + { + suiteTaskId: undefined, + failedHooks: module.failedHooks, + errors: module.errors, + testTaskIds: module.tests.map((test) => test.taskId), + }, + ...module.suites.map((suite) => ({ ...suite, suiteTaskId: suite.taskId })), + ]; + for (const scope of scopes) { + if (scope.suiteTaskId !== undefined) allErrors.push(...scope.errors); + if (scope.failedHooks.includes('beforeAll')) { + const attributed = requestedOf(scope.testTaskIds); + const error = scope.errors[0]; + const outcome = hookOutcome(error); + for (const test of attributed) { + accounted.add(test.taskId); + tests.push({ id: test.id, outcome, message: error?.message }); + } + outsideTest.push({ + file: module.file, + suiteTaskId: scope.suiteTaskId, + outcome, + attributedTo: outcome === 'timeout' ? [] : attributed.map((test) => test.id), + message: error?.message ?? 'beforeAll failed', + }); + } + if (scope.failedHooks.includes('afterAll')) { + const error = scope.errors[scope.errors.length - 1]; + outsideTest.push({ + file: module.file, + suiteTaskId: scope.suiteTaskId, + outcome: hookOutcome(error), + attributedTo: [], + message: error?.message ?? 'afterAll failed', + }); + } + } + + for (const test of module.tests) { + allErrors.push(...test.errors); + const wanted = requestedByTaskId.get(test.taskId); + const ran = test.state === 'passed' || test.state === 'failed'; + if (wanted === undefined) { + if (ran) invalid.push(`test ${test.taskId} (${test.fullName}) ran but was not requested`); + continue; + } + if (accounted.has(test.taskId)) continue; + if (test.fullName !== wanted.fullName) { + flagged.add(test.taskId); + invalid.push( + `task ${test.taskId} has full name ${JSON.stringify(test.fullName)}, the run manifest ` + + `expects ${JSON.stringify(wanted.fullName)}`, + ); + continue; + } + if (!ran) { + flagged.add(test.taskId); + invalid.push(`requested test ${wanted.id} did not run (state ${test.state})`); + continue; + } + accounted.add(test.taskId); + nrOfTests += 1; + if (test.retry > 0) inconclusive.push(`${wanted.id} has retry ${test.retry}`); + tests.push( + test.state === 'passed' + ? { id: wanted.id, outcome: 'survived', message: undefined } + : { id: wanted.id, outcome: classifyFailedTest(test), message: test.errors[0]?.message }, + ); + } + } + + for (const test of run.requested) { + const explained = + accounted.has(test.taskId) || + flagged.has(test.taskId) || + collectionFailedFiles.has(test.file); + if (!explained) invalid.push(`requested test ${test.id} did not run (not reported by Vitest)`); + } + if (run.requested.length === 0) invalid.push('zero tests were requested'); + else if ( + nrOfTests === 0 && + !outsideTest.some((row) => isKill(row.outcome) || row.outcome === 'timeout') + ) { + invalid.push('zero tests ran'); + } + + const killedBy = [ + ...new Set([ + ...tests.filter((test) => isKill(test.outcome)).map((test) => test.id), + ...outsideTest.flatMap((row) => row.attributedTo), + ]), + ]; + const failureMessage = + tests.find((test) => isKill(test.outcome))?.message ?? + outsideTest.find((row) => isKill(row.outcome))?.message; + const base = { hitCount, tests, outsideTest, nrOfTests }; + const decide = ( + status: RunnerStatus, + outcome: OutcomeClass, + reason: string | undefined, + ): RunClassification => ({ + status, + outcome, + reason, + killedBy: status === 'killed' ? killedBy : [], + failureMessage: status === 'killed' ? failureMessage : undefined, + ...base, + }); + + if (run.hitLimit !== undefined && hitCount > run.hitLimit) { + return decide('timeout', 'timeout', `Hit limit reached (${hitCount}/${run.hitLimit})`); + } + const hitLimitError = allErrors.find(isHitLimitError); + if (hitLimitError !== undefined) { + return decide('timeout', 'timeout', `Hit limit reached: ${hitLimitError.message}`); + } + if (run.unhandledErrors.length > 0) { + return decide( + 'error', + 'infrastructure', + `unhandled error during the run: ${describeErrors(run.unhandledErrors)}`, + ); + } + if (invalid.length > 0) return decide('error', 'invalid', invalid.join('; ')); + if (inconclusive.length > 0) { + return decide('error', 'inconclusive', `selected tests use retry: ${inconclusive.join('; ')}`); + } + const outcomes = [...tests.map((test) => test.outcome), ...outsideTest.map((row) => row.outcome)]; + const kill = KILL_PRIORITY.find((outcome) => outcomes.includes(outcome)); + if (kill !== undefined) return decide('killed', kill, undefined); + if (outcomes.includes('timeout')) { + return decide( + 'timeout', + 'timeout', + [...tests, ...outsideTest].find((row) => row.outcome === 'timeout')?.message, + ); + } + return decide('survived', 'survived', undefined); +} diff --git a/gitnexus/tools/mutation/runner/coverage-attribution.ts b/gitnexus/tools/mutation/runner/coverage-attribution.ts new file mode 100644 index 000000000..d6862d34b --- /dev/null +++ b/gitnexus/tools/mutation/runner/coverage-attribution.ts @@ -0,0 +1,153 @@ +/** + * Coverage buckets for the vitest5 runner's dry run (KTD4). + * + * Stryker's instrumenter counts a hit into `mutantCoverage.perTest[currentTestId]` when + * `currentTestId` is set, and into `mutantCoverage.static` otherwise. The runner's setup file never + * sets `currentTestId`; it installs {@link CoverageRecorder.instrumenterView}, whose `static` is a + * getter that picks the bucket for the moment of the hit: + * + * - before the setup file's file-level hook runs (module load and collection): `static`; + * - while a test runs (`TestRunner.getCurrentTest()`, which covers `beforeEach`/`afterEach`): that + * test's per-test bucket; + * - after collection with no current test: a hook hit of the deepest running suite, later + * attributed by {@link attributeHookHits} to every test of that suite. + * + * Buckets are keyed by Vitest task id here; the main process translates them to Stryker ids + * ({@link rekeyCoverage}). This module is imported by the worker setup file, so it imports types + * only. + * + * The hook bucket is also kept apart ({@link hookCoverage}): the dry run records, per mutant, the + * tests a suite hook attributed it to ({@link hookAttributedTests}), the hook marker that caps a + * Phase 1 pair at weak (Phase 1 KTD2, KTD9). + */ +import type { CoverageData, MutantCoverage } from '@stryker-mutator/api/core'; + +export type HitContext = + | { phase: 'collection' } + | { phase: 'run'; testId: string | undefined; runningSuiteIds: readonly string[] }; + +export type HitBucket = + | { kind: 'static' } + | { kind: 'test'; testId: string } + | { kind: 'hook'; suiteId: string }; + +export interface RawCoverage { + static: CoverageData; + /** Keyed by Vitest task id. */ + perTest: Record; + /** Hook hits, keyed by the task id of the suite (or file) whose hook ran. */ + hooks: Record; +} + +export function bucketForHit(context: HitContext): HitBucket { + if (context.phase === 'collection') return { kind: 'static' }; + if (context.testId !== undefined) return { kind: 'test', testId: context.testId }; + const suiteId = context.runningSuiteIds[context.runningSuiteIds.length - 1]; + if (suiteId === undefined) { + throw new Error('coverage: a hit after collection had no current test and no running suite'); + } + return { kind: 'hook', suiteId }; +} + +const bucketIn = (buckets: Record, key: string): CoverageData => + (buckets[key] ??= {}); + +export class CoverageRecorder { + readonly #static: CoverageData = {}; + readonly #perTest: Record = {}; + readonly #hooks: Record = {}; + /** Assign to the instrumenter namespace's `mutantCoverage` before any instrumented code loads. */ + readonly instrumenterView: MutantCoverage; + + constructor(context: () => HitContext) { + const select = (): CoverageData => { + const bucket = bucketForHit(context()); + if (bucket.kind === 'static') return this.#static; + if (bucket.kind === 'test') return bucketIn(this.#perTest, bucket.testId); + return bucketIn(this.#hooks, bucket.suiteId); + }; + const view = { perTest: {} } as MutantCoverage; + Object.defineProperty(view, 'static', { enumerable: true, get: select }); + this.instrumenterView = view; + } + + snapshot(): RawCoverage { + return { + static: { ...this.#static }, + perTest: Object.fromEntries( + Object.entries(this.#perTest).map(([id, data]) => [id, { ...data }]), + ), + hooks: Object.fromEntries(Object.entries(this.#hooks).map(([id, data]) => [id, { ...data }])), + }; + } +} + +const addInto = (into: CoverageData, from: CoverageData): void => { + for (const [mutantId, count] of Object.entries(from)) { + into[mutantId] = (into[mutantId] ?? 0) + count; + } +}; + +/** + * The hook bucket alone, attributed: every hook bucket added to each test below that suite, keyed + * by test task id. `testsBySuite` maps a suite (or file) task id to the task ids of all tests + * below it. + */ +export function hookCoverage( + raw: RawCoverage, + testsBySuite: Readonly>, +): Record { + const perTest: Record = {}; + for (const [suiteId, data] of Object.entries(raw.hooks)) { + const tests = testsBySuite[suiteId]; + if (tests === undefined) throw new Error(`coverage: hook hits for unknown suite ${suiteId}`); + for (const testId of tests) addInto(bucketIn(perTest, testId), data); + } + return perTest; +} + +/** + * Adds every hook bucket to the per-test coverage of each test below that suite + * ({@link hookCoverage}). + */ +export function attributeHookHits( + raw: RawCoverage, + testsBySuite: Readonly>, +): MutantCoverage { + const perTest: Record = {}; + for (const [testId, data] of Object.entries(raw.perTest)) + addInto(bucketIn(perTest, testId), data); + for (const [testId, data] of Object.entries(hookCoverage(raw, testsBySuite))) { + addInto(bucketIn(perTest, testId), data); + } + return { static: { ...raw.static }, perTest }; +} + +/** Per mutant id, the sorted keys of the per-test buckets that hold it (the hook marker). */ +export function hookAttributedTests( + perTest: Readonly>, +): Record { + const byMutant: Record = {}; + for (const testId of Object.keys(perTest).sort()) { + for (const mutantId of Object.keys(perTest[testId])) (byMutant[mutantId] ??= []).push(testId); + } + return byMutant; +} + +/** Translates per-test keys through `idByTaskId`; tests outside the map (skipped) are dropped. */ +export function rekeyCoverage( + coverage: MutantCoverage, + idByTaskId: ReadonlyMap, +): MutantCoverage { + const perTest: Record = {}; + for (const [taskId, data] of Object.entries(coverage.perTest)) { + const id = idByTaskId.get(taskId); + if (id !== undefined) addInto(bucketIn(perTest, id), data); + } + return { static: { ...coverage.static }, perTest }; +} + +export function mergeCoverage(into: MutantCoverage, from: MutantCoverage): void { + addInto(into.static, from.static); + for (const [id, data] of Object.entries(from.perTest)) addInto(bucketIn(into.perTest, id), data); +} diff --git a/gitnexus/tools/mutation/runner/plugin.ts b/gitnexus/tools/mutation/runner/plugin.ts new file mode 100644 index 000000000..8c843044f --- /dev/null +++ b/gitnexus/tools/mutation/runner/plugin.ts @@ -0,0 +1,680 @@ +/** + * Stryker test-runner plugin `vitest5`: runs a mutation scope under the repo's own Vitest 5.0.x + * (KTD1), with per-test coverage keyed by KTD3 ids, task-id test selection (KTD4), the configured + * forks pool with fresh workers and no shared caches (KTD5), KTD17 outcome classes in sidecar + * files (KTD6) and a run manifest shared by every runner process (KTD16). + * + * Single-test selection (Phase 1 KTD2): with `vitest5.testIds` the dry run first collects the + * scope's files, assigns KTD3 ids and maps the requested ids to Vitest task ids. An id that no + * collected test has fails the dry run, naming it, before any mutant run. It then runs one + * task-id specification per file that holds a requested test and checks that the tests that ran + * are exactly the requested ones. The run manifest then lists only those tests as the expected + * test set (the others of a run file as skipped), so mutant runs, static ones included, never + * run another test. Without `testIds` every test of the scope runs, as before. + * + * The dry-run sidecar record also carries the hook marker: per mutant, the tests a suite hook + * attributed it to (coverage-attribution.ts `hookCoverage`). + * + * Load it with `appendPlugins: ['/gitnexus/tools/mutation/runner/plugin.ts']`, + * `testRunner: 'vitest5'`, `testRunnerNodeArgs: ['--import', 'tsx']`, and start Stryker itself + * under `node --import tsx`. The plugin options live under the `vitest5` key (see + * {@link strykerValidationSchema}). Do not set the Stryker `testFiles` option. + * + * The plugin module is also loaded by Stryker's main process for option validation, so it keeps + * its top level light and imports `vitest/node` only in `init`. + */ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + INSTRUMENTER_CONSTANTS, + type MutantCoverage, + type StrykerOptions, +} from '@stryker-mutator/api/core'; +import type { Logger } from '@stryker-mutator/api/logging'; +import { PluginKind, commonTokens, declareClassPlugin, tokens } from '@stryker-mutator/api/plugin'; +import { + DryRunStatus, + MutantRunStatus, + TestStatus, + type DryRunOptions, + type DryRunResult, + type MutantRunOptions, + type MutantRunResult, + type TestResult, + type TestRunner, + type TestRunnerCapabilities, +} from '@stryker-mutator/api/test-runner'; +import type { TestModule, TestProject, TestSpecification, Vitest } from 'vitest/node'; +import { + classifyRun, + type ObservedError, + type ObservedModule, + type RequestedTest, + type RunClassification, + type SuiteHook, + type TestHook, +} from './classify-outcome.js'; +import { hookAttributedTests, mergeCoverage, rekeyCoverage } from './coverage-attribution.js'; +import { PROTOCOL_KEY, type FileMeta, type WorkerContext } from './protocol.js'; +import { + readRunManifest, + selectTests, + writeRunManifest, + type ManifestFile, + type RunManifest, +} from './run-manifest.js'; +import { writeSidecar, type SidecarDraft } from './sidecar.js'; +import { + assignPortableTestIds, + parseTestId, + selectRequestedTests, + selectionProblems, + type CollectedTest, + type FileTestSelection, + type IdentifiedTest, +} from './test-ids.js'; +import { assertSupportedVitest } from './version-guard.js'; + +export const PLUGIN_NAME = 'vitest5'; +const SETUP_FILE = fileURLToPath(new URL('./setup.ts', import.meta.url)); +const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url)); +const WORKER_ENV = 'STRYKER_MUTATOR_WORKER'; + +export interface Vitest5RunnerOptions { + /** Absolute run directory (KTD16); holds the run manifest. */ + runDir: string; + /** Absolute sidecar directory (KTD6). */ + sidecarDir: string; + /** Vitest config, relative to the Stryker sandbox (the runner's working directory). */ + configFile: string; + /** The single Vitest project to run; task ids depend on its name. */ + project: string; + /** Stryker ids (KTD3) of the only tests to run; null runs every test of the scope. */ + testIds: string[] | null; +} + +export const DEFAULT_CONFIG_FILE = 'tools/mutation/vitest.mutation.config.ts'; +export const DEFAULT_PROJECT = 'mutation'; + +export const strykerValidationSchema = { + $schema: 'http://json-schema.org/draft-07/schema', + title: 'Vitest5RunnerOptions', + type: 'object', + properties: { + vitest5: { + description: 'Options of the gitnexus vitest5 test runner (tools/mutation/runner).', + type: 'object', + properties: { + runDir: { + description: 'Absolute run directory; the dry run writes the run manifest here.', + type: 'string', + }, + sidecarDir: { + description: 'Absolute directory for the per-run outcome-class sidecar records.', + type: 'string', + }, + configFile: { + description: 'Vitest config file, relative to the sandbox.', + type: 'string', + default: DEFAULT_CONFIG_FILE, + }, + project: { + description: 'Name of the single Vitest project to run.', + type: 'string', + default: DEFAULT_PROJECT, + }, + testIds: { + description: + 'Stryker test ids (#) of the only tests to run; absent runs every test.', + type: 'array', + items: { type: 'string' }, + minItems: 1, + uniqueItems: true, + }, + }, + required: ['runDir', 'sidecarDir'], + additionalProperties: false, + }, + }, +} as const; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export function parseRunnerOptions(value: unknown): Vitest5RunnerOptions { + if (!isRecord(value)) { + throw new Error(`${PLUGIN_NAME} runner: the "vitest5" options object is missing`); + } + const text = (key: string, fallback?: string): string => { + const entry = value[key] ?? fallback; + if (typeof entry !== 'string' || entry === '') { + throw new Error(`${PLUGIN_NAME} runner: vitest5.${key} must be a non-empty string`); + } + return entry; + }; + const options: Vitest5RunnerOptions = { + runDir: text('runDir'), + sidecarDir: text('sidecarDir'), + configFile: text('configFile', DEFAULT_CONFIG_FILE), + project: text('project', DEFAULT_PROJECT), + testIds: parseTestIdsOption(value.testIds), + }; + for (const key of ['runDir', 'sidecarDir'] as const) { + if (!path.isAbsolute(options[key])) { + throw new Error(`${PLUGIN_NAME} runner: vitest5.${key} must be an absolute path`); + } + } + return options; +} + +const isTestId = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + parseTestId(value); + return true; + } catch { + return false; + } +}; + +function parseTestIdsOption(value: unknown): string[] | null { + if (value === undefined) return null; + if ( + !Array.isArray(value) || + value.length === 0 || + !value.every(isTestId) || + new Set(value).size !== value.length + ) { + throw new Error( + `${PLUGIN_NAME} runner: vitest5.testIds must be a non-empty array of distinct test ids ` + + '(#)', + ); + } + return [...value]; +} + +const toObservedError = (error: unknown): ObservedError => { + if (isRecord(error)) { + return { + name: typeof error.name === 'string' ? error.name : 'Error', + message: typeof error.message === 'string' ? error.message : String(error), + }; + } + return { name: 'Error', message: String(error) }; +}; + +const retryCount = (retry: number | { count?: number } | undefined): number => + typeof retry === 'number' ? retry : (retry?.count ?? 0); + +const relativeFile = (root: string, moduleId: string): string => + path.relative(root, moduleId).split(path.sep).join('/'); + +const pick = (hooks: readonly string[] | undefined, names: readonly T[]): T[] => + names.filter((name) => hooks?.includes(name) ?? false); + +/** A module's tests in collection order, as test-ids.ts reads them. */ +const collectedTests = (module: TestModule): CollectedTest[] => + [...module.children.allTests()].map((test) => ({ + taskId: test.id, + fullName: test.fullName, + location: test.location, + })); + +const describeObservedErrors = (errors: readonly ObservedError[]): string => + errors.map((error) => `${error.name}: ${error.message}`).join('; '); + +const TEST_HOOKS: readonly TestHook[] = ['beforeEach', 'afterEach']; +const SUITE_HOOKS: readonly SuiteHook[] = ['beforeAll', 'afterAll']; + +interface ModuleReport { + module: TestModule; + file: string; + meta: FileMeta | undefined; + observed: ObservedModule; +} + +function reportModule(root: string, module: TestModule): ModuleReport { + const meta = module.meta()[PROTOCOL_KEY]; + const failed = meta?.failedHooks ?? {}; + const file = relativeFile(root, module.moduleId); + const observed: ObservedModule = { + file, + collected: meta !== undefined, + errors: module.errors().map(toObservedError), + failedHooks: pick(failed[module.id], SUITE_HOOKS), + suites: [...module.children.allSuites()].map((suite) => ({ + taskId: suite.id, + failedHooks: pick(failed[suite.id], SUITE_HOOKS), + errors: suite.errors().map(toObservedError), + testTaskIds: [...suite.children.allTests()].map((test) => test.id), + })), + tests: [...module.children.allTests()].map((test) => { + const result = test.result(); + return { + taskId: test.id, + fullName: test.fullName, + state: result.state, + errors: (result.errors ?? []).map(toObservedError), + failedHooks: pick(failed[test.id], TEST_HOOKS), + retry: retryCount(test.options.retry), + }; + }), + hitCount: meta?.hitCount ?? undefined, + }; + return { module, file, meta, observed }; +} + +function toMutantRunResult(classification: RunClassification): MutantRunResult { + switch (classification.status) { + case 'killed': + return { + status: MutantRunStatus.Killed, + killedBy: classification.killedBy, + failureMessage: classification.failureMessage ?? classification.outcome, + nrOfTests: classification.nrOfTests, + }; + case 'survived': + return { status: MutantRunStatus.Survived, nrOfTests: classification.nrOfTests }; + case 'timeout': + return { status: MutantRunStatus.Timeout, reason: classification.reason }; + case 'error': + return { + status: MutantRunStatus.Error, + errorMessage: `${classification.outcome}: ${classification.reason ?? ''}`, + }; + } +} + +export class Vitest5TestRunner implements TestRunner { + static inject = tokens(commonTokens.options, commonTokens.logger); + + readonly #options: Vitest5RunnerOptions; + readonly #log: Logger; + readonly #worker: string; + #root = ''; + #vitest: Vitest | undefined; + #project: TestProject | undefined; + #vitestVersion = ''; + #manifest: RunManifest | undefined; + /** Modules returned by earlier runs: Vitest returns every module in its state, stale ones too. */ + readonly #earlierModules = new WeakSet(); + + constructor(options: StrykerOptions, log: Logger) { + this.#options = parseRunnerOptions(options[PLUGIN_NAME]); + this.#log = log; + this.#worker = process.env[WORKER_ENV] ?? '0'; + } + + capabilities(): TestRunnerCapabilities { + return { reloadEnvironment: true }; + } + + async init(): Promise { + // Vitest workers inherit this environment: Stryker's worker id and a stale active mutant + // must not reach them (KTD5). The worker id was read in the constructor. + delete process.env[WORKER_ENV]; + delete process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]; + this.#root = process.cwd(); + mkdirSync(this.#options.runDir, { recursive: true }); + mkdirSync(this.#options.sidecarDir, { recursive: true }); + this.#vitestVersion = assertSupportedVitest(this.#root, PLUGIN_DIR).version; + // Same environment as `vitest run` (startVitest) before it creates Vitest. + process.env.TEST = 'true'; + process.env.VITEST = 'true'; + process.env.NODE_ENV ??= 'test'; + + const { createVitest } = await import('vitest/node'); + const cacheDir = path.join(this.#root, '.vitest5-runner', `cache-${process.pid}`); + const vitest = await createVitest( + { + root: this.#root, + config: path.resolve(this.#root, this.#options.configFile), + project: [this.#options.project], + watch: false, + cache: false, + fsModuleCache: false, + reporters: [], + }, + { cacheDir }, + ); + this.#vitest = vitest; + const projects = vitest.projects.filter((project) => project.name === this.#options.project); + if (projects.length !== 1) { + throw new Error( + `${PLUGIN_NAME} runner: expected one Vitest project named ${this.#options.project}, ` + + `found ${JSON.stringify(vitest.projects.map((project) => project.name))}`, + ); + } + const [project] = projects; + const { config } = project; + const problems = [ + config.pool === 'forks' ? '' : `pool is ${String(config.pool)}, the runner needs forks`, + config.isolate === false ? 'isolate is off; static mutants need a fresh module graph' : '', + config.includeTaskLocation ? '' : 'includeTaskLocation is off; KTD3 ids need locations', + config.setupFiles.length > 0 && config.sequence.setupFiles !== 'list' + ? 'other setup files run in parallel with the runner setup file; set sequence.setupFiles: list' + : '', + ].filter((problem) => problem !== ''); + if (problems.length > 0) { + throw new Error(`${PLUGIN_NAME} runner: unsupported Vitest config: ${problems.join('; ')}`); + } + config.setupFiles = [SETUP_FILE, ...config.setupFiles]; + this.#project = project; + this.#log.debug( + `${PLUGIN_NAME} runner: Vitest ${this.#vitestVersion}, project ${project.name}, worker ` + + `${this.#worker}, root ${this.#root}`, + ); + } + + #ready(): { vitest: Vitest; project: TestProject } { + if (this.#vitest === undefined || this.#project === undefined) { + throw new Error(`${PLUGIN_NAME} runner: init() has not run`); + } + return { vitest: this.#vitest, project: this.#project }; + } + + /** + * Runs the specifications and returns this run's modules only: one per specification, never a + * module object an earlier run returned (a file that did not run this time is absent). + */ + async #run( + vitest: Vitest, + specs: readonly TestSpecification[], + allTestsRun: boolean, + ): Promise<{ modules: TestModule[]; unhandledErrors: unknown[] }> { + if (specs.length === 0) return { modules: [], unhandledErrors: [] }; + const result = await vitest.runTestSpecifications([...specs], allTestsRun); + const wanted = new Set(specs.map((spec) => spec.moduleId)); + const modules = result.testModules.filter( + (module) => wanted.has(module.moduleId) && !this.#earlierModules.has(module), + ); + for (const module of result.testModules) this.#earlierModules.add(module); + return { modules, unhandledErrors: result.unhandledErrors }; + } + + #provide(project: TestProject, context: Omit) { + project.provide(PROTOCOL_KEY, { + namespace: INSTRUMENTER_CONSTANTS.NAMESPACE, + activeMutantEnv: INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE, + ...context, + }); + } + + /** + * The `testIds` dry run's first step: collects every scope file, assigns KTD3 ids and maps the + * requested ids to task ids per file. Unknown ids and collection failures are problems. + */ + async #collectSelection( + vitest: Vitest, + specs: readonly TestSpecification[], + testIds: readonly string[], + ): Promise<{ files: FileTestSelection[]; problems: string[] }> { + const collected = await vitest.collectTests([...specs]); + const wanted = new Set(specs.map((spec) => spec.moduleId)); + const modules = collected.testModules.filter( + (module) => wanted.has(module.moduleId) && !this.#earlierModules.has(module), + ); + for (const module of collected.testModules) this.#earlierModules.add(module); + const identified = new Map( + modules + .map((module) => ({ module, file: relativeFile(this.#root, module.moduleId) })) + .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)) + .map( + ({ module, file }) => + [file, assignPortableTestIds(file, collectedTests(module), this.#root)] as const, + ), + ); + const selection = selectRequestedTests(identified, testIds); + const problems = [ + ...modules + .filter((module) => module.errors().length > 0) + .map( + (module) => + `${relativeFile(this.#root, module.moduleId)}: collection failed: ` + + describeObservedErrors(module.errors().map(toObservedError)), + ), + ...collected.unhandledErrors.map( + (error) => + `unhandled error while collecting: ${describeObservedErrors([toObservedError(error)])}`, + ), + ...selection.unknown.map( + (id) => `requested test ${JSON.stringify(id)} was not collected (vitest5.testIds)`, + ), + ]; + return { files: selection.files, problems }; + } + + async dryRun(options: DryRunOptions): Promise { + if (options.testFiles !== undefined && options.testFiles.length > 0) { + throw new Error( + `${PLUGIN_NAME} runner: the Stryker testFiles option is not supported (KTD4)`, + ); + } + const { vitest, project } = this.#ready(); + this.#provide(project, { + mode: 'dry-run', + activeMutant: null, + mutantActivation: null, + hitLimit: null, + }); + const specs = (await vitest.globTestSpecifications()).filter( + (spec) => spec.project.name === project.name, + ); + const testIds = this.#options.testIds; + let runSpecs: readonly TestSpecification[] = specs; + if (testIds !== null) { + const selection = await this.#collectSelection(vitest, specs, testIds); + if (selection.problems.length > 0) { + return { status: DryRunStatus.Error, errorMessage: selection.problems.join('\n') }; + } + runSpecs = selection.files.map((file) => + project.createSpecification(path.resolve(this.#root, file.file), { + testIds: file.taskIds, + }), + ); + } + const result = await this.#run(vitest, runSpecs, testIds === null); + const reports = result.modules + .map((module) => reportModule(this.#root, module)) + .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); + + const identified = new Map(); + for (const report of reports) { + identified.set( + report.file, + assignPortableTestIds(report.file, collectedTests(report.module), this.#root), + ); + } + const stateOf = new Map( + reports.flatMap((report) => report.observed.tests.map((test) => [test.taskId, test.state])), + ); + const ran = (test: IdentifiedTest): boolean => + stateOf.get(test.taskId) === 'passed' || stateOf.get(test.taskId) === 'failed'; + // Without testIds the tests that ran are the request; with them, the selection is. + const wanted = testIds === null ? null : new Set(testIds); + const requested: RequestedTest[] = reports.flatMap((report) => + (identified.get(report.file) ?? []) + .filter((test) => (wanted === null ? ran(test) : wanted.has(test.id))) + .map((test) => ({ + id: test.id, + taskId: test.taskId, + fullName: test.fullName, + file: report.file, + })), + ); + const classification = classifyRun({ + requested, + modules: reports.map((report) => report.observed), + unhandledErrors: result.unhandledErrors.map(toObservedError), + hitLimit: undefined, + }); + const idByTaskId = new Map(requested.map((test) => [test.taskId, test.id])); + const hookCoverage: MutantCoverage = { static: {}, perTest: {} }; + for (const report of reports) { + if (report.meta?.hookCoverage) { + mergeCoverage( + hookCoverage, + rekeyCoverage({ static: {}, perTest: report.meta.hookCoverage }, idByTaskId), + ); + } + } + this.#writeSidecar({ + kind: 'dry-run', + mutantId: null, + mutantActivation: null, + hitLimit: null, + requestedTestIds: requested.map((test) => test.id), + hookAttributedTests: hookAttributedTests(hookCoverage.perTest), + result: classification, + }); + + const missingSetup = reports.filter( + (report) => report.meta === undefined && report.observed.errors.length === 0, + ); + const executed = [...identified.values()].flat().filter(ran); + const problems = [ + specs.length === 0 ? 'the scope has no test files' : '', + requested.length === 0 ? 'no test ran' : '', + ...(testIds === null + ? [] + : selectionProblems( + testIds, + executed.map((test) => test.id), + )), + ...missingSetup.map((report) => `the runner setup file did not finish in ${report.file}`), + ...classification.outsideTest.map((row) => `${row.file}: ${row.outcome}: ${row.message}`), + classification.outcome === 'infrastructure' || classification.outcome === 'invalid' + ? `${classification.outcome}: ${classification.reason ?? ''}` + : '', + ].filter((problem) => problem !== ''); + if (problems.length > 0) { + return { status: DryRunStatus.Error, errorMessage: problems.join('\n') }; + } + + const files: ManifestFile[] = reports.map((report) => { + const tests = (identified.get(report.file) ?? []).map((test) => ({ + id: test.id, + taskId: test.taskId, + fullName: test.fullName, + location: test.location ?? null, + notOracleValidated: test.notOracleValidated, + ran: ran(test), + })); + const strip = ({ ran: _ran, ...test }: (typeof tests)[number]) => test; + return { + file: report.file, + tests: tests.filter((test) => test.ran).map(strip), + skipped: tests.filter((test) => !test.ran).map(strip), + }; + }); + const manifest: RunManifest = { + schemaVersion: 1, + project: project.name, + vitestVersion: this.#vitestVersion, + files, + }; + writeRunManifest(this.#options.runDir, manifest); + this.#manifest = manifest; + + const mutantCoverage: MutantCoverage = { static: {}, perTest: {} }; + for (const report of reports) { + if (report.meta?.coverage) { + mergeCoverage(mutantCoverage, rekeyCoverage(report.meta.coverage, idByTaskId)); + } + } + const testById = new Map( + reports.flatMap((report) => + [...report.module.children.allTests()].map((test) => [test.id, { test, report }] as const), + ), + ); + const tests: TestResult[] = [...identified.values()].flat().map((identity) => { + const entry = testById.get(identity.taskId); + const result = entry?.test.result(); + const base = { + id: identity.id, + name: identity.fullName, + timeSpentMs: Math.round(entry?.test.diagnostic()?.duration ?? 0), + fileName: entry?.report.module.moduleId, + }; + if (result?.state === 'passed') return { ...base, status: TestStatus.Success }; + if (result?.state === 'failed') { + return { + ...base, + status: TestStatus.Failed, + failureMessage: result.errors[0]?.message ?? 'failed without an error', + }; + } + return { ...base, status: TestStatus.Skipped }; + }); + return { status: DryRunStatus.Complete, tests, mutantCoverage }; + } + + async mutantRun(options: MutantRunOptions): Promise { + const { vitest, project } = this.#ready(); + const manifest = (this.#manifest ??= readRunManifest(this.#options.runDir)); + if (manifest.project !== project.name) { + throw new Error( + `${PLUGIN_NAME} runner: the run manifest is for project ${manifest.project}, this ` + + `runner runs ${project.name}`, + ); + } + if (options.mutantActivation === 'static' && options.testFilter !== undefined) { + throw new Error( + `${PLUGIN_NAME} runner: static mutant ${options.activeMutant.id} came with a test ` + + 'filter; static mutants run the manifest full test set (KTD4)', + ); + } + const selection = selectTests(manifest, options.testFilter); + const requested: RequestedTest[] = selection.files.flatMap((file) => + file.tests.map((test) => ({ + id: test.id, + taskId: test.taskId, + fullName: test.fullName, + file: file.file, + })), + ); + this.#provide(project, { + mode: 'mutant', + activeMutant: options.activeMutant.id, + mutantActivation: options.mutantActivation, + hitLimit: options.hitLimit ?? null, + }); + const specs: TestSpecification[] = selection.files.map((file) => + project.createSpecification(path.resolve(this.#root, file.file), { + testIds: file.tests.map((test) => test.taskId), + }), + ); + const result = await this.#run(vitest, specs, false); + const classification = classifyRun({ + requested, + modules: result.modules.map((module) => reportModule(this.#root, module).observed), + unhandledErrors: result.unhandledErrors.map(toObservedError), + hitLimit: options.hitLimit, + }); + this.#writeSidecar({ + kind: 'mutant', + mutantId: options.activeMutant.id, + mutantActivation: options.mutantActivation, + hitLimit: options.hitLimit ?? null, + requestedTestIds: requested.map((test) => test.id), + hookAttributedTests: null, + result: classification, + }); + return toMutantRunResult(classification); + } + + #writeSidecar(draft: Omit): void { + writeSidecar(this.#options.sidecarDir, { ...draft, worker: this.#worker }); + } + + async dispose(): Promise { + await this.#vitest?.close(); + this.#vitest = undefined; + this.#project = undefined; + } +} + +export const strykerPlugins = [ + declareClassPlugin(PluginKind.TestRunner, PLUGIN_NAME, Vitest5TestRunner), +]; diff --git a/gitnexus/tools/mutation/runner/protocol.ts b/gitnexus/tools/mutation/runner/protocol.ts new file mode 100644 index 000000000..15797fd64 --- /dev/null +++ b/gitnexus/tools/mutation/runner/protocol.ts @@ -0,0 +1,53 @@ +/** + * Contract between the vitest5 runner (main process, plugin.ts) and its worker setup file + * (setup.ts): the value the runner provides before each run and the metadata the setup file + * writes on the file task when the file's run ends. Both sides import this module, so it holds + * types and constants only. + */ +import type { CoverageData, MutantCoverage } from '@stryker-mutator/api/core'; +import type { MutantActivation } from '@stryker-mutator/api/test-runner'; + +/** `provide`/`inject` key and task-meta key. */ +export const PROTOCOL_KEY = 'gitnexusStrykerVitest5'; // gitleaks:allow -- a provide/inject key, not a credential + +export type WorkerMode = 'dry-run' | 'mutant'; + +export interface WorkerContext { + mode: WorkerMode; + /** Stryker instrumenter namespace (`__stryker__`). */ + namespace: string; + /** Environment variable the instrumenter reads as a fallback (`__STRYKER_ACTIVE_MUTANT__`). */ + activeMutantEnv: string; + activeMutant: string | null; + mutantActivation: MutantActivation | null; + hitLimit: number | null; +} + +export type HookName = 'beforeAll' | 'afterAll' | 'beforeEach' | 'afterEach'; + +export const HOOK_NAMES: readonly HookName[] = ['beforeAll', 'afterAll', 'beforeEach', 'afterEach']; + +/** Written on the file task by the setup file after the file's run, hooks included. */ +export interface FileMeta { + /** Summed instrumenter hit count of the active mutant in this file; null in the dry run. */ + hitCount: number | null; + /** Dry run only: coverage keyed by Vitest task id, hook hits already attributed. */ + coverage: MutantCoverage | null; + /** + * Dry run only: the raw hook bucket, attributed: the part of `coverage.perTest` that came from + * suite hooks (`beforeAll`, `afterAll`), keyed by Vitest task id. The runner turns it into the + * per-mutant hook marker of the dry-run sidecar record. + */ + hookCoverage: Record | null; + /** Task id to the hooks that started for that test or suite and did not finish. */ + failedHooks: Record; +} + +declare module 'vitest' { + interface ProvidedContext { + gitnexusStrykerVitest5: WorkerContext; + } + interface TaskMeta { + gitnexusStrykerVitest5?: FileMeta; + } +} diff --git a/gitnexus/tools/mutation/runner/run-manifest.ts b/gitnexus/tools/mutation/runner/run-manifest.ts new file mode 100644 index 000000000..c075c76f6 --- /dev/null +++ b/gitnexus/tools/mutation/runner/run-manifest.ts @@ -0,0 +1,223 @@ +/** + * Run directory and run manifest of the vitest5 runner (KTD16). + * + * Stryker runs the dry run in one test-runner process; the other processes, and replacements + * started after crashes, timeouts or `maxTestRunnerReuse`, only call `init` and `mutantRun`. The + * dry-run process therefore writes `/run-manifest.json` atomically (temporary name, then + * rename): the test files, each Stryker id (KTD3) with its Vitest task id and location, and the + * expected test set per file (`tests`, the tests that ran in the dry run; `skipped` lists the rest). + * Every runner process loads it before its first mutant run and refuses to run without it. + * + * Test selection (KTD4) accepts only manifest test ids: a Stryker `testFiles` option would hand + * static mutants file paths as their filter, and they would become false survivors. + */ +import { randomBytes } from 'node:crypto'; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { TestLocation } from './test-ids.js'; + +export const RUN_MANIFEST_FILE = 'run-manifest.json'; +export const RUN_MANIFEST_SCHEMA_VERSION = 1; + +export interface ManifestTest { + id: string; + taskId: string; + fullName: string; + location: TestLocation | null; + notOracleValidated: boolean; +} + +export interface ManifestFile { + /** Package-relative POSIX path. */ + file: string; + /** The expected test set: tests that ran in the dry run, in collection order. */ + tests: ManifestTest[]; + /** Tests that the dry run collected but skipped; never selectable. */ + skipped: ManifestTest[]; +} + +export interface RunManifest { + schemaVersion: typeof RUN_MANIFEST_SCHEMA_VERSION; + /** Vitest project name; task ids depend on it. */ + project: string; + vitestVersion: string; + files: ManifestFile[]; +} + +export interface TestSelection { + files: { file: string; tests: ManifestTest[] }[]; +} + +export class RunManifestMissingError extends Error { + override name = 'RunManifestMissingError'; +} + +export class TestFilterError extends Error { + override name = 'TestFilterError'; +} + +const fail = (source: string, detail: string): never => { + throw new Error(`run manifest ${source}: ${detail}`); +}; + +const requireAbsolute = (runDir: string): void => { + if (!path.isAbsolute(runDir)) { + throw new Error(`run manifest: the run directory ${JSON.stringify(runDir)} must be absolute`); + } +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const nonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value !== ''; + +const isPackageRelative = (file: string): boolean => + !file.includes('\\') && + !path.posix.isAbsolute(file) && + !file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'); + +function parseTest(value: unknown, source: string): ManifestTest { + if (!isRecord(value)) return fail(source, 'a test entry must be an object'); + const { id, taskId, fullName, location, notOracleValidated } = value; + if (!nonEmptyString(id)) return fail(source, 'a test entry needs an id'); + if (!nonEmptyString(taskId)) return fail(source, `test ${id} needs a taskId`); + if (typeof fullName !== 'string') return fail(source, `test ${id} needs a fullName`); + if (typeof notOracleValidated !== 'boolean') { + return fail(source, `test ${id} needs notOracleValidated`); + } + return { + id, + taskId, + fullName, + location: parseLocation(location, id, source), + notOracleValidated, + }; +} + +function parseLocation(value: unknown, id: string, source: string): TestLocation | null { + if (value === null) return null; + if (isRecord(value)) { + const { line, column } = value; + if (typeof line === 'number' && typeof column === 'number') return { line, column }; + } + return fail(source, `test ${id} has an invalid location`); +} + +/** Parses and validates manifest text; `source` names it in errors. */ +export function parseRunManifest(text: string, source: string): RunManifest { + let data: unknown; + try { + data = JSON.parse(text); + } catch (error) { + return fail( + source, + `not valid JSON (${error instanceof Error ? error.message : String(error)})`, + ); + } + if (!isRecord(data)) return fail(source, 'must be a JSON object'); + if (data.schemaVersion !== RUN_MANIFEST_SCHEMA_VERSION) { + return fail(source, `unsupported schema version ${JSON.stringify(data.schemaVersion)}`); + } + if (!nonEmptyString(data.project)) return fail(source, 'project must be a non-empty string'); + if (!nonEmptyString(data.vitestVersion)) return fail(source, 'vitestVersion is missing'); + if (!Array.isArray(data.files)) return fail(source, 'files must be an array'); + + const ids = new Set(); + const taskIds = new Set(); + const files = data.files.map((entry): ManifestFile => { + if (!isRecord(entry) || !nonEmptyString(entry.file) || !isPackageRelative(entry.file)) { + return fail( + source, + `file ${JSON.stringify(isRecord(entry) ? entry.file : entry)} must be a package-relative POSIX path`, + ); + } + if (!Array.isArray(entry.tests) || !Array.isArray(entry.skipped)) { + return fail(source, `file ${entry.file} needs tests and skipped arrays`); + } + const tests = entry.tests.map((test) => parseTest(test, source)); + const skipped = entry.skipped.map((test) => parseTest(test, source)); + for (const test of [...tests, ...skipped]) { + if (ids.has(test.id)) fail(source, `Stryker id ${JSON.stringify(test.id)} is listed twice`); + if (taskIds.has(test.taskId)) fail(source, `task id ${test.taskId} is listed twice`); + ids.add(test.id); + taskIds.add(test.taskId); + } + return { file: entry.file, tests, skipped }; + }); + return { + schemaVersion: RUN_MANIFEST_SCHEMA_VERSION, + project: data.project, + vitestVersion: data.vitestVersion, + files, + }; +} + +/** Writes the manifest atomically; returns its path. */ +export function writeRunManifest(runDir: string, manifest: RunManifest): string { + requireAbsolute(runDir); + const text = `${JSON.stringify(manifest, null, 2)}\n`; + parseRunManifest(text, 'to write'); + const target = path.join(runDir, RUN_MANIFEST_FILE); + const temporary = path.join( + runDir, + `.${RUN_MANIFEST_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`, + ); + try { + writeFileSync(temporary, text, { flag: 'wx' }); + renameSync(temporary, target); + } finally { + rmSync(temporary, { force: true }); + } + return target; +} + +/** Loads the manifest the dry-run process wrote; refuses when there is none. */ +export function readRunManifest(runDir: string): RunManifest { + requireAbsolute(runDir); + const target = path.join(runDir, RUN_MANIFEST_FILE); + if (!existsSync(target)) { + throw new RunManifestMissingError( + `vitest5 runner: no run manifest at ${target}. The dry run writes it; a mutant run ` + + 'refuses to start without it (KTD16). Was the dry run skipped or run with another runDir?', + ); + } + return parseRunManifest(readFileSync(target, 'utf8'), target); +} + +/** + * The tests to run for one mutant: the filtered tests per file, or the manifest's full test set + * when there is no filter (a static mutant). Every filter entry must be a manifest test id. + */ +export function selectTests( + manifest: RunManifest, + testFilter: readonly string[] | undefined, +): TestSelection { + if (testFilter === undefined) { + return { + files: manifest.files + .filter((file) => file.tests.length > 0) + .map((file) => ({ file: file.file, tests: file.tests })), + }; + } + const byId = new Map( + manifest.files.flatMap((file) => file.tests.map((test) => [test.id, file.file] as const)), + ); + const rejected = testFilter.filter((id) => !byId.has(id)); + if (rejected.length > 0) { + const looksLikeFile = rejected.some((id) => !id.includes('#')); + throw new TestFilterError( + `vitest5 runner: ${JSON.stringify(rejected.slice(0, 5))} ${rejected.length === 1 ? 'is' : 'are'} ` + + 'not a test id of the run manifest' + + (looksLikeFile + ? '; test file paths are not accepted (do not set the Stryker testFiles option, KTD4)' + : ''), + ); + } + const wanted = new Set(testFilter); + return { + files: manifest.files + .map((file) => ({ file: file.file, tests: file.tests.filter((test) => wanted.has(test.id)) })) + .filter((file) => file.tests.length > 0), + }; +} diff --git a/gitnexus/tools/mutation/runner/setup.ts b/gitnexus/tools/mutation/runner/setup.ts new file mode 100644 index 000000000..d92895db0 --- /dev/null +++ b/gitnexus/tools/mutation/runner/setup.ts @@ -0,0 +1,133 @@ +/** + * Worker setup file of the vitest5 runner. The runner prepends it to the project's setup files, + * so with the forks pool and default isolation it runs in every fresh worker before the test file + * (and anything the test file imports) loads. + * + * - It resets Stryker's instrumenter namespace and removes `__STRYKER_ACTIVE_MUTANT__` from the + * worker's environment, so only the runner decides which mutant is active (KTD5). + * - Mutant run, static activation: the mutant is active from module load. Runtime activation: it + * becomes active when collection has ended. The hit count starts at module load either way. + * - Dry run: coverage is recorded through {@link CoverageRecorder} (KTD4): load and collection + * hits stay static, test hits are per test, and a hit with no current test after collection is + * a hook hit of the deepest running suite. The hook hits are also kept apart, attributed, as + * the file's hook coverage (the hook marker, Phase 1 KTD2). + * + * The file-level `aroundAll` marks the end of collection when it starts (it runs before every + * `beforeAll` hook) and writes the {@link FileMeta} after the suite finished, after every + * `afterAll` hook, even a failing one. Vitest parses the second parameter of the hook as a + * fixture pattern, so it must be written `{}`. + */ +import type { InstrumenterContext } from '@stryker-mutator/api/core'; +import { + TestRunner, + aroundAll, + inject, + type RunnerTask, + type RunnerTestCase, + type RunnerTestFile, + type RunnerTestSuite, +} from 'vitest'; +import { + CoverageRecorder, + attributeHookHits, + hookCoverage, + type HitContext, +} from './coverage-attribution.js'; +import { HOOK_NAMES, PROTOCOL_KEY, type FileMeta, type HookName } from './protocol.js'; + +const context = inject(PROTOCOL_KEY); +if (context === undefined || context === null) { + throw new Error('vitest5 runner setup: no runner context was provided (wrong Vitest copy?)'); +} + +const globals = globalThis as unknown as Record; +const ns: InstrumenterContext = (globals[context.namespace] ??= {}); +delete process.env[context.activeMutantEnv]; +ns.activeMutant = undefined; +ns.currentTestId = undefined; +ns.mutantCoverage = undefined; +ns.hitCount = undefined; +ns.hitLimit = undefined; + +let file: RunnerTestFile | undefined; +let recorder: CoverageRecorder | undefined; + +const isRunningSuite = (task: RunnerTask): task is RunnerTestSuite => + task.type === 'suite' && + task.result !== undefined && + task.result.duration === undefined && + (task.result.state === 'run' || task.result.state === 'fail'); + +/** The file, then each started-but-unfinished suite down to the deepest unambiguous one. */ +function runningSuiteChain(root: RunnerTestFile): string[] { + const chain = [root.id]; + let current: RunnerTestSuite = root; + for (;;) { + const running = current.tasks.filter(isRunningSuite); + if (running.length !== 1) return chain; + current = running[0]; + chain.push(current.id); + } +} + +const NO_SUITES: readonly string[] = Object.freeze([]); + +/** The suite chain is walked only for a hit with no current test, the one bucket that reads it. */ +function hitContext(): HitContext { + if (file === undefined) return { phase: 'collection' }; + const testId = TestRunner.getCurrentTest()?.id; + return { + phase: 'run', + testId, + runningSuiteIds: testId === undefined ? runningSuiteChain(file) : NO_SUITES, + }; +} + +if (context.mode === 'mutant') { + ns.hitCount = 0; + ns.hitLimit = context.hitLimit ?? undefined; + if (context.mutantActivation === 'static') ns.activeMutant = context.activeMutant ?? undefined; +} else { + recorder = new CoverageRecorder(hitContext); + ns.mutantCoverage = recorder.instrumenterView; +} + +function buildFileMeta(root: RunnerTestFile): FileMeta { + const testsBySuite: Record = {}; + const failedHooks: Record = {}; + const visit = (task: RunnerTask, ancestors: readonly string[]): void => { + const hooks = task.result?.hooks ?? {}; + const failed = HOOK_NAMES.filter((name) => hooks[name] === 'run'); + if (failed.length > 0) failedHooks[task.id] = failed; + if (task.type === 'test') { + for (const suiteId of ancestors) testsBySuite[suiteId].push(task.id); + return; + } + if (task.type === 'suite') { + testsBySuite[task.id] = []; + for (const child of task.tasks) visit(child, [...ancestors, task.id]); + } + }; + visit(root, []); + const raw = recorder?.snapshot(); + return { + hitCount: context.mode === 'mutant' ? (ns.hitCount ?? 0) : null, + coverage: raw === undefined ? null : attributeHookHits(raw, testsBySuite), + hookCoverage: raw === undefined ? null : hookCoverage(raw, testsBySuite), + failedHooks, + }; +} + +// The `{}` is required: Vitest parses this parameter as a fixture pattern. +aroundAll(async (runSuite, {}, suite) => { + const root = suite.file; + file = root; + if (context.mode === 'mutant' && context.mutantActivation === 'runtime') { + ns.activeMutant = context.activeMutant ?? undefined; + } + try { + await runSuite(); + } finally { + root.meta[PROTOCOL_KEY] = buildFileMeta(root); + } +}); diff --git a/gitnexus/tools/mutation/runner/sidecar.ts b/gitnexus/tools/mutation/runner/sidecar.ts new file mode 100644 index 000000000..91aab88b6 --- /dev/null +++ b/gitnexus/tools/mutation/runner/sidecar.ts @@ -0,0 +1,114 @@ +/** + * Sidecar records of the vitest5 runner (KTD6). + * + * Stryker's result type has no field for the KTD17 failure classes, so every run writes one JSON + * record to the absolute sidecar directory: `mutant-.worker-.attempt-.json` for a + * mutant run and `dry-run.worker-.attempt-.json` for the dry run. `` is Stryker's + * local mutant id, `` is `STRYKER_MUTATOR_WORKER` and `` is the first attempt number + * not yet taken for that mutant and worker, so a retried mutant never overwrites an earlier + * record. The record is written to a temporary dot-file and then hard-linked to its final name, + * which fails instead of overwriting when the name is taken; a reader never sees a half-written + * record. A missing record means timeout or unknown: Stryker kills a timed-out process and + * retries crashed runs. + */ +import { randomBytes } from 'node:crypto'; +import { linkSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { MutantActivation } from '@stryker-mutator/api/test-runner'; +import type { RunClassification } from './classify-outcome.js'; + +/** 2: the dry-run record carries the hook marker ({@link SidecarRecord.hookAttributedTests}). */ +export const SIDECAR_SCHEMA_VERSION = 2; + +export type SidecarKind = 'dry-run' | 'mutant'; + +export interface SidecarRecord { + schemaVersion: typeof SIDECAR_SCHEMA_VERSION; + kind: SidecarKind; + /** Stryker's local mutant id; null for the dry run. */ + mutantId: string | null; + /** `STRYKER_MUTATOR_WORKER` of the runner process. */ + worker: string; + attempt: number; + pid: number; + mutantActivation: MutantActivation | null; + hitLimit: number | null; + /** Stryker ids the run requested (the manifest's full set for a static mutant or the dry run). */ + requestedTestIds: string[]; + /** + * Dry run only (null for a mutant run): the hook marker. Stryker's local mutant id to the + * Stryker ids of the tests a suite hook (`beforeAll`, `afterAll`) attributed its hits to; a + * mutant no hook hit is absent. + */ + hookAttributedTests: Record | null; + result: RunClassification; +} + +export type SidecarDraft = Omit; + +export interface SidecarIo { + writeFile?: (file: string, data: string) => void; +} + +const safe = (value: string): string => value.replace(/[^A-Za-z0-9_-]/g, '_'); + +export function sidecarFileName( + kind: SidecarKind, + mutantId: string | null, + worker: string, + attempt: number, +): string { + const subject = kind === 'mutant' ? `mutant-${safe(mutantId ?? '')}` : 'dry-run'; + return `${subject}.worker-${safe(worker)}.attempt-${attempt}.json`; +} + +const isErrnoCode = (error: unknown, code: string): boolean => + error instanceof Error && 'code' in error && error.code === code; + +/** Writes one record; returns its final path and attempt number. */ +export function writeSidecar( + dir: string, + draft: SidecarDraft, + io: SidecarIo = {}, +): { file: string; attempt: number } { + if (!path.isAbsolute(dir)) { + throw new Error(`sidecar: the sidecar directory ${JSON.stringify(dir)} must be absolute`); + } + const writeFile = io.writeFile ?? ((file: string, data: string) => writeFileSync(file, data)); + const temporary = path.join( + dir, + `.${sidecarFileName(draft.kind, draft.mutantId, draft.worker, 0)}.${process.pid}.` + + `${randomBytes(4).toString('hex')}.tmp`, + ); + try { + for (let attempt = 1; ; attempt += 1) { + const record: SidecarRecord = { + schemaVersion: SIDECAR_SCHEMA_VERSION, + attempt, + pid: process.pid, + ...draft, + }; + writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`); + const file = path.join( + dir, + sidecarFileName(draft.kind, draft.mutantId, draft.worker, attempt), + ); + try { + linkSync(temporary, file); + return { file, attempt }; + } catch (error) { + if (!isErrnoCode(error, 'EEXIST')) throw error; + } + } + } finally { + rmSync(temporary, { force: true }); + } +} + +/** Reads every published record (temporary files are skipped), sorted by file name. */ +export function readSidecars(dir: string): SidecarRecord[] { + return readdirSync(dir) + .filter((name) => name.endsWith('.json') && !name.startsWith('.')) + .sort() + .map((name) => JSON.parse(readFileSync(path.join(dir, name), 'utf8')) as SidecarRecord); +} diff --git a/gitnexus/tools/mutation/runner/test-ids.ts b/gitnexus/tools/mutation/runner/test-ids.ts new file mode 100644 index 000000000..6418aceb7 --- /dev/null +++ b/gitnexus/tools/mutation/runner/test-ids.ts @@ -0,0 +1,200 @@ +/** + * Stryker test identity for the vitest5 runner (KTD3). + * + * A Stryker test id is `#`: the package-relative POSIX path of the + * test file, then Vitest's full name (suite and test titles joined with `' > '`). Titles can + * contain `#` (for example `(#2415)`), so ids are parsed on the first `#` only and a file path + * may not contain one. + * + * Tests that share a full name inside one file get a `::` suffix (the test's + * location, 1-based as Vitest reports it). Rows of one `it.each`/`test.for` call share the call + * site, so a remaining collision adds the row's ordinal within that call: + * `:::`, where `` is the 0-based index (Vitest's `$index`) among all tests + * declared at that location. Rows that needed the ordinal are not oracle-validated (origin §5.4a + * gate 3). Per-test coverage keys and the run manifest use exactly these ids. + * + * Single-test selection (Phase 1 KTD2): a dry run given `vitest5.testIds` first collects the + * scope's files, assigns these ids, and maps the requested ids to Vitest task ids with + * {@link selectRequestedTests}; it then runs one task-id specification per file. + */ +import path from 'node:path'; + +export const TEST_ID_SEPARATOR = '#'; + +export interface TestLocation { + line: number; + column: number; +} + +/** A collected Vitest test, in collection order. */ +export interface CollectedTest { + taskId: string; + fullName: string; + location: TestLocation | undefined; +} + +export interface IdentifiedTest extends CollectedTest { + id: string; + /** True when the id needed the `it.each` row ordinal (gate 3 cannot validate the row). */ + notOracleValidated: boolean; +} + +export function formatTestId(file: string, name: string): string { + if (file.includes(TEST_ID_SEPARATOR)) { + throw new Error(`test id: file ${JSON.stringify(file)} contains "${TEST_ID_SEPARATOR}"`); + } + return `${file}${TEST_ID_SEPARATOR}${name}`; +} + +/** Splits an id on its first `#`; `name` keeps any location or row suffix. */ +export function parseTestId(id: string): { file: string; name: string } { + const at = id.indexOf(TEST_ID_SEPARATOR); + if (at < 0) throw new Error(`test id ${JSON.stringify(id)} has no "${TEST_ID_SEPARATOR}"`); + return { file: id.slice(0, at), name: id.slice(at + 1) }; +} + +const locationKey = (location: TestLocation): string => `${location.line}:${location.column}`; + +const countBy = (items: readonly T[], key: (item: T) => string): Map => { + const counts = new Map(); + for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); + return counts; +}; + +/** Assigns KTD3 ids to the tests of one file, given in collection order. */ +export function assignTestIds(file: string, tests: readonly CollectedTest[]): IdentifiedTest[] { + const taskIds = new Set(); + for (const test of tests) { + if (taskIds.has(test.taskId)) { + throw new Error(`test ids: ${file} lists task id ${test.taskId} twice`); + } + taskIds.add(test.taskId); + } + + const byName = countBy(tests, (test) => test.fullName); + const located = (test: CollectedTest): TestLocation => { + if (test.location === undefined) { + throw new Error( + `test ids: ${file} has several tests named ${JSON.stringify(test.fullName)} but no ` + + 'test location; the Vitest config must set includeTaskLocation', + ); + } + return test.location; + }; + const byNameAndLocation = countBy( + tests.filter((test) => byName.get(test.fullName) > 1), + (test) => `${test.fullName}\u0000${locationKey(located(test))}`, + ); + const rowAtLocation = new Map(); + const rowOf = new Map(); + for (const test of tests) { + if (test.location === undefined) continue; + const key = locationKey(test.location); + const row = rowAtLocation.get(key) ?? 0; + rowOf.set(test.taskId, row); + rowAtLocation.set(key, row + 1); + } + + const identified = tests.map((test): IdentifiedTest => { + if (byName.get(test.fullName) === 1) { + return { ...test, id: formatTestId(file, test.fullName), notOracleValidated: false }; + } + const location = located(test); + const suffix = `${test.fullName}:${locationKey(location)}`; + if (byNameAndLocation.get(`${test.fullName}\u0000${locationKey(location)}`) === 1) { + return { ...test, id: formatTestId(file, suffix), notOracleValidated: false }; + } + return { + ...test, + id: formatTestId(file, `${suffix}:${rowOf.get(test.taskId)}`), + notOracleValidated: true, + }; + }); + + const seen = new Set(); + for (const test of identified) { + if (seen.has(test.id)) throw new Error(`test ids: ${JSON.stringify(test.id)} is not unique`); + seen.add(test.id); + } + return identified; +} + +/** Remove only the current copy's path prefixes from titles that interpolate fixture paths. */ +export function portableTestName(name: string, packageRoot: string): string { + const root = packageRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + const prefixes = [ + [root, ''], + [path.posix.dirname(root), ''], + ] as const; + for (const [prefix, replacement] of prefixes) { + if (prefix === '/' || prefix === '.') continue; + name = name.replaceAll(`${prefix}/`, `${replacement}/`); + name = name.replaceAll(`${prefix.replaceAll('/', '\\')}\\`, `${replacement}/`); + } + return name; +} + +/** Portable ids; keep raw full names for Vitest's collection/execution consistency check. */ +export function assignPortableTestIds( + file: string, + tests: readonly CollectedTest[], + packageRoot: string, +): IdentifiedTest[] { + return assignTestIds( + file, + tests.map((test) => ({ + ...test, + fullName: portableTestName(test.fullName, packageRoot), + })), + ).map((test, index) => ({ ...test, fullName: tests[index].fullName })); +} + +export interface FileTestSelection { + /** Package-relative POSIX path of the test file. */ + file: string; + /** Vitest task ids of the requested tests of the file, in collection order. */ + taskIds: string[]; +} + +export interface RequestedTestSelection { + /** Files with at least one requested test, in the order of `identified`. */ + files: FileTestSelection[]; + /** Requested ids that no collected test has, in request order. */ + unknown: string[]; +} + +/** Maps requested Stryker ids to the task ids of the collected tests, per file; pure. */ +export function selectRequestedTests( + identified: ReadonlyMap, + testIds: readonly string[], +): RequestedTestSelection { + const wanted = new Set(testIds); + const found = new Set(); + const files: FileTestSelection[] = []; + for (const [file, tests] of identified) { + const selected = tests.filter((test) => wanted.has(test.id)); + for (const test of selected) found.add(test.id); + if (selected.length > 0) files.push({ file, taskIds: selected.map((test) => test.taskId) }); + } + return { files, unknown: testIds.filter((id) => !found.has(id)) }; +} + +/** + * Problems of a `testIds` dry run whose tests did not run exactly as requested: a requested id + * that did not run and a test that ran without being requested, each named; pure. + */ +export function selectionProblems( + requested: readonly string[], + executed: readonly string[], +): string[] { + const asked = new Set(requested); + const ran = new Set(executed); + return [ + ...requested + .filter((id) => !ran.has(id)) + .map((id) => `requested test ${JSON.stringify(id)} did not run (vitest5.testIds)`), + ...executed + .filter((id) => !asked.has(id)) + .map((id) => `test ${JSON.stringify(id)} ran but vitest5.testIds does not request it`), + ]; +} diff --git a/gitnexus/tools/mutation/runner/version-guard.ts b/gitnexus/tools/mutation/runner/version-guard.ts new file mode 100644 index 000000000..0fcc18726 --- /dev/null +++ b/gitnexus/tools/mutation/runner/version-guard.ts @@ -0,0 +1,74 @@ +/** + * Version guard of the vitest5 runner (KTD2). + * + * At init the runner reads the Vitest resolved from the project root (the Stryker sandbox) and + * refuses anything outside 5.0.x. The setup file and the plugin import Vitest from the plugin's + * own location; a second Vitest copy there is an error, because `inject` in the setup file then + * reads another copy's provided context and returns undefined. Copies are compared by the real + * path of their package.json, so a symlinked node_modules (the prepared copy) is one copy. + */ +import { readFileSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +export const SUPPORTED_VITEST = '5.0.x'; +const SUPPORTED_VERSION = /^5\.0\.(?:0|[1-9]\d*)$/; + +export interface VitestInstall { + version: string; + /** Real path of the resolved `vitest/package.json`. */ + packageJsonPath: string; +} + +/** The Vitest that Node resolves from `fromDir`, or undefined when none resolves. */ +export function resolveVitestInstall(fromDir: string): VitestInstall | undefined { + let resolved: string; + try { + resolved = createRequire(path.join(fromDir, '__resolve__.js')).resolve('vitest/package.json'); + } catch { + return undefined; + } + const packageJsonPath = realpathSync(resolved); + const data: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const version = + typeof data === 'object' && + data !== null && + 'version' in data && + typeof data.version === 'string' + ? data.version + : ''; + return { version, packageJsonPath }; +} + +export function checkVitestInstalls( + fromProject: VitestInstall | undefined, + fromPlugin: VitestInstall | undefined, +): VitestInstall { + if (fromProject === undefined) { + throw new Error('vitest5 runner: no Vitest resolves from the project root'); + } + if (!SUPPORTED_VERSION.test(fromProject.version)) { + throw new Error( + `vitest5 runner: Vitest ${fromProject.version} at ${fromProject.packageJsonPath} is not ` + + `supported; the runner is validated on ${SUPPORTED_VITEST} only (KTD2)`, + ); + } + if (fromPlugin === undefined) { + throw new Error( + 'vitest5 runner: no Vitest resolves from the plugin location, so its setup file cannot ' + + 'import Vitest', + ); + } + if (fromPlugin.packageJsonPath !== fromProject.packageJsonPath) { + throw new Error( + `vitest5 runner: the plugin location resolves a second Vitest copy ` + + `(${fromPlugin.packageJsonPath}, project: ${fromProject.packageJsonPath}); inject() in ` + + 'the setup file would return undefined', + ); + } + return fromProject; +} + +export function assertSupportedVitest(projectRoot: string, pluginDir: string): VitestInstall { + return checkVitestInstalls(resolveVitestInstall(projectRoot), resolveVitestInstall(pluginDir)); +} diff --git a/gitnexus/tools/mutation/scope-file.ts b/gitnexus/tools/mutation/scope-file.ts new file mode 100644 index 000000000..38a9d4309 --- /dev/null +++ b/gitnexus/tools/mutation/scope-file.ts @@ -0,0 +1,66 @@ +/** + * Scope file for tools/mutation/vitest.mutation.config.ts (KTD9). + * + * The caller of a mutation run writes a JSON file `{ "testFiles": ["test/unit/a.test.ts"] }` + * with POSIX paths relative to the `gitnexus/` package (the prepared copy or its sandbox) and + * names it in the {@link MUTATION_SCOPE_ENV} environment variable. The config uses the list as + * the `include` of its single `mutation` project. + * + * This module is imported by the Vitest config, so it must stay free of heavy dependencies. + */ +import path from 'node:path'; + +/** Environment variable naming the scope file: absolute, or relative to the package dir. */ +export const MUTATION_SCOPE_ENV = 'GITNEXUS_MUTATION_SCOPE'; + +export interface MutationScope { + testFiles: string[]; +} + +const fail = (detail: string): never => { + throw new Error(`mutation scope: ${detail}`); +}; + +/** Parses and validates the text of a scope file. */ +export function parseMutationScope(text: string): MutationScope { + let data: unknown; + try { + data = JSON.parse(text); + } catch (error) { + return fail(`not valid JSON (${error instanceof Error ? error.message : String(error)})`); + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return fail('must be a JSON object'); + } + const testFiles: unknown = 'testFiles' in data ? data.testFiles : undefined; + if (!Array.isArray(testFiles) || testFiles.length === 0) { + return fail('testFiles must be a non-empty array'); + } + const seen = new Set(); + for (const entry of testFiles) { + if (typeof entry !== 'string') return fail(`entry ${JSON.stringify(entry)} must be a string`); + const segments = entry.split('/'); + if ( + entry.includes('\\') || + path.posix.isAbsolute(entry) || + segments.some((s) => s === '..' || s === '.' || s === '') + ) { + fail(`entry ${JSON.stringify(entry)} must be a package-relative POSIX path`); + } + if (seen.has(entry)) fail(`entry ${JSON.stringify(entry)} is listed twice`); + seen.add(entry); + } + return { testFiles: [...seen] }; +} + +/** The scope file named by {@link MUTATION_SCOPE_ENV}, resolved against the package dir. */ +export function resolveMutationScopePath( + env: Readonly>, + packageDir: string, +): string { + const value = env[MUTATION_SCOPE_ENV]; + if (value === undefined || value === '') { + return fail(`${MUTATION_SCOPE_ENV} must name the scope file of this run`); + } + return path.resolve(packageDir, value); +} diff --git a/gitnexus/tools/mutation/shared/cli.ts b/gitnexus/tools/mutation/shared/cli.ts new file mode 100644 index 000000000..2de9566e0 --- /dev/null +++ b/gitnexus/tools/mutation/shared/cli.ts @@ -0,0 +1,18 @@ +/** + * CLI helpers shared by the mutation tools outside the oracle and the vitest5 runner. The runner + * (runner/) stays self-contained and the oracle (oracle/) keeps its own helpers (KTD15), so + * neither imports this module directly. + */ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** The message of a thrown value: an Error's message, else the value as a string. */ +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** Whether the module at `importMetaUrl` is the script Node was started with. */ +export function isMainModule(importMetaUrl: string): boolean { + const entry = process.argv[1]; + if (!entry) return false; + return importMetaUrl === pathToFileURL(path.resolve(entry)).href; +} diff --git a/gitnexus/tools/mutation/shared/fs.ts b/gitnexus/tools/mutation/shared/fs.ts new file mode 100644 index 000000000..c801c0881 --- /dev/null +++ b/gitnexus/tools/mutation/shared/fs.ts @@ -0,0 +1,19 @@ +/** + * File-system helpers shared by the mutation tools outside the oracle and the vitest5 runner + * (see cli.ts for why those two keep their own). + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** Parses a UTF-8 JSON file; the caller checks the shape. */ +export const readJsonFile = (file: string): unknown => JSON.parse(readFileSync(file, 'utf8')); + +/** True when `child` is `parent` or lies inside it. */ +export const isInside = (parent: string, child: string): boolean => { + const relative = path.relative(parent, child); + const escapes = relative === '..' || relative.startsWith(`..${path.sep}`); + return !escapes && !path.isAbsolute(relative); +}; + +/** True when either path is the other or lies inside it ({@link isInside} both ways). */ +export const overlaps = (a: string, b: string): boolean => isInside(a, b) || isInside(b, a); diff --git a/gitnexus/tools/mutation/vitest.mutation.config.ts b/gitnexus/tools/mutation/vitest.mutation.config.ts new file mode 100644 index 000000000..0386d5ca5 --- /dev/null +++ b/gitnexus/tools/mutation/vitest.mutation.config.ts @@ -0,0 +1,63 @@ +/** + * Vitest config for mutation runs (KTD9). It runs only inside a prepared copy + * (tools/mutation/prepare/prepare.ts) or a Stryker sandbox of one. + * + * Differences from gitnexus/vitest.config.ts, each on gate 1's allowed list: + * - the root test settings are reused, but `projects` is replaced by one `mutation` project + * whose `include` is the `testFiles` of the scope file named by GITNEXUS_MUTATION_SCOPE + * (tools/mutation/scope-file.ts); + * - `gitnexus-shared` imports resolve, through one regex alias per package `exports` key, to the + * relocated sources next to this file's copy (KTD8), never to the checkout's package; + * - `dangerouslyIgnoreUnhandledErrors` is off, so an unhandled error fails the run. + * + * Usage (from the prepared gitnexus/ dir): + * GITNEXUS_MUTATION_SCOPE= npx vitest run --config tools/mutation/vitest.mutation.config.ts + */ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vitest.config.js'; +import { + RELOCATED_SHARED_DIR, + deriveSharedAliases, + parseSharedPackageJson, +} from './prepare/path-map.js'; +import { parseMutationScope, resolveMutationScopePath } from './scope-file.js'; + +const packageDir = fileURLToPath(new URL('../../', import.meta.url)); +const sharedDir = path.join(packageDir, RELOCATED_SHARED_DIR); +const sharedManifest = path.join(sharedDir, 'package.json'); +if (!existsSync(sharedManifest)) { + throw new Error( + `vitest.mutation.config: ${sharedManifest} is missing; run this config inside a prepared copy ` + + '(npm run test:mutation:prepare)', + ); +} +const aliases = deriveSharedAliases( + parseSharedPackageJson(readFileSync(sharedManifest, 'utf8')), + sharedDir, +); +for (const alias of aliases) { + if (!existsSync(alias.replacement)) { + throw new Error(`vitest.mutation.config: alias target ${alias.replacement} is missing`); + } +} +const scope = parseMutationScope( + readFileSync(resolveMutationScopePath(process.env, packageDir), 'utf8'), +); + +const { projects: _baseProjects, ...rootTest } = baseConfig.test ?? {}; + +export default defineConfig({ + ...baseConfig, + resolve: { + ...baseConfig.resolve, + alias: aliases.map(({ find, replacement }) => ({ find, replacement })), + }, + test: { + ...rootTest, + dangerouslyIgnoreUnhandledErrors: false, + projects: [{ extends: true, test: { name: 'mutation', include: scope.testFiles } }], + }, +}); diff --git a/gitnexus/tools/scenario-map/facet/checks.ts b/gitnexus/tools/scenario-map/facet/checks.ts new file mode 100644 index 000000000..9d22a202a --- /dev/null +++ b/gitnexus/tools/scenario-map/facet/checks.ts @@ -0,0 +1,1330 @@ +/** + * Checks of a test instance for the scenario map (Layer 2, U5, R5, KTD6, design §2 "Checks"). + * + * A check is the shape of one `expect(subject)[.not|.resolves|.rejects].matcher(args)` of the test + * body (also `expect.soft(…)`, `expect.assertions(n)`, `expect.hasAssertions()`), in source order: + * - literals become their kind: ``, ``, ``, ``. `true`, `false`, + * `null` and `undefined` stay, as do object keys. Expected values are not lost: the input facet + * (U6, design §5.3 rule 3) keeps them; + * - selector literals in the subject stay exact (design §2, §5.3 rule 4): the literal arguments of + * a subject call that also takes a non-literal operand (another argument, or a receiver that is + * a value rather than a global or a module namespace), as in `getNodesByLabel(r, 'Function')` or + * `graph.get('x')`, and a literal computed key such as `nodes[0]`. A call over literals only, + * such as `fn('a')` or `toMillis(2, 's')`, has inputs, not selectors; + * - names declared in the test body become ``, `` … in order of first use, so renamed + * locals match; imported, file-level and global names stay; + * - per instance (R5): names bound by the test's own `each`/`for` row and by the innermost + * `for…of` element take the instance's values from the static inventory (U3). They are + * abstracted like literals, and exact in selector positions; + * - snapshot matchers carry the sha1 of the snapshot: the inline text (common indentation + * stripped), the `.snap` entry (key `[ > ] `, counted per name as Vitest + * does) or the `toMatchFileSnapshot` file, both read through a {@link SnapshotReader}. + * + * SUPPORTED_MATCHERS, Vitest 5's Jest-style matchers: + * toBe toEqual toStrictEqual toMatchObject toBeTruthy toBeFalsy toBeNull toBeUndefined + * toBeDefined toBeNaN toBeNullable toBeTypeOf toBeInstanceOf toBeGreaterThan + * toBeGreaterThanOrEqual toBeLessThan toBeLessThanOrEqual toBeCloseTo toBeOneOf toContain + * toContainEqual toHaveLength toHaveProperty toMatch toThrow toThrowError toHaveBeenCalled + * toHaveBeenCalledOnce toHaveBeenCalledTimes toHaveBeenCalledWith + * toHaveBeenCalledExactlyOnceWith toHaveBeenLastCalledWith toHaveBeenNthCalledWith + * toHaveBeenCalledBefore toHaveBeenCalledAfter toBeCalled toBeCalledTimes toBeCalledWith + * toHaveReturned toHaveReturnedTimes toHaveReturnedWith toHaveLastReturnedWith + * toHaveNthReturnedWith toReturn toReturnTimes toReturnWith toHaveResolved toHaveResolvedTimes + * toHaveResolvedWith toHaveLastResolvedWith toHaveNthResolvedWith, and the snapshot matchers + * toMatchSnapshot toMatchInlineSnapshot toMatchFileSnapshot toThrowErrorMatchingSnapshot + * toThrowErrorMatchingInlineSnapshot. + * + * Anything else is unsupported, with the form named (design §2): `assert.*` and node:assert + * functions, `expectTypeOf`/`assertType`, chai-style chains (`expect(x).to.equal`, `.should`), + * custom matchers, `toSatisfy` (an arbitrary predicate), other `expect.*` forms (`expect.poll` …); + * an `expect` inside a loop, a conditional, a catch clause or a nested function of the body, where + * data decides how many checks run; an `expect` inside a helper the body calls: a file-level + * declaration, or a function imported from a test-support module (read through a + * {@link ModuleReader}; one that cannot be read is unsupported as not inspected); a snapshot whose + * text cannot be read. A selector whose instance value is not static is `unresolved`. Both block + * strong evidence. + * + * The fingerprint is sha1 over the checks and the unsupported forms in source order. + * {@link checksContextKey} joins it with the context hash (./context.ts): Layer 1's second + * candidate source (KTD5). + * + * Library only; the scenario-map CLI (U14) calls {@link computeChecks} per inventory instance, + * with {@link fsSnapshotReader} and {@link fsModuleReader} over the repo root. The input facet + * (input-facet.ts, rule 4) reuses the selector rule through {@link buildScope}, + * {@link rowValueOf}, {@link isLiteralish}, {@link receiverQueried} and {@link abstractNode}. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { generate } from '@babel/generator'; +import { parse } from '@babel/parser'; +import * as t from '@babel/types'; +import { + extract, + isTestSupportFile, + resolveName, + shapeOf, + type CaseRecord, + type Decl, + type Frame, + type SourceFile, +} from '../../test-dupes/test-dupes.js'; +import { + normalizeRelativePath, + type InventoryInstance, + type RowEntry, + type RowValue, + type StaticInstance, +} from '../types.js'; +import { sha1 } from '../util.js'; +import { contextOf } from './context.js'; + +const SNAPSHOT_MATCHERS: ReadonlySet = new Set([ + 'toMatchSnapshot', + 'toMatchInlineSnapshot', + 'toMatchFileSnapshot', + 'toThrowErrorMatchingSnapshot', + 'toThrowErrorMatchingInlineSnapshot', +]); + +/** The matchers a check may use; see the module header. */ +export const SUPPORTED_MATCHERS: ReadonlySet = new Set([ + 'toBe', + 'toEqual', + 'toStrictEqual', + 'toMatchObject', + 'toBeTruthy', + 'toBeFalsy', + 'toBeNull', + 'toBeUndefined', + 'toBeDefined', + 'toBeNaN', + 'toBeNullable', + 'toBeTypeOf', + 'toBeInstanceOf', + 'toBeGreaterThan', + 'toBeGreaterThanOrEqual', + 'toBeLessThan', + 'toBeLessThanOrEqual', + 'toBeCloseTo', + 'toBeOneOf', + 'toContain', + 'toContainEqual', + 'toHaveLength', + 'toHaveProperty', + 'toMatch', + 'toThrow', + 'toThrowError', + 'toHaveBeenCalled', + 'toHaveBeenCalledOnce', + 'toHaveBeenCalledTimes', + 'toHaveBeenCalledWith', + 'toHaveBeenCalledExactlyOnceWith', + 'toHaveBeenLastCalledWith', + 'toHaveBeenNthCalledWith', + 'toHaveBeenCalledBefore', + 'toHaveBeenCalledAfter', + 'toBeCalled', + 'toBeCalledTimes', + 'toBeCalledWith', + 'toHaveReturned', + 'toHaveReturnedTimes', + 'toHaveReturnedWith', + 'toHaveLastReturnedWith', + 'toHaveNthReturnedWith', + 'toReturn', + 'toReturnTimes', + 'toReturnWith', + 'toHaveResolved', + 'toHaveResolvedTimes', + 'toHaveResolvedWith', + 'toHaveLastResolvedWith', + 'toHaveNthResolvedWith', + ...SNAPSHOT_MATCHERS, +]); + +/** Chain members allowed between `expect(…)` and the matcher. */ +const MODIFIERS: ReadonlySet = new Set(['not', 'resolves', 'rejects']); +/** `expect.(…)` calls that are checks on their own. */ +const EXPECT_STATICS: ReadonlySet = new Set(['assertions', 'hasAssertions']); +/** node:assert functions, recognised when the name resolves to no declaration of the file. */ +const NODE_ASSERT: ReadonlySet = new Set([ + 'ok', + 'equal', + 'notEqual', + 'deepEqual', + 'notDeepEqual', + 'strictEqual', + 'notStrictEqual', + 'deepStrictEqual', + 'notDeepStrictEqual', + 'partialDeepStrictEqual', + 'throws', + 'doesNotThrow', + 'rejects', + 'doesNotReject', + 'match', + 'doesNotMatch', + 'fail', + 'ifError', +]); +const TYPE_ASSERTIONS: ReadonlySet = new Set(['expectTypeOf', 'assertType']); +/** Names that are literals when not redeclared. */ +const LITERAL_GLOBALS: ReadonlySet = new Set(['undefined', 'NaN', 'Infinity']); +/** Globals a free name may be, rather than a binding of an enclosing table row. */ +const GLOBALS: ReadonlySet = new Set([ + ...LITERAL_GLOBALS, + 'globalThis', + 'Math', + 'JSON', + 'Object', + 'Array', + 'Number', + 'String', + 'Boolean', + 'Symbol', + 'BigInt', + 'Date', + 'RegExp', + 'Error', + 'TypeError', + 'RangeError', + 'SyntaxError', + 'Promise', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'Buffer', + 'process', + 'console', + 'URL', + 'URLSearchParams', + 'Intl', + 'Reflect', + 'parseInt', + 'parseFloat', + 'isNaN', + 'isFinite', + 'structuredClone', + 'setTimeout', + 'clearTimeout', + 'queueMicrotask', + 'TextEncoder', + 'TextDecoder', + 'expect', + 'vi', + 'require', + '__dirname', + '__filename', +]); +/** TS nodes that hold an expression; every other `TS*` node is a type and is kept as written. */ +const TS_EXPRESSIONS: ReadonlySet = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'TSInstantiationExpression', +]); +/** How many helper declarations deep an `expect` is looked for. */ +const HELPER_DEPTH = 4; +const IDENTIFIER_KEY = /^[A-Za-z_$][\w$]*$/; +const UNDEFINED: RowValue = { kind: 'undefined' }; + +// ── Public shapes ────────────────────────────────────────────────────────── + +export interface Check { + /** 1-based line of the assertion. */ + line: number; + /** `expect`, `expect.soft`, `expect.assertions` or `expect.hasAssertions`. */ + form: string; + /** `not` / `resolves` / `rejects`, as written. */ + modifiers: string[]; + /** The matcher; `''` for `expect.assertions` / `expect.hasAssertions`. */ + matcher: string; + /** The rendered shape, e.g. `expect(getNodesByLabel(,"Function")).toHaveLength()`. */ + shape: string; + /** sha1 of the snapshot for snapshot matchers, else null. */ + snapshot: string | null; +} + +export interface UnsupportedForm { + /** 1-based line of the construct. */ + line: number; + /** The construct: `assert.equal`, `expect(...).toBeFoo`, a helper's name … */ + form: string; + /** Why it is unsupported: `not an expect assertion`, `inside a loop (ForStatement)` … */ + reason: string; +} + +export interface InstanceChecks { + checks: Check[]; + /** Empty when every assertion form is supported. Non-empty blocks strong evidence. */ + unsupported: UnsupportedForm[]; + /** Selectors whose instance value is not static, sorted. Non-empty blocks strong evidence. */ + unresolved: string[]; + /** sha1 over the checks' shapes and the unsupported forms, in source order. */ + fingerprint: string; +} + +/** Snapshot text by test file (repo path). */ +export interface SnapshotReader { + /** The raw value of `key` in the test file's `__snapshots__/.snap`, else null. */ + entry(testFile: string, key: string): string | null; + /** The text of `snapshotPath`, relative to the test file's directory, else null. */ + file(testFile: string, snapshotPath: string): string | null; +} + +/** Source of a helper module by Layer 0 module key (repo path without extension). */ +export interface ModuleReader { + read(moduleKey: string): SourceFile | null; +} + +export interface ChecksOptions { + /** Without one, file snapshots are unsupported (`snapshot file not read`). */ + snapshots?: SnapshotReader; + /** Without one, calls to imported test-support helpers are unsupported (not inspected). */ + modules?: ModuleReader; +} + +/** One test instance: the call site's record, its static instance and its Vitest full name. */ +export interface CheckSubject { + record: CaseRecord; + /** null when the instance is not known (no one-to-one join, or a count that is not static). */ + instance: StaticInstance | null; + /** Vitest full name, the key of file snapshots; null when not known. */ + fullName: string | null; +} + +// ── Small helpers ────────────────────────────────────────────────────────── + +const gen = (node: t.Node): string => generate(node, { comments: false, compact: true }).code; +const lineOf = (node: t.Node): number => node.loc?.start.line ?? 0; +/** An identifier node with any text, for placeholders such as `` (never parsed back). */ +const rawIdentifier = (name: string): t.Identifier => Object.assign(t.identifier('x'), { name }); +const keyNode = (key: string): t.Node => + IDENTIFIER_KEY.test(key) ? rawIdentifier(key) : t.stringLiteral(key); + +const isNode = (value: unknown): value is t.Node => + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string'; + +/** A copy of `node` with some children replaced; the original is never mutated. */ +function withChildren(node: T, children: Record): T { + return Object.assign(t.cloneNode(node, false), children); +} + +/** The expression inside TS wrappers (`x as T`, `x!`, `x`). */ +function unwrap(node: t.Node): t.Node { + let n = node; + while (TS_EXPRESSIONS.has(n.type) || t.isParenthesizedExpression(n)) { + n = (n as t.TSAsExpression).expression; + } + return n; +} + +/** `a.b.c` for an identifier / non-computed member chain, else null. */ +function dotted(node: t.Node): string | null { + if (t.isIdentifier(node)) return node.name; + if (t.isMemberExpression(node) && !node.computed && t.isIdentifier(node.property)) { + const object = dotted(node.object); + return object === null ? null : `${object}.${node.property.name}`; + } + return null; +} + +/** The identifier a callee chain starts from (through members, calls and TS wrappers). */ +function rootName(node: t.Node): string | null { + let n = node; + for (;;) { + n = unwrap(n); + if (t.isIdentifier(n)) return n.name; + if (t.isMemberExpression(n) || t.isOptionalMemberExpression(n)) n = n.object; + else if (t.isCallExpression(n) || t.isOptionalCallExpression(n)) n = n.callee; + else return null; + } +} + +function memberKey(node: t.MemberExpression | t.OptionalMemberExpression): string | null { + if (!node.computed && t.isIdentifier(node.property)) return node.property.name; + if (t.isStringLiteral(node.property)) return node.property.value; + if (t.isNumericLiteral(node.property)) return String(node.property.value); + return null; +} + +/** The value of `key` in an object row (the last entry wins, as in JS), else `undefined`. */ +function entryValue(entries: readonly RowEntry[], key: string): RowValue { + for (let i = entries.length - 1; i >= 0; i--) if (entries[i].key === key) return entries[i].value; + return UNDEFINED; +} + +function hasUnresolved(value: RowValue): boolean { + if (value.kind === 'unresolved') return true; + if (value.kind === 'array') return value.items.some(hasUnresolved); + if (value.kind === 'object') return value.entries.some((entry) => hasUnresolved(entry.value)); + return false; +} + +/** Text without the blank first and last lines and the common indentation Vitest writes. */ +function stripIndentation(text: string): string { + const lines = text.split('\n'); + const blank = (line: string | undefined): boolean => (line ?? '').trim() === ''; + if (lines.length < 3 || !blank(lines[0]) || !blank(lines[lines.length - 1])) return text; + const body = lines.slice(1, -1); + const indents = body.filter((line) => !blank(line)).map((line) => line.search(/\S/)); + const indent = indents.length === 0 ? 0 : Math.min(...indents); + return body.map((line) => (blank(line) ? '' : line.slice(indent))).join('\n'); +} + +// ── Instance scope: locals and row bindings ──────────────────────────────── + +/** The names one instance's body sees; built by {@link buildScope}. */ +export interface Scope { + record: CaseRecord; + /** Names declared in the body (nested ones too) and own parameters no row binds. */ + locals: ReadonlySet; + /** Names bound by the instance's row; null when the value is not known. */ + rows: ReadonlyMap; + /** Kinds of enclosing table dimensions whose bindings are not tracked. */ + unboundOuter: readonly string[]; + localIds: Map; + unresolved: Set; +} + +function addPattern(pattern: t.Node, into: Set): void { + for (const name of Object.keys(t.getBindingIdentifiers(pattern))) into.add(name); +} + +function declaredNames(root: t.Node, into: Set): void { + t.traverseFast(root, (n) => { + if (t.isVariableDeclarator(n)) addPattern(n.id, into); + else if (t.isFunction(n)) { + if ((t.isFunctionDeclaration(n) || t.isFunctionExpression(n)) && n.id) into.add(n.id.name); + for (const param of n.params) addPattern(param, into); + } else if ((t.isClassDeclaration(n) || t.isClassExpression(n)) && n.id) into.add(n.id.name); + else if (t.isCatchClause(n) && n.param) addPattern(n.param, into); + }); +} + +function objectKey(property: t.ObjectProperty): string | null { + if (!property.computed && t.isIdentifier(property.key)) return property.key.name; + if (t.isStringLiteral(property.key)) return property.key.value; + return null; +} + +/** Binds the names of `pattern` to `value` (null: bound, value unknown). */ +function bindPattern( + pattern: t.Node, + value: RowValue | null, + rows: Map, +): void { + if (t.isIdentifier(pattern)) rows.set(pattern.name, value); + else if (t.isAssignmentPattern(pattern)) { + bindPattern(pattern.left, value !== null && value.kind !== 'undefined' ? value : null, rows); + } else if (t.isArrayPattern(pattern)) { + pattern.elements.forEach((element, index) => { + if (element === null) return; + const item = value?.kind === 'array' ? (value.items[index] ?? UNDEFINED) : null; + bindPattern(element, item, rows); + }); + } else if (t.isObjectPattern(pattern)) { + for (const property of pattern.properties) { + if (t.isRestElement(property)) { + bindPattern(property.argument, null, rows); + continue; + } + const key = objectKey(property); + const entry = + value?.kind === 'object' && key !== null ? entryValue(value.entries, key) : null; + bindPattern(property.value, entry, rows); + } + } else if (t.isRestElement(pattern)) bindPattern(pattern.argument, null, rows); +} + +export function buildScope(subject: CheckSubject): Scope { + const { record, instance } = subject; + const locals = new Set(); + const rows = new Map(); + declaredNames(record.fn.body, locals); + + // The test's own row: Vitest spreads an `each` row (instance.args holds the spread values) and + // passes a `for` row whole; the remaining parameters (a test context) are locals. + const params = record.fn.params; + if (record.each === null) for (const param of params) addPattern(param, locals); + else { + const useFor = shapeOf(record.call.callee)?.mods.includes('for') ?? false; + const args = instance?.args ?? null; + const bound = args !== null ? args.length : useFor ? 1 : params.length; + params.forEach((param, index) => { + if (index >= bound) addPattern(param, locals); + else bindPattern(param, args === null ? null : (args[index] ?? UNDEFINED), rows); + }); + } + + // The innermost `for…of` element; outer loops and `describe.each` rows are not tracked. + const dimensions = instance?.dimensions ?? []; + const loop = record.loop; + const boundLoop = loop !== null && t.isForOfStatement(loop); + const lastForOf = dimensions.map((dimension) => dimension.kind).lastIndexOf('for-of'); + if (boundLoop) { + const pattern = t.isVariableDeclaration(loop.left) ? loop.left.declarations[0]?.id : loop.left; + const dimension = lastForOf >= 0 ? dimensions[lastForOf] : undefined; + const value = dimension === undefined ? null : (dimension.args?.[0] ?? dimension.row); + if (pattern !== undefined) bindPattern(pattern, value, rows); + } + const unboundOuter = dimensions + .filter( + (dimension, index) => + dimension.kind !== 'each' && + dimension.kind !== 'for' && + !(boundLoop && index === lastForOf), + ) + .map((dimension) => dimension.kind); + + const unresolved = new Set(); + for (const name of rows.keys()) { + if (locals.has(name)) unresolved.add(`row binding ${name} is redeclared in the body`); + } + return { record, locals, rows, unboundOuter, localIds: new Map(), unresolved }; +} + +/** The row value an identifier or member read of a row binding evaluates to; null otherwise. */ +export function rowValueOf(node: t.Node, scope: Scope): RowValue | 'unknown' | null { + const inner = unwrap(node); + if (t.isIdentifier(inner)) { + if (scope.locals.has(inner.name) || !scope.rows.has(inner.name)) return null; + const value = scope.rows.get(inner.name) ?? null; + return value === null || value.kind === 'unresolved' ? 'unknown' : value; + } + if (t.isMemberExpression(inner) || t.isOptionalMemberExpression(inner)) { + const base = rowValueOf(inner.object, scope); + if (base === null || base === 'unknown') return base; + const key = memberKey(inner); + if (key === null) return 'unknown'; + if (base.kind === 'object') return entryValue(base.entries, key); + if (base.kind === 'array' && key === 'length') return numberValue(base.items.length); + if (base.kind === 'array' && /^\d+$/.test(key)) return base.items[Number(key)] ?? UNDEFINED; + if (base.kind === 'string' && key === 'length') return numberValue(base.value.length); + return 'unknown'; + } + return null; +} + +const numberValue = (n: number): RowValue => ({ kind: 'number', text: String(n) }); + +// ── Shapes ───────────────────────────────────────────────────────────────── + +function numberNode(text: string): t.Node { + const value = Number(text); + if (Number.isNaN(value)) return t.identifier('NaN'); + const magnitude = Math.abs(value); + const core = Number.isFinite(magnitude) ? t.numericLiteral(magnitude) : t.identifier('Infinity'); + return text.startsWith('-') ? t.unaryExpression('-', core) : core; +} + +/** A row value as code: exact, or with strings, numbers, bigints and regexps as their kind. */ +function rowNode(value: RowValue, exact: boolean): t.Node { + switch (value.kind) { + case 'string': + return exact ? t.stringLiteral(value.value) : rawIdentifier(''); + case 'number': + return exact ? numberNode(value.text) : rawIdentifier(''); + case 'bigint': + return exact ? t.bigIntLiteral(BigInt(value.text)) : rawIdentifier(''); + case 'regexp': + return exact ? t.regExpLiteral(value.pattern, value.flags) : rawIdentifier(''); + case 'boolean': + return t.booleanLiteral(value.value); + case 'null': + return t.nullLiteral(); + case 'undefined': + return t.identifier('undefined'); + case 'array': + return t.arrayExpression(value.items.map((item) => rowNode(item, exact) as t.Expression)); + case 'object': + return t.objectExpression( + value.entries.map((entry) => + t.objectProperty( + keyNode(entry.key) as t.Expression, + rowNode(entry.value, exact) as t.Expression, + ), + ), + ); + case 'unresolved': + return rawIdentifier(''); + } +} + +/** A literal node as code (exact, or as its kind); null for anything else. */ +function literalNode(node: t.Node, exact: boolean): t.Node | null { + if (t.isStringLiteral(node)) + return exact ? t.stringLiteral(node.value) : rawIdentifier(''); + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + const text = node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? ''; + return exact ? t.stringLiteral(text) : rawIdentifier(''); + } + if (t.isNumericLiteral(node)) + return exact ? t.numericLiteral(node.value) : rawIdentifier(''); + if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) { + return exact + ? t.unaryExpression('-', t.numericLiteral(node.argument.value)) + : rawIdentifier(''); + } + if (t.isBigIntLiteral(node)) + return exact ? t.bigIntLiteral(node.value) : rawIdentifier(''); + if (t.isRegExpLiteral(node)) { + return exact ? t.regExpLiteral(node.pattern, node.flags) : rawIdentifier(''); + } + if (t.isBooleanLiteral(node)) return t.booleanLiteral(node.value); + if (t.isNullLiteral(node)) return t.nullLiteral(); + return null; +} + +/** A literal, a literal container, or a row value: data rather than a queried value. */ +export function isLiteralish(node: t.Node, scope: Scope): boolean { + const inner = unwrap(node); + if (literalNode(inner, false) !== null || rowValueOf(inner, scope) !== null) return true; + if (t.isIdentifier(inner)) + return LITERAL_GLOBALS.has(inner.name) && !scope.locals.has(inner.name); + if (t.isArrayExpression(inner)) { + return inner.elements.every( + (e) => e !== null && !t.isSpreadElement(e) && isLiteralish(e, scope), + ); + } + if (t.isObjectExpression(inner)) { + return inner.properties.every( + (p) => t.isObjectProperty(p) && !p.computed && isLiteralish(p.value, scope), + ); + } + return false; +} + +/** + * Whether a member call's receiver is a queried value: a local, a file-level declaration, a named + * import or any computed value. A global (`Math`) or a module namespace / default import is a + * function library, not a value the literal selects from. + */ +export function receiverQueried(callee: t.Node, scope: Scope): boolean { + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; + if (isLiteralish(callee.object, scope)) return false; + let root = unwrap(callee.object); + while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) + root = unwrap(root.object); + if (!t.isIdentifier(root)) return true; + if (scope.locals.has(root.name)) return true; + const decl = resolveName(root.name, scope.record.chain); + if (decl === undefined) return false; + return decl.kind === 'node' || !/#(\*|default)$/.test(decl.key); +} + +function identifierNode(node: t.Identifier, scope: Scope): t.Node { + const { name } = node; + if (scope.locals.has(name)) { + let id = scope.localIds.get(name); + if (id === undefined) { + id = ``; + scope.localIds.set(name, id); + } + return rawIdentifier(id); + } + if ( + scope.unboundOuter.length > 0 && + !GLOBALS.has(name) && + resolveName(name, scope.record.chain) === undefined + ) { + scope.unresolved.add( + `${name}: may be bound by an enclosing ${scope.unboundOuter.join(', ')} row`, + ); + } + return rawIdentifier(name); +} + +/** + * The abstracted copy of `node`. `inSubject` enables selectors; `exact` keeps the literals of this + * node (a selector argument or a literal container inside one) as written. + */ +export function abstractNode( + node: t.Node, + scope: Scope, + inSubject: boolean, + exact: boolean, +): t.Node { + if (node.type.startsWith('TS') && !TS_EXPRESSIONS.has(node.type)) return node; + const literal = literalNode(node, exact); + if (literal !== null) return literal; + const row = rowValueOf(node, scope); + if (row !== null) { + if (exact && (row === 'unknown' || hasUnresolved(row))) { + scope.unresolved.add(`selector ${gen(node)}: instance value unknown`); + } + return row === 'unknown' ? rawIdentifier('') : rowNode(row, exact); + } + const recurse = (child: t.Node, childExact = false): t.Node => + abstractNode(child, scope, inSubject, childExact); + if (t.isIdentifier(node)) return identifierNode(node, scope); + if (t.isCallExpression(node) || t.isOptionalCallExpression(node) || t.isNewExpression(node)) { + const queried = + inSubject && + (node.arguments.some((arg) => !isLiteralish(arg, scope)) || + receiverQueried(node.callee, scope)); + return withChildren(node, { + callee: recurse(node.callee), + arguments: node.arguments.map((arg) => recurse(arg, queried)), + }); + } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + return withChildren(node, { + object: recurse(node.object), + property: node.computed ? recurse(node.property, inSubject) : node.property, + }); + } + if (t.isArrayExpression(node)) { + return withChildren(node, { + elements: node.elements.map((element) => (element === null ? null : recurse(element, exact))), + }); + } + if (t.isObjectExpression(node)) { + return withChildren(node, { properties: node.properties.map((p) => recurse(p, exact)) }); + } + if (t.isObjectProperty(node)) { + const key = node.computed + ? recurse(node.key) + : t.isIdentifier(node.key) + ? keyNode(node.key.name) + : t.isStringLiteral(node.key) || t.isNumericLiteral(node.key) + ? keyNode(String(node.key.value)) + : node.key; + return withChildren(node, { key, value: recurse(node.value, exact), shorthand: false }); + } + if (TS_EXPRESSIONS.has(node.type)) { + return withChildren(node, { + expression: recurse((node as t.TSAsExpression).expression, exact), + }); + } + if (t.isTemplateLiteral(node)) { + return withChildren(node, { + quasis: exact + ? node.quasis + : node.quasis.map((quasi) => t.templateElement({ raw: '', cooked: '' }, quasi.tail)), + expressions: node.expressions.map((expression) => recurse(expression)), + }); + } + if (t.isMetaProperty(node)) return node; + const keepKey = (node as { computed?: unknown }).computed === false; + const children: Record = {}; + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + const value = (node as unknown as Record)[key]; + if ((keepKey && key === 'key') || key === 'label') continue; + children[key] = Array.isArray(value) + ? value.map((item) => (isNode(item) ? recurse(item) : item)) + : isNode(value) + ? recurse(value) + : value; + } + return withChildren(node, children); +} + +// ── Assertions ───────────────────────────────────────────────────────────── + +interface Assertion { + /** `expect`, `expect.soft`, `expect.assertions`, `expect.hasAssertions`. */ + form: string; + /** `expect(...).not.toBe` style name for reports. */ + display: string; + subject: t.Node | null; + message: t.Node | null; + modifiers: string[]; + matcher: string; + args: t.Node[]; +} +interface Rejected { + form: string; + reason: string; + matcher: string; +} + +/** Splits an `expect` call into its parts, or names why the form is unsupported. */ +function parseAssertion(call: t.CallExpression): Assertion | Rejected { + const names: string[] = []; + let n: t.Node = call.callee; + while (t.isMemberExpression(n) && !n.computed && t.isIdentifier(n.property)) { + names.unshift(n.property.name); + n = n.object; + } + const matcher = names.at(-1) ?? ''; + if (t.isIdentifier(n, { name: 'expect' })) { + if (names.length === 0) return { form: 'expect(...)', reason: 'no matcher', matcher }; + const form = `expect.${names.join('.')}`; + if (names.length === 1 && EXPECT_STATICS.has(names[0])) { + return { + form, + display: form, + subject: null, + message: null, + modifiers: [], + matcher: '', + args: call.arguments, + }; + } + return { form, reason: 'not a supported expect form', matcher }; + } + const chain = names.map((name) => `.${name}`).join(''); + if (!t.isCallExpression(n)) { + return { form: `expect(...)${chain}`, reason: 'computed or indirect matcher', matcher }; + } + const head = dotted(n.callee); + if (head !== 'expect' && head !== 'expect.soft') { + return head !== null && head.startsWith('expect.') + ? { form: `${head}(...)`, reason: 'not a supported expect form', matcher } + : { form: `expect(...)${chain}`, reason: 'chained form not supported', matcher }; + } + const display = `${head}(...)${chain}`; + const args = n.arguments; + if (args.length < 1 || args.length > 2 || args.some((arg) => t.isSpreadElement(arg))) { + return { form: `${head}(...)`, reason: `${args.length} expect arguments`, matcher }; + } + if (names.length === 0) return { form: `${head}(...)`, reason: 'no matcher', matcher }; + const modifiers = names.slice(0, -1); + const distinct = new Set(modifiers).size === modifiers.length; + if (!distinct || modifiers.some((modifier) => !MODIFIERS.has(modifier))) { + return { form: display, reason: 'chained form not supported', matcher }; + } + if (!SUPPORTED_MATCHERS.has(matcher)) { + const reason = + matcher === 'toSatisfy' ? 'arbitrary predicate' : 'custom or unsupported matcher'; + return { form: display, reason, matcher }; + } + return { + form: head, + display, + subject: args[0], + message: args[1] ?? null, + modifiers, + matcher, + args: call.arguments, + }; +} + +/** The assertion form a call starts, `expect` for an expect chain, else null. */ +function assertionRoot( + call: t.CallExpression, + chain: Frame[], + locals: ReadonlySet, +): string | null { + const root = rootName(call.callee); + if (root === null) return null; + if (root === 'expect') return 'expect'; + if (root === 'assert') return dotted(call.callee) ?? 'assert'; + if (TYPE_ASSERTIONS.has(root)) return root; + const bare = t.isIdentifier(call.callee); + const free = !locals.has(root) && resolveName(root, chain) === undefined; + return bare && free && NODE_ASSERT.has(root) ? root : null; +} + +// ── One run over an instance ─────────────────────────────────────────────── + +interface Run { + scope: Scope; + fullName: string | null; + options: ChecksOptions; + checks: { start: number; check: Check }[]; + unsupported: { start: number; form: UnsupportedForm }[]; + /** Per snapshot name (the hint, `''` without one): snapshot assertions so far. */ + counters: Map; + /** False once a snapshot assertion ran an unknown number of times. */ + countersKnown: boolean; +} + +function reject(run: Run, node: t.Node, form: string, reason: string): void { + run.unsupported.push({ start: node.start ?? 0, form: { line: lineOf(node), form, reason } }); +} + +/** A string literal, a plain template or a string row value; undefined when absent, else null. */ +function staticText(node: t.Node | undefined, scope: Scope): string | null | undefined { + if (node === undefined) return undefined; + const inner = unwrap(node); + if (t.isStringLiteral(inner)) return inner.value; + if (t.isTemplateLiteral(inner) && inner.expressions.length === 0) { + return inner.quasis[0]?.value.cooked ?? inner.quasis[0]?.value.raw ?? null; + } + const row = rowValueOf(inner, scope); + return row !== null && row !== 'unknown' && row.kind === 'string' ? row.value : null; +} + +/** Counts one snapshot assertion under `hint`; the count is its `.snap` key suffix. */ +function bump(run: Run, hint: string | undefined): number { + const name = hint ?? ''; + const count = (run.counters.get(name) ?? 0) + 1; + run.counters.set(name, count); + return count; +} + +type SnapshotResult = { hash: string; args: string[] } | { reason: string }; + +function snapshotOf(assertion: Assertion, run: Run): SnapshotResult { + const { scope, options } = run; + const args = assertion.args; + const shapeArg = (node: t.Node): string => gen(abstractNode(node, scope, false, false)); + const isProps = (node: t.Node | undefined): boolean => + node !== undefined && t.isObjectExpression(unwrap(node)); + + const inline = (props: t.Node | undefined, snapshot?: t.Node, hint?: t.Node): SnapshotResult => { + const hintText = staticText(hint, scope); + if (hintText === null) { + run.countersKnown = false; + return { reason: 'snapshot hint not static' }; + } + bump(run, hintText); + const text = staticText(snapshot, scope); + if (text === undefined) return { reason: 'inline snapshot not written' }; + if (text === null) return { reason: 'inline snapshot not static' }; + return { + hash: sha1(stripIndentation(text)), + args: [...(props === undefined ? [] : [shapeArg(props)]), ''], + }; + }; + const snapFile = (props: t.Node | undefined, hint?: t.Node): SnapshotResult => { + const hintText = staticText(hint, scope); + if (hintText === null) { + run.countersKnown = false; + return { reason: 'snapshot hint not static' }; + } + const count = bump(run, hintText); + if (!run.countersKnown) return { reason: 'snapshot key unknown' }; + if (run.fullName === null) return { reason: 'test name unknown' }; + if (options.snapshots === undefined) return { reason: 'snapshot file not read' }; + const name = hintText === undefined ? run.fullName : `${run.fullName} > ${hintText}`; + const entry = options.snapshots.entry(scope.record.file, `${name} ${count}`); + if (entry === null) return { reason: '.snap entry missing' }; + return { hash: sha1(entry), args: props === undefined ? [] : [shapeArg(props)] }; + }; + const fileSnapshot = (target?: t.Node, hint?: t.Node): SnapshotResult => { + const hintText = staticText(hint, scope); + if (hintText === null) { + run.countersKnown = false; + return { reason: 'snapshot hint not static' }; + } + bump(run, hintText); + const file = staticText(target, scope); + if (typeof file !== 'string') return { reason: 'file snapshot path not static' }; + if (options.snapshots === undefined) return { reason: 'snapshot file not read' }; + const content = options.snapshots.file(scope.record.file, file); + if (content === null) return { reason: 'file snapshot unreadable' }; + return { hash: sha1(content), args: [''] }; + }; + + // Only an object literal first argument is property matchers; anything else is the snapshot + // text or the hint, and a non-static one is unsupported rather than guessed. + switch (assertion.matcher) { + case 'toMatchInlineSnapshot': + return isProps(args[0]) + ? inline(args[0], args[1], args[2]) + : inline(undefined, args[0], args[1]); + case 'toThrowErrorMatchingInlineSnapshot': + return inline(undefined, args[0], args[1]); + case 'toMatchSnapshot': + return isProps(args[0]) ? snapFile(args[0], args[1]) : snapFile(undefined, args[0]); + case 'toThrowErrorMatchingSnapshot': + return snapFile(undefined, args[0]); + default: + return fileSnapshot(args[0], args[1]); + } +} + +function recordAssertion(call: t.CallExpression, where: string | null, run: Run): void { + const parsed = parseAssertion(call); + if (SNAPSHOT_MATCHERS.has(parsed.matcher) && ('reason' in parsed || where !== null)) { + run.countersKnown = false; + } + if ('reason' in parsed) return reject(run, call, parsed.form, parsed.reason); + if (where !== null) return reject(run, call, parsed.display, `inside ${where}`); + const parts = [parsed.subject, ...parsed.args].filter((part): part is t.Node => part !== null); + for (const part of parts) { + t.traverseFast(part, (n) => { + if (t.isCallExpression(n) && t.isIdentifier(n.callee, { name: 'expect' })) { + reject(run, n, 'expect(...)', 'nested inside another assertion'); + } + }); + } + const { scope } = run; + const subject = + parsed.subject === null ? null : gen(abstractNode(parsed.subject, scope, true, false)); + const head = + subject === null + ? parsed.form + : `${parsed.form}(${subject}${parsed.message === null ? '' : ','})`; + let args: string[]; + let snapshot: string | null = null; + if (SNAPSHOT_MATCHERS.has(parsed.matcher)) { + const result = snapshotOf(parsed, run); + if ('reason' in result) return reject(run, call, parsed.display, result.reason); + snapshot = result.hash; + args = result.args; + } else { + // `expect.assertions(n)` keeps its count; a matcher's arguments are expected values. + const exact = parsed.subject === null; + args = parsed.args.map((arg) => gen(abstractNode(arg, scope, false, exact))); + } + const chain = [...parsed.modifiers, parsed.matcher] + .filter((name) => name !== '') + .map((name) => `.${name}`) + .join(''); + const shape = `${head}${chain}(${args.join(',')})${snapshot === null ? '' : `#${snapshot}`}`; + run.checks.push({ + start: call.start ?? 0, + check: { + line: lineOf(call), + form: parsed.form, + modifiers: parsed.modifiers, + matcher: parsed.matcher, + shape, + snapshot, + }, + }); +} + +/** Walks the body; `where` names the first enclosing construct that makes a check conditional. */ +function visit(node: t.Node | null | undefined, where: string | null, run: Run): void { + if (node === null || node === undefined || typeof node.type !== 'string') return; + const go = (child: t.Node | null | undefined, inside: string | null = where): void => + visit(child, inside, run); + if (t.isCallExpression(node)) { + const root = assertionRoot(node, run.scope.record.chain, run.scope.locals); + if (root === 'expect') return recordAssertion(node, where, run); + if (root !== null) return reject(run, node, root, 'not an expect assertion'); + } + if ( + t.isMemberExpression(node) && + !node.computed && + t.isIdentifier(node.property, { name: 'should' }) + ) { + return reject(run, node, `${dotted(node) ?? '(...)'}`, 'not an expect assertion'); + } + if (t.isIfStatement(node)) { + go(node.test); + go(node.consequent, where ?? 'a conditional (if)'); + go(node.alternate, where ?? 'a conditional (if)'); + return; + } + if (t.isConditionalExpression(node)) { + go(node.test); + go(node.consequent, where ?? 'a conditional (?:)'); + go(node.alternate, where ?? 'a conditional (?:)'); + return; + } + if (t.isLogicalExpression(node)) { + go(node.left); + go(node.right, where ?? `a conditional (${node.operator})`); + return; + } + if (t.isTryStatement(node)) { + go(node.block); + go(node.handler, where ?? 'a catch clause'); + go(node.finalizer); + return; + } + const inside = t.isLoop(node) + ? (where ?? `a loop (${node.type})`) + : t.isSwitchStatement(node) + ? (where ?? 'a conditional (switch)') + : t.isFunction(node) + ? (where ?? 'a nested function') + : where; + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + const value = (node as unknown as Record)[key]; + const children = Array.isArray(value) ? value : [value]; + for (const child of children) if (isNode(child)) go(child, inside); + } +} + +// ── Helpers the body calls ───────────────────────────────────────────────── + +interface HelperRef { + name: string; + /** The member read off a namespace import (`h.verify` → `verify`), else null. */ + member: string | null; + label: string; +} + +/** Names a call may run: its callee root (with the first member) and identifier arguments. */ +function helperRefs( + call: t.CallExpression | t.NewExpression | t.OptionalCallExpression, +): HelperRef[] { + const refs: HelperRef[] = []; + const callee = unwrap(call.callee); + if (t.isIdentifier(callee)) refs.push({ name: callee.name, member: null, label: callee.name }); + else if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + let object: t.Node = callee; + let member: string | null = null; + while (t.isMemberExpression(object) || t.isOptionalMemberExpression(object)) { + member = !object.computed && t.isIdentifier(object.property) ? object.property.name : null; + object = unwrap(object.object); + } + if (t.isIdentifier(object)) { + const label = member === null ? object.name : `${object.name}.${member}`; + refs.push({ name: object.name, member, label }); + } + } + for (const arg of call.arguments) { + if (t.isIdentifier(arg)) refs.push({ name: arg.name, member: null, label: arg.name }); + } + return refs; +} + +type HelperFinding = { form: string } | { notInspected: string } | null; +type ModuleProgram = { file: string; frame: Frame } | null; + +const moduleCache = new WeakMap>(); + +/** The top-level frame of a helper module, parsed by Layer 0; null when it cannot be read. */ +function loadModule(moduleKey: string, reader: ModuleReader | undefined): ModuleProgram { + if (reader === undefined) return null; + let cache = moduleCache.get(reader); + if (cache === undefined) { + cache = new Map(); + moduleCache.set(reader, cache); + } + const cached = cache.get(moduleKey); + if (cached !== undefined) return cached; + const source = reader.read(moduleKey); + const programs = source === null ? null : extract([source], { isTest: () => false }).programs; + const program = programs === null ? null : ([...programs.values()][0] ?? null); + cache.set(moduleKey, program); + return program; +} + +/** The first assertion form reachable from a declaration, through helpers up to the depth limit. */ +function declFinding( + decl: Decl, + member: string | null, + chain: Frame[], + run: Run, + depth: number, + seen: Set, +): HelperFinding { + if (decl.kind === 'node') { + if (seen.has(decl.node)) return null; + seen.add(decl.node); + return nodeFinding(decl.node, chain, run, depth, seen); + } + const match = /^import:(.+)#([^#]+)$/.exec(decl.key); + if (match === null || !isTestSupportFile(`${match[1]}.ts`)) return null; + const [, moduleKey, imported] = match; + const name = imported === '*' ? member : imported; + const visitKey = `${moduleKey}#${name ?? '*'}`; + if (seen.has(visitKey)) return null; + seen.add(visitKey); + const program = name === null ? null : loadModule(moduleKey, run.options.modules); + const target = name === null ? undefined : program?.frame.decls.get(name); + if (program === null || target === undefined) return { notInspected: moduleKey }; + return declFinding(target, null, [program.frame], run, depth + 1, seen); +} + +function nodeFinding( + node: t.Node, + chain: Frame[], + run: Run, + depth: number, + seen: Set, +): HelperFinding { + const found: { form: string | null } = { form: null }; + const calls: (t.CallExpression | t.NewExpression)[] = []; + t.traverseFast(node, (n) => { + if (found.form !== null) return; + if (t.isCallExpression(n)) found.form = assertionRoot(n, chain, new Set()); + if (t.isCallExpression(n) || t.isNewExpression(n)) calls.push(n); + }); + if (found.form !== null) return { form: found.form }; + if (depth >= HELPER_DEPTH) return null; + for (const call of calls) { + for (const ref of helperRefs(call)) { + const decl = resolveName(ref.name, chain); + const finding = + decl === undefined ? null : declFinding(decl, ref.member, chain, run, depth + 1, seen); + if (finding !== null) return finding; + } + } + return null; +} + +/** Marks each helper the body runs that asserts (or cannot be read) once, at its first call. */ +function inspectHelpers(run: Run): void { + const { scope } = run; + const reported = new Set(); + t.traverseFast(scope.record.fn.body, (node) => { + if ( + !t.isCallExpression(node) && + !t.isNewExpression(node) && + !t.isOptionalCallExpression(node) + ) { + return; + } + for (const ref of helperRefs(node)) { + if (reported.has(ref.label) || scope.locals.has(ref.name) || scope.rows.has(ref.name)) + continue; + const decl = resolveName(ref.name, scope.record.chain); + if (decl === undefined) continue; + const finding = declFinding(decl, ref.member, scope.record.chain, run, 0, new Set()); + if (finding === null) continue; + reported.add(ref.label); + const reason = + 'form' in finding + ? `assertion in helper ${ref.label} (${finding.form})` + : `helper ${ref.label} (${finding.notInspected}) not inspected`; + reject(run, node, ref.label, reason); + } + }); +} + +// ── Entry points ─────────────────────────────────────────────────────────── + +/** The checks of one test instance. */ +export function computeChecks(subject: CheckSubject, options: ChecksOptions = {}): InstanceChecks { + const run: Run = { + scope: buildScope(subject), + fullName: subject.fullName, + options, + checks: [], + unsupported: [], + counters: new Map(), + countersKnown: true, + }; + visit(subject.record.fn.body, null, run); + inspectHelpers(run); + const byStart = (items: T[]): T[] => + [...items].sort((a, b) => a.start - b.start); + const checks = byStart(run.checks); + const unsupported = byStart(run.unsupported); + const entries = byStart([ + ...checks.map(({ start, check }) => ({ start, text: check.shape })), + ...unsupported.map(({ start, form }) => ({ start, text: `!${form.form}: ${form.reason}` })), + ]); + return { + checks: checks.map(({ check }) => check), + unsupported: unsupported.map(({ form }) => form), + unresolved: [...run.scope.unresolved].sort(), + fingerprint: sha1(entries.map((entry) => entry.text).join('\n')), + }; +} + +/** The check subject of an inventory instance; null without a call site. */ +export function subjectOf(instance: InventoryInstance): CheckSubject | null { + if (instance.site === null) return null; + return { record: instance.site.record, instance: instance.instance, fullName: instance.fullName }; +} + +/** + * The checks-and-context key (KTD5): instances sharing it are Layer 1 candidates. null without a + * call site, or when the body has no assertion at all (nothing to compare). + */ +export function checksContextKey( + input: InventoryInstance | CheckSubject, + options: ChecksOptions = {}, +): string | null { + const subject = 'record' in input ? input : subjectOf(input); + if (subject === null) return null; + const checks = computeChecks(subject, options); + if (checks.checks.length === 0 && checks.unsupported.length === 0) return null; + return sha1(`${checks.fingerprint}\0${contextOf(subject.record).hash}`); +} + +// ── File system readers ──────────────────────────────────────────────────── + +/** `root` joined with a repo path, or null when the path escapes the root. */ +function insideRoot(root: string, repoPath: string): string | null { + try { + return path.join(root, normalizeRelativePath(repoPath)); + } catch { + return null; + } +} + +function readText(file: string | null): string | null { + if (file === null) return null; + try { + return readFileSync(file, 'utf8'); + } catch { + return null; + } +} + +/** `exports[\`key\`] = \`value\`` entries of a `.snap` file (parsed, never evaluated). */ +function parseSnapFile(text: string): Map | null { + let program: t.Program; + try { + program = parse(text, { sourceType: 'script' }).program; + } catch { + return null; + } + const entries = new Map(); + for (const statement of program.body) { + if (!t.isExpressionStatement(statement)) continue; + const assignment = statement.expression; + if (!t.isAssignmentExpression(assignment) || !t.isMemberExpression(assignment.left)) continue; + const { object, property } = assignment.left; + if (!t.isIdentifier(object, { name: 'exports' }) || !assignment.left.computed) continue; + const key = t.isTemplateLiteral(property) + ? property.quasis[0]?.value.cooked + : t.isStringLiteral(property) + ? property.value + : undefined; + const value = assignment.right; + if (key === undefined || key === null) continue; + if (!t.isTemplateLiteral(value) || value.expressions.length > 0) continue; + entries.set(key, value.quasis[0]?.value.raw ?? ''); + } + return entries; +} + +/** Reads `.snap` entries and file snapshots under `repoRoot`; paths that escape it read null. */ +export function fsSnapshotReader(repoRoot: string): SnapshotReader { + const snapFiles = new Map | null>(); + return { + entry(testFile, key) { + const snapPath = path.posix.join( + path.posix.dirname(testFile), + '__snapshots__', + `${path.posix.basename(testFile)}.snap`, + ); + let entries = snapFiles.get(snapPath); + if (entries === undefined) { + const text = readText(insideRoot(repoRoot, snapPath)); + entries = text === null ? null : parseSnapFile(text); + snapFiles.set(snapPath, entries); + } + return entries?.get(key) ?? null; + }, + file(testFile, snapshotPath) { + if (path.posix.isAbsolute(snapshotPath)) return null; + const target = path.posix.join(path.posix.dirname(testFile), snapshotPath); + return readText(insideRoot(repoRoot, target)); + }, + }; +} + +/** Extensions tried for a module key, in TypeScript's resolution order. */ +const MODULE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.mjs', + '.cjs', + '/index.ts', + '/index.js', +]; + +/** Reads helper modules under `repoRoot` by module key. */ +export function fsModuleReader(repoRoot: string): ModuleReader { + return { + read(moduleKey) { + for (const extension of MODULE_EXTENSIONS) { + const repoPath = `${moduleKey}${extension}`; + const source = readText(insideRoot(repoRoot, repoPath)); + if (source !== null) return { path: repoPath, source }; + } + return null; + }, + }; +} diff --git a/gitnexus/tools/scenario-map/facet/context.ts b/gitnexus/tools/scenario-map/facet/context.ts new file mode 100644 index 000000000..6b8d2edf3 --- /dev/null +++ b/gitnexus/tools/scenario-map/facet/context.ts @@ -0,0 +1,83 @@ +/** + * Context of a test for the scenario map (Layer 2, U5, R5, KTD6, design §2 "Context"). + * + * Design §2: the context is the enclosing hooks, `vi.mock`/`vi.spyOn`/`vi.stubEnv`, env writes + * and wrapper-call arguments, reusing Layer 0's context closure, and two tests must have exactly + * equal contexts for any repeat or row verdict. Layer 0 (tools/test-dupes, U2) already hashes that + * closure into `CaseRecord.contextHash`: enclosing hooks and describe-level statements (module + * mocks, spies and env writes there included), wrapper-call arguments, loop heads, if-tests and + * every declaration the case reaches. This module reuses that hash as is and adds only the two + * parts Layer 0 keeps out of it, because Layer 0 folds them into its identity key instead: + * - setup inside the test body: the body's top-level statements matching Layer 0's own + * `ENV_OR_MOCK` pattern (`process.env`, `vi.mock`/`doMock`/`spyOn`/`stubGlobal`/`stubEnv`), + * exact code; + * - the test call's own options: its modifiers other than `each`/`for` (`fails`, `concurrent`, + * `skipIf(...)` …, sorted), their arguments other than the `each` table, and call arguments + * other than the title and the callback (an options object or a timeout). + * A `vi.mock` nested in a body or callback needs nothing extra: Vitest 5 throws on hoisted mock + * calls outside the module top level, so every running file's `vi.mock` sits in the top frame + * Layer 0 already hashes. + * + * Every instance of one call site shares its context. Row values (`it.each`/`test.for` rows, + * `for…of` elements) are inputs, recorded by the input facet (U6), not context. + * + * Library only; checks.ts joins the context hash with the checks fingerprint for Layer 1. + */ +import { generate } from '@babel/generator'; +import * as t from '@babel/types'; +import { ENV_OR_MOCK, shapeOf, type CaseRecord } from '../../test-dupes/test-dupes.js'; +import { sha1 } from '../util.js'; + +/** Modifiers that repeat a test call; their rows are instance inputs, not context. */ +const ROW_MODIFIERS = new Set(['each', 'for']); + +export interface TestContext { + /** Layer 0's `CaseRecord.contextHash` (U2), reused unchanged. */ + layer0: string; + /** Top-level body statements that mock, spy or write the environment, exact code. */ + bodySetup: string[]; + /** The call's own modifiers (sorted), then modifier and call arguments, exact code. */ + callOptions: string[]; + /** sha1 over the three parts; two tests share a context exactly when their hashes are equal. */ + hash: string; +} + +const codeOf = (node: t.Node): string => generate(node, { comments: false, compact: true }).code; + +function bodySetupOf(record: CaseRecord): string[] { + const body = record.fn.body; + const statements: t.Node[] = t.isBlockStatement(body) ? body.body : [body]; + return statements.map(codeOf).filter((code) => ENV_OR_MOCK.test(code)); +} + +function callOptionsOf(record: CaseRecord): string[] { + const shape = shapeOf(record.call.callee); + const modifiers = (shape?.mods ?? []).filter((mod) => !ROW_MODIFIERS.has(mod)).sort(); + const modifierArgs = (shape?.modArgs ?? []).filter((arg) => arg !== record.each); + const callArgs = record.call.arguments.slice(1).filter((arg) => arg !== record.fn); + return [ + ...modifiers.map((mod) => `.${mod}`), + ...modifierArgs.map((arg) => `mod:${codeOf(arg)}`), + ...callArgs.map((arg) => `arg:${codeOf(arg)}`), + ]; +} + +const memo = new WeakMap(); + +/** The context of a test call; every instance of the call shares it. */ +export function contextOf(record: CaseRecord): TestContext { + const cached = memo.get(record); + if (cached !== undefined) return cached; + const bodySetup = bodySetupOf(record); + const callOptions = callOptionsOf(record); + const hash = sha1( + [ + `layer0:${record.contextHash}`, + ...bodySetup.map((code) => `body:${code}`), + ...callOptions.map((option) => `call:${option}`), + ].join('\0'), + ); + const context: TestContext = { layer0: record.contextHash, bodySetup, callOptions, hash }; + memo.set(record, context); + return context; +} diff --git a/gitnexus/tools/scenario-map/facet/input-facet.ts b/gitnexus/tools/scenario-map/facet/input-facet.ts new file mode 100644 index 000000000..2e01700a3 --- /dev/null +++ b/gitnexus/tools/scenario-map/facet/input-facet.ts @@ -0,0 +1,1218 @@ +/** + * Input facet of a test instance for the scenario map (Layer 2, U6, R5, R9, KTD6, design §2 + * "Input facet", §5.3 rules 1–8). + * + * The facet is a signature of what one runtime instance feeds the code. Its slots are the test + * body's string, template, numeric and bigint literals, and the values its row binds (an + * `it.each` / `test.for` row, the innermost `for…of` element, through checks.ts `rowValueOf`) + * where the body reads them, in source order. Each slot becomes one token: + * - rule 1 / rule 2: a string that looks like code in the language the test names is replaced + * by its anonymised tree shape (source-shape.ts), COBOL by its reserved words; + * - rule 4: a literal the checks keep exact as a selector of an `expect(...)` subject (checks.ts + * `abstractNode` decides) stays exact, `SEL:` (`SEL#`); + * - rule 3 / D2: numbers `0`, `1`, `-1` and negatives stay exact (`#=`), as does a number + * within ±1 of a numeric constant of the test's executed production files or of its static + * import closure (KTD6, tables.ts); strings and other numbers map to their best-fit production + * table (`T:`: executed files, then two import hops, then property columns, never pooled), + * a synthetic file path to its extension's table, anything else to its boundary-aware shape + * (`''`, `'0'`, `'1'`, `'-1'` keep their value); + * - rule 7: a describe whose `beforeAll` / `beforeEach` names a fixture directory resolves the + * test's literals (and the words inside them) against that directory's definitions: the + * definition's shape, its referenced types with their bases and field types (the declaration + * kind for presence-only assertions); resolved literals leave the data part; + * - rule 8: a literal naming one fixture file contributes that file's shape. + * Rule 5 adds the key set of every object literal (and object row value); rule 6 adds the called + * function names (assertions left out) and the slot shapes in source order. + * + * Every slot not kept exact is a discarded value, recorded with its position (slot index), its + * value and its source location for the value-flow check (U10, R9). `unresolved` lists why the + * facet may not stand for the instance (a row value that is not static, an unevaluated captured + * loop binding, a free name under an untracked outer row, unknown provenance or import closure, + * a fixture file that cannot be shaped); it blocks strong evidence. + * + * Residual-difference guard (R5, KTD6, D1): {@link residualDifference} diffs the anonymised body + * skeletons of two facet-equal instances (slots and row reads as placeholders, body locals + * renamed). Any other differing node (an enum member, a regex, a boolean or `null`, an operator) + * makes the pair facet-unequal for the verdict engine; {@link applyResidualGuard} also marks both + * instances unresolved. + * + * Library only; the scenario-map CLI (U14) builds one context per run + * ({@link createRunFacetContext}, or {@link createFacetContext} with in-memory sources) and calls + * {@link computeFacet} per inventory instance ({@link facetSubjectOf}). + */ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { generate } from '@babel/generator'; +import { parse } from '@babel/parser'; +import * as t from '@babel/types'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { classify, type FileVerdict } from '../../mutation/classify/classify.js'; +import { resolveName, type CaseRecord } from '../../test-dupes/test-dupes.js'; +import { + normalizeRelativePath, + productionFilePredicate, + productionRoots, + type InventoryInstance, + type RowValue, + type StaticInstance, +} from '../types.js'; +import { sha1, sortedUnique } from '../util.js'; +import { abstractNode, buildScope, rowValueOf, SUPPORTED_MATCHERS, type Scope } from './checks.js'; +import { + cobolWords, + FixtureIndex, + languageOf, + looksLikeSource, + PATHLIKE, + shapeText, + SourceShaper, + type SourceReader, +} from './source-shape.js'; +import { nearConstant, numberKey, stringKey, TableIndex, type LiteralKey } from './tables.js'; + +/** Default fixture root (rules 7 and 8). */ +export const FIXTURE_ROOT = 'gitnexus/test/fixtures'; +/** Language providers whose string tables name GitNexus builtins (rule 1). */ +export const BUILTINS_DIR = 'gitnexus/src/core/ingestion/languages'; +/** Import hops of the second table tier (design §5.3 rule 3). */ +const TABLE_HOPS = 2; + +/** Body text that marks presence-only assertions (rule 7, a GitNexus-specific heuristic). */ +const PRESENCE_QUERY = /getNodesByLabel/; +const RELATION_QUERY = /getRelationships|CALLS|ACCESSES|EXTENDS|IMPLEMENTS|HAS_METHOD|IMPORTS/; +/** Called names that are assertion plumbing, not builders (rule 6). */ +const ASSERTION_NAMES: ReadonlySet = new Set([ + 'expect', + 'it', + 'test', + 'describe', + 'suite', + 'not', + 'resolves', + 'rejects', + 'soft', + 'assertions', + 'hasAssertions', + ...SUPPORTED_MATCHERS, +]); +const TS_EXPRESSIONS: ReadonlySet = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'TSInstantiationExpression', +]); +/** Child keys holding types only; they never run, so the skeleton leaves them out. */ +const TYPE_KEYS: ReadonlySet = new Set([ + 'typeAnnotation', + 'returnType', + 'typeParameters', + 'typeArguments', + 'superTypeParameters', + 'implements', +]); +/** Node fields the skeleton never compares: positions, comments, formatting. */ +const SKIPPED_FIELDS: ReadonlySet = new Set([ + 'type', + 'start', + 'end', + 'loc', + 'range', + 'extra', + 'leadingComments', + 'trailingComments', + 'innerComments', + 'comments', + 'errors', + 'tokens', + 'shorthand', +]); + +// ── Public shapes ────────────────────────────────────────────────────────── + +/** Repo-path file access of one run. */ +export type FacetSources = SourceReader; + +export interface FacetContextOptions { + sources: FacetSources; + /** Extra path-map roots of the run mode (types.ts `extraRootsOf`). */ + extraRoots?: readonly string[]; + /** Production files of the run (repo paths): the property-column universe. */ + productionFiles: readonly string[]; + /** + * Static import closure per test file (package path → repo paths, test file included): + * classify's `FileVerdict.closure`. + */ + closures: ReadonlyMap; + /** Files whose string tables are GitNexus builtin names; default every file of {@link BUILTINS_DIR}. */ + builtinFiles?: readonly string[]; + /** Repo path of the fixture tree; default {@link FIXTURE_ROOT}. */ + fixtureRoot?: string; +} + +/** One test instance. */ +export interface FacetSubject { + /** Phase 0 test id. */ + id: string; + /** Package path of the test file. */ + file: string; + record: CaseRecord; + /** The joined static instance; null when not known. */ + instance: StaticInstance | null; + /** Files the test executed (Layer 1 provenance, repo paths); null when not known. */ + executedFiles: readonly string[] | null; +} + +export interface SourceLocation { + /** Repo path of the test file. */ + file: string; + /** 1-based line. */ + line: number; + /** 0-based column (Babel). */ + column: number; +} + +/** A concrete value the facet dropped (design §2, §5.3a). */ +export interface DiscardedValue { + /** Slot index in source order; equal facets have the same slots. */ + position: number; + kind: 'string' | 'number' | 'bigint'; + /** The string itself, or the number's text. */ + value: string; + /** Where the body writes the literal, or reads the row value. */ + location: SourceLocation; + origin: 'literal' | 'row'; +} + +export interface FacetParts { + /** The language of the inline source, or `-` when no source or fixture part exists. */ + language: string; + /** Rule 1 / rule 2: source profiles, sorted, distinct. */ + source: string[]; + /** Rules 7 / 8: fixture elements, sorted, distinct. */ + fixtures: string[]; + /** Rules 3 / 4 / 5: data tokens and object key sets, sorted. */ + data: string[]; + /** Rule 6: called function names in source order. */ + calls: string[]; + /** Rule 6: slot shapes in source order. */ + order: string[]; +} + +export interface InputFacet { + /** sha1 over the parts; equal facets have equal fingerprints. */ + fingerprint: string; + parts: FacetParts; + discarded: DiscardedValue[]; + /** Sorted reasons; non-empty blocks strong evidence. */ + unresolved: string[]; + /** `\t` lines of the anonymised body, for {@link residualDifference}. */ + skeleton: string[]; +} + +/** One node that differs between two facet-equal instances; null when the node is absent. */ +export interface ResidualDifference { + path: string; + a: string | null; + b: string | null; +} + +// ── Context ──────────────────────────────────────────────────────────────── + +export interface ClosureFiles { + /** Production files of the whole closure (constants). */ + all: string[]; + /** Production files within {@link TABLE_HOPS} hops (second table tier). */ + near: string[]; +} + +/** Shared state of one run: production tables and constants, parsers, the fixture tree. */ +export class FacetContext { + readonly tables: TableIndex; + readonly shaper: SourceShaper; + readonly isProduction: (repoPath: string) => boolean; + private fixtureIndex: FixtureIndex | null = null; + private builtinNames: ReadonlySet | null = null; + private readonly closureFiles = new Map(); + /** Module specifiers per repo path; null when the file cannot be read. */ + private readonly specifiers = new Map(); + + constructor(private readonly options: FacetContextOptions) { + this.isProduction = productionFilePredicate(options.extraRoots ?? []); + this.tables = new TableIndex({ + read: (file) => options.sources.read(file), + columnFiles: options.productionFiles, + }); + this.shaper = new SourceShaper(options.sources, () => this.builtins()); + } + + /** GitNexus builtin names (rule 1), read on first use. */ + builtins(): ReadonlySet { + if (this.builtinNames === null) { + const files = + this.options.builtinFiles ?? + this.options.sources + .list(BUILTINS_DIR) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts')); + this.builtinNames = this.tables.stringValues(files); + } + return this.builtinNames; + } + + /** The fixture tree (rules 7, 8), listed on first use. */ + fixtures(): FixtureIndex { + if (this.fixtureIndex === null) { + const root = this.options.fixtureRoot ?? FIXTURE_ROOT; + this.fixtureIndex = new FixtureIndex(root, this.options.sources.list(root)); + } + return this.fixtureIndex; + } + + /** The production files of a test file's static import closure; null when not known. */ + closureOf(subject: FacetSubject): ClosureFiles | null { + if (this.closureFiles.has(subject.file)) return this.closureFiles.get(subject.file) ?? null; + const closure = this.options.closures.get(subject.file); + let files: ClosureFiles | null = null; + if (closure !== undefined) { + const hops = hopsVia( + subject.record.file, + closure, + (module) => this.specifiersIn(module), + TABLE_HOPS, + ); + const all = closure.filter((file) => this.isProduction(file)).sort(); + files = { all, near: all.filter((file) => (hops.get(file) ?? Infinity) <= TABLE_HOPS) }; + } + this.closureFiles.set(subject.file, files); + return files; + } + + /** The specifiers a repo path imports, parsed once per run; null when it cannot be read. */ + private specifiersIn(module: string): string[] | null { + if (!this.specifiers.has(module)) { + const source = this.options.sources.read(module); + this.specifiers.set(module, source === null ? null : specifiersOf(source)); + } + return this.specifiers.get(module) ?? null; + } +} + +export const createFacetContext = (options: FacetContextOptions): FacetContext => + new FacetContext(options); + +/** File access under a checkout root; paths that escape it read null. */ +export function fsFacetSources(repoRoot: string): FacetSources { + const resolve = (repoPath: string): string | null => { + try { + return path.join(repoRoot, normalizeRelativePath(repoPath)); + } catch { + return null; + } + }; + return { + read(repoPath) { + const file = resolve(repoPath); + if (file === null) return null; + try { + return readFileSync(file, 'utf8'); + } catch { + return null; + } + }, + list(repoDir) { + const out: string[] = []; + const walk = (relative: string): void => { + const dir = resolve(relative); + if (dir === null) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = `${relative}/${entry.name}`; + if (entry.isDirectory() && entry.name !== 'node_modules') walk(child); + else if (entry.isFile()) out.push(child); + } + }; + walk(repoDir.replace(/\/+$/, '')); + return out.sort(); + }, + }; +} + +export interface RunFacetOptions { + repoRoot: string; + /** Extra path-map roots of the run mode; non-empty in controls mode. */ + extraRoots: readonly string[]; + /** Package paths of the run's test files. */ + testFiles: readonly string[]; + /** classify's verdicts of `testFiles` when the caller already has them; else classified here. */ + verdicts?: readonly FileVerdict[]; +} + +/** + * The facet context of a run over a checkout: closures from the classifier, and as production + * universe every production file of the mode (in controls mode, the fixture project only). + */ +export function createRunFacetContext(options: RunFacetOptions): FacetContext { + const sources = fsFacetSources(options.repoRoot); + const verdicts = + options.verdicts ?? + classify({ repoRoot: options.repoRoot, testFiles: options.testFiles }).files; + const closures = new Map(verdicts.map((verdict) => [verdict.file, verdict.closure])); + const isProduction = productionFilePredicate(options.extraRoots); + const base = new Set(productionRoots()); + const roots = + options.extraRoots.length > 0 + ? productionRoots(options.extraRoots).filter((root) => !base.has(root)) + : productionRoots(); + const productionFiles = roots.flatMap((root) => sources.list(root)).filter(isProduction); + return createFacetContext({ + sources, + extraRoots: options.extraRoots, + productionFiles, + closures, + }); +} + +/** The facet subject of an inventory instance; null without a call site. */ +export function facetSubjectOf( + instance: InventoryInstance, + provenance: { known: boolean; executedFiles: readonly string[] } | null, +): FacetSubject | null { + if (instance.site === null) return null; + return { + id: instance.id, + file: instance.file, + record: instance.site.record, + instance: instance.instance, + executedFiles: provenance !== null && provenance.known ? provenance.executedFiles : null, + }; +} + +// ── Static import hops ───────────────────────────────────────────────────── + +const RESOLVE_SUFFIXES = [ + '', + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.mjs', + '/index.ts', + '/index.js', +]; + +/** The module specifiers a source imports or re-exports at run time. */ +function specifiersOf(source: string): string[] { + let ast: t.File; + try { + ast = parse(source, { + sourceType: 'module', + plugins: ['typescript', 'jsx', 'decorators-legacy'], + errorRecovery: true, + }); + } catch { + return []; + } + const out: string[] = []; + for (const statement of ast.program.body) { + if (t.isImportDeclaration(statement) && statement.importKind !== 'type') { + out.push(statement.source.value); + } else if ( + (t.isExportNamedDeclaration(statement) || t.isExportAllDeclaration(statement)) && + statement.source && + statement.exportKind !== 'type' + ) { + out.push(statement.source.value); + } + } + t.traverseFast(ast, (node) => { + if (!t.isCallExpression(node) || !t.isStringLiteral(node.arguments[0])) return; + if (t.isImport(node.callee) || t.isIdentifier(node.callee, { name: 'require' })) { + out.push(node.arguments[0].value); + } + }); + return out; +} + +/** The closure member a specifier names, else null. */ +function resolveIn(from: string, specifier: string, members: ReadonlySet): string | null { + let base: string; + if (specifier.startsWith('.')) + base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)); + else if (specifier === 'gitnexus-shared') base = 'gitnexus-shared/src/index'; + else if (specifier.startsWith('gitnexus-shared/')) { + base = `gitnexus-shared/src/${specifier.slice('gitnexus-shared/'.length).replace(/^(dist|src)\//, '')}`; + } else return null; + const stem = base.replace(/\.[mc]?[jt]sx?$/, ''); + for (const candidate of [base, ...RESOLVE_SUFFIXES.map((suffix) => `${stem}${suffix}`)]) { + if (members.has(candidate)) return candidate; + } + return null; +} + +/** Import hops from the test file to each closure member, up to `limit`. */ +export function importHops( + testFile: string, + closure: readonly string[], + sources: FacetSources, + limit: number, +): Map { + return hopsVia( + testFile, + closure, + (module) => { + const source = sources.read(module); + return source === null ? null : specifiersOf(source); + }, + limit, + ); +} + +/** {@link importHops} over a module's specifiers (null when it cannot be read). */ +function hopsVia( + testFile: string, + closure: readonly string[], + specifiersOfModule: (module: string) => readonly string[] | null, + limit: number, +): Map { + const members = new Set(closure); + const hops = new Map([[testFile, 0]]); + const queue = [testFile]; + for (let index = 0; index < queue.length; index++) { + const module = queue[index]; + const depth = hops.get(module) ?? 0; + if (depth >= limit) continue; + const specifiers = specifiersOfModule(module); + if (specifiers === null) continue; + for (const specifier of specifiers) { + const target = resolveIn(module, specifier, members); + if (target === null || hops.has(target)) continue; + hops.set(target, depth + 1); + queue.push(target); + } + } + return hops; +} + +// ── Slots ────────────────────────────────────────────────────────────────── + +interface Slot { + kind: DiscardedValue['kind']; + value: string; + /** The number, for numeric slots. */ + number: number | null; + /** Where the body writes or reads it. */ + node: t.Node; + origin: DiscardedValue['origin']; + /** A selector of an expect subject (rule 4). */ + selector: boolean; +} + +interface Collected { + slots: Slot[]; + /** Rule 5 key sets. */ + keys: string[]; + /** Skeleton tokens of slot and row-read nodes. */ + placeholders: Map; +} + +const gen = (node: t.Node): string => generate(node, { comments: false, compact: true }).code; + +function unwrap(node: t.Node): t.Node { + let n = node; + while (TS_EXPRESSIONS.has(n.type) || t.isParenthesizedExpression(n)) { + n = (n as t.TSAsExpression).expression; + } + return n; +} + +const isTypeNode = (node: t.Node): boolean => + node.type.startsWith('TS') && !TS_EXPRESSIONS.has(node.type); + +/** A literal the checks render as a whole: a string, a plain template, a (negated) number. */ +function isLiteralSlot(node: t.Node): boolean { + if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBigIntLiteral(node)) return true; + if (t.isTemplateLiteral(node)) return node.expressions.length === 0; + return ( + t.isUnaryExpression(node, { operator: '-' }) && + (t.isNumericLiteral(node.argument) || t.isBigIntLiteral(node.argument)) + ); +} + +const isPlaceholder = (node: t.Node): boolean => t.isIdentifier(node) && /^<.*>$/.test(node.name); + +function hasPlaceholder(node: t.Node): boolean { + let found = isPlaceholder(node); + t.traverseFast(node, (n) => { + if (isPlaceholder(n)) found = true; + }); + return found; +} + +const isNode = (value: unknown): value is t.Node => + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string'; + +/** The subject of an `expect(x)` / `expect.soft(x)` call, else null. */ +function expectSubject(call: t.CallExpression): t.Node | null { + const callee = call.callee; + const isExpect = + t.isIdentifier(callee, { name: 'expect' }) || + (t.isMemberExpression(callee) && + t.isIdentifier(callee.object, { name: 'expect' }) && + t.isIdentifier(callee.property, { name: 'soft' })); + const subject = call.arguments[0]; + return isExpect && subject !== undefined && !t.isSpreadElement(subject) ? subject : null; +} + +/** + * Rule 4 through checks.ts: the literal and row-read nodes of the expect subjects that + * `abstractNode` renders exact (selectors). The original subject is walked alongside its + * abstracted copy; a literal kept as a literal (not a `` placeholder) is a selector. + */ +function selectorNodes(body: t.Node, scope: Scope): Set { + const exact = new Set(); + const pair = (original: t.Node, abstracted: t.Node): void => { + if (isLiteralSlot(original)) { + if (!isPlaceholder(abstracted)) exact.add(original); + return; + } + if (t.isTemplateLiteral(original)) { + if (t.isTemplateLiteral(abstracted) && abstracted.quasis === original.quasis) { + exact.add(original); + } + } else if ( + (t.isIdentifier(original) || + t.isMemberExpression(original) || + t.isOptionalMemberExpression(original)) && + rowValueOf(original, scope) !== null + ) { + if (!hasPlaceholder(abstracted)) exact.add(original); + return; + } + if (original.type !== abstracted.type) return; + for (const key of t.VISITOR_KEYS[original.type] ?? []) { + if (key === 'key' && (original as { computed?: unknown }).computed !== true) continue; + const left: unknown = (original as unknown as Record)[key]; + const right: unknown = (abstracted as unknown as Record)[key]; + if (Array.isArray(left) && Array.isArray(right) && left.length === right.length) { + left.forEach((item: unknown, index) => { + const other: unknown = right[index]; + if (isNode(item) && isNode(other)) pair(item, other); + }); + } else if (isNode(left) && isNode(right)) pair(left, right); + } + }; + t.traverseFast(body, (node) => { + if (!t.isCallExpression(node)) return; + const subject = expectSubject(node); + if (subject !== null) pair(subject, abstractNode(subject, scope, true, false)); + }); + return exact; +} + +const numberText = (value: number): string => (Object.is(value, -0) ? '-0' : String(value)); + +/** Skeleton text of a row value: slot kinds as ``, everything else exact. */ +function rowSkeleton(value: RowValue): string { + switch (value.kind) { + case 'string': + case 'number': + case 'bigint': + return ''; + case 'boolean': + return String(value.value); + case 'null': + case 'undefined': + return value.kind; + case 'regexp': + return `/${value.pattern}/${value.flags}`; + case 'array': + return `[${value.items.map(rowSkeleton).join(',')}]`; + case 'object': + return `{${value.entries.map((entry) => `${JSON.stringify(entry.key)}:${rowSkeleton(entry.value)}`).join(',')}}`; + case 'unresolved': + return '?'; + } +} + +function objectKeys(node: t.ObjectExpression): string { + const keys = node.properties.map((property) => { + if (!t.isObjectProperty(property) || property.computed) return '…'; + if (t.isIdentifier(property.key)) return property.key.name; + if (t.isStringLiteral(property.key)) return property.key.value; + return '…'; + }); + return `K{${keys.sort().join(',')}}`; +} + +/** The slots of the body in source order, with key sets and skeleton placeholders. */ +function collectSlots( + record: CaseRecord, + scope: Scope, + selectors: ReadonlySet, + unresolved: Set, +): Collected { + const found: { start: number; seq: number; slot: Slot }[] = []; + const keys: string[] = []; + const placeholders = new Map(); + // Direct loop bindings have per-instance values in scope.rows. Declarations inside an + // enclosing loop can capture those values too, but their initializers/closures are not + // evaluated here. Identical body text must not make those inputs look resolved. + const loopFrame = + record.loop === null + ? -1 + : record.chain.findIndex( + (frame) => + frame.extra.length === 2 && + (t.isVariableDeclaration(frame.extra[0]) || t.isLVal(frame.extra[0])), + ); + const loopDeclarations = new Set( + (loopFrame < 0 ? [] : record.chain.slice(loopFrame)).flatMap((frame) => + [...frame.decls.values()].flatMap((decl) => (decl.kind === 'node' ? [decl.node] : [])), + ), + ); + // scope.locals also contains nested callback parameters. Those must not hide an outer + // capture read elsewhere in the test; only bindings in the test's own scope shadow it. + const bodyBindings = new Set(); + for (const param of record.fn.params) { + for (const name of Object.keys(t.getBindingIdentifiers(param))) bodyBindings.add(name); + } + if (t.isBlockStatement(record.fn.body)) { + for (const statement of record.fn.body.body) { + if (t.isVariableDeclaration(statement)) { + for (const name of Object.keys(t.getBindingIdentifiers(statement))) bodyBindings.add(name); + } else if ( + (t.isFunctionDeclaration(statement) || t.isClassDeclaration(statement)) && + statement.id + ) { + bodyBindings.add(statement.id.name); + } + } + } + let seq = 0; + const push = (node: t.Node, slot: Omit, at: t.Node): void => { + found.push({ + start: at.start ?? 0, + seq: seq++, + slot: { ...slot, node, selector: selectors.has(at) }, + }); + }; + const pushRow = (value: RowValue, at: t.Node, code: string): void => { + switch (value.kind) { + case 'string': + push(at, { kind: 'string', value: value.value, number: null, origin: 'row' }, at); + return; + case 'number': + push( + at, + { kind: 'number', value: value.text, number: Number(value.text), origin: 'row' }, + at, + ); + return; + case 'bigint': + push(at, { kind: 'bigint', value: value.text, number: null, origin: 'row' }, at); + return; + case 'array': + for (const item of value.items) pushRow(item, at, code); + return; + case 'object': + keys.push( + `K{${value.entries + .map((entry) => entry.key) + .sort() + .join(',')}}`, + ); + for (const entry of value.entries) pushRow(entry.value, at, code); + return; + case 'unresolved': + unresolved.add(`row value ${code}: ${value.reason}`); + return; + default: + return; + } + }; + const walk = (node: t.Node | null | undefined): void => { + if (!isNode(node) || isTypeNode(node)) return; + if (loopDeclarations.size > 0 && t.isIdentifier(node) && !bodyBindings.has(node.name)) { + const decl = resolveName(node.name, record.chain); + if (decl?.kind === 'node' && loopDeclarations.has(decl.node)) { + unresolved.add(`captured loop binding ${node.name}: instance value unknown`); + } + } + if (t.isIdentifier(node) || t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + const row = rowValueOf(node, scope); + // `name.trim()`, `row[i]`: the read itself is not static but its object is, so the object + // is the input and the member access is code. + const objectRow = t.isIdentifier(node) ? null : rowValueOf(node.object, scope); + const throughObject = row === 'unknown' && objectRow !== null && objectRow !== 'unknown'; + if (row !== null && !throughObject) { + const code = gen(node); + if (row === 'unknown') { + unresolved.add(`row value ${code}: not static`); + placeholders.set(node, ''); + } else { + pushRow(row, node, code); + placeholders.set(node, ``); + } + return; + } + } + if (t.isStringLiteral(node)) { + push(node, { kind: 'string', value: node.value, number: null, origin: 'literal' }, node); + placeholders.set(node, ''); + return; + } + if (t.isTemplateLiteral(node)) { + const text = node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('__X__'); + push(node, { kind: 'string', value: text, number: null, origin: 'literal' }, node); + placeholders.set(node, ''); + node.expressions.forEach(walk); + return; + } + const negated = t.isUnaryExpression(node, { operator: '-' }); + const numeric = negated ? node.argument : node; + if (t.isNumericLiteral(numeric)) { + const value = negated ? -numeric.value : numeric.value; + push( + node, + { kind: 'number', value: numberText(value), number: value, origin: 'literal' }, + node, + ); + placeholders.set(node, ''); + return; + } + if (t.isBigIntLiteral(numeric)) { + const text = `${negated ? '-' : ''}${numeric.value}`; + push(node, { kind: 'bigint', value: text, number: null, origin: 'literal' }, node); + placeholders.set(node, ''); + return; + } + if (t.isObjectExpression(node)) keys.push(objectKeys(node)); + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + if (TYPE_KEYS.has(key) || key === 'label') continue; + if (key === 'key' && (node as { computed?: unknown }).computed !== true) continue; + if ( + key === 'property' && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + !node.computed + ) { + continue; + } + const value: unknown = (node as unknown as Record)[key]; + if (Array.isArray(value)) for (const item of value) walk(isNode(item) ? item : null); + else walk(isNode(value) ? value : null); + } + }; + walk(record.fn.body); + found.sort((a, b) => a.start - b.start || a.seq - b.seq); + return { slots: found.map((entry) => entry.slot), keys, placeholders }; +} + +// ── Rule 6: builder calls ────────────────────────────────────────────────── + +function calledNames(body: t.Node, scope: Scope): string[] { + const calls: { start: number; name: string }[] = []; + t.traverseFast(body, (node) => { + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) { + return; + } + const callee = unwrap(node.callee); + let name: string | null = null; + if (t.isIdentifier(callee)) name = scope.locals.has(callee.name) ? '' : callee.name; + else if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + if (!callee.computed && t.isIdentifier(callee.property)) name = callee.property.name; + else if (t.isStringLiteral(callee.property)) name = callee.property.value; + } + if (name !== null && !ASSERTION_NAMES.has(name)) calls.push({ start: node.start ?? 0, name }); + }); + return calls.sort((a, b) => a.start - b.start).map((call) => call.name); +} + +// ── Skeleton (residual-difference guard) ─────────────────────────────────── + +function attributesOf(node: t.Node): string { + const visitorKeys = new Set(t.VISITOR_KEYS[node.type] ?? []); + const parts: string[] = []; + for (const [key, value] of Object.entries(node)) { + if (SKIPPED_FIELDS.has(key) || visitorKeys.has(key)) continue; + const kind = typeof value; + if (kind === 'string' || kind === 'number' || kind === 'boolean' || kind === 'bigint') { + parts.push(`${key}=${String(value)}`); + } + } + return parts.length > 0 ? `{${parts.sort().join(',')}}` : ''; +} + +/** `\t` lines of the params and body, slots and row reads as placeholders. */ +function skeletonOf( + record: CaseRecord, + scope: Scope, + placeholders: ReadonlyMap, +): string[] { + const lines: string[] = []; + const ids = new Map(); + const localId = (name: string): string => { + let id = ids.get(name); + if (id === undefined) { + id = ``; + ids.set(name, id); + } + return id; + }; + const walk = (node: t.Node, at: string, inParams: boolean): void => { + if (isTypeNode(node)) return; + const placeholder = placeholders.get(node); + if (placeholder !== undefined) { + lines.push(`${at}\t${placeholder}`); + if (t.isTemplateLiteral(node)) { + node.expressions.forEach((expression, index) => + walk(expression, `${at}.expressions[${index}]`, inParams), + ); + } + return; + } + if (t.isIdentifier(node)) { + const token = inParams + ? '' + : scope.locals.has(node.name) + ? localId(node.name) + : `Identifier:${node.name}`; + lines.push(`${at}\t${token}`); + return; + } + lines.push(`${at}\t${node.type}${attributesOf(node)}`); + const computed = (node as { computed?: unknown }).computed === true; + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + if (TYPE_KEYS.has(key)) continue; + const value: unknown = (node as unknown as Record)[key]; + const nameSlot = + key === 'label' || + (key === 'key' && !computed) || + (key === 'property' && + !computed && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))); + if (nameSlot && isNode(value)) { + const name = t.isIdentifier(value) + ? value.name + : t.isStringLiteral(value) + ? value.value + : gen(value); + lines.push(`${at}.${key}\tName:${name}`); + continue; + } + if (Array.isArray(value)) { + value.forEach((item: unknown, index) => { + if (isNode(item)) walk(item, `${at}.${key}[${index}]`, inParams); + else lines.push(`${at}.${key}[${index}]\tnull`); + }); + } else if (isNode(value)) walk(value, `${at}.${key}`, inParams); + } + }; + record.fn.params.forEach((param, index) => walk(param, `params[${index}]`, true)); + walk(record.fn.body, 'body', false); + return lines; +} + +/** + * The nodes in which two facet-equal instances differ beyond their slots (KTD6): top-most + * differing paths only. Empty when the facets differ (the guard applies to facet-equal pairs). + */ +export function residualDifference(a: InputFacet, b: InputFacet): ResidualDifference[] { + if (a.fingerprint !== b.fingerprint) return []; + const toMap = (lines: readonly string[]): Map => + new Map( + lines.map((line) => { + const tab = line.indexOf('\t'); + return [line.slice(0, tab), line.slice(tab + 1)]; + }), + ); + const left = toMap(a.skeleton); + const right = toMap(b.skeleton); + const paths = [...new Set([...left.keys(), ...right.keys()])]; + const differing = paths + .map((at) => ({ path: at, a: left.get(at) ?? null, b: right.get(at) ?? null })) + .filter((entry) => entry.a !== entry.b); + const inside = (child: string, parent: string): boolean => + child.startsWith(`${parent}.`) || child.startsWith(`${parent}[`); + return differing.filter( + (entry) => !differing.some((other) => other !== entry && inside(entry.path, other.path)), + ); +} + +/** {@link residualDifference}, and both facets marked unresolved when it is non-empty (D1). */ +export function applyResidualGuard( + a: InputFacet, + b: InputFacet, +): { a: InputFacet; b: InputFacet; residual: ResidualDifference[] } { + const residual = residualDifference(a, b); + if (residual.length === 0) return { a, b, residual }; + const reason = `residual difference: ${residual.map((entry) => entry.path).join(', ')}`; + const mark = (facet: InputFacet): InputFacet => ({ + ...facet, + unresolved: sortedUnique([...facet.unresolved, reason]), + }); + return { a: mark(a), b: mark(b), residual }; +} + +/** True when two facets discarded the same values at the same positions. */ +export function sameDiscardedValues(a: InputFacet, b: InputFacet): boolean { + const key = (facet: InputFacet): string => + JSON.stringify(facet.discarded.map((entry) => [entry.position, entry.kind, entry.value])); + return key(a) === key(b); +} + +// ── Rules 7 / 8: fixtures ────────────────────────────────────────────────── + +/** Fixture directories named by the `beforeAll` / `beforeEach` hooks around the test. */ +function hookFixtureDirs(record: CaseRecord, index: FixtureIndex): string[] { + const found = new Set(); + for (const frame of record.chain) { + for (const statement of frame.setup) { + if (!t.isExpressionStatement(statement) || !t.isCallExpression(statement.expression)) + continue; + const callee = statement.expression.callee; + if (!t.isIdentifier(callee) || !/^before(All|Each)$/.test(callee.name)) continue; + t.traverseFast(statement, (node) => { + if (!t.isStringLiteral(node)) return; + for (const dir of index.dirs.get(node.value) ?? []) found.add(dir); + }); + } + } + return [...found]; +} + +interface FixtureParts { + parts: string[]; + /** String values resolved against a fixture (they leave the data part). */ + resolved: Set; +} + +async function fixtureParts( + record: CaseRecord, + strings: readonly string[], + ctx: FacetContext, + unresolved: Set, +): Promise { + const parts: string[] = []; + const resolved = new Set(); + const index = ctx.fixtures(); + const presenceOnly = PRESENCE_QUERY.test(record.body) && !RELATION_QUERY.test(record.body); + for (const dir of hookFixtureDirs(record, index)) { + const defs = await ctx.shaper.fixtureDefs(dir); + if (defs.files.size === 0) unresolved.add(`fixture dir ${dir}: no parseable source`); + for (const literal of strings) { + // The whole literal, or the word tokens of a composite one ('RunInterface → DoWork'). + for (const token of [literal, ...(literal.match(/[A-Za-z_$][\w$]*/g) ?? [])]) { + for (const def of defs.names.get(token) ?? []) { + resolved.add(literal); + parts.push(`def:${presenceOnly ? def.kind : def.full}`); + if (presenceOnly) continue; + for (const ref of def.refs) { + for (const field of defs.fields.get(ref) ?? []) parts.push(`field:${field}`); + const type = defs.types.get(ref); + if (type === undefined || ref === token) continue; + parts.push(`type:${type.shape}`); + for (const base of type.bases) { + const baseType = defs.types.get(base); + if (baseType !== undefined && base !== ref) parts.push(`type:${baseType.shape}`); + } + } + } + } + if (literal === '') continue; + for (const [relative, shape] of defs.files) { + if ( + literal === relative || + literal.endsWith(`/${relative}`) || + relative.endsWith(literal) + ) { + parts.push(`file:${shape}`); + resolved.add(literal); + } + } + } + } + for (const literal of strings) { + if (resolved.has(literal) || !PATHLIKE.test(literal)) continue; + const hits = index.files.get(literal) ?? index.files.get(path.posix.basename(literal)) ?? []; + if (hits.length !== 1) continue; + const shape = await ctx.shaper.fixtureFileShape(hits[0]); + if (shape === null) unresolved.add(`fixture file ${literal}: cannot be shaped`); + else parts.push(`fixfile:${shape}`); + resolved.add(literal); + } + return { parts, resolved }; +} + +// ── The facet ────────────────────────────────────────────────────────────── + +interface SlotToken { + /** Data token (rules 3, 4), or null for a source profile. */ + data: string | null; + /** Source profile (rules 1, 2), or null. */ + source: string | null; + order: string; + exact: boolean; +} + +const EXACT_STRING_SHAPES: ReadonlySet = new Set(['EMPTY(0)', 'N0', 'N1', 'N-1']); +/** D2: `0`, `1`, `-1` and every negative stay exact. */ +const boundaryNumber = (value: number): boolean => value === 0 || value === 1 || value < 0; + +/** The input facet of one test instance. */ +export async function computeFacet(subject: FacetSubject, ctx: FacetContext): Promise { + const { record } = subject; + const scope = buildScope({ record, instance: subject.instance, fullName: null }); + const unresolved = new Set(); + if (subject.instance === null && (record.each !== null || record.loop !== null)) { + unresolved.add('instance not known'); + } + for (const reason of subject.instance?.unresolved ?? []) unresolved.add(`instance: ${reason}`); + // checks.ts flags free names an untracked outer row may bind, and unknown selector values. + abstractNode(record.fn.body, scope, false, false); + const selectors = selectorNodes(record.fn.body, scope); + const { slots, keys, placeholders } = collectSlots(record, scope, selectors, unresolved); + + const executed = (subject.executedFiles ?? []).filter((file) => ctx.isProduction(file)).sort(); + if (subject.executedFiles === null) unresolved.add('executed files not known'); + const closure = ctx.closureOf(subject); + if (closure === null) unresolved.add('import closure not known'); + const search = { executed, closure: closure?.near ?? [] }; + const constants = ctx.tables.constantsOf([...new Set([...executed, ...(closure?.all ?? [])])]); + + const strings = slots.filter((slot) => slot.kind === 'string').map((slot) => slot.value); + const literals: LiteralKey[] = slots.map((slot) => + slot.number !== null ? numberKey(slot.number) : stringKey(slot.value.trim()), + ); + const rowText = (subject.instance?.dimensions ?? []) + .map((dimension) => (dimension.node === null ? '' : gen(dimension.node))) + .join('\n'); + const lang = languageOf(`${record.body}\n${rowText}`, record.file, strings); + const fixtures = ctx.fixtures(); + const tableOf = (value: LiteralKey): string | null => ctx.tables.tableOf(search, value, literals); + + const tokenOf = async (slot: Slot): Promise => { + if (slot.kind === 'string') { + const text = slot.value; + if (lang === SupportedLanguages.Cobol) { + const words = cobolWords(text); + if (words.length > 0) { + const profile = `cobol:${words + .map((word) => { + const table = tableOf(stringKey(word)); + return table === null ? word : `T:${table}`; + }) + .sort() + .join(',')}`; + return { data: null, source: profile, order: profile, exact: false }; + } + } + const profile = + lang !== null && looksLikeSource(text) ? await ctx.shaper.syntaxProfile(lang, text) : null; + if (profile !== null) return { data: null, source: profile, order: profile, exact: false }; + if (slot.selector) + return { data: `SEL:${text}`, source: null, order: `SEL:${text}`, exact: true }; + const table = tableOf(stringKey(text.trim())); + const synthetic = + PATHLIKE.test(text) && + !fixtures.files.has(text) && + !fixtures.files.has(path.posix.basename(text)); + const extension = table === null && synthetic ? /(\.\w{1,6})$/.exec(text)?.[1] : undefined; + const extensionTable = + extension === undefined + ? null + : (tableOf(stringKey(extension)) ?? tableOf(stringKey(extension.slice(1)))); + const shape = shapeText(text); + const data = + table !== null ? `T:${table}` : extensionTable !== null ? `T:${extensionTable}` : shape; + const exact = data === shape && EXACT_STRING_SHAPES.has(shape); + return { data, source: null, order: shape, exact }; + } + if (slot.kind === 'bigint') { + const exact = slot.selector || /^-|^[01]$/.test(slot.value); + const token = exact ? `#=${slot.value}n` : `#${shapeText(slot.value)}n`; + return { data: token, source: null, order: token, exact }; + } + const value = slot.number ?? Number.NaN; + if (slot.selector) { + return { data: `SEL#${slot.value}`, source: null, order: `SEL#${slot.value}`, exact: true }; + } + if (boundaryNumber(value) || nearConstant(constants, value)) { + return { data: `#=${slot.value}`, source: null, order: `#=${slot.value}`, exact: true }; + } + const shape = `#${shapeText(slot.value)}`; + const table = tableOf(numberKey(value)); + return { + data: table === null ? shape : `T:${table}`, + source: null, + order: shape, + exact: false, + }; + }; + + const tokens: SlotToken[] = []; + for (const slot of slots) tokens.push(await tokenOf(slot)); + const { parts: fixtureTokens, resolved } = await fixtureParts(record, strings, ctx, unresolved); + + const source = sortedUnique( + tokens.flatMap((token) => (token.source === null ? [] : [token.source])), + ); + const fixtureSet = sortedUnique(fixtureTokens); + const data = [ + ...tokens.flatMap((token, index) => { + const slot = slots[index]; + const isResolved = slot.kind === 'string' && resolved.has(slot.value); + return token.data === null || isResolved ? [] : [token.data]; + }), + ...keys, + ].sort(); + const parts: FacetParts = { + language: source.length > 0 || fixtureSet.length > 0 ? (lang ?? '-') : '-', + source, + fixtures: fixtureSet, + data, + calls: calledNames(record.fn.body, scope), + order: tokens.map((token) => token.order), + }; + const discarded: DiscardedValue[] = []; + slots.forEach((slot, index) => { + const isResolved = slot.kind === 'string' && resolved.has(slot.value); + if (tokens[index].exact && !isResolved) return; + const start = slot.node.loc?.start; + discarded.push({ + position: index, + kind: slot.kind, + value: slot.value, + location: { file: record.file, line: start?.line ?? 0, column: start?.column ?? 0 }, + origin: slot.origin, + }); + }); + for (const reason of scope.unresolved) unresolved.add(reason); + return { + fingerprint: sha1( + [ + parts.language, + `S:${parts.source.join(' / ')}`, + `F:${parts.fixtures.join(' / ')}`, + `D:${parts.data.join(' / ')}`, + `calls:${parts.calls.join(',')}`, + `order:${parts.order.join('\u0001')}`, + ].join('|'), + ), + parts, + discarded, + unresolved: [...unresolved].sort(), + skeleton: skeletonOf(record, scope, placeholders), + }; +} diff --git a/gitnexus/tools/scenario-map/facet/source-shape.ts b/gitnexus/tools/scenario-map/facet/source-shape.ts new file mode 100644 index 000000000..d0bbc7823 --- /dev/null +++ b/gitnexus/tools/scenario-map/facet/source-shape.ts @@ -0,0 +1,578 @@ +/** + * Anonymised source shapes for the input facet (Layer 2, U6, R5, KTD6, design §5.3 rules 1, 2, 7 + * and 8). + * + * Rule 1, inline source: a test literal that looks like code, in the language the test names + * ({@link languageOf}: `SupportedLanguages.X`, a grammar identifier argument, a capture-emitter + * call, a file-name literal's extension, the test path), is parsed with GitNexus's + * `createParserForLanguage` and its anonymised tree hashed ({@link SourceShaper.syntaxProfile}): + * - names the tree declares itself ({@link declaredIn}: declaration nodes, for/range/assignment + * targets, Go `type_spec`) become `ID`; + * - external types and GitNexus builtin names (the string tables of the language providers under + * `gitnexus/src/core/ingestion/languages/`) stay, even when a fixture stubs them; + * - leaf rules apply to leaves only, so composites keep their structure; + * - literal values are dropped and literal kinds kept; + * - comment text is shaped ({@link shapeText}) for inline inputs and ignored in fixture files. + * A parse with more than 20% error nodes was data, not code, and gives no profile. + * + * Rule 2, COBOL: the reserved words a literal uses ({@link COBOL_WORDS}). + * + * Rules 7 and 8, fixtures: {@link FixtureIndex} lists the fixture tree once (directories by name, + * files by base name and by fixture-relative path); {@link SourceShaper.fixtureDefs} parses one + * fixture directory (definitions with their full and header shapes, referenced names, type shapes + * with their bases, declared field types) and {@link SourceShaper.fixtureFileShape} hashes one + * fixture file. + * + * Library only; input-facet.ts owns one {@link SourceShaper} per run. + */ +import path from 'node:path'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type Parser from 'tree-sitter'; +import { getTreeSitterBufferSize } from '../../../src/core/ingestion/constants.js'; +import { createParserForLanguage } from '../../../src/core/tree-sitter/parser-loader.js'; +import { sha1 } from '../util.js'; + +type SyntaxNode = Parser.SyntaxNode; + +/** Files at or above this size are not parsed. */ +export const MAX_SOURCE_CHARS = 200_000; +/** A parse with a larger share of ERROR / missing nodes is data, not code. */ +const MAX_ERROR_SHARE = 0.2; +/** Fixture directories are indexed by name this many levels below the fixture root. */ +const FIXTURE_DIR_DEPTH = 4; + +const sha12 = (text: string): string => sha1(text).slice(0, 12); + +// ── Language detection ───────────────────────────────────────────────────── + +const IDENT_LANG: ReadonlyMap = new Map([ + ['Rust', SupportedLanguages.Rust], + ['Java', SupportedLanguages.Java], + ['CSharp', SupportedLanguages.CSharp], + ['Kotlin', SupportedLanguages.Kotlin], + ['Go', SupportedLanguages.Go], + ['Python', SupportedLanguages.Python], + ['TypeScript', SupportedLanguages.TypeScript], + ['JavaScript', SupportedLanguages.JavaScript], + ['PHP', SupportedLanguages.PHP], + ['Php', SupportedLanguages.PHP], + ['Ruby', SupportedLanguages.Ruby], + ['Swift', SupportedLanguages.Swift], + ['Dart', SupportedLanguages.Dart], + ['CPlusPlus', SupportedLanguages.CPlusPlus], + ['Cpp', SupportedLanguages.CPlusPlus], + ['CPP', SupportedLanguages.CPlusPlus], + ['C', SupportedLanguages.C], + ['Zig', SupportedLanguages.Zig], + ['ObjectiveC', SupportedLanguages.ObjectiveC], + ['Cobol', SupportedLanguages.Cobol], +]); + +/** File extension → language, for fixture files and file-name literals. */ +export const EXT_LANG: ReadonlyMap = new Map([ + ['rs', SupportedLanguages.Rust], + ['java', SupportedLanguages.Java], + ['cs', SupportedLanguages.CSharp], + ['kt', SupportedLanguages.Kotlin], + ['go', SupportedLanguages.Go], + ['py', SupportedLanguages.Python], + ['ts', SupportedLanguages.TypeScript], + ['tsx', SupportedLanguages.TypeScript], + ['js', SupportedLanguages.JavaScript], + ['php', SupportedLanguages.PHP], + ['rb', SupportedLanguages.Ruby], + ['swift', SupportedLanguages.Swift], + ['dart', SupportedLanguages.Dart], + ['cpp', SupportedLanguages.CPlusPlus], + ['cc', SupportedLanguages.CPlusPlus], + ['hpp', SupportedLanguages.CPlusPlus], + ['c', SupportedLanguages.C], + ['h', SupportedLanguages.C], + ['zig', SupportedLanguages.Zig], + ['m', SupportedLanguages.ObjectiveC], + ['cbl', SupportedLanguages.Cobol], + ['cob', SupportedLanguages.Cobol], +]); + +const CALL_LANG: readonly [RegExp, SupportedLanguages][] = [ + [/emitCppScopeCaptures|CPlusPlus/, SupportedLanguages.CPlusPlus], + [/emitCScopeCaptures/, SupportedLanguages.C], + [/emitJsScopeCaptures/, SupportedLanguages.JavaScript], + [/emitTs\w*ScopeCaptures/, SupportedLanguages.TypeScript], + [/\bcobol\(/, SupportedLanguages.Cobol], +]; + +const PATH_LANG: readonly [RegExp, SupportedLanguages][] = [ + [/\/cpp[-/]|cpp-captures/, SupportedLanguages.CPlusPlus], + [/\/c\/|\/c-captures|c-coverage/, SupportedLanguages.C], + [/cobol/, SupportedLanguages.Cobol], + [/\/rust[-/.]|rust-/, SupportedLanguages.Rust], + [/\/java[-/.]/, SupportedLanguages.Java], + [/csharp/, SupportedLanguages.CSharp], + [/kotlin/, SupportedLanguages.Kotlin], + [/\/go[-/.]/, SupportedLanguages.Go], + [/python/, SupportedLanguages.Python], + [/typescript/, SupportedLanguages.TypeScript], + [/javascript/, SupportedLanguages.JavaScript], + [/\/php[-/.]/, SupportedLanguages.PHP], + [/ruby/, SupportedLanguages.Ruby], + [/swift/, SupportedLanguages.Swift], + [/dart/, SupportedLanguages.Dart], + [/zig/, SupportedLanguages.Zig], +]; + +const LANG_ARG = + /,\s*(Rust|Java|CSharp|Kotlin|Go|Python|TypeScript|JavaScript|PHP|Php|Ruby|Swift|Dart|CPlusPlus|Cpp|CPP|Zig)\s*[,)]/; +const LANG_FIRST_ARG = + /\(\s*(Rust|Java|CSharp|Kotlin|Go|Python|TypeScript|JavaScript|PHP|Ruby|Swift|Dart|CPlusPlus|Zig)\s*,/; + +/** + * The language a test feeds inline source in: named in its code (`text`), by a file-name literal, + * or by its path (`testPath`, a repo path); null when none. + */ +export function languageOf( + text: string, + testPath: string, + literals: readonly string[], +): SupportedLanguages | null { + const named = /SupportedLanguages\.(\w+)/.exec(text); + const byName = named === null ? undefined : IDENT_LANG.get(named[1]); + if (byName !== undefined) return byName; + for (const pattern of [LANG_ARG, LANG_FIRST_ARG]) { + const match = pattern.exec(text); + const lang = match === null ? undefined : IDENT_LANG.get(match[1]); + if (lang !== undefined) return lang; + } + for (const [pattern, lang] of CALL_LANG) if (pattern.test(text)) return lang; + for (const literal of literals) { + const match = /^[\w./-]+\.(\w+)$/.exec(literal); + const lang = match === null ? undefined : EXT_LANG.get(match[1]); + if (lang !== undefined) return lang; + } + for (const [pattern, lang] of PATH_LANG) if (pattern.test(testPath)) return lang; + return null; +} + +// ── Literal shapes ───────────────────────────────────────────────────────── + +/** A file path or a file name (`src/a.ts`, `a.ts`), never source code. */ +export const PATHLIKE = /^[\w@.-]+(\/[\w@.-]+)+$|^[\w-]+\.\w{1,5}$/; + +/** True when a literal may be inline source code rather than data. */ +export const looksLikeSource = (text: string): boolean => + !PATHLIKE.test(text) && text.length >= 3 && (/\n/.test(text) || /[{}();=<>:[\]@#*/]/.test(text)); + +/** + * The boundary-aware shape of a literal (design §5.3 rule 3): blank text keeps its length, `0`, + * `1`, `-1` stay exact, other negatives read `NEG`, and letter / digit runs collapse: + * `'User?'` → `'Aa?'`, `'ConcurrentHashMap'` → `'Aa'`, `'User | Repo'` → `'Aa | Aa'`. + */ +export function shapeText(text: string): string { + if (/^\s*$/.test(text)) return `EMPTY(${text.length})`; + if (/^-?[01]$/.test(text)) return `N${text}`; + if (/^-\d/.test(text)) return 'NEG'; + return text + .replace(/[A-Z][a-z]+/g, 'Aa') + .replace(/[A-Z]+/g, 'A') + .replace(/[a-z]+/g, 'a') + .replace(/\d+/g, '0') + .replace(/(Aa)+/g, 'Aa') + .replace(/(A)+/g, 'A') + .replace(/(a)+/g, 'a'); +} + +/** COBOL reserved words (rule 2). */ +export const COBOL_WORDS: ReadonlySet = new Set( + ( + 'PERFORM THRU THROUGH UNTIL VARYING TIMES WITH TEST BEFORE AFTER CALL USING RETURNING BY ' + + 'REFERENCE CONTENT VALUE MOVE TO IF ELSE END-IF EVALUATE WHEN OTHER END-EVALUATE COPY ' + + 'REPLACING EXEC SQL CICS END-EXEC DIVISION SECTION PROCEDURE DATA WORKING-STORAGE ' + + 'LOCAL-STORAGE LINKAGE FILE FD SD SELECT ASSIGN PIC PICTURE OCCURS REDEFINES RENAMES GO ' + + 'GOBACK STOP RUN DISPLAY ACCEPT COMPUTE ADD SUBTRACT MULTIPLY DIVIDE INITIALIZE STRING ' + + 'UNSTRING INSPECT SET OPEN CLOSE READ WRITE REWRITE DELETE START ENTRY PROGRAM-ID ' + + 'IDENTIFICATION ENVIRONMENT CONFIGURATION INPUT-OUTPUT NOT AND OR END-PERFORM END-CALL ' + + 'END-READ EXIT CONTINUE NESTED INITIAL COMMON RECURSIVE FUNCTION-ID CLASS-ID METHOD-ID ' + + 'INVOKE END-PROGRAM 88 LEVEL SEARCH ALL SORT MERGE RELEASE RETURN' + ).split(' '), +); + +/** The distinct COBOL reserved words of a text, upper-cased, in first-seen order. */ +export const cobolWords = (text: string): string[] => + [...new Set(text.toUpperCase().match(/[A-Z][A-Z-]*/g) ?? [])].filter((word) => + COBOL_WORDS.has(word), + ); + +// ── Tree shapes ──────────────────────────────────────────────────────────── + +const DECLARATION = + /declaration|definition|declarator|parameter|binding|pattern|item|_spec$|type_alias|^(class|struct|union|enum)_specifier$/; +const TARGET_OWNER = /for|assignment|with|catch|except|let|var|range/; +const NAME_LEAF = /identifier$|^name$|_name$|^simple_identifier$|^type_identifier$/; +const LITERAL_TYPE = /literal|^true$|^false$|^null$|^nil$|^none$|^boolean$/; +const LITERAL_PART = /content|escape|fragment|character|^true$|^false$/; + +function children(node: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null) out.push(child); + } + return out; +} + +function namedChildren(node: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) out.push(child); + } + return out; +} + +/** The innermost `declarator` of a C-style declaration. */ +function innermostDeclarator(node: SyntaxNode): SyntaxNode | null { + let declarator = node.childForFieldName('declarator'); + while (declarator !== null && declarator.childForFieldName('declarator') !== null) { + declarator = declarator.childForFieldName('declarator'); + } + return declarator; +} + +/** Names a tree declares itself (functions, classes, variables, parameters, loop targets). */ +export function declaredIn(root: SyntaxNode, into: Set = new Set()): Set { + const identifiersOf = (node: SyntaxNode): void => { + if (/identifier/.test(node.type)) into.add(node.text); + for (const child of children(node)) identifiersOf(child); + }; + const visit = (node: SyntaxNode): void => { + if (DECLARATION.test(node.type)) { + const name = node.childForFieldName('name'); + if (name !== null) into.add(name.text); + const declarator = innermostDeclarator(node); + if (declarator !== null && /identifier/.test(declarator.type)) into.add(declarator.text); + const first = namedChildren(node).find((child) => + /^(simple_identifier|identifier|type_identifier)$/.test(child.type), + ); + if (first !== undefined) into.add(first.text); + } + if (TARGET_OWNER.test(node.type)) { + const target = + node.childForFieldName('left') ?? + node.childForFieldName('pattern') ?? + node.childForFieldName('name') ?? + node.childForFieldName('variable'); + if (target !== null) identifiersOf(target); + } + for (const child of children(node)) visit(child); + }; + visit(root); + return into; +} + +export interface ShapeOptions { + /** Names the shaped tree (or fixture directory) declares; undefined keeps no name. */ + declared: ReadonlySet | undefined; + /** GitNexus builtin names, kept as `X:`. */ + builtins: ReadonlySet; + /** `text` shapes comments (inline inputs); `ignore` drops them (fixture files). */ + comments: 'text' | 'ignore'; +} + +/** + * The anonymised shape of a tree: declared names → `ID`, external types and builtins kept, + * literal values dropped (kinds kept), keyword tokens kept. `skipField` drops one child field of + * the root (a definition's `body`, for header shapes). + */ +export function shapeOf(node: SyntaxNode, options: ShapeOptions, skipField?: string): string { + const named = node.isNamed; + if (named && /comment/.test(node.type)) { + return options.comments === 'ignore' ? '' : `C[${shapeText(node.text)}]`; + } + if (named && node.namedChildCount === 0 && NAME_LEAF.test(node.type)) { + // External types and library builtins carry meaning; undeclared placeholders do not. + const typeish = /type|namespace/.test(node.type) || node.parent?.type === 'user_type'; + // A fixture stub of a library type (namespace std { class vector {}; }) still stands for it. + if (typeish && options.builtins.has(node.text)) return `X:${node.text}`; + const external = + options.declared !== undefined && !options.declared.has(node.text) && node.text.length > 1; + const meaningful = typeish || options.builtins.has(node.text); + return external && meaningful ? `X:${node.text}` : 'ID'; + } + if (!named) return /^[a-z_@#]+$/i.test(node.type) ? `'${node.type}'` : node.type; + if (LITERAL_TYPE.test(node.type)) { + const leaf = children(node).every((child) => !child.isNamed || LITERAL_PART.test(child.type)); + if (leaf) return node.type; + } + const kids: string[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (skipField !== undefined && node.fieldNameForChild(i) === skipField) continue; + kids.push(shapeOf(child, options)); + } + return kids.length > 0 ? `${node.type}(${kids.join(' ')})` : node.type; +} + +function errorShare(root: SyntaxNode): number { + let nodes = 0; + let errors = 0; + const count = (node: SyntaxNode): void => { + nodes++; + if (node.type === 'ERROR' || node.isMissing) errors++; + for (const child of children(node)) count(child); + }; + count(root); + return errors / nodes; +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +/** Repo-path file listing and reading, shared with the facet context. */ +export interface SourceReader { + /** Text of a repo path, null when it cannot be read. */ + read(repoPath: string): string | null; + /** Repo paths of the files under a repo directory, recursively, sorted. */ + list(repoDir: string): string[]; +} + +/** The fixture tree: directories by name, files by base name and fixture-relative path. */ +export class FixtureIndex { + readonly dirs = new Map(); + readonly files = new Map(); + + constructor( + readonly root: string, + files: readonly string[], + ) { + const push = (map: Map, key: string, value: string): void => { + const list = map.get(key) ?? []; + if (!list.includes(value)) list.push(value); + map.set(key, list); + }; + for (const file of [...files].sort()) { + const relative = path.posix.relative(root, file); + push(this.files, path.posix.basename(file), file); + push(this.files, relative, file); + const segments = relative.split('/').slice(0, -1); + for (let depth = 0; depth < Math.min(segments.length, FIXTURE_DIR_DEPTH); depth++) { + push(this.dirs, segments[depth], path.posix.join(root, ...segments.slice(0, depth + 1))); + } + } + } +} + +export interface FixtureDef { + /** sha of the definition's shape. */ + full: string; + /** sha of the shape without its body. */ + header: string; + /** Tree-sitter node type. */ + kind: string; + /** Identifiers the definition mentions. */ + refs: ReadonlySet; +} + +export interface FixtureDefs { + /** Function / method / constructor name → its definitions. */ + names: Map; + /** Directory-relative path → sha of the file's shape. */ + files: Map; + /** Type name → its shape and the names its header mentions (bases). */ + types: Map }>; + /** Field name → `:`. */ + fields: Map; +} + +const TYPE_DECL = + /class|struct|interface|enum|record|object_declaration|trait_item|impl_item|type_spec|protocol/; +const FIELD_DECL = /field_declaration|property_declaration|field_definition/; + +function identifiersIn(node: SyntaxNode, skipField?: string): Set { + const out = new Set(); + const walk = (current: SyntaxNode, top: boolean): void => { + if (/identifier/.test(current.type)) out.add(current.text); + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child === null) continue; + if (top && skipField !== undefined && current.fieldNameForChild(i) === skipField) continue; + walk(child, false); + } + }; + walk(node, true); + return out; +} + +function definitionName(node: SyntaxNode): string | null { + let name = node.childForFieldName('name')?.text ?? null; + if (name === null) { + // C/C++: function_definition → declarator → … → identifier / qualified name. + const declarator = innermostDeclarator(node); + const leaf = declarator?.childForFieldName('name') ?? declarator; + name = leaf?.text.split('::').pop() ?? null; + } + if (name === null || /[\s(]/.test(name)) { + const first = namedChildren(node).find((child) => + /^(simple_identifier|identifier)$/.test(child.type), + ); + if (first !== undefined) name = first.text; + } + return name; +} + +/** Tree-sitter parsers by language and the shapes built on them, for one run. */ +export class SourceShaper { + private readonly parsers = new Map(); + private readonly defsCache = new Map(); + private readonly fileShapes = new Map(); + + constructor( + private readonly reader: SourceReader, + private readonly builtins: () => ReadonlySet, + ) {} + + /** A parser for `lang` (TypeScript parses as TSX); null when the grammar is unavailable. */ + async parserFor(lang: SupportedLanguages): Promise { + if (!this.parsers.has(lang)) { + try { + const variant = lang === SupportedLanguages.TypeScript ? 'x.tsx' : undefined; + this.parsers.set(lang, await createParserForLanguage(lang, variant)); + } catch { + this.parsers.set(lang, null); + } + } + return this.parsers.get(lang) ?? null; + } + + private async parse(lang: SupportedLanguages, text: string): Promise { + const parser = await this.parserFor(lang); + if (parser === null) return null; + try { + return parser.parse(text, null, { bufferSize: getTreeSitterBufferSize(text) }).rootNode; + } catch { + return null; + } + } + + /** + * Rule 1 / rule 2: the profile of inline source in `lang`, `shape:` (or `cobol:`); + * null when it does not parse as code. + */ + async syntaxProfile(lang: SupportedLanguages, source: string): Promise { + if (lang === SupportedLanguages.Cobol) { + const words = cobolWords(source); + return words.length > 0 ? `cobol:${words.sort().join(',')}` : null; + } + const root = await this.parse(lang, source.replace(/__X__/g, 'x')); + if (root === null || errorShare(root) > MAX_ERROR_SHARE) return null; + const options: ShapeOptions = { + declared: declaredIn(root), + builtins: this.builtins(), + comments: 'text', + }; + return `shape:${sha12(shapeOf(root, options))}`; + } + + /** Rule 8: the shape of one fixture file (repo path); null when it cannot be shaped. */ + async fixtureFileShape(file: string): Promise { + const cached = this.fileShapes.get(file); + if (cached !== undefined) return cached; + let shape: string | null = null; + const text = this.reader.read(file); + if (text !== null && text.length < MAX_SOURCE_CHARS) { + const lang = EXT_LANG.get(path.posix.extname(file).slice(1)); + if (lang !== undefined && lang !== SupportedLanguages.Cobol) { + const root = await this.parse(lang, text); + const options: ShapeOptions = { + declared: root === null ? undefined : declaredIn(root), + builtins: this.builtins(), + comments: 'ignore', + }; + shape = root === null ? null : sha12(shapeOf(root, options)); + } else shape = sha12(text.replace(/\s+/g, ' ')); + } + this.fileShapes.set(file, shape); + return shape; + } + + /** Rule 7: the definitions, types and fields of one fixture directory (repo path). */ + async fixtureDefs(dir: string): Promise { + const cached = this.defsCache.get(dir); + if (cached !== undefined) return cached; + const defs: FixtureDefs = { + names: new Map(), + files: new Map(), + types: new Map(), + fields: new Map(), + }; + this.defsCache.set(dir, defs); + const parsed: { file: string; root: SyntaxNode }[] = []; + for (const file of this.reader.list(dir)) { + const lang = EXT_LANG.get(path.posix.extname(file).slice(1)); + if (lang === undefined || lang === SupportedLanguages.Cobol) continue; + const text = this.reader.read(file); + if (text === null || text.length >= MAX_SOURCE_CHARS) continue; + const root = await this.parse(lang, text); + if (root !== null) parsed.push({ file, root }); + } + const declared = new Set(); + for (const { root } of parsed) declaredIn(root, declared); + const options: ShapeOptions = { declared, builtins: this.builtins(), comments: 'ignore' }; + const profile = (node: SyntaxNode): string => sha12(shapeOf(node, options)); + const header = (node: SyntaxNode): string => sha12(shapeOf(node, options, 'body')); + const fieldTypes = new Map(); + const push = (map: Map, key: string, value: T): void => { + const list = map.get(key) ?? []; + list.push(value); + map.set(key, list); + }; + for (const { file, root } of parsed) { + defs.files.set(path.posix.relative(dir, file), profile(root)); + const visit = (node: SyntaxNode): void => { + const isType = + (TYPE_DECL.test(node.type) && !/body|list|specifier$/.test(node.type)) || + /^(class|struct|union|enum)_specifier$/.test(node.type); + if (isType) { + const name = + node.childForFieldName('name')?.text ?? + namedChildren(node).find((child) => /identifier/.test(child.type))?.text; + if (name !== undefined && !defs.types.has(name)) { + defs.types.set(name, { shape: profile(node), bases: identifiersIn(node, 'body') }); + } + } + if (FIELD_DECL.test(node.type)) { + const type = node.childForFieldName('type'); + const nameNode = node.childForFieldName('name') ?? node.childForFieldName('declarator'); + if (type !== null && nameNode !== null) { + push(fieldTypes, nameNode.text.replace(/[^\w$].*$/, ''), type); + } + } + if ( + /function|method|constructor/.test(node.type) && + !/call|invocation|reference/.test(node.type) + ) { + const name = definitionName(node); + if (name !== null) { + push(defs.names, name, { + full: profile(node), + header: header(node), + kind: node.type, + refs: identifiersIn(node), + }); + } + } + for (const child of children(node)) visit(child); + }; + visit(root); + } + for (const [name, types] of fieldTypes) { + defs.fields.set( + name, + types.map((type) => { + const leaf = type.text.replace(/[*&[\]\s]|^.*\./g, ''); + return `${profile(type)}:${defs.types.get(leaf)?.shape ?? '-'}`; + }), + ); + } + return defs; + } +} diff --git a/gitnexus/tools/scenario-map/facet/tables.ts b/gitnexus/tools/scenario-map/facet/tables.ts new file mode 100644 index 000000000..61f2f99ce --- /dev/null +++ b/gitnexus/tools/scenario-map/facet/tables.ts @@ -0,0 +1,420 @@ +/** + * Production tables and numeric constants for the input facet (Layer 2, U6, R5, KTD6, design + * §5.3 rule 3). + * + * A table is one list of values written in one production file: + * - a string array (at least two string elements, and at least 80% of its elements strings); + * - the entries of `new Set([...])` and the keys of `new Map([[k, v], ...])`, strings or numbers + * (D3: Set/Map entries are tables, never named constants); + * - the keys of an object literal with at least three properties (D3); + * - the string cases of a `switch`; + * - the word alternatives of a regex literal or of `new RegExp('…')` (`(?:get|set)`). + * Array, switch and regex tables keep strings of two characters or more; Set, Map and object-key + * tables keep every non-empty string. A table has at least two values. + * + * Property columns: across the run's production files, the string values of every array (or + * `new Set([...])`) written as an object property's value, grouped by the property key + * (`prop:`), the descriptor and provider columns of the language registry. Generic keys + * (`name`, `type`, `kind` …) and columns over 300 values are left out. + * + * Numeric constants (the ±1 rule of KTD6): every numeric literal of a production file, negated + * under a unary minus, and every outermost arithmetic expression Babel's `evaluate()` folds with + * confidence (`2 * 50`, `LIMIT + 1` over a `const`). Literals inside Set/Map entries and numeric + * object keys are tables (D3), not constants. + * + * {@link TableIndex} searches for the best-fit table of a value tier by tier (design §5.3 rule + * 3): the test's executed production files, then its static import closure up to two hops, then + * the property columns. Within a tier the table that holds the most of the test's literals wins, + * then the larger one; tables are never pooled across files. + * + * Library only; input-facet.ts builds one {@link TableIndex} per run. + */ +import { parse } from '@babel/parser'; +import traverse, { type NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import { byText } from '../util.js'; + +/** A value as a table holds it: `s:` for strings, `n:` for numbers. */ +export type LiteralKey = string; + +export const stringKey = (value: string): LiteralKey => `s:${value}`; +export const numberKey = (value: number): LiteralKey => `n:${String(value)}`; + +export interface ProductionTable { + /** `#`, or `prop:` for a property column. */ + id: string; + values: ReadonlySet; +} + +export interface FileFacts { + /** Repo path. */ + file: string; + tables: ProductionTable[]; + /** Numeric constants for the ±1 rule. */ + constants: ReadonlySet; + /** Property key → string values of arrays written as that property's value. */ + columns: ReadonlyMap; +} + +/** Where a test's best-fit table is searched, in tier order (repo paths of production files). */ +export interface TableSearch { + /** Production files the test executed (Layer 1 provenance). */ + executed: readonly string[]; + /** Production files within two hops of the test file's static imports. */ + closure: readonly string[]; +} + +const PLUGINS: ['typescript', 'decorators-legacy'] = ['typescript', 'decorators-legacy']; +/** Property keys whose columns mix unrelated values. */ +const GENERIC_KEYS: ReadonlySet = new Set([ + 'name', + 'type', + 'kind', + 'id', + 'label', + 'title', + 'description', + 'message', + 'text', + 'value', + 'key', + 'path', + 'file', + 'filePath', + 'source', + 'target', + 'lang', + 'language', + 'version', + 'default', +]); +const MAX_COLUMN_SIZE = 300; +const MIN_OBJECT_KEYS = 3; + +const EMPTY_FACTS = (file: string): FileFacts => ({ + file, + tables: [], + constants: new Set(), + columns: new Map(), +}); + +/** A string literal or a plain template's text, else null. */ +function stringOf(node: t.Node | null | undefined): string | null { + if (node === null || node === undefined) return null; + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? null; + } + return null; +} + +/** A numeric literal's value, negated under a unary minus, else null. */ +function numberOf(node: t.Node | null | undefined): number | null { + if (node === null || node === undefined) return null; + if (t.isNumericLiteral(node)) return node.value; + if (t.isUnaryExpression(node, { operator: '-' }) && t.isNumericLiteral(node.argument)) { + return -node.argument.value; + } + return null; +} + +/** The words of a regex's alternations: `(?:THRU|THROUGH)`, `\b(STRING|UNSTRING)\b` … */ +export function regexWords(pattern: string): string[] { + const out = new Set(); + const clean = pattern.replace(/\\[sSdDwWbB]/g, ' ').replace(/\\(.)/g, '$1'); + for (const group of clean.match(/\(([^()]*\|[^()]*)\)/g) ?? []) { + for (const alternative of group.replace(/^\((\?[:!=<]+)?|\)$/g, '').split('|')) { + const word = alternative.replace(/[?*+{}[\]^$.]/g, '').trim(); + if (/^[\w-]{2,}$/.test(word)) out.add(word); + } + } + return [...out]; +} + +/** `new Set()` / `new Map()`: the entries array, else null. */ +function collectionEntries(node: t.Node): { kind: 'Set' | 'Map'; array: t.ArrayExpression } | null { + if (!t.isNewExpression(node) || !t.isIdentifier(node.callee)) return null; + const kind = node.callee.name; + const array = node.arguments[0]; + if ((kind !== 'Set' && kind !== 'Map') || !t.isArrayExpression(array)) return null; + return { kind, array }; +} + +function objectKeyOf(property: t.Node): string | null { + if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) return null; + if (property.computed) return stringOf(property.key); + if (t.isIdentifier(property.key)) return property.key.name; + if (t.isStringLiteral(property.key)) return property.key.value; + return null; +} + +interface Collected { + tables: LiteralKey[][]; + columns: Map>; + /** Numeric literal nodes that are table entries or keys, not constants (D3). */ + tableNumbers: Set; +} + +function collectTables(ast: t.File): Collected { + const tables: LiteralKey[][] = []; + const columns = new Map>(); + const tableNumbers = new Set(); + const handled = new WeakSet(); + const add = (values: readonly (LiteralKey | null)[]): void => { + const unique = [...new Set(values.filter((value): value is LiteralKey => value !== null))]; + if (unique.length >= 2) tables.push(unique); + }; + const strictString = (value: string | null): LiteralKey | null => + value !== null && value.length >= 2 ? stringKey(value) : null; + const looseString = (value: string | null): LiteralKey | null => + value !== null && value.length >= 1 ? stringKey(value) : null; + const entryKey = (node: t.Node | null | undefined): LiteralKey | null => { + const n = numberOf(node); + if (n !== null && node) tableNumbers.add(t.isUnaryExpression(node) ? node.argument : node); + return n !== null ? numberKey(n) : looseString(stringOf(node)); + }; + + t.traverseFast(ast, (node) => { + const collection = collectionEntries(node); + if (collection !== null) { + handled.add(collection.array); + const { elements } = collection.array; + if (collection.kind === 'Set') add(elements.map((element) => entryKey(element))); + else { + add( + elements.map((element) => + t.isArrayExpression(element) ? entryKey(element.elements[0]) : null, + ), + ); + for (const element of elements) { + if (t.isArrayExpression(element)) { + t.traverseFast(element, (inner) => { + if (t.isNumericLiteral(inner)) tableNumbers.add(inner); + }); + } + } + } + } + if (t.isArrayExpression(node) && !handled.has(node)) { + const strings = node.elements.map((element) => stringOf(element)); + const count = strings.filter((value) => value !== null).length; + if (count >= 2 && count >= node.elements.length * 0.8) add(strings.map(strictString)); + const keys = node.elements.map((element) => + t.isArrayExpression(element) ? stringOf(element.elements[0]) : null, + ); + if (keys.filter((key) => key !== null).length >= 2) add(keys.map(strictString)); + } + if (t.isObjectProperty(node) && !node.computed) { + const key = objectKeyOf(node); + let value: t.Node = node.value; + const set = collectionEntries(value); + if (set !== null && set.kind === 'Set') value = set.array; + if (key !== null && t.isArrayExpression(value)) { + const values = value.elements + .map((element) => stringOf(element)) + .filter((text): text is string => text !== null && text.length >= 2); + if (values.length > 0) { + const column = columns.get(key) ?? new Set(); + for (const text of values) column.add(text); + columns.set(key, column); + } + } + if (t.isNumericLiteral(node.key)) tableNumbers.add(node.key); + } + if (t.isObjectExpression(node) && node.properties.length >= MIN_OBJECT_KEYS) { + add(node.properties.map((property) => looseString(objectKeyOf(property)))); + } else if (t.isSwitchStatement(node)) { + add(node.cases.map((switchCase) => strictString(stringOf(switchCase.test)))); + } else if (t.isRegExpLiteral(node)) { + add(regexWords(node.pattern).map(stringKey)); + } else if ( + t.isNewExpression(node) && + t.isIdentifier(node.callee, { name: 'RegExp' }) && + node.arguments[0] !== undefined + ) { + const pattern = stringOf(node.arguments[0]); + if (pattern !== null) add(regexWords(pattern).map(stringKey)); + } + }); + return { tables, columns, tableNumbers }; +} + +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** Callees Babel's `evaluate()` folds (`Math.max(1, 2)`); other calls are not constants. */ +const FOLDABLE_CALLEE = /^(Math|Number)\./; + +function collectConstants(ast: t.File, tableNumbers: ReadonlySet): Set { + const constants = new Set(); + const literal = (node: t.NumericLiteral, parent: t.Node | null): void => { + if (tableNumbers.has(node)) return; + if (t.isTSLiteralType(parent)) return; + constants.add(t.isUnaryExpression(parent, { operator: '-' }) ? -node.value : node.value); + }; + const fold = (path: NodePath): void => { + const result = path.evaluate(); + const value: unknown = result.value; + if (result.confident && finite(value)) constants.add(value); + }; + try { + traverse(ast, { + NumericLiteral(path) { + literal(path.node, path.parent); + }, + BinaryExpression(path) { + if (!t.isBinaryExpression(path.parent)) fold(path); + }, + UnaryExpression(path) { + if (!t.isNumericLiteral(path.node.argument)) fold(path); + }, + CallExpression(path) { + const callee = path.node.callee; + const name = + t.isMemberExpression(callee) && + t.isIdentifier(callee.object) && + t.isIdentifier(callee.property) + ? `${callee.object.name}.${callee.property.name}` + : ''; + if (FOLDABLE_CALLEE.test(name)) fold(path); + }, + }); + } catch { + // Babel's scope tracker throws on code that would not compile; keep the plain literals. + constants.clear(); + const parents = new Map(); + t.traverseFast(ast, (node) => { + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + const child: unknown = (node as unknown as Record)[key]; + const children = Array.isArray(child) ? child : [child]; + for (const c of children) if (t.isNode(c)) parents.set(c, node); + } + if (t.isNumericLiteral(node)) literal(node, parents.get(node) ?? null); + }); + } + return constants; +} + +/** Tables, constants and property columns of one production source; null when it does not parse. */ +export function factsOf(file: string, source: string): FileFacts { + let ast: t.File; + try { + ast = parse(source, { sourceType: 'module', plugins: PLUGINS, errorRecovery: true }); + } catch { + return EMPTY_FACTS(file); + } + const { tables, columns, tableNumbers } = collectTables(ast); + return { + file, + tables: tables.map((values, index) => ({ id: `${file}#${index}`, values: new Set(values) })), + constants: collectConstants(ast, tableNumbers), + columns: new Map([...columns].map(([key, values]) => [key, [...values]])), + }; +} + +export interface TableIndexOptions { + /** Text of a repo path, null when it cannot be read. */ + read(repoPath: string): string | null; + /** Production files of the run (repo paths): the property-column universe. */ + columnFiles: readonly string[]; +} + +/** Lazily parsed production facts of one run, with the tiered best-fit search. */ +export class TableIndex { + private readonly facts = new Map(); + private columnTables: ProductionTable[] | null = null; + + constructor(private readonly options: TableIndexOptions) {} + + /** The facts of a production file; empty when it cannot be read or parsed. */ + factsOf(file: string): FileFacts { + let facts = this.facts.get(file); + if (facts === undefined) { + const source = this.options.read(file); + facts = source === null ? EMPTY_FACTS(file) : factsOf(file, source); + this.facts.set(file, facts); + } + return facts; + } + + /** The property columns over the run's production files, built on first use. */ + columns(): readonly ProductionTable[] { + if (this.columnTables === null) { + const merged = new Map>(); + for (const file of [...this.options.columnFiles].sort()) { + for (const [key, values] of this.factsOf(file).columns) { + const column = merged.get(key) ?? new Set(); + for (const value of values) column.add(value); + merged.set(key, column); + } + } + this.columnTables = [...merged] + .filter( + ([key, values]) => + !GENERIC_KEYS.has(key) && values.size >= 2 && values.size <= MAX_COLUMN_SIZE, + ) + .sort(([a], [b]) => byText(a, b)) + .map(([key, values]) => ({ + id: `prop:${key}`, + values: new Set([...values].map(stringKey)), + })); + } + return this.columnTables; + } + + /** + * The best-fit table holding `value`, tier by tier (executed files, two-hop closure, property + * columns); null when no tier has one. `literals` are the test's literal keys (duplicates count). + */ + tableOf(search: TableSearch, value: LiteralKey, literals: readonly LiteralKey[]): string | null { + const inFiles = (files: readonly string[]): ProductionTable[] => + files.flatMap((file) => this.factsOf(file).tables); + return ( + bestFit(inFiles(search.executed), value, literals) ?? + bestFit(inFiles(search.closure), value, literals) ?? + bestFit(this.columns(), value, literals) + ); + } + + /** The numeric constants of `files`, merged. */ + constantsOf(files: readonly string[]): Set { + const merged = new Set(); + for (const file of files) for (const value of this.factsOf(file).constants) merged.add(value); + return merged; + } + + /** Every string value of the tables of `files` (rule 1's GitNexus builtin names). */ + stringValues(files: readonly string[]): Set { + const out = new Set(); + for (const file of files) { + for (const table of this.factsOf(file).tables) { + for (const key of table.values) if (key.startsWith('s:')) out.add(key.slice(2)); + } + } + return out; + } +} + +/** The table that holds `value` and most of `literals`, then the largest; first wins a tie. */ +export function bestFit( + tables: readonly ProductionTable[], + value: LiteralKey, + literals: readonly LiteralKey[], +): string | null { + let best: string | null = null; + let bestScore = 0; + for (const table of tables) { + if (!table.values.has(value)) continue; + const explained = literals.filter((literal) => table.values.has(literal)).length; + const score = explained * 1e6 + table.values.size; + if (score > bestScore) { + best = table.id; + bestScore = score; + } + } + return best; +} + +/** True when `value - 1`, `value` or `value + 1` is one of `constants` (KTD6). */ +export const nearConstant = (constants: ReadonlySet, value: number): boolean => + constants.has(value) || constants.has(value - 1) || constants.has(value + 1); diff --git a/gitnexus/tools/scenario-map/inventory/cases.ts b/gitnexus/tools/scenario-map/inventory/cases.ts new file mode 100644 index 000000000..e8bffa3dd --- /dev/null +++ b/gitnexus/tools/scenario-map/inventory/cases.ts @@ -0,0 +1,945 @@ +/** + * Static test instances of the scenario map (U3, design §5.3, KTD3). + * + * Layer 0's extraction (tools/test-dupes) gives one `CaseRecord` per test call as written. This + * module expands each call into the instances Vitest collects from it, in collection order, with + * the values each instance binds: + * - the test's own `it.each` / `test.each` / `test.for` table (array, tagged template), mirroring + * Vitest 5: an `each` row is spread into the callback only when every row is an array, a `for` + * row never is, and a template table gives one object per row; + * - enclosing `describe.each` / `describe.for` tables, with the same rules; + * - enclosing `for…of` loops over a statically known array. + * Outer constructs vary slowest, as in collection order. + * + * Tables evaluate statically: literals, arrays, objects, templates, arithmetic, `Number.*` + * constants, `.slice()` and spreads, following `const` bindings (Babel scopes) whose array or + * object value never escapes: every reference must be a table argument, a `for…of` source, an + * `expect()` subject, a spread or `.slice()` copy that flows to the same places, a `.length` / + * `.includes()` / `.indexOf()` / `.join()` read, a type query, or a primitive coercion. Anything + * else (an assignment, a mutating or callback method, an argument to another function, an export) + * may change the value, so the binding is not evaluated. Limits: an element mutated through an + * alias (a `for…of` loop variable, or a test's own argument at run time) is not seen. + * + * Fail closed: a construct whose number of instances is not static (a computed table, another + * loop, a test inside a helper or a callback such as `cases.forEach`) gives a call site without + * instances, marked unresolved; a row whose count is known but whose values are not keeps its + * place, with the unknown parts marked `unresolved`. Both block strong evidence. + * + * Each call site also carries the location Vitest reports for its tests (V8's position of the + * call: the callee identifier, a member call's property, or the `(` of a call whose callee is + * itself a call, such as `it.each(t)(…)`) and, when every title is a plain string, its full name. + * + * Library only; the scenario-map CLI (U14) calls {@link loadStaticInventory}. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { generate } from '@babel/generator'; +import traverse, { type Binding, type NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import type { TestLocation } from '../../mutation/runner/test-ids.js'; +import { + eachTableOf, + extract, + roleOf, + shapeOf, + type CaseRecord, + type SourceFile, +} from '../../test-dupes/test-dupes.js'; +import { + fromPackagePath, + toPackagePath, + type DimensionKind, + type InstanceDimension, + type RowEntry, + type RowValue, + type StaticCallSite, + type StaticInstance, + type StaticInventory, +} from '../types.js'; +import { sha1 } from '../util.js'; + +/** Nesting limit for `const` bindings followed while evaluating one value. */ +const MAX_DEPTH = 8; +/** More instances than this at one call site are treated as not static. */ +const MAX_INSTANCES = 10_000; +const TABLE_CALL_ROOTS = new Set(['it', 'test', 'describe', 'suite']); +const TABLE_MODIFIERS = new Set(['each', 'for']); +/** Array methods that read without mutating and return a primitive. */ +const PRIMITIVE_READS = new Set(['length', 'includes', 'indexOf', 'lastIndexOf', 'join']); +const NUMBER_CONSTANTS: ReadonlyMap = new Map([ + ['MAX_SAFE_INTEGER', Number.MAX_SAFE_INTEGER], + ['MIN_SAFE_INTEGER', Number.MIN_SAFE_INTEGER], + ['MAX_VALUE', Number.MAX_VALUE], + ['MIN_VALUE', Number.MIN_VALUE], + ['EPSILON', Number.EPSILON], + ['POSITIVE_INFINITY', Number.POSITIVE_INFINITY], + ['NEGATIVE_INFINITY', Number.NEGATIVE_INFINITY], + ['NaN', Number.NaN], +]); + +type AnyPath = NodePath; + +const codeOf = (node: t.Node): string => generate(node, { comments: false, compact: true }).code; + +// ── Row values ───────────────────────────────────────────────────────────── + +const numberValue = (n: number): RowValue => ({ + kind: 'number', + text: Object.is(n, -0) ? '-0' : String(n), +}); +const UNDEFINED: RowValue = { kind: 'undefined' }; +const unresolvedValue = (node: t.Node, reason: string): RowValue => ({ + kind: 'unresolved', + code: codeOf(node), + reason, +}); + +const IDENTIFIER_KEY = /^[A-Za-z_$][\w$]*$/; + +/** JS-like text of a value; the row fingerprint hashes it, so unresolved reasons stay out. */ +export function renderRowValue(value: RowValue): string { + switch (value.kind) { + case 'string': + return JSON.stringify(value.value); + case 'number': + return value.text; + case 'bigint': + return `${value.text}n`; + case 'boolean': + return String(value.value); + case 'null': + case 'undefined': + return value.kind; + case 'regexp': + return `/${value.pattern}/${value.flags}`; + case 'array': + return `[${value.items.map(renderRowValue).join(', ')}]`; + case 'object': + return `{${value.entries + .map( + ({ key, value: v }) => + `${IDENTIFIER_KEY.test(key) ? key : JSON.stringify(key)}: ${renderRowValue(v)}`, + ) + .join(', ')}}`; + case 'unresolved': + return `?(${value.code})`; + } +} + +/** Every unresolved reason inside a value, depth first. */ +function unresolvedIn(value: RowValue): string[] { + if (value.kind === 'unresolved') return [value.reason]; + if (value.kind === 'array') return value.items.flatMap(unresolvedIn); + if (value.kind === 'object') return value.entries.flatMap((entry) => unresolvedIn(entry.value)); + return []; +} + +/** JS `String(value)` of a primitive value, else null. */ +function stringOf(value: RowValue): string | null { + switch (value.kind) { + case 'string': + return value.value; + case 'number': + return String(Number(value.text)); + case 'bigint': + return value.text; + case 'boolean': + return String(value.value); + case 'null': + case 'undefined': + return value.kind; + default: + return null; + } +} + +/** JS truthiness of a primitive value, else null. */ +function truthy(value: RowValue): boolean | null { + switch (value.kind) { + case 'string': + return value.value !== ''; + case 'number': { + const n = Number(value.text); + return n !== 0 && !Number.isNaN(n); + } + case 'bigint': + return value.text !== '0'; + case 'boolean': + return value.value; + case 'null': + case 'undefined': + return false; + case 'regexp': + case 'array': + case 'object': + return true; + default: + return null; + } +} + +/** Object entries with JS semantics: a repeated key keeps its first position, last value. */ +function objectValue(entries: readonly RowEntry[]): RowValue { + const byKey = new Map(); + for (const { key, value } of entries) byKey.set(key, value); + return { kind: 'object', entries: [...byKey].map(([key, value]) => ({ key, value })) }; +} + +// ── Static evaluation ────────────────────────────────────────────────────── + +/** Looks through TypeScript-only wrappers and parentheses. */ +function unwrap(input: AnyPath): AnyPath { + let current = input; + while ( + current.isTSAsExpression() || + current.isTSSatisfiesExpression() || + current.isTSNonNullExpression() || + current.isTSTypeAssertion() || + current.isParenthesizedExpression() + ) { + current = current.get('expression'); + } + return current; +} + +/** `it.each`, `test.for`, `describe.each` … as a callee or template tag. */ +const isTableTag = (callee: t.Node): boolean => + t.isMemberExpression(callee) && + !callee.computed && + t.isIdentifier(callee.property) && + TABLE_MODIFIERS.has(callee.property.name) && + TABLE_CALL_ROOTS.has(shapeOf(callee)?.root ?? ''); +const isTableCall = (call: t.CallExpression): boolean => isTableTag(call.callee); + +const purity = new WeakMap(); + +/** Whether a `const` binding's array or object value can never change (module docs). */ +function isPure(binding: Binding): boolean { + const known = purity.get(binding); + if (known !== undefined) return known; + purity.set(binding, false); // a cycle stays impure + const declaration = binding.path.parentPath; + const exported = declaration?.parentPath?.isExportNamedDeclaration() ?? false; + const pure = + !exported && + binding.constantViolations.length === 0 && + binding.referencePaths.every((reference) => isPureReference(reference)); + purity.set(binding, pure); + return pure; +} + +/** Whether one reference to a table binding only reads it (see {@link isPure}). */ +function isPureReference(reference: AnyPath): boolean { + let child = reference; + for (;;) { + const parent = child.parentPath; + if (parent === null) return false; + const key = child.key; + if ( + parent.isArrayExpression() || + parent.isSpreadElement() || + parent.isObjectExpression() || + (parent.isObjectProperty() && key === 'value') || + ((parent.isTSAsExpression() || + parent.isTSSatisfiesExpression() || + parent.isTSNonNullExpression() || + parent.isTSTypeAssertion() || + parent.isParenthesizedExpression()) && + key === 'expression') + ) { + child = parent; + continue; + } + if (parent.isTSTypeQuery() || parent.isBinaryExpression()) return true; + if (parent.isTemplateLiteral()) { + // A tag receives the value itself; only a table tag (`it.each\`…\``) is a known reader. + const tagged = parent.parentPath; + return !tagged.isTaggedTemplateExpression() || isTableTag(tagged.node.tag); + } + if (parent.isUnaryExpression()) return parent.node.operator !== 'delete'; + if (parent.isMemberExpression() && key === 'object') { + const property = parent.node.property; + if (parent.node.computed || !t.isIdentifier(property)) return false; + const grand = parent.parentPath; + const written = + (grand.isAssignmentExpression() && parent.key === 'left') || grand.isUpdateExpression(); + if (property.name === 'length') return !written; + const called = grand.isCallExpression() && parent.key === 'callee'; + if (called && PRIMITIVE_READS.has(property.name)) return true; + if (called && property.name === 'slice') { + child = grand; // a copy: its own flow decides + continue; + } + return false; + } + if (parent.isCallExpression() && child.listKey === 'arguments') { + if (key === 0 && isTableCall(parent.node)) return true; + return key === 0 && t.isIdentifier(parent.node.callee, { name: 'expect' }); + } + if (parent.isForOfStatement()) return key === 'right'; + if (parent.isVariableDeclarator() && key === 'init' && t.isIdentifier(parent.node.id)) { + const alias = parent.scope.getBinding(parent.node.id.name); + return alias !== undefined && alias.kind === 'const' && isPure(alias); + } + return false; + } +} + +type Resolved = { init: AnyPath; binding: Binding } | { reason: string }; + +/** The initialiser path of a plain `const` an identifier refers to. */ +function resolveConst(identifier: NodePath): Resolved { + const name = identifier.node.name; + const binding = identifier.scope.getBinding(name); + if (binding === undefined) return { reason: `${name} is not declared in the file` }; + if (binding.kind === 'module') return { reason: `${name} is imported` }; + const declarator = binding.path; + if (binding.kind !== 'const' || !declarator.isVariableDeclarator()) { + return { reason: `${name} is not a const` }; + } + const init = declarator.get('init'); + if (!t.isIdentifier(declarator.node.id) || init.node === null) { + return { reason: `${name} is not a plain const` }; + } + return { init, binding }; +} + +/** The static value of an expression; unknown parts come back as `unresolved` values. */ +export function evaluateValue(input: AnyPath, depth = 0): RowValue { + const p = unwrap(input); + const node = p.node; + if (t.isStringLiteral(node)) return { kind: 'string', value: node.value }; + if (t.isNumericLiteral(node)) return numberValue(node.value); + if (t.isBooleanLiteral(node)) return { kind: 'boolean', value: node.value }; + if (t.isNullLiteral(node)) return { kind: 'null' }; + if (t.isBigIntLiteral(node)) return { kind: 'bigint', text: String(node.value) }; + if (t.isRegExpLiteral(node)) return { kind: 'regexp', pattern: node.pattern, flags: node.flags }; + if (p.isTemplateLiteral()) { + let text = ''; + const expressions = p.get('expressions'); + for (const [index, quasi] of p.node.quasis.entries()) { + if (quasi.value.cooked == null) return unresolvedValue(node, 'invalid template escape'); + text += quasi.value.cooked; + const expression = expressions[index]; + if (expression === undefined) continue; + const part = stringOf(evaluateValue(expression, depth)); + if (part === null) return unresolvedValue(node, `not static: ${codeOf(node)}`); + text += part; + } + return { kind: 'string', value: text }; + } + if (p.isIdentifier()) return evaluateIdentifier(p, depth); + if (p.isUnaryExpression()) return evaluateUnary(p, depth); + if (p.isBinaryExpression()) return evaluateBinary(p, depth); + if (p.isMemberExpression()) return evaluateMember(p, depth); + if (p.isArrayExpression()) { + const items: RowValue[] = []; + for (const element of p.get('elements')) { + if (element.node === null) return unresolvedValue(node, 'array hole'); + if (element.isSpreadElement()) { + const spread = evaluateValue(element.get('argument'), depth); + if (spread.kind !== 'array') { + return unresolvedValue(node, `spread of a non-static value: ${codeOf(element.node)}`); + } + items.push(...spread.items); + } else items.push(evaluateValue(element, depth)); + } + return { kind: 'array', items }; + } + if (p.isObjectExpression()) return evaluateObject(p, depth); + if (p.isCallExpression()) { + const sliced = evaluateSlice(p, depth); + if (sliced !== null) return sliced; + } + return unresolvedValue(node, `not static: ${codeOf(node)}`); +} + +function evaluateIdentifier(p: NodePath, depth: number): RowValue { + const name = p.node.name; + if (p.scope.getBinding(name) === undefined) { + if (name === 'undefined') return UNDEFINED; + if (name === 'NaN') return numberValue(Number.NaN); + if (name === 'Infinity') return numberValue(Number.POSITIVE_INFINITY); + } + const resolved = resolveConst(p); + if ('reason' in resolved) return unresolvedValue(p.node, resolved.reason); + if (depth >= MAX_DEPTH) return unresolvedValue(p.node, `${name} nests too deep`); + const value = evaluateValue(resolved.init, depth + 1); + if ((value.kind === 'array' || value.kind === 'object') && !isPure(resolved.binding)) { + return unresolvedValue(p.node, `${name} may be mutated`); + } + return value; +} + +function evaluateUnary(p: NodePath, depth: number): RowValue { + const { operator } = p.node; + if (operator === 'void') return UNDEFINED; + const argument = evaluateValue(p.get('argument'), depth); + if (operator === '!') { + const value = truthy(argument); + return value === null + ? unresolvedValue(p.node, 'not static') + : { kind: 'boolean', value: !value }; + } + if (argument.kind === 'number' && (operator === '-' || operator === '+' || operator === '~')) { + const n = Number(argument.text); + return numberValue(operator === '-' ? -n : operator === '+' ? n : ~n); + } + if (argument.kind === 'bigint' && operator === '-') { + return { kind: 'bigint', text: String(-BigInt(argument.text)) }; + } + return unresolvedValue(p.node, `not static: ${codeOf(p.node)}`); +} + +const NUMBER_OPERATORS: Readonly number>> = { + '+': (a, b) => a + b, + '-': (a, b) => a - b, + '*': (a, b) => a * b, + '/': (a, b) => a / b, + '%': (a, b) => a % b, + '**': (a, b) => a ** b, + '|': (a, b) => a | b, + '&': (a, b) => a & b, + '^': (a, b) => a ^ b, + '<<': (a, b) => a << b, + '>>': (a, b) => a >> b, + '>>>': (a, b) => a >>> b, +}; + +function evaluateBinary(p: NodePath, depth: number): RowValue { + const left = p.get('left'); + if (!left.isExpression()) return unresolvedValue(p.node, 'not static'); + const a = evaluateValue(left, depth); + const b = evaluateValue(p.get('right'), depth); + const { operator } = p.node; + if (operator === '+' && (a.kind === 'string' || b.kind === 'string')) { + const x = stringOf(a); + const y = stringOf(b); + if (x !== null && y !== null) return { kind: 'string', value: x + y }; + } + const apply = NUMBER_OPERATORS[operator]; + if (apply !== undefined && a.kind === 'number' && b.kind === 'number') { + return numberValue(apply(Number(a.text), Number(b.text))); + } + return unresolvedValue(p.node, `not static: ${codeOf(p.node)}`); +} + +function evaluateMember(p: NodePath, depth: number): RowValue { + const { object, property, computed } = p.node; + if (!computed && t.isIdentifier(property)) { + if (t.isIdentifier(object, { name: 'Number' }) && p.scope.getBinding('Number') === undefined) { + const constant = NUMBER_CONSTANTS.get(property.name); + if (constant !== undefined) return numberValue(constant); + } + if (property.name === 'length') { + const base = evaluateValue(p.get('object'), depth); + if (base.kind === 'string') return numberValue(base.value.length); + if (base.kind === 'array') return numberValue(base.items.length); + } + } + return unresolvedValue(p.node, `not static: ${codeOf(p.node)}`); +} + +function evaluateObject(p: NodePath, depth: number): RowValue { + const entries: RowEntry[] = []; + for (const property of p.get('properties')) { + if (property.isSpreadElement()) { + const spread = evaluateValue(property.get('argument'), depth); + if (spread.kind !== 'object') return unresolvedValue(p.node, 'spread of a non-static object'); + entries.push(...spread.entries); + continue; + } + if (!property.isObjectProperty()) return unresolvedValue(p.node, 'object method'); + const keyNode = property.node.key; + let key: string | null = null; + if (!property.node.computed && t.isIdentifier(keyNode)) key = keyNode.name; + else if (t.isStringLiteral(keyNode)) key = keyNode.value; + else if (t.isNumericLiteral(keyNode)) key = String(keyNode.value); + else { + const keyPath = property.get('key'); + key = keyPath.isExpression() ? stringOf(evaluateValue(keyPath, depth)) : null; + } + if (key === null) return unresolvedValue(p.node, 'computed key'); + const value = property.get('value'); + entries.push({ + key, + value: value.isExpression() + ? evaluateValue(value, depth) + : unresolvedValue(value.node, 'pattern'), + }); + } + return objectValue(entries); +} + +/** `x.slice(a, b)` over a static array or string with numeric arguments, else null. */ +function evaluateSlice(p: NodePath, depth: number): RowValue | null { + const callee = p.get('callee'); + if (!callee.isMemberExpression() || callee.node.computed) return null; + if (!t.isIdentifier(callee.node.property, { name: 'slice' })) return null; + const bounds = sliceBounds(p, depth); + if (bounds === null) return null; + const base = evaluateValue(callee.get('object'), depth); + if (base.kind === 'array') return { kind: 'array', items: base.items.slice(...bounds) }; + if (base.kind === 'string') return { kind: 'string', value: base.value.slice(...bounds) }; + return null; +} + +function sliceBounds(p: NodePath, depth: number): number[] | null { + const args = p.get('arguments'); + if (args.length > 2) return null; + const bounds: number[] = []; + for (const arg of args) { + if (!arg.isExpression()) return null; + const value = evaluateValue(arg, depth); + if (value.kind !== 'number') return null; + bounds.push(Number(value.text)); + } + return bounds; +} + +// ── Tables ───────────────────────────────────────────────────────────────── + +interface TableRow { + value: RowValue; + node: t.Node | null; +} +type Table = { rows: TableRow[]; template: boolean } | { reason: string }; + +/** The rows of a table or loop source, with their nodes; a non-static row count is a reason. */ +function tableRows(input: AnyPath, depth = 0): Table { + const p = unwrap(input); + if (p.isArrayExpression()) { + const rows: TableRow[] = []; + for (const element of p.get('elements')) { + if (element.node === null) return { reason: 'array hole' }; + if (element.isSpreadElement()) { + const spread = tableRows(element.get('argument'), depth); + if ('reason' in spread) { + return { reason: `spread of a non-static value: ${codeOf(element.node)}` }; + } + rows.push(...spread.rows); + } else rows.push({ value: evaluateValue(element, depth), node: element.node }); + } + return { rows, template: false }; + } + if (p.isIdentifier()) { + const resolved = resolveConst(p); + if ('reason' in resolved) return { reason: resolved.reason }; + if (depth >= MAX_DEPTH) return { reason: `${p.node.name} nests too deep` }; + const table = tableRows(resolved.init, depth + 1); + if ('rows' in table && !isPure(resolved.binding)) { + return { reason: `${p.node.name} may be mutated` }; + } + return table; + } + if (p.isCallExpression()) { + const callee = p.get('callee'); + const bounds = sliceBounds(p, depth); + if ( + bounds !== null && + callee.isMemberExpression() && + !callee.node.computed && + t.isIdentifier(callee.node.property, { name: 'slice' }) + ) { + const base = tableRows(callee.get('object'), depth); + return 'rows' in base ? { rows: base.rows.slice(...bounds), template: false } : base; + } + } + if (p.isTemplateLiteral() && p.parentPath.isTaggedTemplateExpression()) { + return templateRows(p, depth); + } + return { reason: `not static: ${codeOf(p.node)}` }; +} + +/** A tagged-template table, read as Vitest 5's `formatTemplateString` reads it. */ +function templateRows(p: NodePath, depth: number): Table { + const cooked = p.node.quasis.map((quasi) => quasi.value.cooked); + if (cooked.some((text) => text == null)) return { reason: 'invalid template escape' }; + const strings = cooked.map((text) => text ?? ''); + const cells = p.get('expressions').map((cell) => evaluateValue(cell, depth)); + if (cells.length === 0) { + const rows = strings.map( + (text): TableRow => ({ value: { kind: 'string', value: text }, node: null }), + ); + return { rows, template: false }; + } + const header = strings.join('').trim().replace(/ /g, '').split('\n')[0].split('|'); + const rows: TableRow[] = []; + for (let row = 0; row < Math.floor(cells.length / header.length); row++) { + const entries = header.map((key, column) => ({ + key, + value: cells[row * header.length + column], + })); + rows.push({ value: objectValue(entries), node: null }); + } + return { rows, template: true }; +} + +/** The callback arguments of each row (Vitest 5 `each` / `for` semantics). */ +function rowArgs(rows: readonly TableRow[], kind: 'each' | 'for'): (RowValue[] | null)[] { + if (kind === 'for') return rows.map((row) => [row.value]); + const allArrays = rows.every((row) => row.value.kind === 'array'); + const someScalar = rows.some( + (row) => row.value.kind !== 'array' && row.value.kind !== 'unresolved', + ); + return rows.map((row) => { + if (allArrays && row.value.kind === 'array') return row.value.items; + return someScalar ? [row.value] : null; + }); +} + +// ── Call sites ───────────────────────────────────────────────────────────── + +interface Dimension { + kind: DimensionKind; + rows: TableRow[]; + args: (RowValue[] | null)[]; +} + +type Expansion = + | { dimensions: Dimension[]; suiteTitles: t.Node[]; eachSuite: boolean } + | { reason: string }; + +/** The node path of `node` inside `root`'s subtree, else null. */ +function pathOf(root: AnyPath, node: t.Node): AnyPath | null { + if (root.node === node) return root; + let found: AnyPath | null = null; + root.traverse({ + enter(p) { + if (p.node === node) { + found = p; + p.stop(); + } + }, + }); + return found; +} + +/** A table dimension for an `each`/`for` table node under `calleePath`, or a reason. */ +function tableDimension( + calleePath: AnyPath, + table: t.Node, + kinds: { each: DimensionKind; for: DimensionKind }, + useFor: boolean, +): Dimension | { reason: string } { + const tablePath = pathOf(calleePath, table); + if (tablePath === null) return { reason: 'table not found' }; + const rows = tableRows(tablePath); + if ('reason' in rows) return rows; + return { + kind: useFor ? kinds.for : kinds.each, + rows: rows.rows, + args: rowArgs(rows.rows, useFor || rows.template ? 'for' : 'each'), + }; +} + +/** Enclosing constructs that repeat the call, outermost first, or why their count is unknown. */ +function enclosing(callPath: AnyPath): Expansion { + const chain: AnyPath[] = []; + for (let p: AnyPath | null = callPath; p !== null; p = p.parentPath) chain.unshift(p); + const dimensions: Dimension[] = []; + const suiteTitles: t.Node[] = []; + let eachSuite = false; + for (let i = 0; i < chain.length - 1; i++) { + const p = chain[i]; + const child = chain[i + 1]; + if (p.isFunction()) { + const host = p.parentPath; + if ( + host === null || + !host.isCallExpression() || + p.listKey !== 'arguments' || + roleOf(host.node, shapeOf(host.node.callee)) !== 'suite' + ) { + return { reason: 'nested-function' }; + } + const title = host.node.arguments[0]; + if (title !== undefined) suiteTitles.push(title); + const table = eachTableOf(host.node.callee); + if (table === null) continue; + eachSuite = true; + const useFor = shapeOf(host.node.callee)?.mods.includes('for') ?? false; + const dimension = tableDimension( + host.get('callee'), + table, + { each: 'describe-each', for: 'describe-for' }, + useFor, + ); + if ('reason' in dimension) { + return { reason: `${useFor ? 'describe-for' : 'describe-each'}: ${dimension.reason}` }; + } + dimensions.push(dimension); + } else if (p.isForOfStatement()) { + if (child.key === 'right') continue; + if (p.node.await) return { reason: 'loop: for await' }; + if (child.key !== 'body') return { reason: `loop: ${p.node.type}` }; + const rows = tableRows(p.get('right')); + if ('reason' in rows) return { reason: `for-of: ${rows.reason}` }; + dimensions.push({ + kind: 'for-of', + rows: rows.rows, + args: rows.rows.map((row) => [row.value]), + }); + } else if (p.isLoop()) { + return { reason: `loop: ${p.node.type}` }; + } else if (p.isClass()) { + return { reason: 'class' }; + } + } + return { dimensions, suiteTitles, eachSuite }; +} + +/** Plain-string title text, else null. */ +function plainTitle(node: t.Node | undefined): string | null { + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? null; + } + return null; +} + +/** V8's position of the call, as Vitest reports it (1-based column). */ +function callLocation(call: t.CallExpression, source: string): TestLocation { + const callee = call.callee; + const at = (loc: t.SourceLocation['start'] | undefined): TestLocation => ({ + line: loc?.line ?? 0, + column: (loc?.column ?? 0) + 1, + }); + if (t.isIdentifier(callee)) return at(callee.loc?.start); + if (t.isMemberExpression(callee)) return at(callee.property.loc?.start); + // The callee is itself a call (`it.each(t)(…)`): V8 points at this call's `(`. + const before = call.typeArguments ?? callee; + let index = before.end ?? 0; + let line = before.loc?.end.line ?? 0; + let column = before.loc?.end.column ?? 0; + const advance = (): void => { + if (source[index] === '\n') { + line++; + column = 0; + } else column++; + index++; + }; + while (index < source.length && source[index] !== '(') { + if (source.startsWith('//', index)) { + while (index < source.length && source[index] !== '\n') advance(); + } else if (source.startsWith('/*', index)) { + advance(); + advance(); + while (index < source.length && !source.startsWith('*/', index)) advance(); + advance(); + advance(); + } else if (/\s/.test(source[index])) advance(); + else break; + } + return { line, column: column + 1 }; +} + +/** Cartesian product of the dimensions' rows, outermost varying slowest. */ +function instancesOf(dimensions: readonly Dimension[], ownTable: boolean): StaticInstance[] { + const indexes: number[][] = [[]]; + for (const dimension of dimensions) { + const next: number[][] = []; + for (const prefix of indexes) { + for (let row = 0; row < dimension.rows.length; row++) next.push([...prefix, row]); + } + indexes.splice(0, indexes.length, ...next); + } + return indexes.map((combination, row): StaticInstance => { + const bound = combination.map( + (index, d): InstanceDimension => ({ + kind: dimensions[d].kind, + index, + row: dimensions[d].rows[index].value, + args: dimensions[d].args[index], + node: dimensions[d].rows[index].node, + }), + ); + const unresolved = new Set(); + for (const dimension of bound) { + for (const reason of unresolvedIn(dimension.row)) + unresolved.add(`${dimension.kind}: ${reason}`); + if (dimension.args === null) unresolved.add('each-row-shape'); + } + const own = ownTable ? bound[bound.length - 1] : undefined; + return { + row, + dimensions: bound, + args: own === undefined ? [] : own.args, + unresolved: [...unresolved].sort(), + rowFingerprint: sha1(bound.map((d) => `${d.kind}:${renderRowValue(d.row)}`).join('\n')), + }; + }); +} + +/** A call site whose number of instances is not static. */ +const unknownSite = (record: CaseRecord, source: string, reason: string): StaticCallSite => ({ + file: toPackagePath(record.file), + record, + location: callLocation(record.call, source), + fullName: null, + instances: null, + unresolved: [reason], +}); + +function expandSite(record: CaseRecord, callPath: AnyPath, source: string): StaticCallSite { + const unknown = (reason: string): StaticCallSite => unknownSite(record, source, reason); + const outer = enclosing(callPath); + if ('reason' in outer) return unknown(outer.reason); + const dimensions = [...outer.dimensions]; + if (record.each !== null) { + const useFor = shapeOf(record.call.callee)?.mods.includes('for') ?? false; + const own = tableDimension( + callPath.get('callee'), + record.each, + { each: 'each', for: 'for' }, + useFor, + ); + if ('reason' in own) return unknown(`each-table: ${own.reason}`); + dimensions.push(own); + } + const count = dimensions.reduce((product, dimension) => product * dimension.rows.length, 1); + if (count > MAX_INSTANCES) return unknown(`too many instances: ${count}`); + const titles = [...outer.suiteTitles, record.call.arguments[0]].map(plainTitle); + const fullName = + outer.eachSuite || record.each !== null || titles.some((title) => title === null) + ? null + : titles.join(' > '); + return { + file: toPackagePath(record.file), + record, + location: callLocation(record.call, source), + fullName, + instances: instancesOf(dimensions, record.each !== null), + unresolved: [], + }; +} + +/** Node paths of the given call nodes in one AST. */ +function callPaths(ast: t.File, calls: ReadonlySet): Map { + const found = new Map(); + traverse(ast, { + CallExpression(p) { + if (calls.has(p.node)) found.set(p.node, p); + }, + }); + return found; +} + +/** + * Expands the test calls of `files` (repo paths) into call sites with instances. Only files in + * `tests` (repo paths; default every file) give records; the others only resolve constants for + * Layer 0's context hash. Sites come per file in the order given, then in walk order. + */ +/** Callees that register no test: the Vitest utilities and the around hooks. */ +const REGISTERS_NOTHING = new Set(['vi', 'expect', 'bench', 'aroundAll', 'aroundEach']); + +const DECLARATIONS = new Set([ + 'ImportDeclaration', + 'ExportNamedDeclaration', + 'ExportDefaultDeclaration', + 'ExportAllDeclaration', + 'VariableDeclaration', + 'FunctionDeclaration', + 'ClassDeclaration', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSEnumDeclaration', + 'TSModuleDeclaration', + 'TSImportEqualsDeclaration', + 'TSDeclareFunction', + 'EmptyStatement', +]); + +/** + * Whether statements at a registration level (the module, a suite's inline callback, a block, + * branch or loop in either) may register tests the inventory does not record: a call whose + * test-dupes role is neither test, suite nor hook (`roleOf`) and that is no Vitest utility, a + * suite whose callback is not written inline, or any other statement. + */ +function registersUnseen(statements: readonly t.Statement[]): boolean { + return statements.some((statement): boolean => { + if (DECLARATIONS.has(statement.type)) return false; + if (t.isBlockStatement(statement)) return registersUnseen(statement.body); + if (t.isIfStatement(statement)) { + return [statement.consequent, statement.alternate].some( + (branch) => branch !== null && registersUnseen([branch]), + ); + } + if (t.isLoop(statement)) return registersUnseen([statement.body]); + if (!t.isExpressionStatement(statement)) return true; + const expression = t.isAwaitExpression(statement.expression) + ? statement.expression.argument + : statement.expression; + if (!t.isCallExpression(expression)) return true; + const shape = shapeOf(expression.callee); + const role = roleOf(expression, shape); + if (role === 'test' || role === 'hook') return false; + if (role === 'other') return !REGISTERS_NOTHING.has(shape?.root ?? ''); + const callback = expression.arguments.at(-1); + return ( + callback === undefined || + !(t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) || + !t.isBlockStatement(callback.body) || + registersUnseen(callback.body.body) + ); + }); +} + +export function expandCases( + files: readonly SourceFile[], + tests: ReadonlySet = new Set(files.map((file) => file.path)), +): StaticInventory { + const corpus = extract([...files], { isTest: (file) => tests.has(file) }); + const byFile = new Map(); + for (const record of corpus.records) { + const list = byFile.get(record.file) ?? []; + list.push(record); + byFile.set(record.file, list); + } + const sites: StaticCallSite[] = []; + for (const { path: file, source } of files) { + const records = byFile.get(file); + const ast = corpus.asts.get(file); + if (records === undefined || ast === undefined) continue; + let paths: Map | string; + try { + paths = callPaths(ast, new Set(records.map((record) => record.call))); + } catch (error) { + // Babel's scope tracker throws on code that would not compile (a duplicate declaration). + paths = `scope analysis failed: ${error instanceof Error ? error.message : String(error)}`; + } + for (const record of records) { + const callPath = typeof paths === 'string' ? undefined : paths.get(record.call); + const failure = typeof paths === 'string' ? paths : 'call not found'; + sites.push( + callPath === undefined + ? unknownSite(record, source, failure) + : expandSite(record, callPath, source), + ); + } + } + return { + sites, + skipped: corpus.skipped.filter((file) => tests.has(file)).map(toPackagePath), + incomplete: files.flatMap(({ path: file }) => { + const ast = corpus.asts.get(file); + const unseen = + tests.has(file) && + ast !== undefined && + (!byFile.has(file) || registersUnseen(ast.program.body)); + return unseen ? [toPackagePath(file)] : []; + }), + }; +} + +/** Reads `packageFiles` (package paths) under `repoRoot` and expands them. */ +export function loadStaticInventory( + repoRoot: string, + packageFiles: readonly string[], +): StaticInventory { + const files = packageFiles.map((file): SourceFile => { + const repoPath = fromPackagePath(file); + return { path: repoPath, source: readFileSync(path.join(repoRoot, repoPath), 'utf8') }; + }); + return expandCases(files); +} diff --git a/gitnexus/tools/scenario-map/inventory/eligible.ts b/gitnexus/tools/scenario-map/inventory/eligible.ts new file mode 100644 index 000000000..6fa16d6b7 --- /dev/null +++ b/gitnexus/tools/scenario-map/inventory/eligible.ts @@ -0,0 +1,154 @@ +/** + * Eligible test files of the scenario map (U3, R1). + * + * Normal runs: the mutation-eligible files are the gate 1 envelope's `accepted.files` + * (`gitnexus/test/fixtures/mutation-oracle/envelope.json`) that a fresh run of the Phase 0 + * classifier (tools/mutation/classify) still accepts; every other test is Out of scope for + * mutation. The scenario fingerprint needs no mutation run: it covers every `*.test.ts` under + * `test/unit` and `test/integration` ({@link FINGERPRINT_ROOTS}; fingerprint plan F8). The + * delta lists files the classifier accepts that the envelope does not hold (not validated by + * gate 1) and envelope files it no longer accepts, with its reasons (`not found` for a file it + * did not see, such as a deleted one). + * + * `controls` runs: both sets are the `*.case.ts` files under the fixture project, its + * `src/` modules become production files through the extra root (KTD7), and neither the envelope + * nor the classifier is consulted. + * + * Library only; the scenario-map CLI (U14) calls {@link resolveEligibility}. + */ +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { classify, type Classification } from '../../mutation/classify/classify.js'; +import { buildPathMap } from '../../mutation/prepare/path-map.js'; +import { readJsonFile } from '../../mutation/shared/fs.js'; +import { + extraRootsOf, + normalizeRelativePath, + toPackagePath, + type Eligibility, + type RunMode, +} from '../types.js'; +import { byText, isRecord } from '../util.js'; + +/** Package paths of the directories whose `*.test.ts` files the fingerprint covers. */ +export const FINGERPRINT_ROOTS: readonly string[] = ['test/unit', 'test/integration']; + +/** Repo path of the gate 1 envelope. */ +export const ENVELOPE_FILE = 'gitnexus/test/fixtures/mutation-oracle/envelope.json'; + +/** The classifier output eligibility needs. */ +export type ClassifierVerdicts = Pick; + +/** The envelope's `accepted.files` (package paths); a malformed envelope throws. */ +export function parseEnvelopeFiles(data: unknown): string[] { + const accepted = isRecord(data) ? data.accepted : undefined; + const files = isRecord(accepted) ? accepted.files : undefined; + if (!Array.isArray(files)) throw new Error('envelope: accepted.files must be an array'); + const seen = new Set(); + return files.map((file: unknown): string => { + if (typeof file !== 'string') throw new Error('envelope: accepted.files must hold strings'); + if (seen.has(file)) throw new Error(`envelope: accepted.files lists ${file} twice`); + seen.add(file); + return file; + }); +} + +const sorted = (files: Iterable): string[] => [...files].sort(byText); + +/** + * R1 for a normal run: envelope files the classifier accepts, plus the delta, and the + * fingerprint's files (`testFiles` with every mutation-eligible file added); pure. + */ +export function computeEligibility( + envelopeFiles: readonly string[], + verdicts: ClassifierVerdicts, + testFiles: readonly string[] = [], +): Eligibility { + const envelope = new Set(envelopeFiles); + const accepted = new Set(verdicts.accepted); + const reasons = new Map(verdicts.rejected.map(({ file, reasons: why }) => [file, why])); + const files = sorted(envelopeFiles.filter((file) => accepted.has(file))); + return { + mode: 'normal', + files, + fingerprintFiles: sorted(new Set([...testFiles, ...files])), + delta: { + notValidated: sorted(verdicts.accepted.filter((file) => !envelope.has(file))), + rejected: sorted(envelopeFiles.filter((file) => !accepted.has(file))).map((file) => ({ + file, + reasons: reasons.get(file) ?? ['not found'], + })), + }, + extraRoots: [], + }; +} + +/** Package paths of the files under `root` (a repo path) whose name ends with `suffix`, sorted. */ +function listFiles(repoRoot: string, root: string, suffix: string): string[] { + const found: string[] = []; + const visit = (relative: string): void => { + for (const entry of readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + const child = `${relative}/${entry.name}`; + if (entry.isDirectory() && entry.name !== 'node_modules') visit(child); + else if (entry.isFile() && entry.name.endsWith(suffix)) found.push(toPackagePath(child)); + } + }; + visit(root); + return sorted(found); +} + +/** Package paths of the `*.case.ts` files under a fixture project (repo path), sorted. */ +export function listCaseFiles(repoRoot: string, fixtureRoot: string): string[] { + return listFiles(repoRoot, normalizeRelativePath(fixtureRoot).replace(/\/+$/, ''), '.case.ts'); +} + +/** Package paths of every `*.test.ts` under {@link FINGERPRINT_ROOTS} that exist, sorted. */ +export function listFingerprintFiles(repoRoot: string): string[] { + return sorted( + FINGERPRINT_ROOTS.map((root) => `gitnexus/${root}`) + .filter((root) => existsSync(path.join(repoRoot, root))) + .flatMap((root) => listFiles(repoRoot, root, '.test.ts')), + ); +} + +export interface ResolveEligibilityOptions { + /** Directory holding `gitnexus/` and `gitnexus-shared/`. */ + repoRoot: string; + mode: RunMode; + /** Classifies the package's unit tests; defaults to a fresh `classify()` over `test/unit`. */ + classifyTests?: (repoRoot: string) => ClassifierVerdicts; +} + +const classifyUnitTests = (repoRoot: string): ClassifierVerdicts => classify({ repoRoot }); + +/** + * The fingerprint's test files and extra roots, without the classifier: all Layer 1 needs, as it + * never mutates. In normal mode these are the files {@link resolveEligibility} gives, whose + * mutation-eligible files are unit tests the fingerprint roots already hold. + */ +export function fingerprintScope(options: { + repoRoot: string; + mode: RunMode; +}): Pick { + const { repoRoot, mode } = options; + const extraRoots = extraRootsOf(mode); + if (mode.kind === 'normal') + return { fingerprintFiles: listFingerprintFiles(repoRoot), extraRoots }; + buildPathMap(extraRoots); // throws for a root outside gitnexus/test/fixtures/mutation- + return { fingerprintFiles: listCaseFiles(repoRoot, mode.fixtureRoot), extraRoots }; +} + +/** The eligible set of a run (R1; `controls` mode enrols the fixture instead). */ +export function resolveEligibility(options: ResolveEligibilityOptions): Eligibility { + const { repoRoot, mode } = options; + const { fingerprintFiles, extraRoots } = fingerprintScope({ repoRoot, mode }); + if (mode.kind === 'controls') { + return { mode: 'controls', files: fingerprintFiles, fingerprintFiles, delta: null, extraRoots }; + } + const envelopeFiles = parseEnvelopeFiles(readJsonFile(path.join(repoRoot, ENVELOPE_FILE))); + return computeEligibility( + envelopeFiles, + (options.classifyTests ?? classifyUnitTests)(repoRoot), + fingerprintFiles, + ); +} diff --git a/gitnexus/tools/scenario-map/inventory/identity.ts b/gitnexus/tools/scenario-map/inventory/identity.ts new file mode 100644 index 000000000..4d9e8ba26 --- /dev/null +++ b/gitnexus/tools/scenario-map/inventory/identity.ts @@ -0,0 +1,302 @@ +/** + * Execution identities and continuity anchors of the scenario map (U3, R2, KTD3). + * + * {@link joinRuntime} joins the runtime records Layer 1 observes (task id, full name, location, + * collection order) to the static call sites of cases.ts. It is pure, so Layer 1 (U4) feeds it + * later and tests feed it in-memory records: + * 1. Each file's records get Phase 0 ids (`assignTestIds`, tools/mutation/runner/test-ids.ts) + * and a row ordinal: their 0-based order among the runtime tests at the same location. The + * id, the location and the row are the execution identity. + * 2. Runtime tests map to the call site on their location's line. When several call sites share + * the line, the column must name exactly one (else `ambiguous-location`); one call site whose + * line holds several runtime columns is a `join-mismatch`. + * 3. A call site joins its runtime tests one-to-one in order when both counts match, and the + * runtime full name must equal a static one where the titles are plain strings. Otherwise + * every runtime test at the site is unsupported for strong evidence (R2): `join-mismatch`, + * or `join-count-unknown` when the static count is not known. + * + * Anchors (design §5.5, KTD3) are keyed by file, describe path, title and row fingerprint, never + * a line; the body fingerprint follows a moved test only when it is unique on both sides + * ({@link matchAnchors}). Identical bodies are exactly the repeats and the rows of one table, so + * a body alone never identifies one of them. + * + * Library only; the scenario-map CLI (U14) calls it. + */ +import { generate } from '@babel/generator'; +import { assignTestIds, type IdentifiedTest } from '../../mutation/runner/test-ids.js'; +import type { CaseRecord } from '../../test-dupes/test-dupes.js'; +import type { + InventoryInstance, + InventoryJoin, + JoinProblem, + RuntimeTestRecord, + StaticCallSite, + StaticInstance, + StaticInventory, + TestAnchor, +} from '../types.js'; +import { sha1 } from '../util.js'; + +const JOIN_PROBLEMS: readonly JoinProblem[] = [ + 'join-mismatch', + 'join-count-unknown', + 'join-name-mismatch', + 'ambiguous-location', + 'no-location', + 'no-static-case', + 'unparsed', +]; + +/** sha1 over a test callback's parameters and body, without comments or formatting. */ +export function bodyFingerprint(record: CaseRecord): string { + const params = record.fn.params.map( + (param) => generate(param, { comments: false, compact: true }).code, + ); + return sha1(`(${params.join(',')})=>${record.body}`); +} + +/** An anchor without its twin suffix: `key` is the base key. */ +function baseAnchor( + site: StaticCallSite, + rowFingerprint: string | null, + row: number | null, +): TestAnchor { + const { describePath, title } = site.record; + const rowPart = rowFingerprint ?? `row:${row ?? ''}`; + return { + key: sha1(JSON.stringify([site.file, describePath, title, rowPart])), + file: site.file, + describePath: [...describePath], + title, + rowFingerprint, + bodyFingerprint: bodyFingerprint(site.record), + }; +} + +interface Joined { + site: StaticCallSite | null; + instance: StaticInstance | null; + unsupported: JoinProblem[]; + unresolved: string[]; +} + +const failed = (problem: JoinProblem, site: StaticCallSite | null = null): Joined => ({ + site, + instance: null, + unsupported: [problem], + unresolved: site?.unresolved ?? [], +}); + +/** One call site against its runtime tests, in collection order. */ +function joinSite(site: StaticCallSite, tests: readonly IdentifiedTest[]): Joined[] { + const { instances } = site; + if (instances === null) return tests.map(() => failed('join-count-unknown', site)); + if (instances.length !== tests.length) return tests.map(() => failed('join-mismatch', site)); + return tests.map((test, index): Joined => { + const instance = instances[index]; + const renamed = site.fullName !== null && test.fullName !== site.fullName; + return { + site, + instance, + unsupported: renamed ? ['join-name-mismatch'] : [], + unresolved: instance.unresolved, + }; + }); +} + +const groupBy = (items: readonly T[], key: (item: T) => K): Map => { + const groups = new Map(); + for (const item of items) { + const group = groups.get(key(item)); + if (group) group.push(item); + else groups.set(key(item), [item]); + } + return groups; +}; + +/** Joins one file's runtime tests to its call sites; returns a result per test. */ +function joinFile( + tests: readonly IdentifiedTest[], + sites: readonly StaticCallSite[], + unparsed: boolean, + reached: Set, +): Map { + const results = new Map(); + const sitesByLine = groupBy(sites, (site) => site.location.line); + const located = tests.filter((test) => test.location !== undefined); + for (const test of tests) { + if (test.location === undefined) results.set(test, failed('no-location')); + } + for (const [line, onLine] of groupBy(located, (test) => test.location?.line ?? 0)) { + const candidates = sitesByLine.get(line) ?? []; + const byColumn = groupBy(onLine, (test) => test.location?.column ?? 0); + const set = (group: readonly IdentifiedTest[], joined: readonly Joined[]): void => + group.forEach((test, index) => results.set(test, joined[index])); + if (unparsed || candidates.length === 0) { + set( + onLine, + onLine.map(() => failed(unparsed ? 'unparsed' : 'no-static-case')), + ); + } else if (candidates.length === 1) { + const [site] = candidates; + reached.add(site); + set( + onLine, + byColumn.size === 1 + ? joinSite(site, onLine) + : onLine.map(() => failed('join-mismatch', site)), + ); + } else { + for (const [column, group] of byColumn) { + const matches = candidates.filter((site) => site.location.column === column); + if (matches.length === 1) reached.add(matches[0]); + set( + group, + matches.length === 1 + ? joinSite(matches[0], group) + : group.map(() => failed('ambiguous-location')), + ); + } + } + } + return results; +} + +/** Row ordinal of each test: its order among the file's tests at the same location. */ +function rowOrdinals(tests: readonly IdentifiedTest[]): Map { + const rows = new Map(); + const seen = new Map(); + for (const test of tests) { + if (test.location === undefined) continue; + const key = `${test.location.line}:${test.location.column}`; + const row = seen.get(key) ?? 0; + rows.set(test, row); + seen.set(key, row + 1); + } + return rows; +} + +/** + * Joins runtime records (files in any order, collection order within a file) to the static + * inventory; pure. Throws like `assignTestIds` on a duplicated task id, or on colliding full + * names without a location (the Vitest config must set `includeTaskLocation`). + */ +export function joinRuntime( + statics: StaticInventory, + runtime: readonly RuntimeTestRecord[], +): InventoryJoin { + const sitesByFile = groupBy(statics.sites, (site) => site.file); + const unparsed = new Set(statics.skipped); + const reached = new Set(); + const instances: InventoryInstance[] = []; + const mismatched = new Set(); + + for (const [file, records] of groupBy(runtime, (record) => record.file)) { + const tests = assignTestIds( + file, + records.map(({ taskId, fullName, location }) => ({ taskId, fullName, location })), + ); + const rows = rowOrdinals(tests); + const joined = joinFile(tests, sitesByFile.get(file) ?? [], unparsed.has(file), reached); + for (const test of tests) { + const result = joined.get(test) ?? failed('no-static-case'); + if (result.site !== null && result.unsupported.includes('join-mismatch')) { + mismatched.add(result.site); + } + instances.push({ + id: test.id, + file, + taskId: test.taskId, + fullName: test.fullName, + location: test.location ?? null, + row: rows.get(test) ?? null, + notOracleValidated: test.notOracleValidated, + site: result.site, + instance: result.instance, + anchor: null, + unsupported: result.unsupported, + unresolved: result.unresolved, + dupeOk: result.site?.record.suppressed ?? null, + }); + } + } + + // Anchors: exact twins (same file, describe path, title and row values) get `~` in order. + const twins = new Map(); + for (const instance of instances) { + if (instance.site === null) continue; + const anchor = baseAnchor( + instance.site, + instance.instance?.rowFingerprint ?? null, + instance.row, + ); + const seen = twins.get(anchor.key) ?? 0; + twins.set(anchor.key, seen + 1); + instance.anchor = seen === 0 ? anchor : { ...anchor, key: `${anchor.key}~${seen}` }; + } + + const byProblem = Object.fromEntries(JOIN_PROBLEMS.map((problem) => [problem, 0])) as Record< + JoinProblem, + number + >; + for (const instance of instances) { + for (const problem of instance.unsupported) byProblem[problem] += 1; + } + const notRun = statics.sites.filter((site) => !reached.has(site)); + return { + instances, + notRun, + summary: { + runtimeTests: runtime.length, + staticSites: statics.sites.length, + staticInstances: statics.sites.reduce((sum, site) => sum + (site.instances?.length ?? 0), 0), + oneToOne: instances.filter((instance) => instance.unsupported.length === 0).length, + byProblem, + joinMismatchSites: mismatched.size, + notRunSites: notRun.length, + }, + }; +} + +export interface AnchorMatch { + current: string; + /** The previous anchor key this one continues; null for a new test. */ + previous: string | null; + via: 'key' | 'body' | null; +} + +/** + * Continuity between two runs' anchors (design §5.5): the same key continues; otherwise a test + * whose body and row fingerprints are unique among the unmatched anchors of both runs continues + * the one previous anchor with the same pair (a moved or renamed test); anything else is new. A + * row whose values are unknown never moves. + */ +export function matchAnchors( + previous: readonly TestAnchor[], + current: readonly TestAnchor[], +): AnchorMatch[] { + const previousKeys = new Set(previous.map((anchor) => anchor.key)); + const currentKeys = new Set(current.map((anchor) => anchor.key)); + const moveKey = (anchor: TestAnchor): string | null => + anchor.rowFingerprint === null + ? null + : `${anchor.bodyFingerprint}\u0000${anchor.rowFingerprint}`; + const unmatchedPrevious = groupBy( + previous.filter((anchor) => !currentKeys.has(anchor.key)), + moveKey, + ); + const unmatchedCurrent = groupBy( + current.filter((anchor) => !previousKeys.has(anchor.key)), + moveKey, + ); + return current.map((anchor): AnchorMatch => { + if (previousKeys.has(anchor.key)) + return { current: anchor.key, previous: anchor.key, via: 'key' }; + const key = moveKey(anchor); + const before = key === null ? undefined : unmatchedPrevious.get(key); + const now = key === null ? undefined : unmatchedCurrent.get(key); + return before?.length === 1 && now?.length === 1 + ? { current: anchor.key, previous: before[0].key, via: 'body' } + : { current: anchor.key, previous: null, via: null }; + }); +} diff --git a/gitnexus/tools/scenario-map/mutation/isolation.ts b/gitnexus/tools/scenario-map/mutation/isolation.ts new file mode 100644 index 000000000..329da0819 --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/isolation.ts @@ -0,0 +1,970 @@ +/** + * Full isolation pass of the scenario map (U8, R7, KTD8, KTD9, design §5.4 "Kill validity"): + * strong evidence needs each test's per-mutant outcomes to agree when the pair runs together and + * when each test runs alone, every mutant in a fresh process. It catches order dependence and + * leaking state that hide a distinguishing mutant. + * + * Repeat stage ({@link selectRepeatStagePairs}, pure). A candidate pair reaches it when, in its + * group's `main` run (observations.ts `comparePair`): + * - the kill sets are known, equal (`sameKills` true) and non-empty (design §2: an empty kill + * set is never part of a repeat), and no universe mutant has another class (R7: a timeout, + * runtime, hook or infrastructure outcome makes the pair Inconclusive anyway); + * - the canonical path is the same: over the common universe, each mutant is covered, static or + * uncovered alike for both tests; + * - the checks-and-context keys (U5 `checksContextKey`) are present and equal; + * - the input facets (U6) have equal fingerprints and no residual difference (D1: a residual + * difference makes the pair facet-unequal). + * Every failed condition is listed (`misses`). The pair's isolation universe is the common + * universe's mutants either test was exposed to: covered, static, or run against it (a row). + * + * Plan ({@link planIsolation}, pure; KTD9). One `together` run per selected pair (lineage: the + * {@link pairKey}, tests: both) over the pair's universe, and one `alone` run per test (lineage: + * the test id), shared by all its pairs, over the union of their universes. Mutate is the + * universe as Stryker ranges ({@link mutateRangesOf}): each mutant's report span (1-based lines and + * columns, end exclusive) becomes `::-:`, since Stryker + * matches ranges against Babel locations (1-based lines, 0-based columns) and reports columns one + * higher (verified against @stryker-mutator/core 10 `project-reader.js` and the instrumenter's + * `locationIncluded`). A range holds every mutant nested in its node, so the run's observations are + * then filtered by global id; ranges inside another range of the same file are dropped. + * + * Runs ({@link runIsolationRun}). Each run is a validated Stryker run (run-group.ts + * `runMutationRun`: two baselines, the KTD8 config check, the scope driver, the dry run against the + * baselines) with the run's tests as `testIds` and `maxTestRunnerReuse` forced to + * {@link ISOLATION_TEST_RUNNER_REUSE}: every mutant gets a fresh test-runner process with its + * modules reset (design §5.4). Its observations (kind `together` or `alone`, the run's lineage; + * observations.ts compact model: a run universe plus the explicit observations) are kept for the + * universe only; universe mutants the run did not evaluate are `missing`. Each result + * ({@link IsolationRunResult}, schema 2: the explicit observations as rows of `runUniverse`; a + * schema 1 file, one observation per test and mutant, is rejected) is written at once, with its + * wall clock, baselines and Stryker summary (the KTD17 `freshProcessMs` calibration source). Work + * layout: + * isolation//.json run result groups/// scratch + * where the run id is `-` plus 16 hex digits of sha1 over the lineage. + * + * Comparison ({@link evaluateIsolation}, pure). Only runs verified against the plan's run manifest + * count (another digest is `run:manifest-mismatch`, KTD14). Per test of the pair, over its universe, + * three observations: `main`, the `together` run, and the test's `alone` run (attempts folded by + * observations.ts `mergeObservations`). Their coverage (`static`, `covered` or `not-covered`) and + * their outcome class must agree; so must the unmutated baseline (the main run's group passed: + * `passed`; a run's baselines give `passed` or the failing test's class). Results: + * agrees every value of every test agrees; + * disagrees two known values differ (`isolation-disagrees`): the entries name each mutant, + * or a null mutant for the baseline; it wins over unknown; + * unknown a value is missing or invalid (`isolation-unknown`): a missing result is never a + * non-kill (design §5.4); the verdict engine makes both Inconclusive; + * not-run the pair did not reach the repeat stage (`not-repeat-stage`), or one of its runs + * has no result (`not-scheduled`): the pair caps at weak (design §2). + * Every result carries the KTD9 disqualifier `hook-stratum-unvalidated` (cap weak) when a mutant + * of the pair's path or kills has an observation of either test, in any of its runs, whose hook + * marker is not false (null, no marker, counts: fail closed). The cap holds until a gate 3 scope + * with a non-empty hook stratum passes. + * + * Library only; the scenario-map CLI (U14) plans and runs the pass per shard, and the verdict + * engine (U12) reads {@link IsolationResult}s. Every runner is injected (run-group.ts + * `GroupRunDeps`), so unit tests never spawn Vitest or Stryker. + */ +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { parseTestId } from '../../mutation/runner/test-ids.js'; +import { readJsonFile } from '../../mutation/shared/fs.js'; +import { residualDifference, type InputFacet } from '../facet/input-facet.js'; +import { + MAIN_LINEAGE, + comparePair, + decodeObservations, + encodeObservations, + extractObservations, + indexObservations, + isRunUniverse, + lineageOf, + mergeObservations, + stateOf, + type LineageObservations, + type MergedObservations, + type Observation, + type ObservationIndex, + type ObservationRow, + type ObservationState, + type PairComparison, + type RunKey, + type RunUniverse, +} from './observations.js'; +import { byText, isRecord, sha1, sortedUnique, writeJson } from '../util.js'; +import type { GroupStatusReason, PairRef } from './plan-shards.js'; +import { + DEFAULT_STRYKER_SETTINGS, + defaultGroupRunDeps, + observationsFile, + runMutationRun, + type BaselineSummary, + type GroupRunDeps, + type ScenarioRunManifest, + type StrykerSettings, + type StrykerSummary, +} from './run-group.js'; + +/** The lineage of a pair's `together` run: its two tests, sorted, as a JSON array. */ +export const pairKey = (a: string, b: string): string => JSON.stringify([a, b].sort(byText)); + +/** Main indexes of the merged observations seen, so the repeat stage and the comparison share one. */ +const mainIndexes = new WeakMap(); + +/** The `main` lineage of each group, indexed (once per merged observations object). */ +function mainIndex(merged: MergedObservations): ObservationIndex { + const known = mainIndexes.get(merged); + if (known !== undefined) return known; + const index = indexObservations( + merged, + (run) => run.kind === 'main' && run.lineage === MAIN_LINEAGE, + ); + mainIndexes.set(merged, index); + return index; +} + +/** The main run of a group. */ +const mainRun = (group: string): RunKey => ({ group, kind: 'main', lineage: MAIN_LINEAGE }); + +/** A test's observation of a mutant in a lineage; undefined without the lineage or one. */ +const stateIn = ( + view: LineageObservations | undefined, + test: string, + mutant: string, +): ObservationState | undefined => (view === undefined ? undefined : stateOf(view, test, mutant)); + +/** Coverage of a mutant for a test in one run. */ +const coverageOf = (entry: ObservationState): 'static' | 'covered' | 'not-covered' => + entry.static ? 'static' : entry.covered ? 'covered' : 'not-covered'; + +/** Whether a test was exposed to the mutant: covered, static, or run against it. */ +const exposed = (entry: ObservationState | undefined): boolean => + entry !== undefined && (entry.covered || entry.static || entry.sources.includes('row')); + +// ── Repeat stage ────────────────────────────────────────────────────────── + +/** What the repeat-stage selection reads; see the module comment. */ +export interface RepeatStageFacts { + /** Merged observations; only each pair's group `main` lineage is read. */ + merged: MergedObservations; + /** U5 checks-and-context key per Phase 0 test id; null or absent when the test has none. */ + checksContext: ReadonlyMap; + /** U6 input facet per Phase 0 test id; absent when it was not computed. */ + facets: ReadonlyMap; +} + +export type RepeatStageMiss = + /** The main run's kill sets are not comparable (`sameKills` null: unknown mutants, no run). */ + | 'kills-unknown' + | 'kills-differ' + /** Both kill sets are empty (design §2). */ + | 'kills-empty' + /** A universe mutant has a class other than an assertion kill or survived (R7). */ + | 'other-classes' + | 'path-differs' + | 'checks-context-missing' + | 'checks-context-differs' + | 'facet-missing' + | 'facet-differs' + /** Equal fingerprints with a residual difference (KTD6 guard, D1). */ + | 'facet-residual'; + +/** + * A main-run comparison without its universe and unknown lists, their sizes kept: what a decision + * retains, so a decision per candidate pair stays O(kills), not O(pairs × mutants). + */ +export type ComparisonSummary = Omit & { + universeSize: number; + unknownSize: number; +}; + +const summaryOf = ({ universe, unknown, ...rest }: PairComparison): ComparisonSummary => ({ + ...rest, + universeSize: universe.length, + unknownSize: unknown.length, +}); + +export interface RepeatStageDecision extends PairRef { + /** {@link pairKey}. */ + key: string; + selected: boolean; + /** Failed conditions, sorted; empty when selected. */ + misses: RepeatStageMiss[]; + /** The main run's comparison, summarised. */ + comparison: ComparisonSummary; + /** Common-universe mutants either test was exposed to, sorted: the isolation universe. */ + universe: string[]; +} + +function facetMisses(a: InputFacet | undefined, b: InputFacet | undefined): RepeatStageMiss[] { + if (a === undefined || b === undefined) return ['facet-missing']; + if (a.fingerprint !== b.fingerprint) return ['facet-differs']; + return residualDifference(a, b).length > 0 ? ['facet-residual'] : []; +} + +function checksMiss(a: string | null | undefined, b: string | null | undefined): RepeatStageMiss[] { + if (a === null || a === undefined || b === null || b === undefined) { + return ['checks-context-missing']; + } + return a === b ? [] : ['checks-context-differs']; +} + +function killMisses( + comparison: Pick, +): RepeatStageMiss[] { + const misses: RepeatStageMiss[] = []; + if (comparison.sameKills === null) misses.push('kills-unknown'); + if (comparison.sameKills === false) misses.push('kills-differ'); + if (comparison.sameKills === true && comparison.killsA.length === 0) misses.push('kills-empty'); + if (comparison.otherClasses.length > 0) misses.push('other-classes'); + return misses; +} + +function decide( + pair: PairRef, + index: ObservationIndex, + facts: RepeatStageFacts, +): RepeatStageDecision { + const run = mainRun(pair.group); + const comparison = comparePair(index, run, pair.a, pair.b); + const view = lineageOf(index, run); + const pathDiffers = comparison.universe.some((mutant) => { + const [x, y] = [stateIn(view, pair.a, mutant), stateIn(view, pair.b, mutant)]; + return x === undefined || y === undefined || coverageOf(x) !== coverageOf(y); + }); + const misses = [ + ...killMisses(comparison), + ...(pathDiffers ? (['path-differs'] as const) : []), + ...checksMiss(facts.checksContext.get(pair.a), facts.checksContext.get(pair.b)), + ...facetMisses(facts.facets.get(pair.a), facts.facets.get(pair.b)), + ].sort(byText); + return { + ...pair, + key: pairKey(pair.a, pair.b), + selected: misses.length === 0, + misses, + comparison: summaryOf(comparison), + universe: comparison.universe.filter( + (mutant) => exposed(stateIn(view, pair.a, mutant)) || exposed(stateIn(view, pair.b, mutant)), + ), + }; +} + +/** Each pair's repeat-stage decision, in input order; pure. See the module comment. */ +export function selectRepeatStagePairs( + pairs: readonly PairRef[], + facts: RepeatStageFacts, +): RepeatStageDecision[] { + const index = mainIndex(facts.merged); + return pairs.map((pair) => decide(pair, index, facts)); +} + +// ── Plan ────────────────────────────────────────────────────────────────── + +export type IsolationRunKind = 'together' | 'alone'; + +export interface IsolationRunSpec { + /** `-<16 hex digits of sha1 over the lineage>`: path-safe. */ + id: string; + group: string; + kind: IsolationRunKind; + /** The {@link pairKey} of a together run; the test id of an alone run. */ + lineage: string; + /** Phase 0 test ids, sorted. */ + tests: string[]; + /** Package paths of their files, sorted. */ + testFiles: string[]; + /** Global mutant ids, sorted. */ + universe: string[]; + /** Driver mutate entries ({@link mutateRangesOf}). */ + mutate: string[]; + /** Keys of the pairs that read the run, sorted. */ + pairs: string[]; +} + +export const ISOLATION_PLAN_SCHEMA_VERSION = 1; + +export interface IsolationPlan { + schemaVersion: typeof ISOLATION_PLAN_SCHEMA_VERSION; + /** Sorted by group, kind and lineage. */ + runs: IsolationRunSpec[]; +} + +export interface MutantSpan { + /** Repo path of the original file. */ + file: string; + /** Report positions: 1-based lines and columns, end exclusive. */ + start: { line: number; column: number }; + end: { line: number; column: number }; +} + +/** tools/mutation/prepare/mutant-identity.ts `formatGlobalId`. */ +const GLOBAL_ID = /^(.+)@[0-9a-f]{16}:(\d+):(\d+)-(\d+):(\d+):[^:]+:[0-9a-f]{16}$/; + +/** The file and report span of a global mutant id; throws for another string. */ +export function spanOfGlobalId(globalId: string): MutantSpan { + const match = GLOBAL_ID.exec(globalId); + if (match === null) throw new Error(`isolation: ${JSON.stringify(globalId)} is no global id`); + const [, file, startLine, startColumn, endLine, endColumn] = match; + return { + file, + start: { line: Number(startLine), column: Number(startColumn) }, + end: { line: Number(endLine), column: Number(endColumn) }, + }; +} + +const before = (a: MutantSpan['start'], b: MutantSpan['start']): boolean => + a.line < b.line || (a.line === b.line && a.column <= b.column); + +/** Whether `inner` lies inside `outer` (same file). */ +const contains = (outer: MutantSpan, inner: MutantSpan): boolean => + outer.file === inner.file && before(outer.start, inner.start) && before(inner.end, outer.end); + +/** + * The driver mutate entries covering a universe: one Stryker range per mutant span, Babel columns + * (report column minus one), spans inside another dropped, sorted by file and position. + */ +export function mutateRangesOf(universe: readonly string[]): string[] { + const spans = [ + ...new Map( + universe.map((globalId) => { + const span = spanOfGlobalId(globalId); + return [JSON.stringify(span), span]; + }), + ).values(), + ]; + return spans + .filter((span) => !spans.some((other) => other !== span && contains(other, span))) + .sort( + (a, b) => + byText(a.file, b.file) || + a.start.line - b.start.line || + a.start.column - b.start.column || + a.end.line - b.end.line || + a.end.column - b.end.column, + ) + .map( + (span) => + `${span.file}:${span.start.line}:${span.start.column - 1}-` + + `${span.end.line}:${span.end.column - 1}`, + ); +} + +/** The run id of a run kind and lineage. */ +export const isolationRunId = (kind: IsolationRunKind, lineage: string): string => + `${kind}-${sha1(lineage).slice(0, 16)}`; + +function runSpec( + group: string, + kind: IsolationRunKind, + lineage: string, + tests: readonly string[], + universe: Iterable, + pairs: Iterable, +): IsolationRunSpec { + const sortedTests = sortedUnique(tests); + const mutants = sortedUnique(universe); + return { + id: isolationRunId(kind, lineage), + group, + kind, + lineage, + tests: sortedTests, + testFiles: sortedUnique(sortedTests.map((test) => parseTestId(test).file)), + universe: mutants, + mutate: mutateRangesOf(mutants), + pairs: sortedUnique(pairs), + }; +} + +/** The isolation runs of the selected pairs; pure. See the module comment. */ +export function planIsolation(decisions: readonly RepeatStageDecision[]): IsolationPlan { + const selected = decisions.filter((decision) => decision.selected); + const alone = new Map< + string, + { group: string; test: string; universe: Set; pairs: Set } + >(); + for (const decision of selected) { + for (const test of [decision.a, decision.b]) { + const key = JSON.stringify([decision.group, test]); + const entry = alone.get(key) ?? { + group: decision.group, + test, + universe: new Set(), + pairs: new Set(), + }; + for (const mutant of decision.universe) entry.universe.add(mutant); + entry.pairs.add(decision.key); + alone.set(key, entry); + } + } + const runs = [ + ...selected.map((decision) => + runSpec( + decision.group, + 'together', + decision.key, + [decision.a, decision.b], + decision.universe, + [decision.key], + ), + ), + ...[...alone.values()].map((entry) => + runSpec(entry.group, 'alone', entry.test, [entry.test], entry.universe, entry.pairs), + ), + ]; + const unique = [ + ...new Map(runs.map((run) => [JSON.stringify([run.group, run.id]), run])).values(), + ]; + return { + schemaVersion: ISOLATION_PLAN_SCHEMA_VERSION, + runs: unique.sort( + (a, b) => byText(a.group, b.group) || byText(a.kind, b.kind) || byText(a.lineage, b.lineage), + ), + }; +} + +// ── Runs ────────────────────────────────────────────────────────────────── + +/** Stryker's `maxTestRunnerReuse` in every isolation run: a fresh process per mutant (KTD9). */ +export const ISOLATION_TEST_RUNNER_REUSE = 1; + +/** 2: the compact model, a run universe plus explicit observations as rows (observations.ts). */ +export const ISOLATION_RUN_SCHEMA_VERSION = 2; + +/** One isolation run's result; see the module comment. */ +export interface IsolationRunResult extends Omit { + schemaVersion: typeof ISOLATION_RUN_SCHEMA_VERSION; + shard: number; + manifestDigest: string; + status: 'complete' | 'inconclusive'; + reason: GroupStatusReason | null; + detail: string | null; + settings: StrykerSettings; + baselines: BaselineSummary[]; + stryker: StrykerSummary | null; + /** The run universe restricted to the planned universe; null unless complete. */ + runUniverse: RunUniverse | null; + /** The run's explicit observations of universe mutants; empty unless complete. */ + observations: Observation[]; + /** Universe mutants a complete run did not evaluate, sorted. */ + missing: string[]; + /** Mutants the run evaluated outside the universe (nested in a range), dropped. */ + outsideUniverse: number; + wallClockMs: number; +} + +export interface IsolationRunOptions { + /** Absolute repo checkout. */ + repoRoot: string; + /** Absolute work directory, outside the checkout. */ + workDir: string; + shard: number; + /** The run manifest the shard verified its prepared copy against. */ + manifest: ScenarioRunManifest; + /** Absolute prepared `gitnexus/` package of the shard's verified copy. */ + packageDir: string; + /** `maxTestRunnerReuse` is always {@link ISOLATION_TEST_RUNNER_REUSE}. */ + stryker?: Partial>; +} + +const SAFE_RUN_ID = /^(together|alone)-[0-9a-f]{16}$/; + +/** `/isolation//.json`; throws for an unsafe group or run id. */ +export function isolationRunFile(workDir: string, group: string, id: string): string { + // Validates the group id as the group run does. + observationsFile(workDir, group); + if (!SAFE_RUN_ID.test(id)) throw new Error(`isolation: unsafe run id ${JSON.stringify(id)}`); + return path.join(workDir, 'isolation', group, `${id}.json`); +} + +/** Runs one isolation run and writes its result at once; see the module comment. */ +export async function runIsolationRun( + spec: IsolationRunSpec, + options: IsolationRunOptions, + deps: GroupRunDeps = defaultGroupRunDeps, +): Promise { + const started = deps.now(); + const file = isolationRunFile(options.workDir, spec.group, spec.id); + // A result left by an earlier run would read as this run's. + rmSync(file, { force: true }); + const runDir = path.join(options.workDir, 'groups', spec.group, spec.id); + mkdirSync(runDir, { recursive: true }); + const settings: StrykerSettings = { + ...DEFAULT_STRYKER_SETTINGS, + ...options.stryker, + maxTestRunnerReuse: ISOLATION_TEST_RUNNER_REUSE, + }; + const run = await runMutationRun( + { + repoRoot: options.repoRoot, + manifest: options.manifest, + packageDir: options.packageDir, + runDir, + tests: spec.tests, + testFiles: spec.testFiles, + mutate: spec.mutate, + settings, + }, + deps, + ); + const extracted = + run.results === null + ? null + : extractObservations(run.results, { + group: spec.group, + kind: spec.kind, + lineage: spec.lineage, + shard: options.shard, + }); + const universe = new Set(spec.universe); + const evaluated = new Set(extracted?.universe.mutants.map((mutant) => mutant.id) ?? []); + const result: IsolationRunResult = { + schemaVersion: ISOLATION_RUN_SCHEMA_VERSION, + id: spec.id, + group: spec.group, + kind: spec.kind, + lineage: spec.lineage, + tests: spec.tests, + testFiles: spec.testFiles, + universe: spec.universe, + mutate: spec.mutate, + shard: options.shard, + manifestDigest: options.manifest.digest, + status: run.failure === null ? 'complete' : 'inconclusive', + reason: run.failure?.reason ?? null, + detail: run.failure?.detail ?? null, + settings, + baselines: run.baselines, + stryker: run.stryker, + runUniverse: + extracted === null + ? null + : { + ...extracted.universe, + mutants: extracted.universe.mutants.filter((mutant) => universe.has(mutant.id)), + }, + observations: (extracted?.observations ?? []).filter((entry) => universe.has(entry.mutant)), + missing: run.results === null ? [] : spec.universe.filter((mutant) => !evaluated.has(mutant)), + outsideUniverse: [...evaluated].filter((mutant) => !universe.has(mutant)).length, + wallClockMs: Math.round(deps.now() - started), + }; + const written: IsolationRunFile = { + ...result, + observations: encodeObservations(result.runUniverse, result.observations), + }; + writeJson(file, written); + return result; +} + +/** Runs every run of a plan in order; each result is written as soon as it finishes. */ +export async function runIsolationPlan( + plan: IsolationPlan, + options: IsolationRunOptions, + deps: GroupRunDeps = defaultGroupRunDeps, +): Promise { + const results: IsolationRunResult[] = []; + for (const spec of plan.runs) results.push(await runIsolationRun(spec, options, deps)); + return results; +} + +/** An isolation run result as written: its explicit observations as rows of its run universe. */ +export type IsolationRunFile = Omit & { + observations: ObservationRow[]; +}; + +const isIsolationRunFile = (value: unknown): value is IsolationRunFile => + isRecord(value) && + value.schemaVersion === ISOLATION_RUN_SCHEMA_VERSION && + typeof value.group === 'string' && + (value.kind === 'together' || value.kind === 'alone') && + typeof value.lineage === 'string' && + (value.runUniverse === null || isRunUniverse(value.runUniverse)) && + Array.isArray(value.observations) && + Array.isArray(value.baselines) && + Array.isArray(value.missing); + +/** Reads one isolation run result; another schema (schema 1 included) or a bad row throws. */ +export function readIsolationRun(file: string): IsolationRunResult { + const data = readJsonFile(file); + if (isRecord(data) && data.schemaVersion !== ISOLATION_RUN_SCHEMA_VERSION) { + throw new Error( + `isolation: ${file} has schema ${JSON.stringify(data.schemaVersion)}, not ` + + `${ISOLATION_RUN_SCHEMA_VERSION}: it was written before the compact observation model ` + + '(one observation per test and mutant); re-run its shard', + ); + } + if (!isIsolationRunFile(data)) { + throw new Error(`isolation: ${file} is not an isolation run result`); + } + return { ...data, observations: decodeObservations(data.runUniverse, data.observations) }; +} + +/** + * Every isolation run result under `/isolation/`, sorted by group and run id. A file that + * does not read (truncated, another schema, a bad row) throws, or, with `onUnreadable`, is passed + * to it and left out. + */ +export function readIsolationRuns( + workDir: string, + onUnreadable?: (file: string, error: unknown) => void, +): IsolationRunResult[] { + const root = path.join(workDir, 'isolation'); + if (!existsSync(root)) return []; + const read = (file: string): IsolationRunResult[] => { + if (onUnreadable === undefined) return [readIsolationRun(file)]; + try { + return [readIsolationRun(file)]; + } catch (error) { + onUnreadable(file, error); + return []; + } + }; + return readdirSync(root) + .sort(byText) + .flatMap((group) => + readdirSync(path.join(root, group)) + .filter((name) => name.endsWith('.json')) + .sort(byText) + .flatMap((name) => read(path.join(root, group, name))), + ); +} + +// ── Comparison ──────────────────────────────────────────────────────────── + +export type IsolationStatus = 'agrees' | 'disagrees' | 'unknown' | 'not-run'; + +export type IsolationReason = + | 'isolation-disagrees' + | 'isolation-unknown' + | 'not-repeat-stage' + | 'not-scheduled'; + +/** The runs a test is compared across. */ +export type ComparedRun = 'main' | IsolationRunKind; + +export interface IsolationDisagreement { + test: string; + /** Global mutant id; null for the unmutated baseline. */ + mutant: string | null; + field: 'baseline' | 'coverage' | 'outcome'; + /** The value in each run; null when unknown there. */ + main: string | null; + together: string | null; + alone: string | null; +} + +export interface IsolationUnknown { + test: string; + /** Global mutant id; null for the unmutated baseline. */ + mutant: string | null; + /** Runs without a valid value, in main, together, alone order. */ + runs: ComparedRun[]; + /** Why, sorted: `missing`, `run:`, or the observation's reasons. */ + reasons: string[]; +} + +export interface IsolationDisqualifier { + kind: 'hook-stratum-unvalidated'; + cap: 'weak'; + /** Hook-attributed mutants of the pair's path or kills, sorted. */ + mutants: string[]; +} + +export interface IsolationResult extends PairRef { + key: string; + status: IsolationStatus; + /** Null when the runs agree. */ + reason: IsolationReason | null; + /** The repeat-stage misses of a `not-repeat-stage` pair; empty otherwise. */ + misses: RepeatStageMiss[]; + /** The pair's isolation universe. */ + universe: string[]; + disagreements: IsolationDisagreement[]; + unknown: IsolationUnknown[]; + disqualifiers: IsolationDisqualifier[]; + /** Run ids that decided: the together run and both alone runs; empty when not scheduled. */ + runs: string[]; +} + +/** What the comparison reads; see the module comment. */ +export interface IsolationEvidence { + /** Merged observations holding each pair's group `main` lineage; other entries are ignored. */ + merged: MergedObservations; + /** Every isolation run result found, from every shard. */ + runs: readonly IsolationRunResult[]; + /** Digest of the plan's run manifest: a run verified against another one is not used. */ + manifestDigest: string; +} + +interface RunView { + ids: string[]; + /** Run-level reasons: a failed run's `run:`. */ + reasons: string[]; + baselines: BaselineSummary[]; + /** The merged observations of its verified results; undefined when they have none. */ + observations: LineageObservations | undefined; +} + +/** + * Every result of one group, kind and lineage (several shards may have run it), folded. A result + * verified against another run manifest contributes only its reason, `run:manifest-mismatch`. + */ +function runViews( + runs: readonly IsolationRunResult[], + manifestDigest: string, +): Map { + const grouped = new Map(); + for (const run of runs) { + const key = JSON.stringify([run.group, run.kind, run.lineage]); + const known = grouped.get(key); + if (known === undefined) grouped.set(key, [run]); + else known.push(run); + } + return new Map( + [...grouped.entries()].map(([key, results]): [string, RunView] => { + const own = results.filter((run) => run.manifestDigest === manifestDigest); + const [first] = results; + const merged = mergeObservations( + own.flatMap((run) => run.observations), + own.flatMap((run) => (run.runUniverse === null ? [] : [run.runUniverse])), + ); + return [ + key, + { + ids: sortedUnique(results.map((run) => run.id)), + reasons: sortedUnique([ + ...(own.length < results.length ? ['run:manifest-mismatch'] : []), + ...own.flatMap((run) => + run.status === 'complete' ? [] : [`run:${run.reason ?? '?'}`], + ), + ]), + baselines: own.flatMap((run) => run.baselines), + observations: lineageOf(indexObservations(merged), first), + }, + ]; + }), + ); +} + +/** + * The unmutated baseline of a test in a run: `passed`, the failing class, or null when unknown + * (no baseline ran, a selection problem, or the test did not run). + */ +function baselineValue(baselines: readonly BaselineSummary[], test: string): string | null { + for (const baseline of baselines) { + const failing = baseline.failing.find((entry) => entry.test === test); + if (failing !== undefined && failing.outcome !== null) return failing.outcome; + } + const passed = + baselines.length > 0 && + baselines.every( + (baseline) => + baseline.problems.length === 0 && !baseline.failing.some((entry) => entry.test === test), + ); + return passed ? 'passed' : null; +} + +type Slot = { value: string | null; reasons: string[] }; + +/** Appends the disagreement or the unknown entry of one test and field across the three runs. */ +function compareSlots( + test: string, + mutant: string | null, + field: IsolationDisagreement['field'], + slots: Record, + into: { disagreements: IsolationDisagreement[]; unknown: IsolationUnknown[] }, +): void { + const order: ComparedRun[] = ['main', 'together', 'alone']; + const known = new Set( + order.flatMap((run) => (slots[run].value === null ? [] : [slots[run].value])), + ); + if (known.size > 1) { + into.disagreements.push({ + test, + mutant, + field, + main: slots.main.value, + together: slots.together.value, + alone: slots.alone.value, + }); + } + const unknownRuns = order.filter((run) => slots[run].value === null); + // One unknown entry per test and mutant: the outcome field repeats the coverage field's runs. + if (unknownRuns.length > 0 && field !== 'outcome') { + into.unknown.push({ + test, + mutant, + runs: unknownRuns, + reasons: sortedUnique(unknownRuns.flatMap((run) => slots[run].reasons)), + }); + } +} + +/** + * A run's coverage and outcome slots for one test and mutant: unknown without a valid observation, + * with the observation's reasons, else the run's own (`run:`), else `missing`. + */ +function observedSlots( + observations: LineageObservations | undefined, + runReasons: readonly string[], + test: string, + mutant: string, +): [Slot, Slot] { + const entry = stateIn(observations, test, mutant); + if (entry === undefined || !entry.valid || entry.outcome === null) { + const reasons = + entry !== undefined && entry.reasons.length > 0 + ? entry.reasons + : runReasons.length > 0 + ? [...runReasons] + : ['missing']; + return [ + { value: null, reasons }, + { value: null, reasons }, + ]; + } + return [ + { value: coverageOf(entry), reasons: [] }, + { value: entry.outcome, reasons: [] }, + ]; +} + +/** The KTD9 hook disqualifier of a pair; see the module comment. */ +function hookDisqualifiers( + decision: RepeatStageDecision, + sources: readonly (LineageObservations | undefined)[], +): IsolationDisqualifier[] { + const touched = sortedUnique([ + ...decision.universe, + ...decision.comparison.killsA, + ...decision.comparison.killsB, + ]); + const hooked = touched.filter((mutant) => + sources.some((view) => + [decision.a, decision.b].some((test) => { + const entry = stateIn(view, test, mutant); + return entry !== undefined && entry.hookAttributed !== false; + }), + ), + ); + return hooked.length === 0 + ? [] + : [{ kind: 'hook-stratum-unvalidated', cap: 'weak', mutants: hooked }]; +} + +/** Each pair's isolation result, in input order; pure. See the module comment. */ +export function evaluateIsolation( + decisions: readonly RepeatStageDecision[], + evidence: IsolationEvidence, +): IsolationResult[] { + const mains = mainIndex(evidence.merged); + const views = runViews(evidence.runs, evidence.manifestDigest); + const viewOf = (group: string, kind: IsolationRunKind, lineage: string): RunView | undefined => + views.get(JSON.stringify([group, kind, lineage])); + return decisions.map((decision): IsolationResult => { + const main = lineageOf(mains, mainRun(decision.group)); + const together = viewOf(decision.group, 'together', decision.key); + const alone = new Map( + [decision.a, decision.b].map((test) => [test, viewOf(decision.group, 'alone', test)]), + ); + const base = { + group: decision.group, + a: decision.a, + b: decision.b, + key: decision.key, + universe: decision.universe, + disqualifiers: hookDisqualifiers(decision, [ + main, + together?.observations, + ...[...alone.values()].map((view) => view?.observations), + ]), + }; + const notRun = (reason: 'not-repeat-stage' | 'not-scheduled'): IsolationResult => ({ + ...base, + status: 'not-run', + reason, + misses: decision.misses, + disagreements: [], + unknown: [], + runs: [], + }); + if (!decision.selected) return notRun('not-repeat-stage'); + const aloneViews = [...alone.values()]; + if (together === undefined || aloneViews.some((view) => view === undefined)) { + return notRun('not-scheduled'); + } + const found = { + disagreements: [] as IsolationDisagreement[], + unknown: [] as IsolationUnknown[], + }; + for (const test of [decision.a, decision.b]) { + const own = alone.get(test); + const baseline = (view: RunView | undefined): Slot => { + const value = view === undefined ? null : baselineValue(view.baselines, test); + return { + value, + reasons: value === null ? [...(view?.reasons ?? []), 'baseline-unknown'] : [], + }; + }; + compareSlots( + test, + null, + 'baseline', + { + main: { value: 'passed', reasons: [] }, + together: baseline(together), + alone: baseline(own), + }, + found, + ); + for (const mutant of decision.universe) { + const [mainCoverage, mainOutcome] = observedSlots(main, [], test, mutant); + const [togetherCoverage, togetherOutcome] = observedSlots( + together.observations, + together.reasons, + test, + mutant, + ); + const [aloneCoverage, aloneOutcome] = observedSlots( + own?.observations, + own?.reasons ?? [], + test, + mutant, + ); + compareSlots( + test, + mutant, + 'coverage', + { main: mainCoverage, together: togetherCoverage, alone: aloneCoverage }, + found, + ); + compareSlots( + test, + mutant, + 'outcome', + { main: mainOutcome, together: togetherOutcome, alone: aloneOutcome }, + found, + ); + } + } + const status: IsolationStatus = + found.disagreements.length > 0 + ? 'disagrees' + : found.unknown.length > 0 + ? 'unknown' + : 'agrees'; + return { + ...base, + status, + reason: + status === 'disagrees' + ? 'isolation-disagrees' + : status === 'unknown' + ? 'isolation-unknown' + : null, + misses: [], + disagreements: found.disagreements, + unknown: found.unknown, + runs: sortedUnique([...together.ids, ...aloneViews.flatMap((view) => view?.ids ?? [])]), + }; + }); +} diff --git a/gitnexus/tools/scenario-map/mutation/observations.ts b/gitnexus/tools/scenario-map/mutation/observations.ts new file mode 100644 index 000000000..8ebea31e4 --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/observations.ts @@ -0,0 +1,920 @@ +/** + * Raw Layer 3 observations of the scenario map (U7, R6, R7, R11, KTD8, design §5.4): what one + * group run says about one test and one mutant, the unit that shards merge. + * + * Compact model. A run's observations are its run universe ({@link RunUniverse}) plus its explicit + * observations; every other observation of the run is implied by the universe and never stored, so + * a run holds O(covered entries + per-test rows) observations, not O(tests × mutants). + * + * Extraction ({@link extractObservations}) reads a scope-driver `results.json` (schema 2, + * tools/mutation/driver/normalise-results.ts). The run's tests are its selected tests: its + * `scope.testIds`, or every test that was not skipped when the run selected none; a test the run + * collected but skipped is no test of the run. Its universe is every mutant Stryker evaluated + * (every status but `Ignored` and `Pending`). The {@link RunUniverse} records the run reference, + * the tests, per mutant its id, file, static flag, Stryker status, own reasons and whether the run + * has a hook marker for it, and the run-level reasons. For each mutant and test of the run: + * - one explicit observation per per-test row of every sidecar attempt (`source: row`), with the + * row's outcome class and the attempt's index (oldest first); + * - no row, and the mutant static, the test in its `coveredBy`, or no `coveredBy` at all: + * explicit; the test's file failed to import or collect in the latest attempt (its file-level + * `killed-outside-test:collection` row): that class (`source: collection`), trusted as that + * attempt's rows are; a Stryker timeout without a sidecar is a `timeout` that cannot be + * attributed to a test (`source: stryker-timeout`, invalid), anything else unknown, outcome + * null (`source: no-row`, invalid `missing-result`). A missing result is never a non-kill + * (§5.4); + * - no row, not covered, but a suite hook attributed the mutant to the test: explicit, a + * `survived` (`source: not-covered`) whose hook marker is true; + * - otherwise implied ({@link impliedObservation}): Stryker never ran the test against the + * non-static mutant, a `survived` (`source: not-covered`; NoCoverage is survived, KTD13), its + * hook marker false (null when the run has no marker for the mutant). + * An observation is invalid, with its reasons, when the run carries a run-level integrity flag + * (`run-flag:`), the mutant carries one (`mutant-flag:`), its span-map check failed + * (`span-map`), its attempts disagree (`attempts-disagree`, KTD8), or the row's attempt has a run + * class whose rows cannot be trusted (`run-timeout`, `run-infrastructure`, `run-inconclusive`, + * `run-invalid`: classify-outcome.ts; such a run can still hold assertion rows). An implied + * observation carries the run's and the mutant's reasons. + * + * Identity (R11). An observation is keyed by group, run kind, lineage, attempt, test and global + * mutant id ({@link observationKey}). The lineage names one run among the group's runs of its + * kind: `main` for the group run, the pair for a `together` run, the test for an `alone` run + * (isolation, U8). A run is its lineage plus the shard that ran it. The test is its Phase 0 id, the + * execution identity inside one run (KTD3; the baselines prove the identities stable). The global + * mutant id carries the original file's content hash (tools/mutation/prepare/mutant-identity.ts). + * + * Merging ({@link mergeObservations}) folds the observations of one lineage per test and mutant, + * explicit and implied alike (an implied one comes from each run of the lineage that selected the + * test, holds the non-static mutant and has no explicit observation of the pair): agreeing classes + * keep the class; different classes become one `inconclusive` observation (`class-disagreement`); + * reasons are united. Observations of different groups or lineages are never folded: two groups + * can mutate one file, and a mutant can survive one and die in another. The result keeps the + * folded observations of every key with an explicit observation, plus the run universes; a key + * with implied observations only is folded on read ({@link stateOf}, {@link observationOf}) from + * an index ({@link indexObservations}). Verdicts, weak tests, gaps and subsumption are recomputed + * from merged observations, never summed. + * + * Comparison ({@link comparePair}) looks at one lineage only. The pair's universe is the mutants of + * that run with a valid observation for both tests; a mutant of the run that either test lacks or + * has invalid is unknown, and with any unknown mutant, or none at all (a run that was never + * observed), the kill sets are not compared. Only `killed-by-test:assertion` is a kill (R6). A + * mutant on which either test has another class is an other-class mutant of the pair, except when + * both test files failed to import (`killed-outside-test:collection`): neither test ran, so it + * cannot tell them apart and counts as a non-kill for both. + * + * Work files ({@link encodeObservations}, {@link decodeObservations}): a run's explicit + * observations are written as rows indexing its universe's tests and mutants, so a file holds each + * test id and mutant id once. + * + * Library only; pure (an index caches the implied observations it folds). The group run + * (run-group.ts) writes the observations of each group as soon as it finishes; the merge stage and + * the verdict engine (U12) read them. + */ +import type { + NormalisedMutant, + PerTestRow, + RunResults, +} from '../../mutation/driver/normalise-results.js'; +import type { MutantStatus } from '../../mutation/prepare/mutant-identity.js'; +import type { OutcomeClass } from '../../mutation/runner/classify-outcome.js'; +import { parseTestId } from '../../mutation/runner/test-ids.js'; +import { byText, isRecord, sha1, sortedUnique } from '../util.js'; + +/** The run kinds of Layer 3: the group run, and the isolation pass's together and alone runs. */ +export type RunKind = 'main' | 'together' | 'alone'; + +/** The lineage of the group run of a group. */ +export const MAIN_LINEAGE = 'main'; + +/** Which run an observation comes from. */ +export interface RunRef { + /** Candidate group id (path-coverage/candidates.ts). */ + group: string; + kind: RunKind; + /** The run among the group's runs of its kind; see the module comment. */ + lineage: string; + /** 0-based shard that ran it. */ + shard: number; +} + +/** A run lineage: the runs of one group, kind and lineage, whichever shard ran them. */ +export type RunKey = Pick; + +export type ObservationSource = 'row' | 'not-covered' | 'collection' | 'stryker-timeout' | 'no-row'; + +export interface Observation extends RunRef { + /** Index of the sidecar attempt (oldest first) the row came from; null without a row. */ + attempt: number | null; + /** Phase 0 test id. */ + test: string; + /** Global mutant id. */ + mutant: string; + /** Repo path of the mutated file. */ + file: string; + /** The test is in the mutant's `coveredBy`. */ + covered: boolean; + /** A suite hook attributed the mutant's dry-run hits to the test; null without a marker. */ + hookAttributed: boolean | null; + static: boolean; + /** The outcome class; null when unknown. */ + outcome: OutcomeClass | null; + source: ObservationSource; + /** False when the observation cannot be trusted; `reasons` says why, sorted. */ + valid: boolean; + reasons: string[]; +} + +/** Several observations of one lineage, test and mutant, folded. */ +export interface MergedObservation extends Omit { + /** Attempt indices that contributed, sorted. */ + attempts: number[]; + /** Shards that contributed, sorted. */ + shards: number[]; + sources: ObservationSource[]; +} + +/** A merged observation without its test: what a lookup reads (tests share an implied one). */ +export type ObservationState = Omit; + +/** One mutant of a run universe. */ +export interface UniverseMutant { + /** Global mutant id. */ + id: string; + /** Repo path of the mutated file. */ + file: string; + static: boolean; + /** Stryker's raw status. */ + status: MutantStatus; + /** The mutant's own reasons (`mutant-flag:`, `span-map`, `attempts-disagree`), sorted. */ + reasons: string[]; + /** The run has a hook marker for the mutant (its `hookAttributedTests` is not null). */ + hookMarker: boolean; +} + +/** What one run evaluated: the reference that implies its unstored observations. */ +export interface RunUniverse extends RunRef { + /** The run's selected tests (Phase 0 ids), sorted. */ + tests: string[]; + /** The mutants Stryker evaluated, sorted by id. */ + mutants: UniverseMutant[]; + /** False when the run carries a run-level reason. */ + valid: boolean; + /** Run-level reasons (`run-flag:`), sorted. */ + reasons: string[]; +} + +/** One run's extraction: its universe and its explicit observations. */ +export interface RunObservations { + universe: RunUniverse; + /** Explicit observations, sorted by mutant, then test. */ + observations: Observation[]; +} + +/** Merged observations of any number of runs; see the module comment. */ +export interface MergedObservations { + /** + * One per lineage, test and mutant with an explicit observation (implied ones of other runs + * folded in), sorted by group, kind, lineage, mutant and test. + */ + explicit: MergedObservation[]; + /** Every run universe, sorted by group, kind, lineage and shard. */ + universes: RunUniverse[]; +} + +/** Whether Stryker evaluated a mutant: `Ignored` and `Pending` are outside every universe. */ +export const isEvaluated = (mutant: Pick): boolean => + mutant.status !== 'Ignored' && mutant.status !== 'Pending'; + +/** Run classes whose per-test rows cannot be trusted (classify-outcome.ts). */ +const UNTRUSTED_RUN: ReadonlySet = new Set([ + 'timeout', + 'infrastructure', + 'inconclusive', + 'invalid', +]); + +const ASSERTION_KILL: OutcomeClass = 'killed-by-test:assertion'; +const COLLECTION: OutcomeClass = 'killed-outside-test:collection'; + +const push = (map: Map, key: K, value: V): void => { + const list = map.get(key); + if (list === undefined) map.set(key, [value]); + else list.push(value); +}; + +// ── Extraction ──────────────────────────────────────────────────────────── + +/** The selected tests of a run: its requested ids, or every test that was not skipped. */ +const testsOf = (results: RunResults): string[] => + sortedUnique( + results.scope.testIds ?? results.tests.filter((test) => !test.skipped).map((test) => test.id), + ); + +/** Reasons that hold for every observation of a run. */ +function runReasons(results: RunResults): string[] { + return sortedUnique( + results.flags.filter((flag) => flag.localId === null).map((flag) => `run-flag:${flag.kind}`), + ); +} + +function universeMutant( + mutant: NormalisedMutant, + spanFailures: ReadonlySet, +): UniverseMutant { + return { + id: mutant.globalId, + file: mutant.file, + static: mutant.static === true, + status: mutant.status, + reasons: sortedUnique([ + ...mutant.flags.map((kind) => `mutant-flag:${kind}`), + ...(spanFailures.has(mutant.globalId) ? ['span-map'] : []), + ...(mutant.attemptsDisagree ? ['attempts-disagree'] : []), + ]), + hookMarker: mutant.hookAttributedTests !== null, + }; +} + +const refOf = (run: RunRef): RunRef => ({ + group: run.group, + kind: run.kind, + lineage: run.lineage, + shard: run.shard, +}); + +/** The explicit observations of one mutant; see the module comment. */ +function mutantObservations( + mutant: NormalisedMutant, + entry: UniverseMutant, + universe: RunUniverse, +): Observation[] { + const reasons = [...universe.reasons, ...entry.reasons]; + // Every observation of the mutant without its own extra reason carries exactly these. + const shared = sortedUnique(reasons); + const covered = new Set(mutant.coveredBy ?? []); + const hooked = mutant.hookAttributedTests === null ? null : new Set(mutant.hookAttributedTests); + const rows = new Map(); + for (const row of mutant.perTest) push(rows, row.testId, row); + const exposedAll = entry.static || mutant.coveredBy === null; + // The latest attempt's test files that failed to import or collect (no per-test rows). + const latest = mutant.attempts.length - 1; + const collectionFailed = new Set( + (mutant.sidecar?.outsideTest ?? []) + .filter((row) => row.suiteTaskId === undefined && row.outcome === COLLECTION) + .map((row) => row.file), + ); + const ref = refOf(universe); + const make = ( + test: string, + attempt: number | null, + outcome: OutcomeClass | null, + source: ObservationSource, + extra: readonly string[], + ): Observation => { + const all = extra.length === 0 ? shared : sortedUnique([...reasons, ...extra]); + return { + ...ref, + attempt, + test, + mutant: mutant.globalId, + file: mutant.file, + covered: covered.has(test), + hookAttributed: hooked === null ? null : hooked.has(test), + static: entry.static, + outcome, + source, + valid: all.length === 0, + reasons: all, + }; + }; + /** Reasons not to trust a row of the attempt (its run class, KTD8). */ + const distrustOf = (attempt: number): string[] => { + const run = mutant.attempts[attempt]?.outcome; + return run === undefined ? ['attempt-missing'] : UNTRUSTED_RUN.has(run) ? [`run-${run}`] : []; + }; + const explicit = exposedAll + ? universe.tests + : universe.tests.filter( + (test) => rows.has(test) || covered.has(test) || hooked?.has(test) === true, + ); + return explicit.flatMap((test): Observation[] => { + const own = rows.get(test); + if (own !== undefined) { + return own.map((row) => make(test, row.attempt, row.outcome, 'row', distrustOf(row.attempt))); + } + if (!exposedAll && !covered.has(test)) return [make(test, null, 'survived', 'not-covered', [])]; + if (collectionFailed.has(parseTestId(test).file)) { + return [make(test, latest, COLLECTION, 'collection', distrustOf(latest))]; + } + if (mutant.outcomeSource === 'stryker-timeout') { + return [make(test, null, 'timeout', 'stryker-timeout', ['stryker-timeout'])]; + } + return [make(test, null, null, 'no-row', ['missing-result'])]; + }); +} + +/** + * The universe and the explicit observations of one scope-driver run; see the module comment. + * Observations are sorted by mutant, then test. + */ +export function extractObservations(results: RunResults, ref: RunRef): RunObservations { + const reasons = runReasons(results); + const spanFailures = new Set(results.spanMap.failures.map((failure) => failure.globalId)); + const evaluated = results.mutants + .filter(isEvaluated) + .sort((a, b) => byText(a.globalId, b.globalId)); + const universe: RunUniverse = { + ...refOf(ref), + tests: testsOf(results), + mutants: evaluated.map((mutant) => universeMutant(mutant, spanFailures)), + valid: reasons.length === 0, + reasons, + }; + return { + universe, + observations: evaluated.flatMap((mutant, index) => + mutantObservations(mutant, universe.mutants[index], universe), + ), + }; +} + +/** + * The observation a run implies for a selected test and a non-static mutant of its universe that + * have no explicit observation: Stryker never ran the test against the mutant, a valid `survived` + * unless the run or the mutant carries a reason. Never stored; see the module comment. + */ +export function impliedObservation( + universe: RunUniverse, + mutant: UniverseMutant, + test: string, +): Observation { + const reasons = sortedUnique([...universe.reasons, ...mutant.reasons]); + return { + ...refOf(universe), + attempt: null, + test, + mutant: mutant.id, + file: mutant.file, + covered: false, + hookAttributed: mutant.hookMarker ? false : null, + static: mutant.static, + outcome: 'survived', + source: 'not-covered', + valid: reasons.length === 0, + reasons, + }; +} + +// ── Merging ─────────────────────────────────────────────────────────────── + +/** The run lineage of an observation: group, kind and lineage. */ +const lineageKey = (entry: RunKey): string => + JSON.stringify([entry.group, entry.kind, entry.lineage]); + +/** The R11 key: group, run kind, lineage, attempt, test and global mutant id. */ +export const observationKey = (entry: Observation): string => + JSON.stringify([entry.group, entry.kind, entry.lineage, entry.attempt, entry.test, entry.mutant]); + +/** Folds the observations of one lineage, test and mutant; see the module comment. */ +function fold(entries: readonly Observation[]): MergedObservation { + const [first] = entries; + const outcomes = new Set( + entries.flatMap((entry) => (entry.outcome === null ? [] : [entry.outcome])), + ); + const coverage = new Set(entries.map((entry) => entry.covered)); + const markers = new Set(entries.map((entry) => entry.hookAttributed)); + const reasons = [ + ...entries.flatMap((entry) => entry.reasons), + ...(outcomes.size > 1 ? ['class-disagreement'] : []), + ...(coverage.size > 1 ? ['coverage-disagreement'] : []), + ...(markers.size > 1 ? ['hook-marker-disagreement'] : []), + ]; + const all = sortedUnique(reasons); + const [single] = [...outcomes]; + return { + group: first.group, + kind: first.kind, + lineage: first.lineage, + test: first.test, + mutant: first.mutant, + file: first.file, + covered: coverage.has(true), + hookAttributed: markers.has(true) ? true : markers.has(false) ? false : null, + static: entries.some((entry) => entry.static), + outcome: outcomes.size > 1 ? 'inconclusive' : (single ?? null), + attempts: [ + ...new Set(entries.flatMap((entry) => (entry.attempt === null ? [] : [entry.attempt]))), + ].sort((a, b) => a - b), + shards: [...new Set(entries.map((entry) => entry.shard))].sort((a, b) => a - b), + sources: [...new Set(entries.map((entry) => entry.source))].sort(byText), + valid: all.length === 0, + reasons: all, + }; +} + +/** A run universe with its lookups (an index's run). */ +export interface IndexedRun { + universe: RunUniverse; + tests: ReadonlySet; + mutants: ReadonlyMap; + /** The run implies observations of its tests: it has a non-static mutant. */ + implies: boolean; + /** Implied states by mutant, folded on first read. */ + implied: Map; +} + +const indexedRun = (universe: RunUniverse): IndexedRun => ({ + universe, + tests: new Set(universe.tests), + mutants: new Map(universe.mutants.map((mutant) => [mutant.id, mutant])), + implies: universe.mutants.some((mutant) => !mutant.static), + implied: new Map(), +}); + +/** The universe mutant a run implies an observation of for the test; undefined when none. */ +function impliedBy(run: IndexedRun, test: string, mutant: string): UniverseMutant | undefined { + if (!run.tests.has(test)) return undefined; + const entry = run.mutants.get(mutant); + return entry === undefined || entry.static ? undefined : entry; +} + +interface KeyedUniverse { + universe: RunUniverse; + lineage: string; +} + +/** Universes with their lineage keys, each computed once, in input order. */ +const keyedUniverses = (universes: readonly RunUniverse[]): KeyedUniverse[] => + universes.map((universe) => ({ universe, lineage: lineageKey(universe) })); + +/** Universe order: by lineage, then shard. */ +const universeOrder = (x: KeyedUniverse, y: KeyedUniverse): number => + byText(x.lineage, y.lineage) || x.universe.shard - y.universe.shard; + +/** + * Merges observations and run universes from any number of shards and runs: the folded + * observation of each lineage, test and mutant with an explicit observation, and the universes + * that imply the rest; see the module comment. + */ +export function mergeObservations( + observations: readonly Observation[], + universes: readonly RunUniverse[], +): MergedObservations { + const keyed = keyedUniverses(universes); + const runs = new Map(); + for (const { universe, lineage } of keyed) push(runs, lineage, indexedRun(universe)); + const byKey = new Map(); + for (const entry of observations) { + const lineage = lineageKey(entry); + const key = JSON.stringify([lineage, entry.mutant, entry.test]); + const known = byKey.get(key); + if (known === undefined) byKey.set(key, { lineage, entries: [entry] }); + else known.entries.push(entry); + } + const explicit = [...byKey.entries()] + .sort(([a], [b]) => byText(a, b)) + .map(([, { lineage, entries }]) => { + const [first] = entries; + const shards = new Set(entries.map((entry) => entry.shard)); + const implied = (runs.get(lineage) ?? []).flatMap((run) => { + if (shards.has(run.universe.shard)) return []; + const mutant = impliedBy(run, first.test, first.mutant); + return mutant === undefined ? [] : [impliedObservation(run.universe, mutant, first.test)]; + }); + return fold([...entries, ...implied]); + }); + return { explicit, universes: keyed.sort(universeOrder).map((entry) => entry.universe) }; +} + +// ── Index ───────────────────────────────────────────────────────────────── + +/** The merged observations of one lineage, indexed; read through {@link stateOf}. */ +export interface LineageObservations extends RunKey { + /** The lineage's runs, one per shard that ran it. */ + runs: readonly IndexedRun[]; + /** Every mutant with an observation in the lineage (explicit or implied), sorted. */ + mutants: readonly string[]; + /** Explicit merged observations, by test, then mutant. */ + explicit: ReadonlyMap>; +} + +export interface ObservationIndex { + /** By lineage (JSON of group, kind and lineage), in that order. */ + lineages: ReadonlyMap; + /** Per test, the lineages where it has an observation, in lineage order. */ + byTest: ReadonlyMap; +} + +/** Observations sorted by group, kind, lineage, mutant and test (each key computed once). */ +const inObservationOrder = (entries: readonly MergedObservation[]): MergedObservation[] => + entries + .map((entry) => ({ + entry, + order: JSON.stringify([entry.group, entry.kind, entry.lineage, entry.mutant, entry.test]), + })) + .sort((x, y) => byText(x.order, y.order)) + .map(({ entry }) => entry); + +/** + * Indexes merged observations by lineage and test, keeping the lineages `keep` accepts. The input + * order does not matter. + */ +export function indexObservations( + merged: MergedObservations, + keep: (run: RunKey) => boolean = () => true, +): ObservationIndex { + // Filtering before the (stable) sorts keeps the order filtering after them gives. + const runs = new Map(); + for (const { universe, lineage } of keyedUniverses(merged.universes.filter(keep)).sort( + universeOrder, + )) { + push(runs, lineage, indexedRun(universe)); + } + const explicit = new Map(); + for (const entry of inObservationOrder(merged.explicit.filter(keep))) { + push(explicit, lineageKey(entry), entry); + } + const keys = sortedUnique([...runs.keys(), ...explicit.keys()]); + const lineages = new Map(); + const byTest = new Map(); + for (const key of keys) { + const own = runs.get(key) ?? []; + const entries = explicit.get(key) ?? []; + const [group, kind, lineage] = JSON.parse(key) as [string, RunKind, string]; + const byMutant = new Map>(); + for (const entry of entries) { + const tested = byMutant.get(entry.test) ?? new Map(); + byMutant.set(entry.test, tested.set(entry.mutant, entry)); + } + const view: LineageObservations = { + group, + kind, + lineage, + runs: own, + mutants: sortedUnique([ + ...own.flatMap((run) => + run.universe.tests.length === 0 + ? [] + : run.universe.mutants.flatMap((mutant) => (mutant.static ? [] : [mutant.id])), + ), + ...entries.map((entry) => entry.mutant), + ]), + explicit: byMutant, + }; + lineages.set(key, view); + const tests = sortedUnique([ + ...byMutant.keys(), + ...own.flatMap((run) => (run.implies ? run.universe.tests : [])), + ]); + for (const test of tests) push(byTest, test, view); + } + return { lineages, byTest }; +} + +/** The indexed lineage of a run; undefined when it has no observation. */ +export const lineageOf = (index: ObservationIndex, run: RunKey): LineageObservations | undefined => + index.lineages.get(lineageKey(run)); + +/** The folded implied state of one run and mutant, cached in the run. */ +function impliedState(run: IndexedRun, mutant: UniverseMutant): ObservationState { + const cached = run.implied.get(mutant.id); + if (cached !== undefined) return cached; + const reasons = sortedUnique([...run.universe.reasons, ...mutant.reasons]); + const state: ObservationState = { + group: run.universe.group, + kind: run.universe.kind, + lineage: run.universe.lineage, + mutant: mutant.id, + file: mutant.file, + covered: false, + hookAttributed: mutant.hookMarker ? false : null, + static: mutant.static, + outcome: 'survived', + attempts: [], + shards: [run.universe.shard], + sources: ['not-covered'], + valid: reasons.length === 0, + reasons, + }; + run.implied.set(mutant.id, state); + return state; +} + +/** + * The merged observation of a test and mutant in a lineage, without its test: the explicit one, + * else the fold of what its runs imply; undefined when the test has none. Allocates nothing for a + * lineage of one run (the hot path of {@link comparePair}). + */ +export function stateOf( + view: LineageObservations, + test: string, + mutant: string, +): ObservationState | undefined { + const found = view.explicit.get(test)?.get(mutant); + if (found !== undefined) return found; + let first: { run: IndexedRun; entry: UniverseMutant } | undefined; + let count = 0; + for (const run of view.runs) { + const entry = impliedBy(run, test, mutant); + if (entry === undefined) continue; + count += 1; + first ??= { run, entry }; + } + if (first === undefined) return undefined; + if (count === 1) return impliedState(first.run, first.entry); + return fold( + view.runs.flatMap((run) => { + const entry = impliedBy(run, test, mutant); + return entry === undefined ? [] : [impliedObservation(run.universe, entry, test)]; + }), + ); +} + +/** {@link stateOf} with the test: the merged observation of a test and mutant in a lineage. */ +export function observationOf( + view: LineageObservations, + test: string, + mutant: string, +): MergedObservation | undefined { + const found = view.explicit.get(test)?.get(mutant); + if (found !== undefined) return found; + const state = stateOf(view, test, mutant); + return state === undefined ? undefined : { ...state, test }; +} + +/** The mutants a test has an observation of in a lineage, sorted. */ +export const mutantsOf = (view: LineageObservations, test: string): string[] => + view.mutants.filter((mutant) => stateOf(view, test, mutant) !== undefined); + +/** + * The observations of a mutant in a lineage that are implied only (tests without an explicit + * one): one state for a lineage of one run, which every such test shares; empty when none. + */ +export function impliedStatesOf(view: LineageObservations, mutant: string): ObservationState[] { + const lacking = (test: string): boolean => view.explicit.get(test)?.get(mutant) === undefined; + if (view.runs.length === 1) { + const [run] = view.runs; + const entry = run.mutants.get(mutant); + if (entry === undefined || entry.static) return []; + return run.universe.tests.some(lacking) ? [impliedState(run, entry)] : []; + } + const tests = sortedUnique(view.runs.flatMap((run) => run.universe.tests)).filter(lacking); + return tests.flatMap((test) => { + const state = stateOf(view, test, mutant); + return state === undefined ? [] : [state]; + }); +} + +/** Every mutant with an observation in any run, sorted. */ +export const observedMutants = (merged: MergedObservations): string[] => + sortedUnique([ + ...merged.universes.flatMap((universe) => + universe.tests.length === 0 + ? [] + : universe.mutants.flatMap((mutant) => (mutant.static ? [] : [mutant.id])), + ), + ...merged.explicit.map((entry) => entry.mutant), + ]); + +// ── Comparison ──────────────────────────────────────────────────────────── + +/** One pair of tests compared inside one run lineage (R11). */ +export interface PairComparison { + group: string; + kind: RunKind; + lineage: string; + a: string; + b: string; + /** `u` + 16 hex digits of sha1 over the lineage and the sorted universe: names the comparison. */ + universeId: string; + /** Mutants of the run with a valid observation for both tests, sorted. */ + universe: string[]; + /** Mutants of the run that either test lacks or has invalid, sorted. */ + unknown: string[]; + /** Universe mutants each test killed with an assertion, sorted. */ + killsA: string[]; + killsB: string[]; + /** Universe mutants where either test's class is neither an assertion kill nor survived. */ + otherClasses: string[]; + /** + * Whether the kill sets are equal; null when any mutant is unknown or the run observed no + * mutant at all (nothing to compare). + */ + sameKills: boolean | null; +} + +const ordinary = (entry: ObservationState): boolean => + entry.outcome === ASSERTION_KILL || entry.outcome === 'survived'; +/** Both tests' files failed to import: neither test ran, so the mutant cannot tell them apart. */ +const bothCollection = (x: ObservationState, y: ObservationState): boolean => + x.outcome === COLLECTION && y.outcome === COLLECTION; + +/** `u` + 16 hex digits of sha1 over a lineage key and a universe. */ +const universeIdFor = (lineage: string, universe: readonly string[]): string => + `u${sha1(JSON.stringify([lineage, universe])).slice(0, 16)}`; + +/** The universe id of every mutant of a lineage view, computed once per view. */ +const fullUniverseIds = new WeakMap(); + +function universeIdIn( + view: LineageObservations, + lineage: string, + universe: readonly string[], +): string { + const full = + universe.length === view.mutants.length && + universe.every((mutant, i) => mutant === view.mutants[i]); + if (!full) return universeIdFor(lineage, universe); + const known = fullUniverseIds.get(view); + if (known !== undefined && known.lineage === lineage) return known.id; + const id = universeIdFor(lineage, view.mutants); + fullUniverseIds.set(view, { lineage, id }); + return id; +} + +/** + * {@link PairComparison.universeId} of a universe of a lineage view: the id {@link comparePair} + * gives a comparison of the view's lineage with that universe (memoised when it is every mutant + * of the view). + */ +export const lineageUniverseId = (view: LineageObservations, universe: readonly string[]): string => + universeIdIn(view, lineageKey(view), universe); + +/** Compares two tests over their common universe in one run lineage; pure. */ +export function comparePair( + index: ObservationIndex, + run: RunKey, + a: string, + b: string, +): PairComparison { + const lineage = lineageKey(run); + const view = index.lineages.get(lineage); + const universe: string[] = []; + const unknown: string[] = []; + const killsA: string[] = []; + const killsB: string[] = []; + const otherClasses: string[] = []; + for (const mutant of view === undefined ? [] : view.mutants) { + // One lookup when `a` is unknown already, or when both sides are one test. + const x = stateOf(view, a, mutant); + const y = x?.valid !== true ? undefined : a === b ? x : stateOf(view, b, mutant); + if (x?.valid !== true || y?.valid !== true) { + unknown.push(mutant); + continue; + } + universe.push(mutant); + if (x.outcome === ASSERTION_KILL) killsA.push(mutant); + if (y.outcome === ASSERTION_KILL) killsB.push(mutant); + if ((!ordinary(x) || !ordinary(y)) && !bothCollection(x, y)) otherClasses.push(mutant); + } + return { + group: run.group, + kind: run.kind, + lineage: run.lineage, + a, + b, + universeId: + view === undefined ? universeIdFor(lineage, universe) : universeIdIn(view, lineage, universe), + universe, + unknown, + killsA, + killsB, + otherClasses, + sameKills: + unknown.length > 0 || universe.length === 0 + ? null + : killsA.length === killsB.length && killsA.every((mutant, i) => killsB[i] === mutant), + }; +} + +// ── Work files ──────────────────────────────────────────────────────────── + +/** + * One explicit observation in a work file: its test and mutant as indexes into the run universe's + * `tests` and `mutants`; file and static flag come from the mutant, validity from the reasons. + */ +export type ObservationRow = [ + test: number, + mutant: number, + attempt: number | null, + outcome: OutcomeClass | null, + source: ObservationSource, + covered: boolean, + hookAttributed: boolean | null, + reasons: string[], +]; + +const sameRun = (a: RunRef, b: RunRef): boolean => + a.group === b.group && a.kind === b.kind && a.lineage === b.lineage && a.shard === b.shard; + +/** A run's explicit observations as rows of its universe; throws for one outside the universe. */ +export function encodeObservations( + universe: RunUniverse | null, + observations: readonly Observation[], +): ObservationRow[] { + if (observations.length === 0) return []; + if (universe === null) throw new Error('observations: explicit observations need a universe'); + const tests = new Map(universe.tests.map((test, index) => [test, index])); + const mutants = new Map(universe.mutants.map((mutant, index) => [mutant.id, index])); + return observations.map((entry): ObservationRow => { + const [test, mutant] = [tests.get(entry.test), mutants.get(entry.mutant)]; + const own = mutant === undefined ? undefined : universe.mutants[mutant]; + if ( + test === undefined || + own === undefined || + !sameRun(entry, universe) || + own.file !== entry.file || + own.static !== entry.static || + entry.valid !== (entry.reasons.length === 0) + ) { + throw new Error( + `observations: ${observationKey(entry)} does not fit its run universe; not written`, + ); + } + return [ + test, + mutant, + entry.attempt, + entry.outcome, + entry.source, + entry.covered, + entry.hookAttributed, + entry.reasons, + ]; + }); +} + +const SOURCES: ReadonlySet = new Set([ + 'row', + 'not-covered', + 'collection', + 'stryker-timeout', + 'no-row', +]); + +const isRow = (row: unknown, tests: number, mutants: number): row is ObservationRow => + Array.isArray(row) && + row.length === 8 && + Number.isInteger(row[0]) && + row[0] >= 0 && + row[0] < tests && + Number.isInteger(row[1]) && + row[1] >= 0 && + row[1] < mutants && + (row[2] === null || Number.isInteger(row[2])) && + (row[3] === null || typeof row[3] === 'string') && + SOURCES.has(row[4]) && + typeof row[5] === 'boolean' && + (row[6] === null || typeof row[6] === 'boolean') && + Array.isArray(row[7]) && + row[7].every((reason: unknown) => typeof reason === 'string'); + +/** The explicit observations of a work file's rows; throws for a malformed row. */ +export function decodeObservations( + universe: RunUniverse | null, + rows: readonly unknown[], +): Observation[] { + if (rows.length === 0) return []; + if (universe === null) throw new Error('observations: rows without a run universe'); + const ref = refOf(universe); + return rows.map((row, index): Observation => { + if (!isRow(row, universe.tests.length, universe.mutants.length)) { + throw new Error(`observations: row ${index} is not an observation of its run universe`); + } + const [test, mutant, attempt, outcome, source, covered, hookAttributed, reasons] = row; + const own = universe.mutants[mutant]; + return { + ...ref, + attempt, + test: universe.tests[test], + mutant: own.id, + file: own.file, + covered, + hookAttributed, + static: own.static, + outcome, + source, + valid: reasons.length === 0, + reasons, + }; + }); +} + +/** Shallow shape check of a run universe read from a work file. */ +export const isRunUniverse = (value: unknown): value is RunUniverse => + isRecord(value) && + typeof value.group === 'string' && + (value.kind === 'main' || value.kind === 'together' || value.kind === 'alone') && + typeof value.lineage === 'string' && + typeof value.shard === 'number' && + Array.isArray(value.tests) && + Array.isArray(value.mutants) && + value.mutants.every( + (mutant: unknown) => + isRecord(mutant) && + typeof mutant.id === 'string' && + typeof mutant.file === 'string' && + typeof mutant.static === 'boolean' && + Array.isArray(mutant.reasons) && + typeof mutant.hookMarker === 'boolean', + ) && + typeof value.valid === 'boolean' && + Array.isArray(value.reasons); diff --git a/gitnexus/tools/scenario-map/mutation/plan-shards.ts b/gitnexus/tools/scenario-map/mutation/plan-shards.ts new file mode 100644 index 000000000..4a78e557a --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/plan-shards.ts @@ -0,0 +1,450 @@ +/** + * Shard plan of Layer 3 (U7, KTD7, KTD14, KTD17, R11): each candidate group's whole-file mutate + * scope, its cost estimate, a deterministic bin-packing of the groups into shards, the deadline + * check a shard makes before it starts a group, and how the merge stage reads each pair's schedule. + * + * Mutate scope (KTD7, {@link groupScope}). The production files (types.ts + * `productionFilePredicate`) that any test of the group executed (Layer 1 provenance, the + * coverage work file's `executedFiles`) or statically imports (the Phase 0 classifier's + * `FileVerdict.closure` of its test file), whole, with no line ranges. Per test, the excluded + * files are the repo files under `gitnexus/` it executed, or its test file statically imports + * (design §5.4: static closure), that are neither test files (`test/**`, `*.test|spec|case.*`) nor + * mutated, such as `gitnexus/tools/` code; they cap the test at weak (`excluded-file`). Nothing is + * dropped silently: tests without known provenance and test files whose classifier closure is + * incomplete (a rejected file's walk stops at the rejected import, as for every controls fixture + * file, which imports from `test/fixtures/`) are listed. + * + * Cost (KTD17, {@link estimateGroupCost}), in milliseconds: a fixed per-run overhead, plus the + * mutated bytes times the group's tests, plus the isolation term: candidate pairs times covered + * mutants times {@link ISOLATION_RUNS} runs at the measured fresh-process cost. The covered + * mutants are not known before the run; the estimate takes every mutant of the mutate files + * (mutated bytes times mutants per byte), an upper bound. + * + * Packing ({@link packShards}). A group is never split, so both tests of every pair run in one + * run. A group whose estimate exceeds one shard's budget is not run (`group-over-budget`). The + * others go, largest estimate first (ties by group id), to the least-loaded shard (ties by the + * lower index); each shard runs its groups in group-id order. The result depends only on the + * set of estimates, never on input order. + * + * Deadline ({@link fitsBeforeDeadline}). A shard starts a group only when the group's estimate + * fits the time left before {@link SHARD_DEADLINE_MS}; a group it never starts writes no result. + * + * Schedule ({@link resolvePairSchedules}). A pair is evaluated only when its group ran to + * completion with both its tests, against the plan's run manifest. Otherwise it is Inconclusive: + * `group-over-budget`; `not-scheduled` (no shard reported the group, or the group run lacks one + * of its tests, R11); `manifest-mismatch` (the result was verified against another run manifest, + * or the shard's prepared copy differed from it, KTD14); or the reason of a failed group run. + * + * Library only; pure. The plan stage (U14) builds the plan; run-group.ts runs one shard of it. + */ +import { parseTestId } from '../../mutation/runner/test-ids.js'; +import { productionFilePredicate } from '../types.js'; +import { byText, sortedUnique } from '../util.js'; + +export const SHARD_PLAN_SCHEMA_VERSION = 1; + +/** A shard starts no group whose estimate does not fit before this deadline (KTD17). */ +export const SHARD_DEADLINE_MS = 300 * 60_000; + +/** The isolation pass's runs per pair: together, and each test alone (KTD9). */ +export const ISOLATION_RUNS = 3; + +export interface CostModel { + /** Fixed cost of one group run: two baselines, Stryker's start, prepare and dry run. */ + perRunMs: number; + /** Mutant-run cost per mutated byte and group test. */ + perByteTestMs: number; + /** Mutants per mutated byte: estimates the covered mutants of the isolation term. */ + mutantsPerByte: number; + /** Measured cost of one mutant run in a fresh process (`--max-test-runner-reuse 1`). */ + freshProcessMs: number; +} + +/** + * First calibration, from one controls-fixture group run (U7, WSL2, concurrency 2): a group of + * 2 tests and 11 mutants took about 98 s (two baselines 6 s, the scope driver's prepare and + * Stryker's start about 70 s, the mutant runs about 0.25 s per mutant and covering test), and + * the fixture holds about 0.04 mutants per source byte. `freshProcessMs` comes from the isolation + * pass (U8) on four controls pairs (same machine, concurrency 2, `maxTestRunnerReuse` 1): 54 mutant + * runs in 10 Stryker runs took 298 s from the dry run to the last sidecar, about 5.5 s per mutant; + * each of those runs also cost about 130 s besides its mutant runs (two baselines, the scope + * driver's prepare, Stryker's start and its test-runner processes), which the isolation term does + * not count. The full local run (U14) re-measures all four on the eligible category. + */ +export const DEFAULT_COST_MODEL: Readonly = Object.freeze({ + perRunMs: 100_000, + perByteTestMs: 10, + mutantsPerByte: 0.04, + freshProcessMs: 5_500, +}); + +// ── Mutate scope (KTD7) ─────────────────────────────────────────────────── + +/** A test's Layer 1 provenance, as the coverage work file records it. */ +export interface TestProvenance { + known: boolean; + /** Repo paths, `node_modules/…` and `outside:…` entries. */ + executedFiles: readonly string[]; +} + +/** A test file's static import closure (repo paths), from the Phase 0 classifier. */ +export interface StaticClosure { + closure: readonly string[]; + /** False when the classifier rejected the file: its walk stops at a rejected import. */ + complete: boolean; +} + +export interface ExcludedFiles { + test: string; + mark: 'excluded-file'; + /** Repo paths, sorted. */ + files: string[]; +} + +export interface GroupScope { + /** Candidate group id. */ + id: string; + /** Phase 0 test ids, sorted. */ + tests: string[]; + /** Package paths of their files, sorted. */ + testFiles: string[]; + /** Candidate pairs of the group. */ + pairs: number; + /** Repo paths of the whole production files to mutate, sorted. */ + mutate: string[]; + /** Tests with excluded files, sorted by test. */ + excluded: ExcludedFiles[]; + /** Test files (package paths) whose static closure is incomplete or missing. */ + closureIncomplete: string[]; + /** Tests whose Layer 1 provenance is unknown or missing. */ + provenanceUnknown: string[]; +} + +export interface GroupScopeInput { + group: { id: string; tests: readonly string[]; pairs: number }; + /** Per Phase 0 test id. */ + provenance: ReadonlyMap; + /** Per test file (package path). */ + closures: ReadonlyMap; + /** Extra path-map roots (controls mode). */ + extraRoots?: readonly string[]; +} + +/** Test-side names, as types.ts: never production, never excluded. */ +const TEST_NAME = /\.(test|spec|case)\.[mc]?[jt]sx?$/; +/** Files that hold code; a data file in a closure (`.json`) is never an excluded file. */ +const SCRIPT = /\.[mc]?[jt]sx?$/; +const PACKAGE_PREFIX = 'gitnexus/'; + +/** A repo path under `gitnexus/test/`, or named like a test (KTD7 test files). */ +export const isTestPath = (repoPath: string): boolean => + repoPath.startsWith(`${PACKAGE_PREFIX}test/`) || TEST_NAME.test(repoPath); + +/** KTD7 excluded files: repo files under `gitnexus/` that are neither test files nor mutated. */ +export function excludedFiles(files: readonly string[], mutate: ReadonlySet): string[] { + return sortedUnique( + files.filter( + (file) => + file.startsWith(PACKAGE_PREFIX) && + SCRIPT.test(file) && + !isTestPath(file) && + !mutate.has(file), + ), + ); +} + +/** Only repo paths can be production; `node_modules/…` and `outside:…` entries cannot. */ +const isRepoPath = (file: string): boolean => + file.startsWith(PACKAGE_PREFIX) || file.startsWith('gitnexus-shared/'); + +/** The group's KTD7 mutate scope and per-test excluded files; see the module comment. */ +export function groupScope(input: GroupScopeInput): GroupScope { + const production = productionFilePredicate(input.extraRoots ?? []); + const tests = sortedUnique(input.group.tests); + const fileOf = new Map(tests.map((test) => [test, parseTestId(test).file])); + const testFiles = sortedUnique(fileOf.values()); + const executed = (test: string): readonly string[] => + input.provenance.get(test)?.executedFiles ?? []; + const closure = (test: string): readonly string[] => + input.closures.get(fileOf.get(test) ?? '')?.closure ?? []; + const mutate = sortedUnique( + tests + .flatMap((test) => [...executed(test), ...closure(test)]) + .filter((file) => isRepoPath(file) && production(file)), + ); + const mutated = new Set(mutate); + const excluded = tests.flatMap((test): ExcludedFiles[] => { + const files = excludedFiles([...executed(test), ...closure(test)], mutated); + return files.length === 0 ? [] : [{ test, mark: 'excluded-file', files }]; + }); + return { + id: input.group.id, + tests, + testFiles, + pairs: input.group.pairs, + mutate, + excluded, + closureIncomplete: testFiles.filter((file) => input.closures.get(file)?.complete !== true), + provenanceUnknown: tests.filter((test) => input.provenance.get(test)?.known !== true), + }; +} + +// ── Cost and packing (KTD17) ────────────────────────────────────────────── + +export interface GroupCostInput { + tests: number; + pairs: number; + /** Total bytes of the group's mutate files. */ + mutatedBytes: number; +} + +/** The KTD17 estimate of one group, in whole milliseconds; see the module comment. */ +export function estimateGroupCost( + input: GroupCostInput, + model: Readonly = DEFAULT_COST_MODEL, +): number { + const coveredMutants = input.mutatedBytes * model.mutantsPerByte; + return Math.ceil( + model.perRunMs + + input.mutatedBytes * input.tests * model.perByteTestMs + + input.pairs * coveredMutants * ISOLATION_RUNS * model.freshProcessMs, + ); +} + +export interface GroupEstimate { + id: string; + estimateMs: number; +} + +export interface ShardAssignment { + /** 0-based shard index (the CI matrix entry). */ + index: number; + /** Group ids in run order (sorted). */ + groups: string[]; + /** Sum of the groups' estimates. */ + estimateMs: number; +} + +export interface Packing { + shards: ShardAssignment[]; + /** Groups never run, sorted: their pairs are `group-over-budget`. */ + overBudget: string[]; +} + +function checkEstimates(groups: readonly GroupEstimate[]): void { + const seen = new Set(); + for (const group of groups) { + if (seen.has(group.id)) throw new Error(`shard plan: group ${group.id} is listed twice`); + seen.add(group.id); + if (!Number.isFinite(group.estimateMs) || group.estimateMs < 0) { + throw new Error(`shard plan: group ${group.id} has an invalid estimate`); + } + } +} + +/** Bin-packs the groups into `shards` shards; see the module comment. */ +export function packShards( + groups: readonly GroupEstimate[], + shards: number, + budgetMs: number = SHARD_DEADLINE_MS, +): Packing { + if (!Number.isInteger(shards) || shards < 1) { + throw new Error(`shard plan: the shard count must be a positive integer, got ${shards}`); + } + checkEstimates(groups); + const overBudget = groups.filter((group) => group.estimateMs > budgetMs); + const runnable = groups + .filter((group) => group.estimateMs <= budgetMs) + .sort((a, b) => b.estimateMs - a.estimateMs || byText(a.id, b.id)); + const loads = Array.from( + { length: shards }, + (_, index): ShardAssignment => ({ index, groups: [], estimateMs: 0 }), + ); + for (const group of runnable) { + const target = loads.reduce((best, shard) => + shard.estimateMs < best.estimateMs ? shard : best, + ); + target.groups.push(group.id); + target.estimateMs += group.estimateMs; + } + return { + shards: loads.map((shard) => ({ ...shard, groups: shard.groups.sort(byText) })), + overBudget: sortedUnique(overBudget.map((group) => group.id)), + }; +} + +/** + * The fewest shards, up to `maxShards`, whose packing keeps every shard within the budget + * (over-budget groups never run and do not count); `maxShards` when none does. + */ +export function shardsNeeded( + groups: readonly GroupEstimate[], + budgetMs: number, + maxShards: number, +): number { + for (let shards = 1; shards < maxShards; shards++) { + const packing = packShards(groups, shards, budgetMs); + if (packing.shards.every((shard) => shard.estimateMs <= budgetMs)) return shards; + } + return maxShards; +} + +/** Whether a shard that has run for `elapsedMs` may start a group estimated at `estimateMs`. */ +export const fitsBeforeDeadline = ( + estimateMs: number, + elapsedMs: number, + deadlineMs: number = SHARD_DEADLINE_MS, +): boolean => elapsedMs + estimateMs <= deadlineMs; + +// ── Plan ────────────────────────────────────────────────────────────────── + +export interface PlannedGroup extends GroupScope { + mutatedBytes: number; + estimateMs: number; +} + +export interface ShardPlan extends Packing { + schemaVersion: typeof SHARD_PLAN_SCHEMA_VERSION; + budgetMs: number; + costModel: CostModel; + /** Every group, sorted by id. */ + groups: PlannedGroup[]; +} + +export interface ShardPlanInput { + scopes: readonly GroupScope[]; + /** Byte size of a repo file. */ + sizeOf: (repoPath: string) => number; + /** Number of shards (see {@link shardsNeeded}). */ + shards: number; + /** One shard's budget; defaults to {@link SHARD_DEADLINE_MS}. */ + budgetMs?: number; + costModel?: Readonly; +} + +/** Estimates every group and packs them; pure given `sizeOf`. */ +export function buildShardPlan(input: ShardPlanInput): ShardPlan { + const budgetMs = input.budgetMs ?? SHARD_DEADLINE_MS; + const costModel = { ...(input.costModel ?? DEFAULT_COST_MODEL) }; + const groups = [...input.scopes] + .sort((a, b) => byText(a.id, b.id)) + .map((scope): PlannedGroup => { + const mutatedBytes = scope.mutate.reduce((sum, file) => sum + input.sizeOf(file), 0); + const estimateMs = estimateGroupCost( + { tests: scope.tests.length, pairs: scope.pairs, mutatedBytes }, + costModel, + ); + return { ...scope, mutatedBytes, estimateMs }; + }); + return { + schemaVersion: SHARD_PLAN_SCHEMA_VERSION, + budgetMs, + costModel, + groups, + ...packShards(groups, input.shards, budgetMs), + }; +} + +// ── Schedule (R11) ──────────────────────────────────────────────────────── + +/** Why a group run gave no usable observations; its pairs are Inconclusive with the reason. */ +export type GroupStatusReason = + /** The shard's prepared copy, or a group test, does not match the run manifest (KTD14). */ + | 'manifest-mismatch' + /** A plain Vitest baseline did not pass every test of the group (KTD8). */ + | 'baseline-failed' + /** The two baselines ran different test identities (KTD8). */ + | 'baseline-unstable' + /** Stryker's dry run did not pass with the baselines' identities (KTD8). */ + | 'dry-run-unstable' + /** The generated Stryker config lacks perTest, disableBail or static mutants (KTD8). */ + | 'config-invalid' + /** The scope driver failed: prepare rejected a file or Stryker exited non-zero. */ + | 'stryker-failed'; + +/** What the schedule reads of one group result (run-group.ts `GroupObservations`). */ +export interface GroupOutcome { + group: string; + shard: number; + /** Digest of the run manifest the shard verified its prepared copy against. */ + manifestDigest: string; + status: 'complete' | 'inconclusive'; + reason: GroupStatusReason | null; + detail: string | null; + /** Phase 0 ids of the tests the group run covered. */ + tests: string[]; +} + +export type ScheduleReason = GroupStatusReason | 'group-over-budget' | 'not-scheduled'; + +export interface PairRef { + a: string; + b: string; + group: string; +} + +export interface PairSchedule extends PairRef { + status: 'evaluated' | 'inconclusive'; + /** Null when evaluated. */ + reason: ScheduleReason | null; + /** The shard whose result decided; null when none did. */ + shard: number | null; + detail: string | null; +} + +export interface ScheduleInput { + plan: Pick; + /** Digest of the plan's run manifest. */ + manifestDigest: string; + pairs: readonly PairRef[]; + /** Every group result found, from every shard. */ + outcomes: readonly GroupOutcome[]; +} + +/** Each pair's schedule, in input order; see the module comment. */ +export function resolvePairSchedules(input: ScheduleInput): PairSchedule[] { + const overBudget = new Set(input.plan.overBudget); + const byGroup = new Map(); + for (const outcome of [...input.outcomes].sort((x, y) => x.shard - y.shard)) { + const known = byGroup.get(outcome.group); + if (known === undefined) byGroup.set(outcome.group, [outcome]); + else known.push(outcome); + } + // The tests of each group's first outcome, as a set (a pair checks two of them). + const testsOf = new Map>(); + const firstTests = (outcome: GroupOutcome): ReadonlySet => { + const known = testsOf.get(outcome); + if (known !== undefined) return known; + const tests = new Set(outcome.tests); + testsOf.set(outcome, tests); + return tests; + }; + return input.pairs.map((pair): PairSchedule => { + const inconclusive = ( + reason: ScheduleReason, + shard: number | null, + detail: string | null, + ): PairSchedule => ({ ...pair, status: 'inconclusive', reason, shard, detail }); + if (overBudget.has(pair.group)) return inconclusive('group-over-budget', null, null); + const outcomes = byGroup.get(pair.group) ?? []; + if (outcomes.length === 0) return inconclusive('not-scheduled', null, null); + const foreign = outcomes.find((outcome) => outcome.manifestDigest !== input.manifestDigest); + if (foreign !== undefined) { + return inconclusive( + 'manifest-mismatch', + foreign.shard, + `verified against run manifest ${foreign.manifestDigest}`, + ); + } + const failed = outcomes.find((outcome) => outcome.status === 'inconclusive'); + if (failed !== undefined) { + return inconclusive(failed.reason ?? 'not-scheduled', failed.shard, failed.detail); + } + const [first] = outcomes; + const tests = firstTests(first); + if (!tests.has(pair.a) || !tests.has(pair.b)) { + return inconclusive('not-scheduled', first.shard, 'the group run lacks a test of the pair'); + } + return { ...pair, status: 'evaluated', reason: null, shard: first.shard, detail: null }; + }); +} diff --git a/gitnexus/tools/scenario-map/mutation/row-deletion.ts b/gitnexus/tools/scenario-map/mutation/row-deletion.ts new file mode 100644 index 000000000..bda38a60d --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/row-deletion.ts @@ -0,0 +1,1080 @@ +/** + * Row-deletion operator of the scenario map (U9, R8, KTD10, design §5.4 "Row-deletion operator"). + * Stryker never mutates object keys, so two tests that read two rows of one production table can + * kill the same mutants and still pin different rows. Deleting each row in turn tells them apart. + * + * Plan ({@link planRowDeletion}, pure). The pair's differing discarded values are the U6 facet + * entries at the same position with the same kind and another value. For each such position the + * two values (strings trimmed, numbers as numbers: the facet's table keys) must both be keys or + * entries of ONE supported production table: + * - the search goes tier by tier like the facet's (design §5.3 rule 3; property columns never + * count): the production files either test executed, then their two-hop import closure + * ({@link pairTableSearch}); the first tier in which U6's table index (tables.ts `factsOf`) + * has a table holding both values decides; + * - in that tier's files, the supported constructs whose key set equals such an index table: + * an object literal (plain identifier or string keys), `new Set([...])` (literal entries) or + * `new Map([...])` (`[key, value]` entries with literal keys), possibly under `as`, + * `satisfies`, `!` or `Object.freeze(...)`; + * - none: the position is not applicable (`no-common-table`: no table holds both values; + * `no-supported-table`: only an array, switch or regex table does); more than one: + * `ambiguous-table` (deleting from the wrong one would read as "neither fails"); + * - the one table is unsupported (`unsupported-table: `) when it holds a spread, a + * computed key, a getter or setter, a duplicate key or a non-literal entry, or is wrapped in + * `new Proxy(...)` or passed to `Object.assign(...)`, or when the declaring module (only that + * module) writes into it through its binding: an assignment, `++`/`--` or `delete` on a + * member, a member in a destructuring or `for…in/of` target, a call of `add`, `set`, + * `delete` or `clear`, `Object.assign/defineProperty/defineProperties/setPrototypeOf` or + * `Reflect.set/defineProperty/deleteProperty/setPrototypeOf` on it, `new Proxy(, …)`, or + * a reassignment of the binding. A table that is neither bound to a declaration nor read in + * place (`{…}[k]`) is `untracked`: its writes cannot be checked. A module that does not parse + * is `unparseable`. Not seen (accepted limits of the one-module scan): writes from another + * module, through an alias of the binding, or by a function the table is passed to. + * Each planned position deletes its two keys (deduplicated across positions): the entry's text + * and its trailing comma, if any, are spliced out of the source, never regenerated, and the result + * must parse (`splice-unparseable`). The global id is prepare/mutant-identity.ts `formatGlobalId` + * over the original file's repo path and content hash, the entry's span (1-based lines and + * columns, end exclusive), mutator {@link ROW_DELETION_MUTATOR} and an empty replacement. + * + * Runs ({@link runRowDeletion}). One throwaway copy per batch: `/row-deletion/prepared`, + * prepared like a shard's copy (tools/mutation/prepare, nothing blanked) and verified against the + * run manifest (run-group.ts `verifyPreparedCopy`, else `manifest-mismatch` and nothing runs); + * each table file's copy must have the planned content hash (`copy-mismatch`). Per pair, through + * vitest-run.ts `runVitest` with the pair's two test ids, each call a fresh Vitest instance (a + * reused one would serve cached transforms and turn a deletion into a false "neither fails"): + * 1. a control run of the unedited copy: both tests pass (`control-failed`), and the run's Vite + * module graph shows every table file transformed (`probeFiles`/`loadedFiles`, else + * `edit-not-loaded`); either problem makes the pair inconclusive and runs no deletion; + * 2. one run per deletion, with the spliced text written into the copy and the original text + * restored after the run, whatever happens; a run that throws is inconclusive (`run-error`). + * Scratch: `/row-deletion/pairs//{control,deletion-}/`. + * + * Outcomes. A deletion run ({@link classifyDeletionRun}) is inconclusive when the run is invalid, + * errored or timed out, has an outside-test row, a test without a pass or an assertion failure (a + * runtime error, a timeout, a hook: runner/classify-outcome.ts classes), or did not load the + * edited file; else it is asymmetric when exactly one test fails by assertion (Row) and symmetric + * when both or neither do. The pair ({@link combineRowDeletion}) is asymmetric if any deletion is, + * else inconclusive if any deletion or position is, else symmetric if any deletion ran, else not + * applicable. Design §2: symmetric and not applicable allow strong evidence, inconclusive caps it + * at weak, asymmetric is Row. + * + * Library only; the scenario-map CLI (U14) plans the pairs and calls {@link runRowDeletion}. The + * runner and the copy are injected ({@link RowDeletionDeps}), so unit tests never spawn Vitest. + */ +import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { parse } from '@babel/parser'; +import traverse, { type NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import { + contentHash, + formatGlobalId, + type MutantIdentity, + type ReportLocation, +} from '../../mutation/prepare/mutant-identity.js'; +import { PACKAGE_DIR, buildPathMap, toPreparedPath } from '../../mutation/prepare/path-map.js'; +import { + prepare, + type PrepareOptions, + type PrepareReport, +} from '../../mutation/prepare/prepare.js'; +import type { OutcomeClass } from '../../mutation/runner/classify-outcome.js'; +import { errorMessage } from '../../mutation/shared/cli.js'; +import { overlaps } from '../../mutation/shared/fs.js'; +import type { + DiscardedValue, + FacetContext, + FacetSubject, + InputFacet, +} from '../facet/input-facet.js'; +import { + numberKey, + stringKey, + type LiteralKey, + type TableIndex, + type TableSearch, +} from '../facet/tables.js'; +import { extraRootsOf } from '../types.js'; +import { byText, sha256, sortedUnique } from '../util.js'; +import { verifyPreparedCopy, type ScenarioRunManifest } from './run-group.js'; +import { + runVitest, + vitestSelectionOf, + type VitestRunOptions, + type VitestRunResult, +} from './vitest-run.js'; + +/** The mutator name of a row deletion's global id. */ +export const ROW_DELETION_MUTATOR = 'RowDeletion'; + +export type RowDeletionOutcome = 'symmetric' | 'asymmetric' | 'not-applicable' | 'inconclusive'; +export type DeletionOutcome = 'symmetric' | 'asymmetric' | 'inconclusive'; + +/** A construct that makes a table unsupported (design §5.4); see the module comment. */ +export type UnsupportedConstruct = + | 'spread' + | 'computed-key' + | 'getter' + | 'setter' + | 'duplicate-key' + | 'non-literal-entry' + | 'object-assign' + | 'proxy' + | 'write' + | 'untracked'; + +export type TableKind = 'object' | 'Set' | 'Map'; + +export interface RowTable { + /** Repo path of the declaring production file. */ + file: string; + kind: TableKind; + /** The declared binding; null for a table read in place. */ + binding: string | null; + /** 1-based line of the literal. */ + line: number; +} + +export interface PlannedDeletion { + globalId: string; + identity: MutantIdentity; + /** The deleted key or entry (tables.ts `LiteralKey`). */ + key: LiteralKey; + table: RowTable; + /** Offsets in the original text, end exclusive: the entry and its trailing comma. */ + splice: { start: number; end: number }; +} + +export interface PositionPlan { + /** Facet slot index (U6 `DiscardedValue.position`). */ + position: number; + /** The two tests' values at the position. */ + values: [string, string]; + status: 'planned' | 'not-applicable' | 'inconclusive'; + /** Why the position is not planned; empty when it is. */ + reasons: string[]; + table: RowTable | null; +} + +export interface RowDeletionPlan { + /** Phase 0 ids of the pair's tests. */ + a: string; + b: string; + /** `planned` when at least one deletion is planned. */ + status: 'planned' | 'not-applicable' | 'inconclusive'; + /** Reasons of the unplanned positions, sorted and distinct. */ + reasons: string[]; + positions: PositionPlan[]; + /** Distinct global ids, in position order. */ + deletions: PlannedDeletion[]; +} + +export interface RowDeletionPairInput { + a: string; + b: string; + /** The U6 facets of `a` and `b`. */ + facets: readonly [InputFacet, InputFacet]; + /** Where the table is searched ({@link pairTableSearch}). */ + search: TableSearch; +} + +export interface RowDeletionEnv { + /** The run's table index (FacetContext `tables`). */ + tables: TableIndex; + /** Text of a repo path, null when it cannot be read; the index's sources. */ + read: (repoPath: string) => string | null; +} + +const PLUGINS: ['typescript', 'decorators-legacy'] = ['typescript', 'decorators-legacy']; +/** Nodes between a table literal and its use that keep its value. */ +const WRAPPERS: ReadonlySet = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', + 'TSNonNullExpression', + 'ParenthesizedExpression', +]); +const MUTATING_METHODS: ReadonlySet = new Set(['add', 'set', 'delete', 'clear']); +const WRITING_CALLS: ReadonlySet = new Set([ + 'Object.defineProperty', + 'Object.defineProperties', + 'Object.setPrototypeOf', + 'Reflect.set', + 'Reflect.defineProperty', + 'Reflect.deleteProperty', + 'Reflect.setPrototypeOf', +]); + +// ── Table search ─────────────────────────────────────────────────────────── + +/** + * The tiers a pair's table is searched in: the production files either test executed (Layer 1 + * provenance), then the production files within two import hops of either test file (the facet's + * second tier). Both sorted and distinct. + */ +export function pairTableSearch(ctx: FacetContext, subjects: readonly FacetSubject[]): TableSearch { + const executed = subjects.flatMap((subject) => + (subject.executedFiles ?? []).filter((file) => ctx.isProduction(file)), + ); + const closure = subjects.flatMap((subject) => ctx.closureOf(subject)?.near ?? []); + return { executed: sortedUnique(executed), closure: sortedUnique(closure) }; +} + +/** A discarded value as the table index holds it; null for values no table holds. */ +function tableKeyOf(entry: DiscardedValue): LiteralKey | null { + if (entry.kind === 'string') return stringKey(entry.value.trim()); + if (entry.kind !== 'number') return null; + const value = Number(entry.value); + return Number.isFinite(value) ? numberKey(value) : null; +} + +// ── Table constructs of a module ─────────────────────────────────────────── + +interface ScannedTable extends RowTable { + values: ReadonlySet; + /** The node of each key's first entry. */ + entries: ReadonlyMap; + unsupported: ReadonlySet; +} + +/** A string literal or a plain template's text, else null (tables.ts `stringOf`). */ +function stringOf(node: t.Node | null | undefined): string | null { + if (node === null || node === undefined) return null; + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? null; + } + return null; +} + +/** A Set entry or Map key as the table index holds it (tables.ts `entryKey`), else null. */ +function entryKeyOf(node: t.Node | null | undefined): LiteralKey | null { + if (node === null || node === undefined) return null; + if (t.isNumericLiteral(node)) return numberKey(node.value); + if (t.isUnaryExpression(node, { operator: '-' }) && t.isNumericLiteral(node.argument)) { + return numberKey(-node.argument.value); + } + const text = stringOf(node); + return text !== null && text.length > 0 ? stringKey(text) : null; +} + +/** An object property's key as the table index holds it (tables.ts `objectKeyOf`), else null. */ +function objectKeyOf(property: t.ObjectProperty | t.ObjectMethod): LiteralKey | null { + const text = property.computed + ? stringOf(property.key) + : t.isIdentifier(property.key) + ? property.key.name + : t.isStringLiteral(property.key) + ? property.key.value + : null; + return text !== null && text.length > 0 ? stringKey(text) : null; +} + +/** `Object.assign` for `Object.assign(...)`, `Proxy` for `new Proxy(...)`: the callee's name. */ +function calleeName(callee: t.Node): string | null { + if (t.isIdentifier(callee)) return callee.name; + if (t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.object)) { + return t.isIdentifier(callee.property) ? `${callee.object.name}.${callee.property.name}` : null; + } + return null; +} + +function memberName(member: t.MemberExpression | t.OptionalMemberExpression): string | null { + if (!member.computed && t.isIdentifier(member.property)) return member.property.name; + return stringOf(member.property); +} + +/** The outermost path above `path` that only wraps its value. */ +function unwrap(path: NodePath): NodePath { + let current = path; + for (;;) { + const parent = current.parentPath; + if (parent === null) return current; + const frozen = + t.isCallExpression(parent.node) && + calleeName(parent.node.callee) === 'Object.freeze' && + parent.node.arguments.length === 1 && + parent.node.arguments[0] === current.node; + if (!WRAPPERS.has(parent.node.type) && !frozen) return current; + current = parent; + } +} + +/** True when a member expression is the target of a destructuring or `for…in/of` assignment. */ +function isPatternTarget(member: NodePath): boolean { + const parent = member.parentPath; + if (parent === null) return false; + const node = parent.node; + if (t.isArrayPattern(node) || t.isRestElement(node)) return true; + if (t.isAssignmentPattern(node)) return node.left === member.node; + if (t.isObjectProperty(node)) { + return t.isObjectPattern(parent.parent) && node.value === member.node; + } + if (t.isForOfStatement(node) || t.isForInStatement(node)) return node.left === member.node; + return false; +} + +/** What one reference to the table's binding does to it; empty for a read. */ +function referenceUse(reference: NodePath): UnsupportedConstruct[] { + const use = unwrap(reference); + const parent = use.parentPath; + if (parent === null) return []; + const node = parent.node; + if ( + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + node.object === use.node + ) { + const outer = parent.parent; + if (t.isAssignmentExpression(outer) && outer.left === node) return ['write']; + if (t.isUpdateExpression(outer)) return ['write']; + if (t.isUnaryExpression(outer, { operator: 'delete' })) return ['write']; + const called = + (t.isCallExpression(outer) || t.isOptionalCallExpression(outer)) && outer.callee === node; + if (called && MUTATING_METHODS.has(memberName(node) ?? '')) return ['write']; + return isPatternTarget(parent) ? ['write'] : []; + } + if ((t.isCallExpression(node) || t.isNewExpression(node)) && node.arguments[0] === use.node) { + const callee = calleeName(node.callee) ?? ''; + if (t.isNewExpression(node)) return callee === 'Proxy' ? ['proxy'] : []; + if (callee === 'Object.assign') return ['object-assign']; + if (WRITING_CALLS.has(callee)) return ['write']; + } + return []; +} + +/** How a table literal is held: its binding and what the module does to it. */ +function usageOf(literal: NodePath): { + binding: string | null; + unsupported: UnsupportedConstruct[]; +} { + const root = unwrap(literal); + const parent = root.parentPath; + const node = parent?.node; + if ( + parent && + t.isVariableDeclarator(node) && + node.init === root.node && + t.isIdentifier(node.id) + ) { + const name = node.id.name; + const binding = parent.scope.getBinding(name); + if (binding === undefined) return { binding: name, unsupported: ['untracked'] }; + const unsupported: UnsupportedConstruct[] = + binding.constantViolations.length > 0 ? ['write'] : []; + for (const reference of binding.referencePaths) unsupported.push(...referenceUse(reference)); + return { binding: name, unsupported }; + } + if ( + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + node.object === root.node + ) { + return { binding: null, unsupported: [] }; + } + if (t.isNewExpression(node) && calleeName(node.callee) === 'Proxy') { + return { binding: null, unsupported: ['proxy'] }; + } + if (t.isCallExpression(node) && calleeName(node.callee) === 'Object.assign') { + return { binding: null, unsupported: ['object-assign'] }; + } + return { binding: null, unsupported: ['untracked'] }; +} + +interface EntryScan { + entries: Map; + unsupported: UnsupportedConstruct[]; +} + +function addEntry(scan: EntryScan, key: LiteralKey, node: t.Node): void { + if (scan.entries.has(key)) scan.unsupported.push('duplicate-key'); + else scan.entries.set(key, node); +} + +function objectEntries(node: t.ObjectExpression): EntryScan { + const scan: EntryScan = { entries: new Map(), unsupported: [] }; + for (const property of node.properties) { + if (t.isSpreadElement(property)) { + scan.unsupported.push('spread'); + continue; + } + if (property.computed) scan.unsupported.push('computed-key'); + if (t.isObjectMethod(property) && property.kind === 'get') scan.unsupported.push('getter'); + if (t.isObjectMethod(property) && property.kind === 'set') scan.unsupported.push('setter'); + const key = objectKeyOf(property); + if (key !== null) addEntry(scan, key, property); + } + return scan; +} + +function collectionEntries(kind: 'Set' | 'Map', array: t.ArrayExpression): EntryScan { + const scan: EntryScan = { entries: new Map(), unsupported: [] }; + for (const element of array.elements) { + const keyNode = kind === 'Set' || !t.isArrayExpression(element) ? element : element.elements[0]; + if (t.isSpreadElement(element) || t.isSpreadElement(keyNode)) { + scan.unsupported.push('spread'); + continue; + } + const key = entryKeyOf(keyNode); + if (key === null || element === null) scan.unsupported.push('non-literal-entry'); + else addEntry(scan, key, element); + } + return scan; +} + +const parseModule = (source: string): t.File => + parse(source, { sourceType: 'module', plugins: PLUGINS }); + +/** Every object literal, `new Set([...])` and `new Map([...])` of a module; null when it does not parse. */ +function scanTables(file: string, source: string): ScannedTable[] | null { + let ast: t.File; + try { + ast = parseModule(source); + } catch { + return null; + } + return tablesIn(file, ast); +} + +/** {@link scanTables} over a parsed module; null when the traversal throws. */ +function tablesIn(file: string, ast: t.File): ScannedTable[] | null { + const tables: ScannedTable[] = []; + const record = (kind: TableKind, literal: NodePath, scan: EntryScan): void => { + if (scan.entries.size < 2) return; + const usage = usageOf(literal); + tables.push({ + file, + kind, + binding: usage.binding, + line: literal.node.loc?.start.line ?? 0, + values: new Set(scan.entries.keys()), + entries: scan.entries, + unsupported: new Set([...scan.unsupported, ...usage.unsupported]), + }); + }; + try { + traverse(ast, { + ObjectExpression(path) { + record('object', path, objectEntries(path.node)); + }, + NewExpression(path) { + const { callee, arguments: args } = path.node; + const array = args[0]; + if (!t.isIdentifier(callee) || !t.isArrayExpression(array)) return; + if (callee.name === 'Set' || callee.name === 'Map') { + record(callee.name, path, collectionEntries(callee.name, array)); + } + }, + }); + } catch { + return null; + } + return tables; +} + +/** + * The module-level table bindings of a module (a top-level `const` holding an object literal, + * `new Set([...])` or `new Map([...])`), each with the constructs that make row deletion refuse + * it; an empty list is a table row deletion supports. The value-flow check (U10, value-flow/ + * sinks.ts) classifies a key read of exactly these tables as a supported table read. A name bound + * to more than one table literal is `ambiguous`. Null when the module does not parse. + */ +export function scanSupportedTables( + file: string, + source: string, +): Map | null { + let ast: t.File; + try { + ast = parseModule(source); + } catch { + return null; + } + return scanSupportedTablesIn(file, ast); +} + +/** + * {@link scanSupportedTables} over a module already parsed with the same options (`module`, + * `typescript` and `decorators-legacy`), so a caller that parsed it does not parse it again. + */ +export function scanSupportedTablesIn( + file: string, + ast: t.File, +): Map | null { + const tables = tablesIn(file, ast); + if (tables === null) return null; + const topLevel = new Set( + ast.program.body.flatMap((statement) => { + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration !== null + ? statement.declaration + : statement; + if (!t.isVariableDeclaration(declaration) || declaration.kind !== 'const') return []; + return declaration.declarations.flatMap((declarator) => + t.isIdentifier(declarator.id) ? [declarator.id.name] : [], + ); + }), + ); + const out = new Map(); + for (const table of tables) { + if (table.binding === null || !topLevel.has(table.binding)) continue; + const constructs = [...table.unsupported].sort(byText); + out.set(table.binding, out.has(table.binding) ? ['ambiguous'] : constructs); + } + return out; +} + +// ── Plan ─────────────────────────────────────────────────────────────────── + +/** The end of an entry's splice: after its trailing comma, past whitespace and comments. */ +function spliceEnd(source: string, end: number): number { + let index = end; + while (index < source.length) { + if (/\s/.test(source[index])) index += 1; + else if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index); + index = newline === -1 ? source.length : newline; + } else if (source.startsWith('/*', index)) { + const close = source.indexOf('*/', index + 2); + if (close === -1) return end; + index = close + 2; + } else return source[index] === ',' ? index + 1 : end; + } + return end; +} + +const parses = (source: string): boolean => { + try { + parseModule(source); + return true; + } catch { + return false; + } +}; + +/** A Babel location (1-based lines, 0-based columns) as a report span (1-based, end exclusive). */ +const reportLocation = (loc: t.SourceLocation): ReportLocation => ({ + start: { line: loc.start.line, column: loc.start.column + 1 }, + end: { line: loc.end.line, column: loc.end.column + 1 }, +}); + +type TableChoice = + | { kind: 'none'; reason: 'no-common-table' | 'no-supported-table' } + | { kind: 'problem'; reasons: string[] } + | { kind: 'table'; table: ScannedTable }; + +const sameSet = (a: ReadonlySet, b: ReadonlySet): boolean => + a.size === b.size && [...a].every((value) => b.has(value)); + +/** What planning reads more than once across the pairs of one env. */ +interface PlannerCache { + sources: Map; + scans: Map; + /** `contentHash` of each file's planning text. */ + fileHashes: Map; + /** Whether a file's planning text parses without a splice, by `\0\0`. */ + splices: Map; +} + +/** + * One cache per env: a run plans every pair over one env, so each file is read, scanned and + * hashed once and each splice checked once. `env.read` returns the same text for a file + * throughout (the run's checkout). + */ +const plannerCaches = new WeakMap(); + +class Planner { + private readonly cache: PlannerCache; + + constructor(private readonly env: RowDeletionEnv) { + let cache = plannerCaches.get(env); + if (cache === undefined) { + cache = { sources: new Map(), scans: new Map(), fileHashes: new Map(), splices: new Map() }; + plannerCaches.set(env, cache); + } + this.cache = cache; + } + + source(file: string): string | null { + const { sources } = this.cache; + if (!sources.has(file)) sources.set(file, this.env.read(file)); + return sources.get(file) ?? null; + } + + /** The text a file's deletions are planned on: its source, empty when it cannot be read. */ + text(file: string): string { + return this.source(file) ?? ''; + } + + fileHash(file: string): string { + const { fileHashes } = this.cache; + let hash = fileHashes.get(file); + if (hash === undefined) { + hash = contentHash(this.text(file)); + fileHashes.set(file, hash); + } + return hash; + } + + /** Whether the file's text still parses with `[start, end)` cut out. */ + spliceParses(file: string, start: number, end: number): boolean { + const { splices } = this.cache; + const key = `${file}\0${start}\0${end}`; + let parsesCut = splices.get(key); + if (parsesCut === undefined) { + const text = this.text(file); + parsesCut = parses(text.slice(0, start) + text.slice(end)); + splices.set(key, parsesCut); + } + return parsesCut; + } + + private scan(file: string): ScannedTable[] | null { + const { scans } = this.cache; + if (!scans.has(file)) { + const source = this.source(file); + scans.set(file, source === null ? null : scanTables(file, source)); + } + return scans.get(file) ?? null; + } + + /** The one supported table holding both keys; see the module comment. */ + choose(search: TableSearch, a: LiteralKey, b: LiteralKey): TableChoice { + for (const tier of [search.executed, search.closure]) { + const matching = sortedUnique(tier).flatMap((file) => { + const tables = this.env.tables + .factsOf(file) + .tables.filter((table) => table.values.has(a) && table.values.has(b)); + return tables.length === 0 ? [] : [{ file, tables }]; + }); + if (matching.length === 0) continue; + const unparseable = matching.filter(({ file }) => this.scan(file) === null); + if (unparseable.length > 0) return { kind: 'problem', reasons: ['unparseable'] }; + const candidates = matching.flatMap(({ file, tables }) => + (this.scan(file) ?? []).filter( + (table) => + table.values.has(a) && + table.values.has(b) && + tables.some((indexed) => sameSet(indexed.values, table.values)), + ), + ); + if (candidates.length === 0) return { kind: 'none', reason: 'no-supported-table' }; + if (candidates.length > 1) return { kind: 'problem', reasons: ['ambiguous-table'] }; + const [table] = candidates; + if (table.unsupported.size > 0) { + return { + kind: 'problem', + reasons: sortedUnique([...table.unsupported].map((c) => `unsupported-table: ${c}`)), + }; + } + return { kind: 'table', table }; + } + return { kind: 'none', reason: 'no-common-table' }; + } +} + +const rowTableOf = (table: ScannedTable): RowTable => ({ + file: table.file, + kind: table.kind, + binding: table.binding, + line: table.line, +}); + +/** The deletion of one key of a chosen table; null when its splice does not parse. */ +function deletionOf( + planner: Planner, + table: ScannedTable, + key: LiteralKey, +): PlannedDeletion | null { + const node = table.entries.get(key); + if (node === undefined || typeof node.start !== 'number' || typeof node.end !== 'number') { + return null; + } + if (node.loc === null || node.loc === undefined) return null; + const splice = { start: node.start, end: spliceEnd(planner.text(table.file), node.end) }; + if (!planner.spliceParses(table.file, splice.start, splice.end)) return null; + const identity: MutantIdentity = { + file: table.file, + fileHash: planner.fileHash(table.file), + location: reportLocation(node.loc), + mutatorName: ROW_DELETION_MUTATOR, + replacement: '', + }; + return { globalId: formatGlobalId(identity), identity, key, table: rowTableOf(table), splice }; +} + +/** Plans the row deletions of one pair; see the module comment. Pure over `env`. */ +export function planRowDeletion(input: RowDeletionPairInput, env: RowDeletionEnv): RowDeletionPlan { + const planner = new Planner(env); + const [facetA, facetB] = input.facets; + const others = new Map(facetB.discarded.map((entry) => [entry.position, entry])); + const positions: PositionPlan[] = []; + const deletions = new Map(); + for (const entry of facetA.discarded) { + const other = others.get(entry.position); + if (other === undefined || other.kind !== entry.kind || other.value === entry.value) continue; + const base = { + position: entry.position, + values: [entry.value, other.value] as [string, string], + }; + const [keyA, keyB] = [tableKeyOf(entry), tableKeyOf(other)]; + const choice: TableChoice = + keyA === null || keyB === null || keyA === keyB + ? { kind: 'none', reason: 'no-common-table' } + : planner.choose(input.search, keyA, keyB); + if (choice.kind === 'none') { + positions.push({ ...base, status: 'not-applicable', reasons: [choice.reason], table: null }); + continue; + } + if (choice.kind === 'problem') { + positions.push({ ...base, status: 'inconclusive', reasons: choice.reasons, table: null }); + continue; + } + const { table } = choice; + const planned = [keyA, keyB].map((key) => + key === null ? null : deletionOf(planner, table, key), + ); + if (planned.some((deletion) => deletion === null)) { + positions.push({ + ...base, + status: 'inconclusive', + reasons: ['splice-unparseable'], + table: rowTableOf(table), + }); + continue; + } + for (const deletion of planned) + if (deletion !== null) deletions.set(deletion.globalId, deletion); + positions.push({ ...base, status: 'planned', reasons: [], table: rowTableOf(table) }); + } + const reasons = sortedUnique(positions.flatMap((position) => position.reasons)); + const status = + deletions.size > 0 + ? 'planned' + : positions.some((position) => position.status === 'inconclusive') + ? 'inconclusive' + : 'not-applicable'; + return { + a: input.a, + b: input.b, + status, + reasons: positions.length === 0 ? ['no-differing-values'] : reasons, + positions, + deletions: [...deletions.values()], + }; +} + +// ── Classification ───────────────────────────────────────────────────────── + +const USABLE: ReadonlySet = new Set(['survived', 'killed-by-test:assertion']); + +const allLoaded = (run: VitestRunResult, probeFiles: readonly string[]): boolean => + run.loadedFiles !== undefined && probeFiles.every((file) => run.loadedFiles?.includes(file)); + +/** The control run's problem: `control-failed`, `edit-not-loaded`, or null when it is usable. */ +export function classifyControlRun( + run: VitestRunResult, + tests: readonly [string, string], + probeFiles: readonly string[], +): 'control-failed' | 'edit-not-loaded' | null { + const passed = tests.every( + (id) => run.tests.find((test) => test.id === id)?.outcome === 'survived', + ); + const clean = + run.problems.length === 0 && + run.unhandledErrors.length === 0 && + run.status === 'survived' && + run.outsideTest.length === 0; + if (!passed || !clean) return 'control-failed'; + return allLoaded(run, probeFiles) ? null : 'edit-not-loaded'; +} + +/** One deletion run's outcome; `reason` names why it is inconclusive. */ +export function classifyDeletionRun( + run: VitestRunResult, + tests: readonly [string, string], + probeFiles: readonly string[], +): { outcome: DeletionOutcome; reason: string | null } { + const inconclusive = (reason: string) => ({ outcome: 'inconclusive' as const, reason }); + if (run.problems.length > 0 || run.status === 'error' || run.status === 'timeout') { + return inconclusive(run.outcome); + } + const outside = run.outsideTest[0]; + if (outside !== undefined) return inconclusive(outside.outcome); + const outcomes = tests.map((id) => run.tests.find((test) => test.id === id)?.outcome ?? null); + const unusable = outcomes.find((outcome) => outcome === null || !USABLE.has(outcome)); + if (unusable !== undefined) return inconclusive(unusable ?? 'missing'); + if (!allLoaded(run, probeFiles)) return inconclusive('edit-not-loaded'); + const failing = outcomes.filter((outcome) => outcome === 'killed-by-test:assertion').length; + return { outcome: failing === 1 ? 'asymmetric' : 'symmetric', reason: null }; +} + +/** The pair outcome over its deletions and unplanned positions; see the module comment. */ +export function combineRowDeletion(outcomes: readonly RowDeletionOutcome[]): RowDeletionOutcome { + if (outcomes.includes('asymmetric')) return 'asymmetric'; + if (outcomes.includes('inconclusive')) return 'inconclusive'; + if (outcomes.includes('symmetric')) return 'symmetric'; + return 'not-applicable'; +} + +// ── Runs ─────────────────────────────────────────────────────────────────── + +export interface RunSummary { + outcome: OutcomeClass; + /** Each test's outcome class, in pair order; null when it has no row. */ + tests: { id: string; outcome: OutcomeClass | null; message: string | null }[]; + loadedFiles: string[] | null; + problems: string[]; + wallClockMs: number; +} + +export interface DeletionResult { + globalId: string; + key: LiteralKey; + table: RowTable; + outcome: DeletionOutcome; + /** Why the deletion is inconclusive; null otherwise. */ + reason: string | null; + run: RunSummary | null; +} + +export interface RowDeletionResult { + a: string; + b: string; + outcome: RowDeletionOutcome; + /** Why the pair is inconclusive or not applicable, sorted and distinct. */ + reasons: string[]; + /** Error text of a run that threw or of a mismatching copy; null otherwise. */ + detail: string | null; + positions: PositionPlan[]; + control: RunSummary | null; + deletions: DeletionResult[]; + wallClockMs: number; +} + +export interface RowDeletionDeps { + runVitest: (options: VitestRunOptions) => Promise; + prepare: (options: PrepareOptions) => PrepareReport; + /** Milliseconds on a monotonic clock. */ + now: () => number; +} + +export const defaultRowDeletionDeps: RowDeletionDeps = { + runVitest, + prepare, + now: () => performance.now(), +}; + +export interface RowDeletionRunOptions { + /** Absolute repo checkout. */ + repoRoot: string; + /** Absolute work directory, outside the checkout. */ + workDir: string; + /** The run manifest the copy is verified against. */ + manifest: ScenarioRunManifest; + plans: readonly RowDeletionPlan[]; + /** Upper bound of each Vitest run's worker processes (the CLI's `--concurrency`). */ + maxWorkers?: number; +} + +const summarize = (run: VitestRunResult, tests: readonly string[]): RunSummary => ({ + outcome: run.outcome, + tests: tests.map((id) => { + const test = run.tests.find((entry) => entry.id === id); + return { id, outcome: test?.outcome ?? null, message: test?.message ?? null }; + }), + loadedFiles: run.loadedFiles ?? null, + problems: [...run.problems, ...run.unhandledErrors], + wallClockMs: run.wallClockMs, +}); + +/** `/row-deletion/pairs/<16 hex digits of sha256 over the two ids>`. */ +const pairDir = (workDir: string, plan: RowDeletionPlan): string => + path.join(workDir, 'row-deletion', 'pairs', sha256(`${plan.a}\n${plan.b}`).slice(0, 16)); + +interface Copy { + preparedRoot: string; + packageDir: string; + /** Prepared-root path of each repo path. */ + preparedPathOf: (repoPath: string) => string; + /** The manifest check's mismatches; empty when the copy is verified. */ + mismatches: string[]; +} + +interface Finish { + reasons: string[]; + detail: string | null; + control: RunSummary | null; + deletions: DeletionResult[]; +} + +function resultOf(plan: RowDeletionPlan, finish: Finish, wallClockMs: number): RowDeletionResult { + const outcomes: RowDeletionOutcome[] = [ + ...plan.positions.map( + (position): RowDeletionOutcome => + position.status === 'planned' ? 'not-applicable' : position.status, + ), + ...finish.deletions.map((deletion) => deletion.outcome), + ]; + const outcome = plan.positions.length === 0 ? 'not-applicable' : combineRowDeletion(outcomes); + const reasons = sortedUnique([ + ...plan.reasons, + ...finish.reasons, + ...finish.deletions.flatMap((deletion) => + deletion.reason === null || finish.reasons.includes(deletion.reason) + ? [] + : [`deletion: ${deletion.reason}`], + ), + ]); + return { + a: plan.a, + b: plan.b, + outcome, + reasons: outcome === 'asymmetric' ? [] : reasons, + detail: finish.detail, + positions: plan.positions, + control: finish.control, + deletions: finish.deletions, + wallClockMs, + }; +} + +/** Every planned deletion inconclusive for one reason, none run. */ +const skipped = ( + plan: RowDeletionPlan, + reason: string, + detail: string | null, + control: RunSummary | null = null, +): Finish => ({ + reasons: [reason], + detail, + control, + deletions: plan.deletions.map((deletion) => ({ + globalId: deletion.globalId, + key: deletion.key, + table: deletion.table, + outcome: 'inconclusive', + reason, + run: null, + })), +}); + +/** The control run and one run per deletion of one planned pair, in the verified copy. */ +async function runPlan( + plan: RowDeletionPlan, + copy: Copy, + options: Pick, + deps: RowDeletionDeps, +): Promise { + const { manifest, workDir, maxWorkers } = options; + if (copy.mismatches.length > 0) { + return skipped(plan, 'manifest-mismatch', copy.mismatches.join('; ')); + } + const tests: [string, string] = [plan.a, plan.b]; + const listed = new Set(manifest.tests.map((test) => test.id)); + const unlisted = tests.filter((test) => !listed.has(test)); + if (unlisted.length > 0) { + return skipped(plan, 'manifest-mismatch', `not in the run manifest: ${unlisted.join(', ')}`); + } + + const files = new Map(); + for (const deletion of plan.deletions) { + const file = deletion.identity.file; + if (files.has(file)) continue; + const prepared = copy.preparedPathOf(file); + const absolute = path.join(copy.preparedRoot, prepared); + let original: string; + try { + original = readFileSync(absolute, 'utf8'); + } catch (error) { + return skipped(plan, 'copy-mismatch', `${prepared}: ${errorMessage(error)}`); + } + if (contentHash(original) !== deletion.identity.fileHash) { + return skipped(plan, 'copy-mismatch', `${prepared} differs from the planned ${file}`); + } + files.set(file, { absolute, packagePath: prepared.slice(PACKAGE_DIR.length + 1), original }); + } + + const dir = pairDir(workDir, plan); + // Scratch of an earlier run (a Vite cache among it) would leak into this one. + rmSync(dir, { recursive: true, force: true }); + const vitest = vitestSelectionOf(manifest.mode); + const run = (name: string, probeFiles: string[]): Promise => + deps.runVitest({ + packageDir: copy.packageDir, + testIds: tests, + vitest, + workDir: path.join(dir, name), + probeFiles, + maxWorkers, + }); + + const probeFiles = [...files.values()].map((file) => file.packagePath); + let control: VitestRunResult; + try { + control = await run('control', probeFiles); + } catch (error) { + return skipped(plan, 'control-failed', errorMessage(error)); + } + const problem = classifyControlRun(control, tests, probeFiles); + if (problem !== null) { + return skipped(plan, problem, control.reason, summarize(control, tests)); + } + + const deletions: DeletionResult[] = []; + let detail: string | null = null; + for (const [index, deletion] of plan.deletions.entries()) { + const file = files.get(deletion.identity.file); + if (file === undefined) throw new Error(`row deletion: ${deletion.identity.file} not read`); + const { start, end } = deletion.splice; + const base = { globalId: deletion.globalId, key: deletion.key, table: deletion.table }; + writeFileSync(file.absolute, file.original.slice(0, start) + file.original.slice(end)); + try { + const result = await run(`deletion-${index + 1}`, [file.packagePath]); + deletions.push({ + ...base, + ...classifyDeletionRun(result, tests, [file.packagePath]), + run: summarize(result, tests), + }); + } catch (error) { + deletions.push({ ...base, outcome: 'inconclusive', reason: 'run-error', run: null }); + detail ??= errorMessage(error); + } finally { + writeFileSync(file.absolute, file.original); + } + } + return { reasons: [], detail, control: summarize(control, tests), deletions }; +} + +/** + * Runs the planned row deletions of a batch of pairs in one throwaway copy; see the module + * comment. Plans without deletions come back as they are, and no copy is made for them. + */ +export async function runRowDeletion( + options: RowDeletionRunOptions, + deps: RowDeletionDeps = defaultRowDeletionDeps, +): Promise { + const { repoRoot, workDir, manifest, plans } = options; + if (overlaps(repoRoot, workDir)) { + throw new Error(`row deletion: the work directory ${workDir} must lie outside ${repoRoot}`); + } + let copy: Copy | null = null; + const results: RowDeletionResult[] = []; + for (const plan of plans) { + const started = deps.now(); + if (plan.deletions.length === 0) { + results.push(resultOf(plan, { reasons: [], detail: null, control: null, deletions: [] }, 0)); + continue; + } + if (copy === null) { + const preparedRoot = path.join(workDir, 'row-deletion', 'prepared'); + const pathMap = buildPathMap(extraRootsOf(manifest.mode)); + deps.prepare({ repoRoot, outDir: preparedRoot, mutate: [], pathMap }); + copy = { + preparedRoot, + packageDir: path.join(preparedRoot, PACKAGE_DIR), + preparedPathOf: (repoPath) => toPreparedPath(repoPath, pathMap), + mismatches: verifyPreparedCopy(manifest, preparedRoot).mismatches, + }; + } + const finish = await runPlan(plan, copy, options, deps); + results.push(resultOf(plan, finish, Math.round(deps.now() - started))); + } + return results; +} diff --git a/gitnexus/tools/scenario-map/mutation/run-group.ts b/gitnexus/tools/scenario-map/mutation/run-group.ts new file mode 100644 index 000000000..7bae09079 --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/run-group.ts @@ -0,0 +1,886 @@ +/** + * Layer 3 group and shard runs of the scenario map (U7, R6, R7, R11, KTD7, KTD8, KTD14, KTD17). + * + * Run manifest (KTD14, {@link ScenarioRunManifest}, `scenario-run-manifest.json`, schema 1). The + * plan stage writes it from its freshly prepared copy, before anything runs there; every shard and + * the value-flow job verify their own prepared copy against it ({@link verifyPreparedCopy}): + * mode run mode (`normal`, or `controls` with the fixture root) + * tests[] the eligible tests: Phase 0 id and package path, sorted by id + * files prepared-root-relative POSIX path (`gitnexus/…`, including the relocated + * `gitnexus/relocated/gitnexus-shared/…`) to the sha256 of its content, for + * every file of the prepared package; a symlink hashes as `symlink:`; + * the package's `node_modules` link and run caches (`.stryker-tmp`, + * `.vitest5-runner`) are left out + * versions installed `vitest` and `@stryker-mutator/core` + * digest sha256 over `files` and `versions` + * A mismatch makes the shard's results invalid: each of its groups gets a `manifest-mismatch` + * result and nothing runs. + * + * Group run ({@link runGroup}), for one planned group (plan-shards.ts `GroupScope`): + * 1. every group test must be in the run manifest; + * 2. two plain Vitest 5 baselines of the group's tests in the shard's verified copy + * (vitest-run.ts, a fresh Vitest each): each must pass every test, and both must run the + * same identities (Phase 0 id and location), else the group is Inconclusive + * (`baseline-failed`, `baseline-unstable`) and emits no observations (KTD8); + * 3. the driver scope (`testFiles`, `testIds`, the whole-file `mutate` list, KTD7) and its + * Stryker config: the config must carry perTest coverage, `disableBail` and static mutants + * ({@link assertMutationConfig}) before any mutant runs, else `config-invalid`; + * 4. the scope driver (tools/mutation/driver/run-scope.ts) runs Stryker on the vitest5 runner; + * the config it wrote is checked again, and its dry run must pass with the baselines' + * identities (`dry-run-unstable`); a driver failure is `stryker-failed`; + * 5. the run universe and the explicit observations (observations.ts, compact model: the + * unstored ones are implied by the universe) are extracted and the group result is written at + * once to `/observations/.json` ({@link GroupObservations}), whatever the + * outcome, so a later failure of the shard loses nothing (KTD17). + * Group result file (schema 2, {@link writeGroupObservations}, {@link readGroupObservations}): the + * group outcome, scope, baselines and Stryker summary, `runUniverse` (null unless complete) and + * `observations`, the explicit observations as rows of the universe (observations.ts + * `ObservationRow`). Schema 1 materialised one observation per test and mutant, which overflowed + * V8's string limit on large runs; a schema 1 file is rejected. + * Steps 1 to 4 are {@link runMutationRun}, which the isolation pass's together and alone runs + * (isolation.ts, U8) share, so every Layer 3 Stryker run passes the same KTD8 checks. + * + * Shard run ({@link runShard}): prepares `/shard-/prepared` (tools/mutation/prepare, + * nothing blanked), verifies it against the run manifest, then runs its planned groups in order, + * starting each only when its estimate fits before the deadline (plan-shards.ts); a group it does + * not start writes no result (its pairs are `not-scheduled`). It writes `/shard-.json` + * ({@link ShardReport}). Work layout, every path under the work directory (outside the checkout): + * observations/.json group results groups//baseline-{1,2}/ Vitest scratch + * groups//scope.json driver scope groups//stryker/ driver run + * shard-/prepared/ verified copy shard-.json shard report + * + * Library only; the scenario-map CLI (U14) calls {@link runShard}. Every runner is injected + * ({@link GroupRunDeps}), so unit tests never spawn Vitest or Stryker. + */ +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import type { RunResults } from '../../mutation/driver/normalise-results.js'; +import { runScope, type RunScopeOptions } from '../../mutation/driver/run-scope.js'; +import { + DEFAULT_IGNORED_DIRS, + buildStrykerConfig, + parseDriverScope, + type StrykerConfigInput, +} from '../../mutation/driver/stryker-config.js'; +import { PACKAGE_DIR, buildPathMap } from '../../mutation/prepare/path-map.js'; +import { + prepare, + type PrepareOptions, + type PrepareReport, +} from '../../mutation/prepare/prepare.js'; +import type { OutcomeClass } from '../../mutation/runner/classify-outcome.js'; +import { readRunManifest, type RunManifest } from '../../mutation/runner/run-manifest.js'; +import { parseTestId, type TestLocation } from '../../mutation/runner/test-ids.js'; +import { errorMessage } from '../../mutation/shared/cli.js'; +import { overlaps, readJsonFile } from '../../mutation/shared/fs.js'; +import { extraRootsOf, toPackagePath, type RunMode } from '../types.js'; +import { byText, isRecord, sameList, sha256, writeJson } from '../util.js'; +import { + MAIN_LINEAGE, + decodeObservations, + encodeObservations, + extractObservations, + isEvaluated, + isRunUniverse, + type Observation, + type ObservationRow, + type RunUniverse, +} from './observations.js'; +import { + fitsBeforeDeadline, + type GroupOutcome, + type GroupScope, + type GroupStatusReason, + type ShardPlan, +} from './plan-shards.js'; +import { + runVitest, + vitestSelectionOf, + type VitestRunOptions, + type VitestRunResult, +} from './vitest-run.js'; + +// ── Run manifest (KTD14) ────────────────────────────────────────────────── + +export const RUN_MANIFEST_SCHEMA_VERSION = 1; +export const RUN_MANIFEST_FILE = 'scenario-run-manifest.json'; + +export interface ToolVersions { + vitest: string; + stryker: string; +} + +export interface ScenarioRunManifest { + schemaVersion: typeof RUN_MANIFEST_SCHEMA_VERSION; + mode: RunMode; + /** Eligible tests, sorted by id. */ + tests: { id: string; file: string }[]; + digest: string; + files: Record; + versions: ToolVersions; +} + +export interface ManifestCheck { + ok: boolean; + /** `changed: `, `missing: `, `extra: `, ` -> `; capped. */ + mismatches: string[]; +} + +/** Top-level entries of the prepared package that are no input: the dependency link, run caches. */ +const UNHASHED = new Set(['node_modules', '.stryker-tmp', '.vitest5-runner']); +const MAX_MISMATCHES = 20; + +/** Every file of the prepared package, as `{: sha256}`, sorted. */ +export function hashPreparedTree(preparedRoot: string): Record { + const hashes: [string, string][] = []; + const visit = (relative: string, top: boolean): void => { + for (const entry of readdirSync(path.join(preparedRoot, relative)).sort(byText)) { + if (top && UNHASHED.has(entry)) continue; + const child = `${relative}/${entry}`; + const absolute = path.join(preparedRoot, child); + const stat = lstatSync(absolute); + if (stat.isSymbolicLink()) hashes.push([child, sha256(`symlink:${readlinkSync(absolute)}`)]); + else if (stat.isDirectory()) visit(child, false); + else if (stat.isFile()) hashes.push([child, sha256(readFileSync(absolute))]); + } + }; + visit(PACKAGE_DIR, true); + return Object.fromEntries(hashes.sort(([a], [b]) => byText(a, b))); +} + +function installedVersion(preparedRoot: string, name: string): string { + const file = path.join(preparedRoot, PACKAGE_DIR, 'node_modules', name, 'package.json'); + const data = readJsonFile(file); + if (typeof data !== 'object' || data === null || !('version' in data)) { + throw new Error(`run manifest: ${file} names no version`); + } + return String(data.version); +} + +const toolVersions = (preparedRoot: string): ToolVersions => ({ + vitest: installedVersion(preparedRoot, 'vitest'), + stryker: installedVersion(preparedRoot, '@stryker-mutator/core'), +}); + +const digestOf = (files: Readonly>, versions: ToolVersions): string => + sha256( + JSON.stringify({ + files: Object.entries(files).sort(([a], [b]) => byText(a, b)), + versions: [versions.vitest, versions.stryker], + }), + ); + +/** The run manifest of a freshly prepared copy and the eligible tests (Phase 0 ids). */ +export function buildRunManifest(input: { + preparedRoot: string; + mode: RunMode; + tests: readonly string[]; +}): ScenarioRunManifest { + const files = hashPreparedTree(input.preparedRoot); + const versions = toolVersions(input.preparedRoot); + return { + schemaVersion: RUN_MANIFEST_SCHEMA_VERSION, + mode: input.mode, + tests: [...new Set(input.tests)].sort(byText).map((id) => ({ id, file: parseTestId(id).file })), + digest: digestOf(files, versions), + files, + versions, + }; +} + +/** Compares a prepared copy with the run manifest; see the module comment. */ +export function verifyPreparedCopy( + manifest: ScenarioRunManifest, + preparedRoot: string, +): ManifestCheck { + const mismatches: string[] = []; + if (digestOf(manifest.files, manifest.versions) !== manifest.digest) { + mismatches.push('the run manifest digest does not match its own files'); + } + const files = hashPreparedTree(preparedRoot); + const paths = [...new Set([...Object.keys(manifest.files), ...Object.keys(files)])].sort(byText); + for (const file of paths) { + const [expected, actual] = [manifest.files[file], files[file]]; + if (expected === actual) continue; + if (actual === undefined) mismatches.push(`missing: ${file}`); + else if (expected === undefined) mismatches.push(`extra: ${file}`); + else mismatches.push(`changed: ${file}`); + } + const versions = toolVersions(preparedRoot); + for (const tool of ['vitest', 'stryker'] as const) { + if (manifest.versions[tool] !== versions[tool]) { + mismatches.push(`${tool} ${manifest.versions[tool]} -> ${versions[tool]}`); + } + } + const shown = + mismatches.length > MAX_MISMATCHES + ? [...mismatches.slice(0, MAX_MISMATCHES), `and ${mismatches.length - MAX_MISMATCHES} more`] + : mismatches; + return { ok: mismatches.length === 0, mismatches: shown }; +} + +const isRunManifest = (value: unknown): value is ScenarioRunManifest => + isRecord(value) && + value.schemaVersion === RUN_MANIFEST_SCHEMA_VERSION && + isRecord(value.mode) && + isRecord(value.files) && + isRecord(value.versions) && + Array.isArray(value.tests) && + typeof value.digest === 'string'; + +/** Reads a run manifest file; a wrong schema or an inconsistent digest throws. */ +export function readScenarioRunManifest(file: string): ScenarioRunManifest { + const manifest = readJsonFile(file); + if (!isRunManifest(manifest)) { + throw new Error(`run manifest: ${file} is not a scenario-map run manifest`); + } + if (digestOf(manifest.files, manifest.versions) !== manifest.digest) { + throw new Error(`run manifest: ${file} has a digest that does not match its files`); + } + return manifest; +} + +export function writeScenarioRunManifest(dir: string, manifest: ScenarioRunManifest): string { + const file = path.join(dir, RUN_MANIFEST_FILE); + writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); + return file; +} + +// ── Checks (KTD8) ───────────────────────────────────────────────────────── + +/** + * Throws unless a generated Stryker config carries perTest coverage, `disableBail` and static + * mutants (Stryker's `ignoreStatic` not set) (KTD8). + */ +export function assertMutationConfig(config: unknown): void { + if (!isRecord(config)) throw new Error('mutation config: not a config object'); + const missing = [ + config.coverageAnalysis === 'perTest' ? '' : 'perTest coverage (coverageAnalysis)', + config.disableBail === true ? '' : 'disableBail', + config.ignoreStatic === undefined || config.ignoreStatic === false + ? '' + : 'static mutants (ignoreStatic is set)', + ].filter((entry) => entry !== ''); + if (missing.length > 0) { + throw new Error(`mutation config: the generated config lacks ${missing.join(', ')}`); + } +} + +export interface BaselineSummary { + wallClockMs: number; + /** The run's outcome class (classify-outcome.ts). */ + outcome: OutcomeClass; + /** `@:` of every requested test that ran, sorted. */ + identities: string[]; + /** Requested tests that did not pass. */ + failing: { test: string; outcome: OutcomeClass | null; message: string | null }[]; + /** Selection problems and unhandled errors. */ + problems: string[]; +} + +/** A test's execution identity: its Phase 0 id and location (KTD3). */ +const identityOf = (test: { id: string; location: TestLocation | null }): string => + `${test.id}@${test.location === null ? '?' : `${test.location.line}:${test.location.column}`}`; + +export function summarizeBaseline(run: VitestRunResult): BaselineSummary { + return { + wallClockMs: run.wallClockMs, + outcome: run.outcome, + identities: run.tests + .filter((test) => test.state === 'passed' || test.state === 'failed') + .map(identityOf) + .sort(byText), + failing: run.tests + .filter((test) => test.outcome !== 'survived') + .map((test) => ({ test: test.id, outcome: test.outcome, message: test.message })), + problems: [...run.problems, ...run.unhandledErrors.map((error) => `unhandled error: ${error}`)], + }; +} + +const listDifference = (a: readonly string[], b: readonly string[]): string[] => + a.filter((entry) => !b.includes(entry)); + +/** The KTD8 baseline rule: every baseline passes, all with the first one's identities. */ +export function checkBaselines( + baselines: readonly BaselineSummary[], +): { reason: 'baseline-failed' | 'baseline-unstable'; detail: string } | null { + for (const [index, baseline] of baselines.entries()) { + const why = [ + ...baseline.problems, + ...baseline.failing.map( + (entry) => + `${entry.test} ${entry.outcome ?? 'no result'}` + + (entry.message === null ? '' : `: ${entry.message}`), + ), + ]; + if (why.length === 0 && baseline.outcome !== 'survived') why.push(`run ${baseline.outcome}`); + if (why.length > 0) { + return { reason: 'baseline-failed', detail: `baseline ${index + 1}: ${why.join('; ')}` }; + } + } + const [first, ...rest] = baselines; + for (const [index, baseline] of rest.entries()) { + if (!sameList(first.identities, baseline.identities)) { + return { + reason: 'baseline-unstable', + detail: + `baseline ${index + 2} ran other identities than baseline 1: only in 1: ` + + `${JSON.stringify(listDifference(first.identities, baseline.identities))}; only in ` + + `${index + 2}: ${JSON.stringify(listDifference(baseline.identities, first.identities))}`, + }; + } + } + return null; +} + +/** Why Stryker's dry run does not match the baselines (KTD8); null when it does. */ +function dryRunProblem( + results: RunResults, + readManifest: (runDir: string) => RunManifest, + identities: readonly string[], +): string | null { + if (results.dryRun === null) return 'the run wrote no dry-run record'; + if (results.dryRun.outcome !== 'survived') return `the dry run is ${results.dryRun.outcome}`; + if (results.paths.runDir === null) return 'the run has no run directory'; + const ran = readManifest(results.paths.runDir) + .files.flatMap((file) => file.tests.map(identityOf)) + .sort(byText); + if (sameList(ran, identities)) return null; + return ( + `the dry run ran other identities: only in the baselines: ` + + `${JSON.stringify(listDifference(identities, ran))}; only in the dry run: ` + + JSON.stringify(listDifference(ran, identities)) + ); +} + +/** + * The Stryker sandbox's ignored directories: the driver's defaults, and in controls mode + * `test/fixtures` replaced by each of its entries except the fixture project (as gate 2's + * conformance driver does), so the sandbox holds the fixture and no other. + */ +export function sandboxIgnoredDirs(mode: RunMode, fixtureEntries: readonly string[]): string[] { + if (mode.kind !== 'controls') return [...DEFAULT_IGNORED_DIRS]; + const fixture = toPackagePath(mode.fixtureRoot).replace(/\/+$/, ''); + const parent = path.posix.dirname(fixture); + const own = path.posix.basename(fixture); + return [ + ...DEFAULT_IGNORED_DIRS.filter((dir) => dir !== parent), + ...[...fixtureEntries] + .filter((entry) => entry !== own) + .sort(byText) + .map((entry) => `${parent}/${entry}`), + ]; +} + +const fixtureEntriesOf = (repoRoot: string, mode: RunMode): string[] => + mode.kind === 'controls' + ? readdirSync(path.join(repoRoot, path.posix.dirname(mode.fixtureRoot.replace(/\/+$/, '')))) + : []; + +// ── Group run ───────────────────────────────────────────────────────────── + +/** 2: the compact model, a run universe plus explicit observations as rows (observations.ts). */ +export const GROUP_OBSERVATIONS_SCHEMA_VERSION = 2; + +export interface StrykerSettings { + concurrency: number; + maxTestRunnerReuse: number; + timeoutMS: number; +} + +/** The scope driver CLI's defaults. */ +export const DEFAULT_STRYKER_SETTINGS: Readonly = Object.freeze({ + concurrency: 2, + maxTestRunnerReuse: 0, + timeoutMS: 10_000, +}); + +export interface StrykerSummary { + exitCode: number; + wallClockMs: number; + /** Absolute path of the driver's `results.json`. */ + resultsFile: string; + mutants: number; + /** Mutants in the universe (neither Ignored nor Pending). */ + evaluated: number; + /** Integrity flags of the run (normalise-results.ts). */ + flags: number; +} + +/** One group's result file; see the module comment. */ +export interface GroupObservations extends GroupOutcome { + schemaVersion: typeof GROUP_OBSERVATIONS_SCHEMA_VERSION; + scope: GroupScope; + baselines: BaselineSummary[]; + stryker: StrykerSummary | null; + /** The run universe that implies the unstored observations; null unless complete. */ + runUniverse: RunUniverse | null; + /** Explicit observations; empty unless the group is complete. */ + observations: Observation[]; + wallClockMs: number; +} + +/** A group result as written: its explicit observations as rows of its run universe. */ +export type GroupObservationsFile = Omit & { + observations: ObservationRow[]; +}; + +export interface GroupRunDeps { + runVitest: (options: VitestRunOptions) => Promise; + runScope: (options: RunScopeOptions) => Promise<{ results: RunResults; exitCode: number }>; + buildConfig: (input: StrykerConfigInput) => unknown; + readJson: (file: string) => unknown; + readDryRunManifest: (runDir: string) => RunManifest; + /** Milliseconds on a monotonic clock. */ + now: () => number; +} + +export const defaultGroupRunDeps: GroupRunDeps = { + runVitest, + runScope, + buildConfig: buildStrykerConfig, + readJson: readJsonFile, + readDryRunManifest: readRunManifest, + now: () => performance.now(), +}; + +export interface GroupRunOptions { + /** Absolute repo checkout. */ + repoRoot: string; + /** Absolute work directory, outside the checkout. */ + workDir: string; + shard: number; + /** The run manifest the shard verified its prepared copy against. */ + manifest: ScenarioRunManifest; + /** Absolute prepared `gitnexus/` package of the shard's verified copy. */ + packageDir: string; + scope: GroupScope; + stryker?: Partial; +} + +const SAFE_GROUP_ID = /^[A-Za-z0-9_-]+$/; + +/** `/observations/.json`. */ +export function observationsFile(workDir: string, group: string): string { + if (!SAFE_GROUP_ID.test(group)) { + throw new Error(`group run: unsafe group id ${JSON.stringify(group)}`); + } + return path.join(workDir, 'observations', `${group}.json`); +} + +const isGroupObservationsFile = (value: unknown): value is GroupObservationsFile => + isRecord(value) && + value.schemaVersion === GROUP_OBSERVATIONS_SCHEMA_VERSION && + typeof value.group === 'string' && + typeof value.manifestDigest === 'string' && + isRecord(value.scope) && + (value.runUniverse === null || isRunUniverse(value.runUniverse)) && + Array.isArray(value.observations) && + Array.isArray(value.tests); + +/** Writes a group result (any extra fields kept), its observations as universe rows. */ +export function writeGroupObservations(file: string, result: T): void { + const written: GroupObservationsFile = { + ...result, + observations: encodeObservations(result.runUniverse, result.observations), + }; + writeJson(file, written); +} + +/** Reads a group result file; another schema (schema 1 included) or a malformed row throws. */ +export function readGroupObservations(file: string): GroupObservations { + const data = readJsonFile(file); + if (isRecord(data) && data.schemaVersion !== GROUP_OBSERVATIONS_SCHEMA_VERSION) { + throw new Error( + `group run: ${file} has schema ${JSON.stringify(data.schemaVersion)}, not ` + + `${GROUP_OBSERVATIONS_SCHEMA_VERSION}: it was written before the compact observation ` + + 'model (one observation per test and mutant); re-run its shard', + ); + } + if (!isGroupObservationsFile(data)) { + throw new Error(`group run: ${file} is not a group observations file`); + } + return { ...data, observations: decodeObservations(data.runUniverse, data.observations) }; +} + +interface GroupResultInput { + scope: GroupScope; + shard: number; + manifestDigest: string; + status: GroupObservations['status']; + reason: GroupStatusReason | null; + detail: string | null; + baselines: BaselineSummary[]; + stryker: StrykerSummary | null; + runUniverse: RunUniverse | null; + observations: Observation[]; + wallClockMs: number; +} + +/** Builds a group result and writes it at once (KTD17). */ +function writeGroupResult(workDir: string, input: GroupResultInput): GroupObservations { + const result: GroupObservations = { + schemaVersion: GROUP_OBSERVATIONS_SCHEMA_VERSION, + group: input.scope.id, + shard: input.shard, + manifestDigest: input.manifestDigest, + status: input.status, + reason: input.reason, + detail: input.detail, + tests: [...input.scope.tests], + scope: scopeOf(input.scope), + baselines: input.baselines, + stryker: input.stryker, + runUniverse: input.runUniverse, + observations: input.observations, + wallClockMs: input.wallClockMs, + }; + writeGroupObservations(observationsFile(workDir, result.group), result); + return result; +} + +const scopeOf = (scope: GroupScope): GroupScope => ({ + id: scope.id, + tests: scope.tests, + testFiles: scope.testFiles, + pairs: scope.pairs, + mutate: scope.mutate, + excluded: scope.excluded, + closureIncomplete: scope.closureIncomplete, + provenanceUnknown: scope.provenanceUnknown, +}); + +const strykerSummary = (results: RunResults): StrykerSummary => ({ + exitCode: results.stryker.exitCode, + wallClockMs: results.stryker.wallClockMs, + resultsFile: path.join(results.paths.outDir, 'results.json'), + mutants: results.mutants.length, + evaluated: results.mutants.filter(isEvaluated).length, + flags: results.flags.length, +}); + +/** + * One validated Stryker run of a set of tests (steps 1 to 4 of the module comment), shared by the + * group run and the isolation pass's together and alone runs (isolation.ts, U8). + */ +export interface MutationRunInput { + /** Absolute repo checkout. */ + repoRoot: string; + manifest: ScenarioRunManifest; + /** Absolute prepared `gitnexus/` package of the shard's verified copy. */ + packageDir: string; + /** Absolute scratch directory: `baseline-{1,2}/`, `scope.json`, `stryker/`; must exist. */ + runDir: string; + /** Phase 0 ids of the tests to run, sorted. */ + tests: readonly string[]; + /** Package paths of their files, sorted. */ + testFiles: readonly string[]; + /** Driver mutate entries: whole files (KTD7) or Stryker ranges (KTD9). */ + mutate: readonly string[]; + settings: StrykerSettings; +} + +export interface MutationRunOutcome { + baselines: BaselineSummary[]; + stryker: StrykerSummary | null; + /** The driver's results; null unless the run passed every KTD8 check. */ + results: RunResults | null; + /** Why the run gives no usable observations; null when it does. */ + failure: { reason: GroupStatusReason; detail: string } | null; +} + +/** Runs and checks one Stryker run (KTD8); see {@link MutationRunInput}. Never throws. */ +export async function runMutationRun( + input: MutationRunInput, + deps: GroupRunDeps = defaultGroupRunDeps, +): Promise { + const { manifest, runDir } = input; + const baselines: BaselineSummary[] = []; + let stryker: StrykerSummary | null = null; + const inconclusive = (reason: GroupStatusReason, detail: string): MutationRunOutcome => ({ + baselines, + stryker, + results: null, + failure: { reason, detail }, + }); + + const listed = new Set(manifest.tests.map((test) => test.id)); + const unlisted = input.tests.filter((test) => !listed.has(test)); + if (unlisted.length > 0) { + return inconclusive('manifest-mismatch', `not in the run manifest: ${unlisted.join(', ')}`); + } + + const selection = vitestSelectionOf(manifest.mode); + for (const index of [1, 2]) { + try { + const run = await deps.runVitest({ + packageDir: input.packageDir, + testIds: input.tests, + vitest: selection, + workDir: path.join(runDir, `baseline-${index}`), + // The Stryker concurrency bounds the baselines' Vitest workers too. + maxWorkers: input.settings.concurrency, + }); + baselines.push(summarizeBaseline(run)); + } catch (error) { + return inconclusive('baseline-failed', `baseline ${index}: ${errorMessage(error)}`); + } + const problem = checkBaselines(baselines); + if (problem !== null) return inconclusive(problem.reason, problem.detail); + } + + const extraRoots = extraRootsOf(manifest.mode); + const pathMap = buildPathMap(extraRoots); + const scopeText = `${JSON.stringify( + { testFiles: input.testFiles, mutate: input.mutate, testIds: input.tests }, + null, + 2, + )}\n`; + const scopeFile = path.join(runDir, 'scope.json'); + const strykerDir = path.join(runDir, 'stryker'); + const ignoredDirs = sandboxIgnoredDirs( + manifest.mode, + fixtureEntriesOf(input.repoRoot, manifest.mode), + ); + // Normal mode keeps the runner's default config and project, as every Phase 0 run does. + const strykerVitest = manifest.mode.kind === 'controls' ? selection : undefined; + const { settings } = input; + try { + const driverScope = parseDriverScope(scopeText, pathMap); + writeFileSync(scopeFile, scopeText); + assertMutationConfig( + deps.buildConfig({ + variant: 'vitest5', + outDir: strykerDir, + mutate: driverScope.mutate, + ...settings, + ignoredDirs, + pathMap, + vitest: strykerVitest, + testIds: driverScope.testIds, + }), + ); + } catch (error) { + return inconclusive('config-invalid', errorMessage(error)); + } + + let results: RunResults; + try { + ({ results } = await deps.runScope({ + repoRoot: input.repoRoot, + scopeFile, + outDir: strykerDir, + variant: 'vitest5', + ...settings, + command: undefined, + ignoredDirs, + extraRoots, + vitest: strykerVitest, + })); + } catch (error) { + return inconclusive('stryker-failed', errorMessage(error)); + } + stryker = strykerSummary(results); + try { + assertMutationConfig(deps.readJson(results.paths.configFile)); + } catch (error) { + return inconclusive('config-invalid', `${results.paths.configFile}: ${errorMessage(error)}`); + } + let dryRun: string | null; + try { + dryRun = dryRunProblem(results, deps.readDryRunManifest, baselines[0].identities); + } catch (error) { + dryRun = errorMessage(error); + } + if (dryRun !== null) return inconclusive('dry-run-unstable', dryRun); + return { baselines, stryker, results, failure: null }; +} + +/** Runs one group; see the module comment. Always writes and returns its result. */ +export async function runGroup( + options: GroupRunOptions, + deps: GroupRunDeps = defaultGroupRunDeps, +): Promise { + const started = deps.now(); + const { scope, manifest } = options; + // Validates the group id before any path is built from it. + observationsFile(options.workDir, scope.id); + const groupDir = path.join(options.workDir, 'groups', scope.id); + mkdirSync(groupDir, { recursive: true }); + const run = await runMutationRun( + { + repoRoot: options.repoRoot, + manifest, + packageDir: options.packageDir, + runDir: groupDir, + tests: scope.tests, + testFiles: scope.testFiles, + mutate: scope.mutate, + settings: { ...DEFAULT_STRYKER_SETTINGS, ...options.stryker }, + }, + deps, + ); + const extracted = + run.results === null + ? null + : extractObservations(run.results, { + group: scope.id, + kind: 'main', + lineage: MAIN_LINEAGE, + shard: options.shard, + }); + return writeGroupResult(options.workDir, { + scope, + shard: options.shard, + manifestDigest: manifest.digest, + status: run.failure === null ? 'complete' : 'inconclusive', + reason: run.failure?.reason ?? null, + detail: run.failure?.detail ?? null, + baselines: run.baselines, + stryker: run.stryker, + runUniverse: extracted?.universe ?? null, + observations: extracted?.observations ?? [], + wallClockMs: Math.round(deps.now() - started), + }); +} + +// ── Shard run ───────────────────────────────────────────────────────────── + +export const SHARD_REPORT_SCHEMA_VERSION = 1; + +export interface ShardReport { + schemaVersion: typeof SHARD_REPORT_SCHEMA_VERSION; + shard: number; + manifestDigest: string; + manifestCheck: ManifestCheck; + /** Groups with a result file, in run order. */ + ran: { + group: string; + status: GroupObservations['status']; + reason: GroupStatusReason | null; + wallClockMs: number; + }[]; + /** Groups not started: their estimate did not fit before the deadline. */ + notStarted: { group: string; estimateMs: number; elapsedMs: number }[]; + wallClockMs: number; +} + +export interface ShardRunDeps extends GroupRunDeps { + prepare: (options: PrepareOptions) => PrepareReport; +} + +export const defaultShardRunDeps: ShardRunDeps = { ...defaultGroupRunDeps, prepare }; + +export interface ShardRunOptions { + repoRoot: string; + /** Absolute work directory, outside the checkout; shared with the other stages. */ + workDir: string; + /** 0-based shard index of the plan. */ + shard: number; + plan: ShardPlan; + manifest: ScenarioRunManifest; + stryker?: Partial; + /** Defaults to plan-shards.ts `SHARD_DEADLINE_MS`. */ + deadlineMs?: number; +} + +/** `/shard-.json`. */ +export const shardReportFile = (workDir: string, shard: number): string => + path.join(workDir, `shard-${shard}.json`); + +/** Runs one shard of the plan; see the module comment. */ +export async function runShard( + options: ShardRunOptions, + deps: ShardRunDeps = defaultShardRunDeps, +): Promise { + const started = deps.now(); + const { plan, manifest, shard, workDir, repoRoot } = options; + const assignment = plan.shards.find((entry) => entry.index === shard); + if (assignment === undefined) throw new Error(`shard run: the plan has no shard ${shard}`); + if (overlaps(repoRoot, workDir)) { + throw new Error(`shard run: the work directory ${workDir} must lie outside ${repoRoot}`); + } + const planned = new Map(plan.groups.map((group) => [group.id, group])); + mkdirSync(workDir, { recursive: true }); + // A result left by an earlier run would read as this run's. + for (const group of assignment.groups) rmSync(observationsFile(workDir, group), { force: true }); + + const preparedRoot = path.join(workDir, `shard-${shard}`, 'prepared'); + deps.prepare({ + repoRoot, + outDir: preparedRoot, + mutate: [], + pathMap: buildPathMap(extraRootsOf(manifest.mode)), + }); + const manifestCheck = verifyPreparedCopy(manifest, preparedRoot); + const ran: ShardReport['ran'] = []; + const notStarted: ShardReport['notStarted'] = []; + for (const id of assignment.groups) { + const group = planned.get(id); + if (group === undefined) throw new Error(`shard run: group ${id} is not in the plan`); + if (!manifestCheck.ok) { + writeGroupResult(workDir, { + scope: group, + shard, + manifestDigest: manifest.digest, + status: 'inconclusive', + reason: 'manifest-mismatch', + detail: `the prepared copy differs from the run manifest: ${manifestCheck.mismatches.join('; ')}`, + baselines: [], + stryker: null, + runUniverse: null, + observations: [], + wallClockMs: 0, + }); + ran.push({ group: id, status: 'inconclusive', reason: 'manifest-mismatch', wallClockMs: 0 }); + continue; + } + const elapsedMs = deps.now() - started; + if (!fitsBeforeDeadline(group.estimateMs, elapsedMs, options.deadlineMs)) { + notStarted.push({ + group: id, + estimateMs: group.estimateMs, + elapsedMs: Math.round(elapsedMs), + }); + continue; + } + const result = await runGroup( + { + repoRoot, + workDir, + shard, + manifest, + packageDir: path.join(preparedRoot, PACKAGE_DIR), + scope: group, + stryker: options.stryker, + }, + deps, + ); + ran.push({ + group: id, + status: result.status, + reason: result.reason, + wallClockMs: result.wallClockMs, + }); + } + const report: ShardReport = { + schemaVersion: SHARD_REPORT_SCHEMA_VERSION, + shard, + manifestDigest: manifest.digest, + manifestCheck, + ran, + notStarted, + wallClockMs: Math.round(deps.now() - started), + }; + // `workDir` was created above. + writeJson(shardReportFile(workDir, shard), report, true); + return report; +} diff --git a/gitnexus/tools/scenario-map/mutation/vitest-run.ts b/gitnexus/tools/scenario-map/mutation/vitest-run.ts new file mode 100644 index 000000000..f30511379 --- /dev/null +++ b/gitnexus/tools/scenario-map/mutation/vitest-run.ts @@ -0,0 +1,478 @@ +/** + * Plain Vitest 5 runs of selected tests in a prepared tree (U7, KTD2, KTD8): Layer 3's baselines, + * and later row deletion (U9) and the trace run (U11), share it. + * + * Each call creates a fresh Vitest instance (`createVitest`) and closes it, with the environment + * `vitest run` sets (as tools/mutation/fidelity/fidelity.ts and the vitest5 runner do: `TEST`, + * `VITEST`, `NODE_ENV`) plus `GITNEXUS_MUTATION_SCOPE` naming a scope file of the selected tests' + * files, the working directory in the prepared package, and Vite's cache in the call's work dir. + * The process environment and working directory are restored afterwards. The config is + * tools/mutation/vitest.mutation.config.ts (project `mutation`) or, in controls mode, the fixture + * project's own config ({@link vitestSelectionOf}), the configs the Stryker run uses. + * + * Selection is the vitest5 runner's (tools/mutation/runner/plugin.ts, Phase 1 KTD2): every file + * of the selection is collected, its tests get their Phase 0 ids (runner/test-ids.ts + * `assignTestIds`), the requested ids map to Vitest task ids, and one task-id specification per + * file runs. An id that was not collected, or a file that failed to collect, is a problem and + * nothing runs. The runner's own setup file (runner/setup.ts, in dry-run mode, so it never + * activates a mutant) records failed hooks on each file task, so the run is classified by + * runner/classify-outcome.ts exactly as a Stryker run's tests are: every requested test gets its + * outcome class (`survived` for a pass), plus the run's status and outside-test rows. + * + * Extra setup files (U11): `setupFiles` go after the runner's setup file, in list order, and + * `provide` gives them their context (the value-flow trace hook). + * + * Workers: `maxWorkers` caps the instance's worker processes (Vitest's own default is a share of + * the host's cores); the scenario-map CLI passes its `--concurrency`, the bound it gives Stryker, + * so a local run never fans out over every core ({@link inProcessVitestOptions}, which + * path-coverage/run-coverage.ts shares). + * + * Load probe (U9): with `probeFiles`, the result's `loadedFiles` lists those the project's Vite + * module graph transformed before the instance closed. Workers fetch every inlined module through + * that graph, and a fresh instance with `cache: false` holds nothing else, so row deletion proves + * with it that its run evaluated the edited file (KTD10), without a V8 hook. + * + * Library only: run-group.ts, row-deletion.ts and value-flow/trace.ts call {@link runVitest}. + */ +import { mkdirSync, realpathSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core'; +import type { CliOptions, TestModule, TestProject, Vitest } from 'vitest/node'; +import type { VitestSelection } from '../../mutation/driver/stryker-config.js'; +import { + classifyRun, + type ObservedError, + type ObservedModule, + type OutcomeClass, + type OutsideTestOutcome, + type RequestedTest, + type RunnerStatus, + type SuiteHook, + type TestHook, +} from '../../mutation/runner/classify-outcome.js'; +import { PROTOCOL_KEY, type WorkerContext } from '../../mutation/runner/protocol.js'; +import { + assignPortableTestIds, + parseTestId, + selectRequestedTests, + type CollectedTest, + type IdentifiedTest, + type TestLocation, +} from '../../mutation/runner/test-ids.js'; +import { MUTATION_SCOPE_ENV, parseMutationScope } from '../../mutation/scope-file.js'; +import { toPackagePath, type RunMode } from '../types.js'; +import { byText, isRecord, sortedUnique, toPosixRelative } from '../util.js'; + +/** The mutation config and project (the vitest5 runner's defaults). */ +export const MUTATION_CONFIG = 'tools/mutation/vitest.mutation.config.ts'; +export const MUTATION_PROJECT = 'mutation'; +/** The project the scenario-map controls fixture's vitest.config.ts declares. */ +export const CONTROLS_PROJECT = 'scenario-map-controls'; +/** The vitest5 runner's worker setup file, relative to the (prepared) package. */ +export const RUNNER_SETUP_FILE = 'tools/mutation/runner/setup.ts'; + +/** The Vitest config and project of a run mode. */ +export function vitestSelectionOf(mode: RunMode): VitestSelection { + return mode.kind === 'controls' + ? { + configFile: `${toPackagePath(mode.fixtureRoot)}/vitest.config.ts`, + project: CONTROLS_PROJECT, + } + : { configFile: MUTATION_CONFIG, project: MUTATION_PROJECT }; +} + +export interface VitestRunOptions { + /** Absolute prepared `gitnexus/` package directory. */ + packageDir: string; + /** Phase 0 ids of the tests to run: non-empty and distinct. */ + testIds: readonly string[]; + vitest: VitestSelection; + /** Absolute scratch directory of this call (scope file, Vite cache); created. */ + workDir: string; + /** + * Package paths of files whose loading the caller must prove (row deletion's edited file, U9): + * the result's `loadedFiles` lists those this run transformed. + */ + probeFiles?: readonly string[]; + /** + * Extra worker setup files (package paths) run after the runner's own and before the project's + * (the value-flow trace hook, U11); with any, setup files run in list order. + */ + setupFiles?: readonly string[]; + /** Called with the project before the run: provides the extra setup files' context. */ + provide?: (project: Pick) => void; + /** Upper bound of the instance's worker processes (the CLI's `--concurrency`). */ + maxWorkers?: number; +} + +export interface InProcessVitestInput { + /** Absolute POSIX package root. */ + root: string; + /** Config file, relative to the root. */ + configFile: string; + /** The projects to keep; undefined keeps every project of the config. */ + projects?: readonly string[]; + /** Upper bound of the worker processes; undefined leaves Vitest's default. */ + maxWorkers?: number; + /** Runs of each test (`repeats`); undefined runs each once. */ + repeats?: number; +} + +/** + * The `createVitest` options of an in-process run: no watch, no cache, no reporter, and at most + * `maxWorkers` worker processes when given (module header). + */ +export function inProcessVitestOptions(input: InProcessVitestInput): CliOptions { + return { + root: input.root, + config: path.join(input.root, input.configFile), + project: input.projects === undefined ? undefined : [...input.projects], + watch: false, + cache: false, + fsModuleCache: false, + ...(input.repeats === undefined ? {} : { repeats: input.repeats }), + ...(input.maxWorkers === undefined ? {} : { maxWorkers: input.maxWorkers }), + reporters: [], + }; +} + +/** What {@link runVitest} uses of a Vitest instance. */ +export type VitestSession = Pick< + Vitest, + 'projects' | 'close' | 'collectTests' | 'runTestSpecifications' +>; +/** `vitest/node`'s `createVitest`, narrowed to {@link VitestSession}; unit tests inject a fake. */ +export type CreateVitest = ( + options: CliOptions, + viteOverrides: { cacheDir: string }, +) => Promise; + +const loadCreateVitest = async (): Promise => + (await import('vitest/node')).createVitest; + +export interface VitestTestResult { + id: string; + taskId: string | null; + fullName: string | null; + location: TestLocation | null; + /** Vitest's state; `missing` when the test was not collected or not reported. */ + state: 'passed' | 'failed' | 'skipped' | 'pending' | 'missing'; + /** Its outcome class; null when it has no row (it did not run). */ + outcome: OutcomeClass | null; + message: string | null; +} + +export interface VitestRunResult { + /** The requested tests, in request order. */ + tests: VitestTestResult[]; + status: RunnerStatus; + outcome: OutcomeClass; + reason: string | null; + outsideTest: OutsideTestOutcome[]; + /** Why nothing ran: ids that were not collected, files that failed to collect. */ + problems: string[]; + unhandledErrors: string[]; + wallClockMs: number; + /** + * With `probeFiles`: those of them that a Vite environment of the project transformed during + * this call (a module of the file holds a transform result), in probe order. The instance is + * fresh and Vite pre-transforms nothing for Node tests, so a transform means a worker fetched + * the file's current text to evaluate it. + */ + loadedFiles?: string[]; +} + +const toObservedError = (error: unknown): ObservedError => + isRecord(error) + ? { + name: typeof error.name === 'string' ? error.name : 'Error', + message: typeof error.message === 'string' ? error.message : String(error), + } + : { name: 'Error', message: String(error) }; + +const describeErrors = (errors: readonly unknown[]): string => + errors + .map(toObservedError) + .map((error) => `${error.name}: ${error.message}`) + .join('; '); + +const retryCount = (retry: number | { count?: number } | undefined): number => + typeof retry === 'number' ? retry : (retry?.count ?? 0); + +const pick = (hooks: readonly string[] | undefined, names: readonly T[]): T[] => + names.filter((name) => hooks?.includes(name) ?? false); + +const TEST_HOOKS: readonly TestHook[] = ['beforeEach', 'afterEach']; +const SUITE_HOOKS: readonly SuiteHook[] = ['beforeAll', 'afterAll']; + +const collectedTests = (module: TestModule): CollectedTest[] => + [...module.children.allTests()].map((test) => ({ + taskId: test.id, + fullName: test.fullName, + location: test.location, + })); + +/** One module as classify-outcome.ts reads it; mirrors the vitest5 runner's `reportModule`. */ +function observeModule(file: string, module: TestModule): ObservedModule { + const meta = module.meta()[PROTOCOL_KEY]; + const failed = meta?.failedHooks ?? {}; + return { + file, + collected: meta !== undefined, + errors: module.errors().map(toObservedError), + failedHooks: pick(failed[module.id], SUITE_HOOKS), + suites: [...module.children.allSuites()].map((suite) => ({ + taskId: suite.id, + failedHooks: pick(failed[suite.id], SUITE_HOOKS), + errors: suite.errors().map(toObservedError), + testTaskIds: [...suite.children.allTests()].map((test) => test.id), + })), + tests: [...module.children.allTests()].map((test) => { + const result = test.result(); + return { + taskId: test.id, + fullName: test.fullName, + state: result.state, + errors: (result.errors ?? []).map(toObservedError), + failedHooks: pick(failed[test.id], TEST_HOOKS), + retry: retryCount(test.options.retry), + }; + }), + hitCount: undefined, + }; +} + +/** True when a Vite environment of the project holds a transformed module of `file` (absolute). */ +function wasTransformed(project: TestProject, file: string): boolean { + return Object.values(project.vite.environments).some((environment) => + [...(environment.moduleGraph.getModulesByFile(file) ?? [])].some( + (module) => module.transformResult !== null, + ), + ); +} + +/** The checks the vitest5 runner makes before it adds its setup file; throws on a problem. */ +function checkProject(project: TestProject, configFile: string): void { + const { config } = project; + const problems = [ + config.pool === 'forks' ? '' : `pool is ${String(config.pool)}, not forks`, + config.isolate === false ? 'isolate is off' : '', + config.includeTaskLocation ? '' : 'includeTaskLocation is off; test ids need locations', + config.setupFiles.length > 0 && config.sequence.setupFiles !== 'list' + ? 'setup files run in parallel; set sequence.setupFiles: list' + : '', + ].filter((problem) => problem !== ''); + if (problems.length > 0) { + throw new Error(`vitest run: unsupported Vitest config ${configFile}: ${problems.join('; ')}`); + } +} + +const ENV_NAMES = [ + 'TEST', + 'VITEST', + 'NODE_ENV', + MUTATION_SCOPE_ENV, + INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE, +] as const; + +interface Session { + vitest: VitestSession; + project: TestProject; + root: string; +} + +/** Collects, selects and runs the requested tests in an open Vitest instance. */ +async function runSelected( + session: Session, + files: readonly string[], + testIds: readonly string[], +): Promise> { + const { vitest, project, root } = session; + const relative = (moduleId: string): string => + path.posix.relative(root, toPosixRelative(moduleId)); + const specs = files.map((file) => project.createSpecification(path.join(root, file))); + const collected = await vitest.collectTests(specs); + const earlier = new Set(collected.testModules); + const identified = new Map( + collected.testModules + .map((module) => ({ module, file: relative(module.moduleId) })) + .filter(({ file }) => files.includes(file)) + .sort((a, b) => byText(a.file, b.file)) + .map(({ module, file }) => [file, assignPortableTestIds(file, collectedTests(module), root)]), + ); + const selection = selectRequestedTests(identified, testIds); + const problems = [ + ...collected.testModules + .filter((module) => module.errors().length > 0) + .map( + (module) => + `${relative(module.moduleId)}: collection failed: ${describeErrors(module.errors())}`, + ), + ...collected.unhandledErrors.map( + (error) => `unhandled error while collecting: ${describeErrors([error])}`, + ), + ...selection.unknown.map((id) => `requested test ${JSON.stringify(id)} was not collected`), + ]; + const byId = new Map([...identified.values()].flat().map((test) => [test.id, test])); + const requested: RequestedTest[] = testIds.flatMap((id) => { + const test = byId.get(id); + return test === undefined + ? [] + : [{ id, taskId: test.taskId, fullName: test.fullName, file: parseTestId(id).file }]; + }); + const missing = (id: string): VitestTestResult => { + const test = byId.get(id); + return { + id, + taskId: test?.taskId ?? null, + fullName: test?.fullName ?? null, + location: test?.location ?? null, + state: 'missing', + outcome: null, + message: null, + }; + }; + if (problems.length > 0) { + return { + tests: testIds.map(missing), + status: 'error', + outcome: 'invalid', + reason: problems.join('; '), + outsideTest: [], + problems, + unhandledErrors: [], + }; + } + + const runSpecs = selection.files.map((file) => + project.createSpecification(path.join(root, file.file), { testIds: file.taskIds }), + ); + const result = await vitest.runTestSpecifications(runSpecs, false); + const wanted = new Set(runSpecs.map((spec) => spec.moduleId)); + const modules = result.testModules + .filter((module) => wanted.has(module.moduleId) && !earlier.has(module)) + .map((module) => ({ module, file: relative(module.moduleId) })) + .sort((a, b) => byText(a.file, b.file)); + const classification = classifyRun({ + requested, + modules: modules.map(({ module, file }) => observeModule(file, module)), + unhandledErrors: result.unhandledErrors.map(toObservedError), + hitLimit: undefined, + }); + const rowOf = new Map(classification.tests.map((row) => [row.id, row])); + const stateOf = new Map( + modules.flatMap(({ module }) => + [...module.children.allTests()].map((test) => [test.id, test.result().state] as const), + ), + ); + return { + tests: testIds.map((id): VitestTestResult => { + const base = missing(id); + const row = rowOf.get(id); + const state = base.taskId === null ? undefined : stateOf.get(base.taskId); + return { + ...base, + state: state ?? 'missing', + outcome: row?.outcome ?? null, + message: row?.message ?? null, + }; + }), + status: classification.status, + outcome: classification.outcome, + reason: classification.reason ?? null, + outsideTest: classification.outsideTest, + problems: [], + unhandledErrors: result.unhandledErrors.map((error) => describeErrors([error])), + }; +} + +/** + * Runs the requested tests with a fresh Vitest instance; see the module comment. `createVitest` + * defaults to `vitest/node`'s. + */ +export async function runVitest( + options: VitestRunOptions, + createVitest?: CreateVitest, +): Promise { + const started = performance.now(); + const testIds = [...options.testIds]; + if (testIds.length === 0 || new Set(testIds).size !== testIds.length) { + throw new Error('vitest run: the test ids must be non-empty and distinct'); + } + const files = sortedUnique(testIds.map((id) => parseTestId(id).file)); + mkdirSync(options.workDir, { recursive: true }); + const scopeFile = path.join(options.workDir, 'scope.json'); + const scopeText = `${JSON.stringify({ testFiles: files }, null, 2)}\n`; + parseMutationScope(scopeText); + writeFileSync(scopeFile, scopeText); + const root = toPosixRelative(realpathSync(options.packageDir)); + const { configFile, project: projectName } = options.vitest; + + const savedEnv = new Map(ENV_NAMES.map((name) => [name, process.env[name]])); + const savedCwd = process.cwd(); + // The environment startVitest/prepareVitest sets before creating Vitest, plus the scope file. + process.env.TEST = 'true'; + process.env.VITEST = 'true'; + process.env.NODE_ENV ??= 'test'; + process.env[MUTATION_SCOPE_ENV] = scopeFile; + delete process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]; + process.chdir(root); + try { + const create = createVitest ?? (await loadCreateVitest()); + const vitest = await create( + inProcessVitestOptions({ + root, + configFile, + projects: [projectName], + maxWorkers: options.maxWorkers, + }), + { cacheDir: path.join(options.workDir, 'vite-cache') }, + ); + try { + const projects = vitest.projects.filter((candidate) => candidate.name === projectName); + if (projects.length !== 1) { + throw new Error( + `vitest run: expected one Vitest project named ${projectName} in ${configFile}, found ` + + JSON.stringify(vitest.projects.map((candidate) => candidate.name)), + ); + } + const [project] = projects; + checkProject(project, configFile); + const extraSetupFiles = (options.setupFiles ?? []).map((file) => path.join(root, file)); + project.config.setupFiles = [ + path.join(root, RUNNER_SETUP_FILE), + ...extraSetupFiles, + ...project.config.setupFiles, + ]; + if (extraSetupFiles.length > 0) project.config.sequence.setupFiles = 'list'; + const context: WorkerContext = { + mode: 'dry-run', + namespace: INSTRUMENTER_CONSTANTS.NAMESPACE, + activeMutantEnv: INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE, + activeMutant: null, + mutantActivation: null, + hitLimit: null, + }; + project.provide(PROTOCOL_KEY, context); + options.provide?.(project); + const run = await runSelected({ vitest, project, root }, files, testIds); + const probed = + options.probeFiles === undefined + ? {} + : { + loadedFiles: options.probeFiles.filter((file) => + wasTransformed(project, path.posix.join(root, file)), + ), + }; + return { ...run, wallClockMs: Math.round(performance.now() - started), ...probed }; + } finally { + await vitest.close(); + } + } finally { + process.chdir(savedCwd); + for (const [name, value] of savedEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} diff --git a/gitnexus/tools/scenario-map/path-coverage/candidates.ts b/gitnexus/tools/scenario-map/path-coverage/candidates.ts new file mode 100644 index 000000000..915ef938e --- /dev/null +++ b/gitnexus/tools/scenario-map/path-coverage/candidates.ts @@ -0,0 +1,302 @@ +/** + * Layer 1 candidate retrieval of the scenario map (U4, R3, KTD5, KTD17, design §5.2). + * + * Two sources make a pair of tests a candidate: + * - `jaccard`: exact Jaccard similarity of their V8 block sets (the work file's block indices, + * run-coverage.ts) of at least {@link JACCARD_THRESHOLD}, checked in integers. All pairs are + * found without comparing every pair: blocks are ordered rarest first (by how many tests cover + * them, then by index), each set is indexed by its prefix of `|x| - ceil(t * |x|) + 1` blocks, + * and only sets that share a prefix block and pass the size-ratio filter + * (`min / max >= t`) are verified. A pair at the threshold always shares a prefix block, so the + * filters drop no qualifying pair. Empty block sets (tests that ran no production code) never + * pair through this source; + * - `checks-context`: both tests have the same checks-and-context key (facet/checks.ts + * `checksContextKey`), whatever their blocks. + * + * Each pair records its exact Jaccard (null when a set is empty), whether the block sets are + * identical (KTD5: a pair that is not caps at weak evidence) and whether it is path-unattributable + * (either test ran concurrently, design §5.2: such a pair is Inconclusive). Groups are the + * connected components of the pairs; they only schedule Layer 3 and never decide a verdict. A + * group's id is a hash of its sorted test ids, so it does not depend on input order or on other + * groups. The stats name the largest group (most tests, then most pairs) for shard planning + * (KTD17). + * + * Library only; pure. The scenario-map CLI (U14) and run-coverage.ts call {@link findCandidates}. + */ +import { ascending, byText, sha1 } from '../util.js'; + +/** Jaccard >= 99 / 100 makes a candidate (R3). */ +export const JACCARD_THRESHOLD = Object.freeze({ numerator: 99, denominator: 100 }); + +export type CandidateSource = 'jaccard' | 'checks-context'; + +export interface CandidateTest { + /** Phase 0 test id. */ + id: string; + /** Block indices (any integer ids), each once. */ + blocks: readonly number[]; + concurrent: boolean; + /** Checks-and-context key; null when the test has none (no call site, no assertion). */ + checksContext: string | null; +} + +export interface CandidatePair { + /** The smaller test id. */ + a: string; + b: string; + /** In the order `jaccard`, `checks-context`. */ + sources: CandidateSource[]; + /** Exact Jaccard of the block sets; null when either set is empty. */ + jaccard: number | null; + /** The block sets are equal (KTD5). */ + identicalBlocks: boolean; + /** Either test ran concurrently: its path cannot be attributed (design §5.2). */ + pathUnattributable: boolean; + /** Id of the pair's group. */ + group: string; +} + +export interface CandidateGroup { + /** `g` + the first 16 hex digits of sha1 over the sorted test ids, newline-joined. */ + id: string; + /** Sorted test ids. */ + tests: string[]; + pairs: number; +} + +export interface CandidateStats { + /** Tests given. */ + tests: number; + pairs: number; + /** Pairs found only by Jaccard, only by checks and context, and by both. */ + bySource: { jaccard: number; checksContext: number; both: number }; + groups: number; + /** The group with the most tests, then the most pairs, then the smallest id. */ + largestGroup: { id: string; tests: number; pairs: number } | null; +} + +export interface CandidateResult { + /** Sorted by `a`, then `b`. */ + pairs: CandidatePair[]; + /** Sorted by id. */ + groups: CandidateGroup[]; + stats: CandidateStats; +} + +/** Size of the intersection of two ascending arrays. */ +function intersectionSize(a: readonly number[], b: readonly number[]): number { + let shared = 0; + for (let i = 0, j = 0; i < a.length && j < b.length; ) { + if (a[i] === b[j]) { + shared += 1; + i += 1; + j += 1; + } else if (a[i] < b[j]) i += 1; + else j += 1; + } + return shared; +} + +const sortedBlocks = (blocks: readonly number[]): number[] => [...new Set(blocks)].sort(ascending); + +/** Jaccard similarity of two block sets; 1 for two empty sets. */ +export function jaccard(a: readonly number[], b: readonly number[]): number { + const x = sortedBlocks(a); + const y = sortedBlocks(b); + const shared = intersectionSize(x, y); + const union = x.length + y.length - shared; + return union === 0 ? 1 : shared / union; +} + +const { numerator: T_NUM, denominator: T_DEN } = JACCARD_THRESHOLD; + +/** `shared / union >= t`, in integers. */ +const meetsThreshold = (shared: number, union: number): boolean => shared * T_DEN >= T_NUM * union; + +/** Prefix length of a set of `size` blocks: `size - ceil(t * size) + 1`. */ +const prefixLength = (size: number): number => size - Math.ceil((T_NUM * size) / T_DEN) + 1; + +interface Indexed { + test: CandidateTest; + /** Ascending block indices. */ + blocks: number[]; +} + +/** Index pairs (into `items`) at Jaccard >= t, through the prefix and size filters. */ +function jaccardPairs(items: readonly Indexed[]): [number, number][] { + const frequency = new Map(); + for (const item of items) { + for (const block of item.blocks) frequency.set(block, (frequency.get(block) ?? 0) + 1); + } + const rarestFirst = (a: number, b: number): number => + (frequency.get(a) ?? 0) - (frequency.get(b) ?? 0) || a - b; + const order = items + .map((item, index) => ({ index, size: item.blocks.length })) + .filter(({ size }) => size > 0) + .sort((p, q) => p.size - q.size || byText(items[p.index].test.id, items[q.index].test.id)); + const postings = new Map(); + const found: [number, number][] = []; + for (const { index, size } of order) { + const prefix = [...items[index].blocks].sort(rarestFirst).slice(0, prefixLength(size)); + const seen = new Set(); + for (const block of prefix) { + for (const other of postings.get(block) ?? []) { + if (seen.has(other)) continue; + seen.add(other); + // `other` was indexed earlier, so it is no larger: the size-ratio filter. + if (items[other].blocks.length * T_DEN < T_NUM * size) continue; + const shared = intersectionSize(items[other].blocks, items[index].blocks); + const union = items[other].blocks.length + size - shared; + if (meetsThreshold(shared, union)) found.push([other, index]); + } + } + for (const block of prefix) { + const list = postings.get(block); + if (list) list.push(index); + else postings.set(block, [index]); + } + } + return found; +} + +/** Index pairs (into `items`) sharing a non-null checks-and-context key. */ +function checksContextPairs(items: readonly Indexed[]): [number, number][] { + const buckets = new Map(); + items.forEach((item, index) => { + const key = item.test.checksContext; + if (key === null) return; + const bucket = buckets.get(key); + if (bucket) bucket.push(index); + else buckets.set(key, [index]); + }); + const found: [number, number][] = []; + for (const bucket of buckets.values()) { + for (let i = 0; i < bucket.length; i += 1) { + for (let j = i + 1; j < bucket.length; j += 1) found.push([bucket[i], bucket[j]]); + } + } + return found; +} + +const groupId = (tests: readonly string[]): string => `g${sha1(tests.join('\n')).slice(0, 16)}`; + +/** Connected components of the pairs, as sorted test-id lists keyed by each member. */ +function components(pairs: readonly { a: string; b: string }[]): Map { + const parent = new Map(); + const find = (id: string): string => { + let root = id; + while (parent.get(root) !== root) root = parent.get(root) ?? root; + for (let node = id; node !== root; ) { + const next = parent.get(node) ?? root; + parent.set(node, root); + node = next; + } + return root; + }; + for (const { a, b } of pairs) { + if (!parent.has(a)) parent.set(a, a); + if (!parent.has(b)) parent.set(b, b); + const [ra, rb] = [find(a), find(b)]; + if (ra !== rb) parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb); + } + const members = new Map(); + for (const id of parent.keys()) { + const root = find(id); + const list = members.get(root); + if (list) list.push(id); + else members.set(root, [id]); + } + const byMember = new Map(); + for (const list of members.values()) { + list.sort(byText); + for (const id of list) byMember.set(id, list); + } + return byMember; +} + +/** + * Candidate pairs, groups and stats of a set of tests (module header). Test ids must be unique. + * `focus` (a diff-scoped run, fingerprint plan E8) keeps only the pairs with a focused test, before + * the groups are formed. + */ +export function findCandidates( + tests: readonly CandidateTest[], + focus: (id: string) => boolean = () => true, +): CandidateResult { + const items: Indexed[] = [...tests] + .sort((p, q) => byText(p.id, q.id)) + .map((test) => ({ test, blocks: sortedBlocks(test.blocks) })); + for (let i = 1; i < items.length; i += 1) { + if (items[i].test.id === items[i - 1].test.id) { + throw new Error(`candidates: test id ${items[i].test.id} is listed twice`); + } + } + + const sources = new Map }>(); + const add = (pairs: readonly [number, number][], source: CandidateSource): void => { + for (const [x, y] of pairs) { + const [i, j] = x < y ? [x, y] : [y, x]; + const key = `${i}:${j}`; + const entry = sources.get(key) ?? { i, j, found: new Set() }; + entry.found.add(source); + sources.set(key, entry); + } + }; + add(jaccardPairs(items), 'jaccard'); + add(checksContextPairs(items), 'checks-context'); + + const raw = [...sources.values()] + .sort((p, q) => p.i - q.i || p.j - q.j) + .filter(({ i, j }) => focus(items[i].test.id) || focus(items[j].test.id)) + .map(({ i, j, found }) => { + const [x, y] = [items[i], items[j]]; + const shared = intersectionSize(x.blocks, y.blocks); + const union = x.blocks.length + y.blocks.length - shared; + const empty = x.blocks.length === 0 || y.blocks.length === 0; + return { + a: x.test.id, + b: y.test.id, + sources: (['jaccard', 'checks-context'] as const).filter((s) => found.has(s)), + jaccard: empty ? null : shared / union, + identicalBlocks: shared === x.blocks.length && shared === y.blocks.length, + pathUnattributable: x.test.concurrent || y.test.concurrent, + }; + }); + + const membersOf = components(raw); + const groupOf = new Map(); + const pairs: CandidatePair[] = raw.map((pair) => { + const members = membersOf.get(pair.a) ?? [pair.a, pair.b]; + let group = groupOf.get(members); + if (group === undefined) { + group = { id: groupId(members), tests: members, pairs: 0 }; + groupOf.set(members, group); + } + group.pairs += 1; + return { ...pair, sources: [...pair.sources], group: group.id }; + }); + const groups = [...groupOf.values()].sort((p, q) => byText(p.id, q.id)); + const largest = [...groups].sort( + (p, q) => q.tests.length - p.tests.length || q.pairs - p.pairs || byText(p.id, q.id), + )[0]; + const count = (wanted: readonly CandidateSource[]): number => + pairs.filter((pair) => pair.sources.join() === wanted.join()).length; + return { + pairs, + groups, + stats: { + tests: tests.length, + pairs: pairs.length, + bySource: { + jaccard: count(['jaccard']), + checksContext: count(['checks-context']), + both: count(['jaccard', 'checks-context']), + }, + groups: groups.length, + largestGroup: + largest === undefined + ? null + : { id: largest.id, tests: largest.tests.length, pairs: largest.pairs }, + }, + }; +} diff --git a/gitnexus/tools/scenario-map/path-coverage/coverage.setup.ts b/gitnexus/tools/scenario-map/path-coverage/coverage.setup.ts new file mode 100644 index 000000000..6513676a9 --- /dev/null +++ b/gitnexus/tools/scenario-map/path-coverage/coverage.setup.ts @@ -0,0 +1,176 @@ +/** + * Layer 1 coverage hook of the scenario map (U4, R3, R4, KTD4, design §5.2): per-test V8 block + * coverage in the vitest5 runner's hook pattern. run-coverage.ts puts it + * first among the project's setup files and provides its context (`inject`, records.ts + * `CoverageContext`). With the forks pool and default isolation every test file runs in a fresh + * fork, so this file starts before the test file (and anything it imports) loads, and its record + * covers exactly one test file. + * + * - V8 precise coverage (`node:inspector`, call counts, block detail) starts here. Each take + * returns what ran since the previous take, and records.ts turns it into a window. + * - The file-level `aroundAll` starts before every `beforeAll` hook: its first take is the module + * load and collection window, whose blocks are discarded. When the file's run ends (after every + * `afterAll` hook, even a failing one) it takes the tail and writes the raw record. + * - The file-level `aroundEach` is the outermost wrapper of every test attempt (repeat or retry): + * the take before `runTest()` closes the window that holds the `beforeAll` work of the suites + * starting with this test, the take after it closes the test's own window, from before its + * `beforeEach` hooks to after its `afterEach` hooks, cleanups and `onTestFinished` callbacks. + * The test comes from `TestRunner.getCurrentTest()`; its suite chain and `concurrent` flag go + * with the window. + * - Pass-through wrappers record worker spawns (`worker_threads.Worker`, `child_process` spawn, + * spawnSync, fork, exec, execSync, execFile, execFileSync) and native loads (`process.dlopen`) + * with the first stack frame outside the hook; the next take carries them. + * + * This file and records.ts run inside every window, so their scripts are never listed. Vitest + * parses the second parameter of the around hooks as a fixture pattern, so it must be written + * `{}`. + */ +import { createHash } from 'node:crypto'; +import { Session } from 'node:inspector/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import type { WorkerOptions } from 'node:worker_threads'; +import { + TestRunner, + aroundAll, + aroundEach, + inject, + type RunnerTestCase, + type RunnerTestSuite, +} from 'vitest'; +import { + COVERAGE_KEY, + CoverageAttributor, + CoverageInterner, + callSite, + isCallable, + rawRecordName, + scriptPath, + spawnTarget, + wrapCall, + writeRawRecord, + type RuntimeEvents, + type WindowDelta, +} from './records.js'; + +const context = inject(COVERAGE_KEY); +if (context === undefined || context === null) { + throw new Error( + 'scenario-map coverage: no context was provided (run it through run-coverage.ts)', + ); +} + +const self = new Set( + [import.meta.url, new URL('./records.ts', import.meta.url).href].map( + (url) => scriptPath(url) ?? fileURLToPath(url), + ), +); +const isSelf = (file: string): boolean => self.has(file); + +// ── Spawns and native loads ──────────────────────────────────────────────── + +const events: RuntimeEvents = { natives: [], spawns: [] }; +const drainEvents = (): RuntimeEvents => ({ + natives: events.natives.splice(0), + spawns: events.spawns.splice(0), +}); +const noteSpawn = (api: string, args: readonly unknown[]): void => { + events.spawns.push({ api, target: spawnTarget(args), site: callSite(new Error().stack, isSelf) }); +}; + +const CHILD_PROCESS_APIS = [ + 'spawn', + 'spawnSync', + 'fork', + 'exec', + 'execSync', + 'execFile', + 'execFileSync', +] as const; +const childProcess = process.getBuiltinModule('node:child_process'); +for (const name of CHILD_PROCESS_APIS) { + const original: unknown = Reflect.get(childProcess, name); + if (isCallable(original)) { + Reflect.set(childProcess, name, wrapCall(original, `child_process.${name}`, noteSpawn)); + } +} + +const workerThreads = process.getBuiltinModule('node:worker_threads'); +const OriginalWorker = workerThreads.Worker; +class RecordedWorker extends OriginalWorker { + constructor(filename: string | URL, options?: WorkerOptions) { + noteSpawn('worker_threads.Worker', [filename]); + super(filename, options); + } +} +Object.defineProperty(workerThreads, 'Worker', { + value: RecordedWorker, + writable: true, + configurable: true, + enumerable: true, +}); +syncBuiltinESMExports(); + +const originalDlopen = process.dlopen.bind(process); +process.dlopen = (module: object, filename: string, flags?: number): void => { + events.natives.push(filename); + if (flags === undefined) originalDlopen(module, filename); + else originalDlopen(module, filename, flags); +}; + +// ── Coverage windows ─────────────────────────────────────────────────────── + +const session = new Session(); +session.connect(); +await session.post('Profiler.enable'); +await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); + +const interner = new CoverageInterner(context.blockRoots, self); +const attributor = new CoverageAttributor(); + +async function take(): Promise { + const { result } = await session.post('Profiler.takePreciseCoverage'); + return interner.summarize(result, drainEvents()); +} + +/** Suite task ids from the file down to the test's parent, each once. */ +function suiteChain(test: RunnerTestCase): string[] { + const chain: string[] = []; + for (let suite: RunnerTestSuite | undefined = test.suite; suite; suite = suite.suite) { + chain.push(suite.id); + } + chain.push(test.file.id); + return [...new Set(chain.reverse())]; +} + +// The `{}` is required: Vitest parses this parameter as a fixture pattern. +aroundAll(async (runSuite, {}, suite) => { + attributor.load(await take()); + try { + await runSuite(); + } finally { + attributor.finish(await take()); + const file = suite.file.filepath; + const digest = createHash('sha256').update(file).digest('hex').slice(0, 16); + writeRawRecord( + context.outDir, + rawRecordName(file, process.pid, digest), + attributor.record(file, interner), + ); + } +}); + +// The `{}` is required: Vitest parses this parameter as a fixture pattern. +aroundEach(async (runTest, {}) => { + const test = TestRunner.getCurrentTest(); + if (test === undefined) { + await runTest(); + return; + } + attributor.testStart(test.id, suiteChain(test), test.concurrent === true, await take()); + try { + await runTest(); + } finally { + attributor.testEnd(test.id, await take()); + } +}); diff --git a/gitnexus/tools/scenario-map/path-coverage/partitions.ts b/gitnexus/tools/scenario-map/path-coverage/partitions.ts new file mode 100644 index 000000000..11a425957 --- /dev/null +++ b/gitnexus/tools/scenario-map/path-coverage/partitions.ts @@ -0,0 +1,149 @@ +/** + * Sequential and parallel partitions of a scenario-map Vitest run (fingerprint plan E2, E3). + * + * gitnexus/vitest.config.ts runs the files of its `fileParallelism: false` projects (`lbug-db`: + * native LadybugDB handles conflict when two forks hold one database; `cli-e2e`: real CLI spawns) + * one at a time. + * The mutation config (tools/mutation/vitest.mutation.config.ts, gate-3 hashed) replaces the + * projects with one, so a scenario-map run that holds such files (the fingerprint's unit and + * integration tests, F8) runs them in a partition of their own with one worker, and every other + * file with the caller's workers. {@link sequentialMatcherFor} reads those projects' `include` + * patterns (package-relative globs) from the base config. + * + * The prepare step (tools/mutation/prepare/prepare.ts, gate-3 hashed) leaves `vendor/` and `dist/` + * out of the prepared copy; integration tests load both (vendored grammars, leiden, native + * extensions; the built CLI and its worker scripts), so {@link copyBuildOutput} copies them from + * the checkout into the copy a normal-mode fingerprint run uses (fingerprint plan E6). + * + * Library only; pure but for {@link sequentialMatcherFor}, which reads the base config module, + * {@link runVitestPartitioned}, which runs the injected Vitest runner, and {@link copyBuildOutput}. + */ +import { cpSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import baseConfig from '../../../vitest.config.js'; +import { parseTestId } from '../../mutation/runner/test-ids.js'; +import type { VitestRunOptions, VitestRunResult } from '../mutation/vitest-run.js'; +import type { RunMode } from '../types.js'; +import { isRecord } from '../util.js'; + +/** The `include` patterns of a Vitest config's `fileParallelism: false` projects; empty if none. */ +export function sequentialPatterns(config: unknown): string[] { + const test = isRecord(config) ? config.test : undefined; + const projects = isRecord(test) && Array.isArray(test.projects) ? test.projects : []; + return projects.flatMap((project: unknown): string[] => { + const inner = isRecord(project) ? project.test : undefined; + if (!isRecord(inner) || inner.fileParallelism !== false || !Array.isArray(inner.include)) { + return []; + } + return inner.include.filter((entry: unknown): entry is string => typeof entry === 'string'); + }); +} + +/** Whether a package path matches any of the patterns (Node's glob matching, as Vitest's). */ +export function sequentialMatcher(patterns: readonly string[]): (file: string) => boolean { + return (file) => patterns.some((pattern) => path.posix.matchesGlob(file, pattern)); +} + +/** A run mode's sequential files: the base config's in normal mode, none in controls mode. */ +export const sequentialMatcherFor = (mode: RunMode['kind']): ((file: string) => boolean) => + mode === 'controls' ? () => false : sequentialMatcher(sequentialPatterns(baseConfig)); + +/** Splits package paths into the sequential and the parallel partition, keeping their order. */ +export function partitionFiles( + files: readonly string[], + isSequential: (file: string) => boolean, +): { sequential: string[]; parallel: string[] } { + return { + sequential: files.filter(isSequential), + parallel: files.filter((file) => !isSequential(file)), + }; +} + +/** + * Several partial runs of one request as one result: the tests in request order, the first run + * that did not end `survived` deciding status, outcome and reason (every run survived: the + * first's), the other lists joined, the wall clocks added. Pure. + */ +export function mergeVitestRuns( + testIds: readonly string[], + runs: readonly VitestRunResult[], +): VitestRunResult { + const [first] = runs; + if (first === undefined) throw new Error('mergeVitestRuns: no run to merge'); + const decisive = runs.find((run) => run.outcome !== 'survived') ?? first; + const byId = new Map(runs.flatMap((run) => run.tests.map((test) => [test.id, test] as const))); + const loaded = runs.flatMap((run) => run.loadedFiles ?? []); + return { + tests: testIds.flatMap((id) => { + const test = byId.get(id); + return test === undefined ? [] : [test]; + }), + status: decisive.status, + outcome: decisive.outcome, + reason: decisive.reason, + outsideTest: runs.flatMap((run) => run.outsideTest), + problems: runs.flatMap((run) => run.problems), + unhandledErrors: runs.flatMap((run) => run.unhandledErrors), + wallClockMs: runs.reduce((total, run) => total + run.wallClockMs, 0), + ...(runs.some((run) => run.loadedFiles !== undefined) ? { loadedFiles: loaded } : {}), + }; +} + +/** + * Runs a Vitest request in its two partitions ({@link partitionFiles} over the tests' files): the + * sequential tests with one worker, then the rest with the request's workers, each in a + * subdirectory of its work dir; one partition is the request itself. + */ +export async function runVitestPartitioned( + run: (options: VitestRunOptions) => Promise, + options: VitestRunOptions, + isSequential: (file: string) => boolean, +): Promise { + const { sequential, parallel } = partitionFiles(options.testIds, (id) => + isSequential(parseTestId(id).file), + ); + if (sequential.length === 0 || parallel.length === 0) { + return run(sequential.length === 0 ? options : { ...options, maxWorkers: 1 }); + } + const runs = [ + await run({ + ...options, + testIds: sequential, + maxWorkers: 1, + workDir: path.join(options.workDir, 'sequential'), + }), + await run({ ...options, testIds: parallel, workDir: path.join(options.workDir, 'parallel') }), + ]; + return mergeVitestRuns(options.testIds, runs); +} + +/** Package entries the prepare step leaves out and integration tests load (E6). */ +const BUILD_OUTPUT = ['vendor', 'dist'] as const; + +/** + * Copies {@link BUILD_OUTPUT} from the checkout's package into a prepared package of a normal-mode + * run (the controls fixture needs neither); an entry the checkout lacks is skipped (the tests that + * load it fail, and the fingerprint holds their pairs Inconclusive). + */ +export function copyBuildOutput( + mode: RunMode['kind'], + checkoutPackage: string, + preparedPackage: string, +): void { + if (mode === 'controls') return; + for (const entry of BUILD_OUTPUT) { + const from = path.join(checkoutPackage, entry); + if (existsSync(from)) cpSync(from, path.join(preparedPackage, entry), { recursive: true }); + } + // Package tests compare and execute the shipped copies of the registry hook. + for (const plugin of [ + 'gitnexus-claude-plugin', + 'gitnexus-cursor-integration', + 'gitnexus-factory-plugin', + ]) { + const from = path.join(checkoutPackage, '..', plugin, 'hooks'); + if (existsSync(from)) { + cpSync(from, path.join(preparedPackage, '..', plugin, 'hooks'), { recursive: true }); + } + } +} diff --git a/gitnexus/tools/scenario-map/path-coverage/prefilter.ts b/gitnexus/tools/scenario-map/path-coverage/prefilter.ts new file mode 100644 index 000000000..3a6d6c6dd --- /dev/null +++ b/gitnexus/tools/scenario-map/path-coverage/prefilter.ts @@ -0,0 +1,86 @@ +/** + * The test files Layer 1 must run for the fingerprint (fingerprint plan E7). + * + * Past the path rule, a pair is a duplicate, a possible duplicate or a table row only when both + * tests have the same checks-and-context key or a key is unknown: `checks-differ` makes every other + * pair Distinct unless its inputs are equal (verdict/fingerprint.ts). The key is static (KTD5, + * facet/checks.ts over the static inventory), so a file whose every test has a key no other test + * shares needs no run. A file stays when one of its tests has a key another test shares or no key, + * when a call's instance count is not static, when a body reads a file snapshot (the runtime full + * name picks the entry, and the static one may differ), when it may register tests the inventory + * cannot see (inventory/cases.ts `incomplete`: a helper such as + * test/helpers/rename-integration-cases.ts `defineRenameFixtureTests`), or when it did not parse. + * + * Given up: `different-checks` (the same path and input, other checks) between a test of a skipped + * file and any other test; the last full run over the unit tests had none in 6,264 candidate pairs. + * The plan and coverage stages apply it unless mutation scope is `all` or the run is the controls. + * + * Library only; it reads the test files and their snapshot and helper modules. + */ +import { checksContextKey, fsModuleReader, fsSnapshotReader } from '../facet/checks.js'; +import { loadStaticInventory } from '../inventory/cases.js'; + +/** Matchers that read a `.snap` file entry named after the runtime test name. */ +const FILE_SNAPSHOT = /\.(?:toMatchSnapshot|toMatchFileSnapshot|toThrowErrorMatchingSnapshot)\(/; + +export interface Layer1Files { + /** The files Layer 1 runs, in input order. */ + files: string[]; + /** + * The others: every test has a checks-and-context key no other test shares, or (diff scope) a + * static key, none of which a changed file's test has. + */ + skipped: string[]; +} + +/** + * Splits package-path test files into those Layer 1 must run and the rest. With `changed` (a + * diff-scoped run, fingerprint plan E8) only pairs with a changed file's test count: the changed + * files the whole-suite rule keeps, every file with a test whose key a changed file's test has, + * and every file with a test of no static key, which can pair with any test. + */ +export function layer1Files( + repoRoot: string, + files: readonly string[], + changed: ReadonlySet | null = null, +): Layer1Files { + const inventory = loadStaticInventory(repoRoot, files); + const options = { snapshots: fsSnapshotReader(repoRoot), modules: fsModuleReader(repoRoot) }; + // Files with a test of no static key: it can pair with any test. + const unknown = new Set([...inventory.skipped, ...inventory.incomplete]); + const keys: { file: string; key: string }[] = []; + for (const site of inventory.sites) { + if (site.instances === null || FILE_SNAPSHOT.test(site.record.body)) { + unknown.add(site.file); + continue; + } + for (const instance of site.instances) { + const key = checksContextKey( + { record: site.record, instance, fullName: site.fullName }, + options, + ); + if (key === null) unknown.add(site.file); + else keys.push({ file: site.file, key }); + } + } + const count = new Map(); + for (const { key } of keys) count.set(key, (count.get(key) ?? 0) + 1); + // A key only one test has pairs with nothing. + const shared = (key: string): boolean => (count.get(key) ?? 0) > 1; + const keep = new Set([...unknown, ...keys.filter((k) => shared(k.key)).map((k) => k.file)]); + const changedKeys = new Set( + keys.filter(({ file, key }) => changed?.has(file) === true && shared(key)).map((k) => k.key), + ); + const run = + changed === null + ? keep + : new Set([ + ...[...keep].filter((file) => changed.has(file)), + ...keys.filter(({ key }) => changedKeys.has(key)).map(({ file }) => file), + ...unknown, + ]); + return { + files: files.filter((file) => run.has(file)), + skipped: files.filter((file) => !run.has(file)), + }; +} diff --git a/gitnexus/tools/scenario-map/path-coverage/records.ts b/gitnexus/tools/scenario-map/path-coverage/records.ts new file mode 100644 index 000000000..9d1b51151 --- /dev/null +++ b/gitnexus/tools/scenario-map/path-coverage/records.ts @@ -0,0 +1,432 @@ +/** + * Worker side of the Layer 1 coverage hook (U4, R3, R4, KTD4, design §5.2): the raw per-file record + * coverage.setup.ts writes, and the pure pieces it is built from. run-coverage.ts reads the records + * and turns them into the work file. + * + * - {@link CoverageInterner} turns one `Profiler.takePreciseCoverage` result (a window) into + * interned block keys and executed scripts. A block key is `