diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index fd2a00747a..7bafecb216 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -14,11 +14,69 @@ concurrency: cancel-in-progress: true jobs: + # The open pull request map is read in a job that never checks out or runs pull-request + # code, so the token cannot reach anything the contributor controls. Only the filtered map + # crosses into the job that runs the gate. Each entry carries the draft flag and the base ref: a + # draft pull request is not gated, and a pull request whose base is another branch is not the + # branch this unit lands on, so either one makes the gate measure against the event base instead. + stacked_map: + name: Resolve the stacked parent + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + # The map is published as an artifact for the gate job, which is the only artifact this + # job writes; it never sees pull-request code because this job checks out nothing. + actions: write + steps: + - name: Read the open pull request heads + id: read_map + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # The fallback must itself be a command: this step runs bash with -e, so a bare '[]' + # is executed as a program name and an API failure would abort the job instead of + # measuring against the event base. + # --jq runs once per page, so a paginated call emits one JSON array per page; the + # gate reads that stream, not a single array. + # The base ref travels with every entry: the gate only measures a unit against a parent + # that targets the branch the unit itself targets. + STACKED_MAP="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" --jq '[.[] | {number: .number, headSha: .head.sha, draft: (.draft == true), baseRef: .base.ref}]')" || STACKED_MAP="$(printf '[]')" + # Publish the map as a file in an artifact rather than as a job output that the gate + # step then receives as an argument: Linux caps one argument or environment string at + # 128 KiB (MAX_ARG_STRLEN), and a map past that cap makes the gate step unstartable + # instead of merely large. A failed write aborts this job: a truncated or missing map + # would leave the gate measuring against the event base while still reporting success. + case "$STACKED_MAP" in + "["*) ;; + *) echo "::error title=Stacked unit base::The stacked map is not a JSON array" >&2; exit 1 ;; + esac + printf '%s\n' "$STACKED_MAP" > stacked-map.json + + # A failed upload must not skip the gate: the download side already treats a missing + # artifact as "measure against the event base" (stricter, never looser), and that + # fallback can only run if this job still succeeds. + - name: Publish the stacked map + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-stacked-map + path: stacked-map.json + if-no-files-found: error + retention-days: 1 + mutation-diff: name: mutation-diff + needs: stacked_map if: github.event_name == 'merge_group' || github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 30 + # Only the filtered map crosses into this job, and it crosses as a file: the token stays in the + # job that read it. actions:write covers reading that artifact and publishing the reports below. + permissions: + contents: read + actions: write steps: - name: Record merge-queue enforcement if: github.event_name == 'merge_group' @@ -46,14 +104,34 @@ jobs: if: github.event_name == 'pull_request' run: pnpm test:mutation-ci + - name: Read the stacked map + if: github.event_name == 'pull_request' + # Downloaded outside the checkout on purpose: a pull request can commit .mutation/stacked-map.json + # itself, and with continue-on-error a missing artifact would leave that committed file in place for + # the gate to read as if it were the map. Under RUNNER_TEMP a missing artifact means no file, which + # degrades to the event base - stricter than the intended measurement, never looser - and the gate + # logs it. + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: mutation-stacked-map + path: ${{ runner.temp }}/mutation-stacked-map + - name: Enforce executable-line scope and run advisory mutation testing if: github.event_name == 'pull_request' env: HEAD_SHA: ${{ github.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | BASE_SHA="$(git rev-parse "$HEAD_SHA^1")" - node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" + # The merge commit's second parent is the pull request head. When that head's own + # parent is another open pull request's head, this PR is a stacked unit, so the gate + # measures only this unit's delta instead of every unmerged ancestor. + # The event already carries that head for provenance, so it wins here; deriving it from + # the merge commit stays as the fallback for a manual run with no pull_request payload. + PR_HEAD_SHA="${PR_HEAD_SHA:-$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")}" + node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" --pr-head "$PR_HEAD_SHA" --pr-base-ref "$PR_BASE_REF" --stacked-map-file "${RUNNER_TEMP}/mutation-stacked-map/stacked-map.json" - name: Upload mutation reports id: mutation_report diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 7dfe0006d2..e73eb7aae6 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -290,10 +290,138 @@ export function resolvePullRequestBase(repoRoot, baseSha, headSha) { return parents[0] } -export function selectFromGit(repoRoot, baseSha, headSha) { +// A stacked unit branch is built on the previous unit's head, so the pull request's own base (main) +// attributes every unmerged ancestor to this pull request. When the head commit's first parent is the +// head of another open pull request, that parent is the unit's real base and the diff contains only +// this unit's delta. A pull request whose parent is not another pull request's head keeps the event +// base, so a multi-commit pull request is never charged only its last commit. The parent must also be +// a pull request against the same branch this unit targets, so a parent stacked from a feature branch +// cannot lend its unmeasured feature-branch delta to a unit that lands on main. +export function resolveStackedUnitBase(repoRoot, eventBaseSha, prHeadSha, openPullRequests = [], childBaseRef) { + validateSha(eventBaseSha, "base SHA") + validateSha(prHeadSha, "pull request head SHA") + const parents = git(repoRoot, ["rev-list", "--parents", "-n", "1", prHeadSha]).trim().split(/\s+/).slice(1) + // A unit is one commit on top of its parent. A head with more than one parent is a merge on the + // unit branch itself, so its diff is not a single unit delta and the event base is kept even when + // its first parent is an open pull request head. + if (parents.length !== 1) return { baseSha: eventBaseSha, stackedOn: null } + const parentSha = parents[0].toLowerCase() + // The merge queue runs no gate of its own, so the per-pull-request gate is the only enforcement + // point for the changed-line cap. A DRAFT pull request is never gated (the workflow skips draft + // runs), so its head cannot be the base this unit is measured against: the unit would be charged + // only its own delta while the draft's content rides into the base through the unit that merges + // it. Such a unit keeps the event base, so that content is charged to the pull request that would + // actually land it. A parent that reaches this point was gated: the workflow ran the gate on that + // pull request against its own base. Whether that run passed is enforced on the parent's own pull + // request, not here - no branch ruleset requires the mutation-diff check upstream, so requiring a + // green parent would turn an advisory measurement into a hard dependency between two units, and a + // unit would be charged its parent's unrelated findings on top of its own. + // The base-ref test belongs inside the find. Two open pull requests can share one head SHA while + // targeting different branches (the same commit pushed to two branches), and stopping at the first + // SHA match would fall back to the event base even when a later entry targets the branch this unit + // lands on - charging the unit every unmerged ancestor of a branch it is not merging into. + // A parent whose base is a feature branch is measured against that feature branch by its own run, so + // measuring this unit against its head hides the parent's feature-branch delta from the changed-line + // cap while merging this unit lands that delta on main. Without either base ref the comparison cannot + // be made, and the unit keeps the event base. + // The predicate requires draft === false rather than accepting anything that is not true: an + // entry whose draft flag is missing or not a boolean (an inline --stacked-map entry, an older + // map format) has not proven the parent was gated, so it keeps the event base. + const parent = openPullRequests.find( + (pr) => + pr.draft === false && + String(pr.headSha).toLowerCase() === parentSha && + Boolean(childBaseRef) && + (typeof pr.baseRef === "string" ? pr.baseRef : "") === childBaseRef, + ) + if (!parent) return { baseSha: eventBaseSha, stackedOn: null } + return { baseSha: parentSha, stackedOn: parent.number } +} + +// The ci command decides two things from the commit graph: which base to charge this pull request +// to, and which commit to diff against. A stacked unit diffs against its own pull request head, +// because the merge commit also carries whatever main advanced since the parent unit. A plain +// pull request keeps the merge commit as the diff head. +export function resolveCiInvocation( + repoRoot, + eventBaseSha, + mergeCommitSha, + prHeadSha, + openPullRequests = [], + childBaseRef, +) { + const resolved = resolveStackedUnitBase(repoRoot, eventBaseSha, prHeadSha, openPullRequests, childBaseRef) + return { + baseSha: resolved.baseSha, + diffHead: resolved.stackedOn ? prHeadSha : mergeCommitSha, + stackedOn: resolved.stackedOn, + } +} + +// Selection, source reads, and Stryker must all see the same tree. The workflow checks out +// GitHub's merge commit, which also carries whatever main advanced since the parent unit, so for +// a stacked unit the working tree is moved to the unit head before mutation runs; otherwise the +// selectors come from one tree and the mutated source comes from another. +export function alignExecutionTree(repoRoot, diffHeadSha) { + validateSha(diffHeadSha, "diff head SHA") + const current = git(repoRoot, ["rev-parse", "HEAD"]).trim().toLowerCase() + const wanted = String(diffHeadSha).toLowerCase() + if (current === wanted) return false + git(repoRoot, ["checkout", "--quiet", wanted]) + return true +} + +// The workflow installs dependencies from the merge commit it checks out, and a stacked unit then moves +// the working tree to the pull request head. When the two trees declare different dependencies, the +// installed Vitest and Stryker belong to the merge tree while selection, source reads, and mutation run +// against the unit head, so the toolchain has to be reinstalled from the aligned tree before it runs. +// Every dependency declaration the workspace installs from is listed here: the lockfile, the workspace +// membership, and each package's manifest. +export const DEPENDENCY_MANIFEST_PATHS = [ + ":(glob)**/pnpm-lock.yaml", + ":(glob)**/pnpm-workspace.yaml", + ":(glob)**/package.json", +] + +export function dependencyManifestDrift(repoRoot, installedSha, alignedSha) { + const changed = git(repoRoot, [ + "diff", + "--name-only", + `${installedSha}..${alignedSha}`, + "--", + ...DEPENDENCY_MANIFEST_PATHS, + ]) + return changed.trim().split(/\n+/).filter(Boolean) +} + +/** + * The detail of a failed `pnpm install --frozen-lockfile`. pnpm writes its diagnostics to stderr, but a + * killed or signal-terminated run leaves stderr empty, and "" is a valid ?? operand: with ?? the failure + * would report a blank detail and lose the exit status that is the only remaining fact. || treats the + * empty string as absent, so the chain reaches the exit status. + */ +export function reinstallFailureDetail(result) { + return result.error?.message || result.stderr?.trim() || `exit ${result.status}` +} + +function reinstallAlignedDependencies(repoRoot, driftedManifests) { + console.log( + `Reinstalling dependencies from the aligned tree: ${driftedManifests.join(", ")} differ from the tree they were installed from.`, + ) + const result = spawnSync("pnpm", ["install", "--frozen-lockfile"], { cwd: repoRoot, encoding: "utf8" }) + if (result.error || result.status !== 0) { + throw new Error( + `The aligned tree changed ${driftedManifests.join(", ")} and the reinstall failed: ${reinstallFailureDetail(result)}`, + ) + } +} + +export function selectFromGit(repoRoot, baseSha, headSha, options = {}) { validateSha(baseSha, "base SHA") validateSha(headSha, "head SHA") - baseSha = resolvePullRequestBase(repoRoot, baseSha, headSha) + // A stacked base was resolved from the pull request head, not from the merge commit, so it must + // survive: re-deriving it from the merge commit would charge the whole unmerged chain to this unit. + if (!options.preserveBase) baseSha = resolvePullRequestBase(repoRoot, baseSha, headSha) const mergeBase = git(repoRoot, ["merge-base", baseSha, headSha]).trim() const nameStatus = git(repoRoot, ["diff", "--name-status", "-z", "--find-renames", `${mergeBase}...${headSha}`]) const entries = parseNameStatus(nameStatus) @@ -575,12 +703,17 @@ export function formatBlockingMutants(blockingMutants, packageRoot) { } export function formatSummary(rows, advisories, manifest = {}) { - const lines = [ - "## Changed-code mutation testing", - "", + const lines = ["## Changed-code mutation testing", ""] + if (manifest.stackedOn) { + lines.push( + `Stacked unit: measured against the head of parent PR #${manifest.stackedOn}, not the event base.`, + "", + ) + } + lines.push( "| Package | Changed executable lines | Valid | Killed | Timeout | Survived | No coverage | Result |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", - ] + ) for (const row of rows) { lines.push( `| ${row.id} | ${row.changedLines} | ${row.valid} | ${row.killed} | ${row.timeout} | ${row.survived} | ${row.noCoverage} | ${row.result} |`, @@ -633,13 +766,19 @@ export function formatSummary(rows, advisories, manifest = {}) { } if (manifest.baseSha && manifest.headSha) { + // A stacked unit was measured against its parent head with the working tree aligned to the pull + // request head. From a merge-commit checkout the same command would select the unit's lines but + // mutate whatever main advanced, so the checkout belongs in the reproduction. lines.push( "", "### Reproduce locally", "", - "From a full checkout containing both commits:", + manifest.stackedOn + ? "From a full checkout containing both commits, checked out at the pull request head:" + : "From a full checkout containing both commits:", "", "```bash", + ...(manifest.stackedOn ? [`git checkout ${manifest.headSha}`] : []), "pnpm install --frozen-lockfile", `node scripts/stryker-diff.mjs ci --base ${manifest.baseSha} --head ${manifest.headSha}`, "```", @@ -707,6 +846,24 @@ export function appendSummary(rows, advisories, manifest, commentFile) { } } +/** + * A failed dependency reinstall aborts the gate before any manifest exists, so without this the only + * trace of the run is a step log: the author cannot see which base the gate chose, which is the fact + * that explains a stacked unit's numbers. The caller rethrows - this only makes the failure visible. + */ +export function summarizeReinstallFailure(invocation, driftedManifests, error) { + appendSummary( + [], + [ + `Dependency reinstall failed for the aligned tree (${driftedManifests.join(", ")}): ${error.message} The gate measured nothing; fix the lockfile drift and re-run.`, + ], + // The base and the diff head belong in this summary too: once the parent PR's head advances, the + // selected base is the only way to trace which commit the run measured, and formatSummary turns + // these two into the "Reproduce locally" command. + { stackedOn: invocation.stackedOn, baseSha: invocation.baseSha, headSha: invocation.diffHead }, + ) +} + export function evaluateReport(report, packageEntry) { const counts = mutantCounts(report) const advisories = [] @@ -833,18 +990,137 @@ function argument(name) { return index === -1 ? undefined : process.argv[index + 1] } +// The workflow passes the open pull requests as JSON of { number, headSha }. gh api --paginate +// emits one array per page, so the map can be a stream of arrays rather than one document; accept +// both shapes. An unparsable map must not change the gate's base, so it degrades to the event base +// instead of throwing. +/** + * The stacked map arrives either as an inline argument (local runs) or as a file written by the + * stacked_map job. The file is the CI path on purpose: Linux caps one argument or environment string + * (MAX_ARG_STRLEN, 128 KiB), so a map past that cap would make the gate step unstartable instead of + * merely large. A map that cannot be read falls back to the event base, which charges this unit every + * unmerged ancestor - stricter than the intended measurement, never looser - and says so in the log. + */ +export function readStackedMap(repoRoot, stackedMapArg, stackedMapFile) { + if (!stackedMapFile) return stackedMapArg ?? "" + const mapPath = path.resolve(repoRoot, stackedMapFile) + try { + return fs.readFileSync(mapPath, "utf8") + } catch (error) { + const reason = + error instanceof Error && error.code === "ENOENT" ? "the artifact was not published" : error.message + console.warn( + `::warning title=Stacked unit base::Could not read the stacked map from ${stackedMapFile} (${reason}); measuring against the event base.`, + ) + return "" + } +} + +export function parseStackedMap(value) { + if (!value) return [] + // gh api --paginate emits one JSON array per page, so the input is a stream of arrays rather + // than a single array. Consume the whole stream: if any part of it cannot be parsed, the map + // is not trustworthy as a whole and the caller must fall back to the event base instead of + // keeping a partial entry that could select a parent base. + const text = String(value) + const entries = [] + let index = 0 + while (index < text.length) { + while (index < text.length && /[\s,]/.test(text[index])) index += 1 + if (index >= text.length) break + if (text[index] !== "[") return [] + let depth = 0 + let inString = false + let end = -1 + for (let i = index; i < text.length; i++) { + const char = text[i] + if (inString) { + if (char === "\\") { + i += 1 + continue + } + if (char === '"') inString = false + continue + } + if (char === '"') { + inString = true + continue + } + if (char === "[") depth += 1 + else if (char === "]") { + depth -= 1 + if (depth === 0) { + end = i + break + } + } + } + if (depth !== 0 || end < 0) return [] + let parsed + try { + parsed = JSON.parse(text.slice(index, end + 1)) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + entries.push( + ...parsed.filter((entry) => entry && entry.number && /^[0-9a-f]{40}$/i.test(String(entry.headSha))), + ) + index = end + 1 + } + return entries +} + function main() { const command = process.argv[2] if (command !== "ci") - throw new Error("Usage: node scripts/stryker-diff.mjs ci --base --head [--reports ]") + throw new Error( + "Usage: node scripts/stryker-diff.mjs ci --base --head [--pr-head ] [--reports ] [--stacked-map | --stacked-map-file ] [--pr-base-ref ]", + ) const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") const baseSha = argument("--base") const headSha = argument("--head") if (!baseSha || !headSha) throw new Error("--base and --head are required") + const openPullRequests = parseStackedMap( + readStackedMap(repoRoot, argument("--stacked-map"), argument("--stacked-map-file")), + ) + // --pr-head is the pull request head itself. The workflow runs on GitHub's merge commit, whose + // first parent is the base tip, so the merge commit cannot identify the stacked unit; its second + // parent can. A plain pull request keeps the merge commit as the diff head; a stacked unit + // uses its own head, and the working tree is aligned to that head below. + const prHeadSha = argument("--pr-head") ?? headSha + // The branch this pull request targets. A stacked parent is only accepted when it targets the same + // branch, so the parent's own gate and this unit's gate measure against the same base. + const prBaseRef = argument("--pr-base-ref") + const invocation = resolveCiInvocation(repoRoot, baseSha, headSha, prHeadSha, openPullRequests, prBaseRef) + if (invocation.stackedOn) + console.log( + `Stacked unit: measured against the head of parent PR #${invocation.stackedOn}, not the event base.`, + ) + if (invocation.stackedOn && alignExecutionTree(repoRoot, invocation.diffHead)) { + console.log( + `Aligned the working tree to the diff head ${invocation.diffHead.slice(0, 12)} so selection, source reads, and mutation run on the same tree.`, + ) + const driftedManifests = dependencyManifestDrift(repoRoot, headSha, invocation.diffHead) + if (driftedManifests.length > 0) { + try { + reinstallAlignedDependencies(repoRoot, driftedManifests) + } catch (error) { + summarizeReinstallFailure(invocation, driftedManifests, error) + throw error + } + } + } + const reportRoot = path.resolve(repoRoot, argument("--reports") ?? "reports/mutation") - const manifest = selectFromGit(repoRoot, baseSha, headSha) + const manifest = { + ...selectFromGit(repoRoot, invocation.baseSha, invocation.diffHead, { + preserveBase: Boolean(invocation.stackedOn), + }), + stackedOn: invocation.stackedOn, + } if (manifest.packages.length === 0) { appendSummary([], manifest.advisories, manifest, path.join(reportRoot, PR_COMMENT_FILENAME)) console.log("No changed executable lines in mutation-tested packages; mutation testing is not applicable.") diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 339e44e676..c26afe12d0 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -30,6 +30,14 @@ import { parseNameStatus, parseVitestTestFiles, preferDirectTestFiles, + parseStackedMap, + readStackedMap, + reinstallFailureDetail, + summarizeReinstallFailure, + dependencyManifestDrift, + alignExecutionTree, + resolveCiInvocation, + resolveStackedUnitBase, resolveStrykerTempDir, resolveVitestBinary, shouldUseVitestRelated, @@ -64,11 +72,79 @@ describe("mutation testing workflow", () => { assert.ok(workflow.includes("PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}")) assert.ok(!workflow.includes(" HEAD_SHA: ${{ github.event.pull_request.head.sha }}")) assert.ok(workflow.includes('BASE_SHA="$(git rev-parse "$HEAD_SHA^1")"')) + assert.ok(workflow.includes("pull-requests: read")) + // The event value is the authoritative provenance for the PR comment, so the run block keeps it + // when present and derives it from the merge commit only as a fallback for a manual run with no + // pull_request payload. Both spellings must stay: the derivation is what identifies the stacked + // unit when nothing supplies the head. + assert.ok( + workflow.includes( + 'PR_HEAD_SHA="${PR_HEAD_SHA:-$(git rev-parse "$HEAD_SHA^2" 2>/dev/null || git rev-parse "$HEAD_SHA")}"', + ), + ) + // The map crosses jobs as a file, never as an argument or an environment string: Linux caps one + // argument/env string at 128 KiB (MAX_ARG_STRLEN), and a map past that cap makes the step + // unstartable rather than merely large. + assert.ok(workflow.includes('--stacked-map-file "${RUNNER_TEMP}/mutation-stacked-map/stacked-map.json"')) + // The map must land outside the checkout: a pull request can commit .mutation/stacked-map.json, + // and with continue-on-error a missing artifact would leave that committed file in front of the + // gate, which then measures against whatever base the pull request chose for itself. + assert.ok(workflow.includes("path: ${{ runner.temp }}/mutation-stacked-map")) + assert.ok(!workflow.includes("path: .mutation")) + assert.ok(!workflow.includes('--stacked-map "$STACKED_MAP"')) + assert.ok(workflow.includes("name: mutation-stacked-map")) + assert.ok(workflow.includes('--pr-head "$PR_HEAD_SHA"')) + // The open pull request map must cover every page, otherwise a parent unit beyond the first + // page is missing and the gate charges the whole unmerged chain again. + // gh api rejects --slurp together with --jq, so pagination has to keep the per-page filter + // and the parser has to accept the resulting stream of page arrays. + assert.ok(workflow.includes("gh api --paginate")) + assert.ok(!workflow.includes("--slurp")) + // The map has to carry the draft flag: the gate refuses a draft head as a stacked base, + // because a draft pull request is not gated (see the draft-base case below). + assert.ok( + workflow.includes( + "[.[] | {number: .number, headSha: .head.sha, draft: (.draft == true), baseRef: .base.ref}]", + ), + ) + // The gate has to know which branch this pull request targets, otherwise it cannot tell whether + // the parent it measures against lands on the same branch. + assert.ok(workflow.includes("PR_BASE_REF: ${{ github.event.pull_request.base.ref }}")) + assert.ok(workflow.includes('--pr-base-ref "$PR_BASE_REF"')) + // The fallback has to be a command: these steps run bash with -e, so a bare '[]' is executed + // as a program name instead of producing JSON. + assert.ok(workflow.includes("printf '[]'")) assert.ok(!workflow.includes("github.event.pull_request.base.sha")) assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) assert.ok(workflow.includes("open the package's mutation.html file")) assert.ok(workflow.includes("Enforce executable-line scope and run advisory mutation testing")) - assert.equal(workflow.match(/continue-on-error: true/g)?.length, 1) + // The gate hands its environment to the Vitest discovery subprocesses, so the token has to stay + // in a job that never checks out or runs pull-request code; only the filtered map crosses over. + const gateJob = workflow.slice(workflow.indexOf(" mutation-diff:")) + assert.ok(!gateJob.includes("GH_TOKEN")) + // The map arrives from the artifact the map job publishes, into the job that runs the gate. + assert.ok(gateJob.includes("uses: actions/download-artifact@")) + assert.ok(gateJob.includes("name: mutation-stacked-map")) + assert.ok(!workflow.includes("needs.stacked_map.outputs.stacked_map")) + const mapJob = workflow.slice(workflow.indexOf(" stacked_map:"), workflow.indexOf(" mutation-diff:")) + assert.ok(mapJob.includes("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}")) + assert.ok(!mapJob.includes("actions/checkout")) + assert.ok(!mapJob.includes("setup-node-pnpm")) + assert.ok(!mapJob.includes("pnpm test:mutation-ci")) + assert.ok(workflow.includes("needs: stacked_map")) + // The map is validated before it is written, and the write is a plain file in the job that + // uploads it: no step output, no job output, and no argument for the gate to receive. + assert.ok(mapJob.includes(`printf '%s\\n' "$STACKED_MAP" > stacked-map.json`)) + assert.ok(mapJob.includes("path: stacked-map.json")) + assert.ok(mapJob.includes("if-no-files-found: error")) + assert.ok(!mapJob.includes("GITHUB_OUTPUT")) + // Both jobs need actions:write - one to publish the map, one to read it and publish the reports. + assert.equal(workflow.match(/actions: write/g)?.length, 2) + // A missing map artifact degrades to the event base inside the script, so the download step must + // not fail the job. The upload that produces it is tolerant for the same reason: a failed upload + // would otherwise fail this job and skip the gate entirely, so the fallback could never run. The + // report upload keeps its own tolerance. + assert.equal(workflow.match(/continue-on-error: true/g)?.length, 3) assert.equal(workflow.match(/Could not write the job summary/g)?.length, 2) const script = fs.readFileSync(path.join(repositoryRoot, "scripts/stryker-diff.mjs"), "utf8") assert.ok( @@ -185,6 +261,621 @@ describe("pull request revision selection", () => { }) }) +describe("stacked unit base resolution", () => { + const createSyntheticStack = () => { + const repository = fs.mkdtempSync(path.join(os.tmpdir(), "mutation-stack-")) + // Setup can fail halfway (a missing git config, a failed commit), and the callers only get the + // repository path back on success, so cleanup has to happen here or the temp directory leaks. + try { + const run = (...args) => execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim() + const write = (filePath, contents) => { + fs.mkdirSync(path.join(repository, path.dirname(filePath)), { recursive: true }) + fs.writeFileSync(path.join(repository, filePath), contents) + } + + run("init", "--quiet", "--initial-branch", "main") + run("config", "user.email", "gate@example.com") + run("config", "user.name", "Gate") + run("config", "commit.gpgsign", "false") + + write("packages/core/src/unrelated.ts", "export const unrelated = () => 1\n") + run("add", ".") + run("commit", "--quiet", "-m", "initial") + const eventBaseSha = run("rev-parse", "HEAD") + + run("checkout", "--quiet", "-b", "unit-1") + write("packages/core/src/unit1.ts", "export const unit1 = () => 1\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 1") + const parentSha = run("rev-parse", "HEAD") + + // The stacked unit is one commit on top of the parent unit's head. + write("packages/core/src/unit2.ts", "export const unit2 = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 2") + const childSha = run("rev-parse", "HEAD") + + return { repository, eventBaseSha, parentSha, childSha, run, write } + } catch (error) { + fs.rmSync(repository, { recursive: true, force: true }) + throw error + } + } + + it("measures a stacked unit against the parent pull request head", () => { + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const resolved = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(resolved.baseSha, parentSha) + assert.equal(resolved.stackedOn, 1) + + const manifest = selectFromGit(repository, resolved.baseSha, childSha) + assert.deepEqual( + manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit2.ts"], + ) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("preserves the resolved stacked base when selection runs on the merge commit", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + // GitHub runs the gate on its own merge commit: first parent is the base tip, second + // parent is the pull request head. + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + write("packages/core/src/unrelated.ts", "export const unrelated = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + const resolved = resolveStackedUnitBase( + repository, + advancedBase, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(resolved.baseSha, parentSha) + assert.equal(resolved.stackedOn, 1) + + // Without preserving the resolved base, selection re-derives it from the merge commit and + // charges the whole unmerged chain to this unit. + const charged = selectFromGit(repository, resolved.baseSha, mergeSha) + assert.deepEqual( + charged.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"], + ) + + // With the base preserved, the unit is measured against its own head: the merge commit also + // carries the base advance, which is not this unit's delta. + const unit = selectFromGit(repository, resolved.baseSha, childSha, { preserveBase: true }) + assert.deepEqual( + unit.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit2.ts"], + ) + + // A non-stacked pull request still measures against the merge commit, so the base actually + // merged into is used and the base advance is not charged to the pull request. + const plain = selectFromGit(repository, advancedBase, mergeSha, { preserveBase: true }) + assert.deepEqual( + plain.packages.flatMap((entry) => entry.files.map((file) => file.path)), + ["packages/core/src/unit1.ts", "packages/core/src/unit2.ts"], + ) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("keeps the event base when the parent commit is not another pull request head", () => { + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const resolved = resolveStackedUnitBase(repository, eventBaseSha, childSha, [], "main") + assert.equal(resolved.baseSha, eventBaseSha) + assert.equal(resolved.stackedOn, null) + + // A multi-commit pull request must not be charged only its last commit. + const manifest = selectFromGit(repository, resolved.baseSha, childSha) + assert.deepEqual(manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)).sort(), [ + "packages/core/src/unit1.ts", + "packages/core/src/unit2.ts", + ]) + void parentSha + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("keeps the event base when the parent head belongs to a draft pull request", () => { + // A draft pull request is not gated, so it cannot be the base a unit is measured against: the + // unit would be charged only its own delta while the draft's content lands through it. The + // unit keeps the event base instead, so that content is charged where it would land. + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const draft = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: true, baseRef: "main" }], + "main", + ) + assert.equal(draft.baseSha, eventBaseSha) + assert.equal(draft.stackedOn, null) + + // An entry whose draft flag is missing proves nothing about gating, so it keeps the event + // base like a known draft does. + const unconfirmed = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, baseRef: "main", draft: undefined }], + "main", + ) + assert.equal(unconfirmed.baseSha, eventBaseSha) + assert.equal(unconfirmed.stackedOn, null) + + // The same entry, ready for review: the parent was gated, so it is the unit's base again. + const ready = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(ready.baseSha, parentSha) + assert.equal(ready.stackedOn, 1) + + // The cumulative delta the cap has to see when the parent never gets gated. + const manifest = selectFromGit(repository, draft.baseSha, childSha, { preserveBase: true }) + assert.deepEqual(manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)).sort(), [ + "packages/core/src/unit1.ts", + "packages/core/src/unit2.ts", + ]) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("refuses a stacked parent that targets a different branch than this unit", () => { + // A parent whose base is a feature branch was measured against that branch by its own gate. + // Measuring this unit against its head would hide the parent's feature-branch delta from the + // changed-line cap while merging this unit lands that delta on main, so the unit keeps the + // event base and the cap sees the whole chain. + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const featureBranchParent = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "feature/other" }], + "main", + ) + assert.equal(featureBranchParent.baseSha, eventBaseSha) + assert.equal(featureBranchParent.stackedOn, null) + + // The same parent head, targeting the branch this unit targets, is the unit's real base. + const sameBase = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(sameBase.baseSha, parentSha) + assert.equal(sameBase.stackedOn, 1) + + // Nothing can be compared when either side is unknown, so both keep the event base. + const unknownUnitBase = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + undefined, + ) + assert.equal(unknownUnitBase.stackedOn, null) + const unknownParentBase = resolveStackedUnitBase( + repository, + eventBaseSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false }], + "main", + ) + assert.equal(unknownParentBase.stackedOn, null) + + // The cumulative delta the cap has to see when the parent cannot serve as the base. + const manifest = selectFromGit(repository, featureBranchParent.baseSha, childSha, { preserveBase: true }) + assert.deepEqual(manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)).sort(), [ + "packages/core/src/unit1.ts", + "packages/core/src/unit2.ts", + ]) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("matches a stacked parent by base branch when another pull request shares its head", () => { + // Two open pull requests can point at the same commit while targeting different branches. Stopping + // at the first head-SHA match rejects that parent for the wrong reason and falls back to the event + // base, charging this unit every unmerged ancestor of a branch it is not merging into. + const { repository, eventBaseSha, parentSha, childSha } = createSyntheticStack() + + try { + const map = [ + { number: 9, headSha: parentSha, draft: false, baseRef: "feature/other" }, + { number: 1, headSha: parentSha, draft: false, baseRef: "main" }, + ] + + const unitOnMain = resolveStackedUnitBase(repository, eventBaseSha, childSha, map, "main") + assert.equal(unitOnMain.baseSha, parentSha) + assert.equal(unitOnMain.stackedOn, 1) + + // The same map read by a unit that targets the feature branch picks the other pull request, + // which is the only parent whose own gate measured against this unit's base. + const unitOnFeature = resolveStackedUnitBase(repository, eventBaseSha, childSha, map, "feature/other") + assert.equal(unitOnFeature.baseSha, parentSha) + assert.equal(unitOnFeature.stackedOn, 9) + + // A map whose entries match no base still degrades instead of guessing. + const noMatch = resolveStackedUnitBase(repository, eventBaseSha, childSha, map, "release/none") + assert.equal(noMatch.baseSha, eventBaseSha) + assert.equal(noMatch.stackedOn, null) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("reads the stacked map from the artifact file the workflow publishes", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "stacked-map-")) + try { + fs.writeFileSync( + path.join(directory, "stacked-map.json"), + '[{ "number": 1, "headSha": "' + "a".repeat(40) + '", "draft": false, "baseRef": "main" }]', + ) + const fromFile = parseStackedMap(readStackedMap(directory, "[]", "stacked-map.json")) + assert.deepEqual(fromFile, [{ number: 1, headSha: "a".repeat(40), draft: false, baseRef: "main" }]) + + // The inline form stays available for local runs, where no artifact exists. + const inline = parseStackedMap( + readStackedMap( + directory, + '[{ "number": 2, "headSha": "' + "b".repeat(40) + '", "draft": false, "baseRef": "main" }]', + undefined, + ), + ) + assert.deepEqual(inline, [{ number: 2, headSha: "b".repeat(40), draft: false, baseRef: "main" }]) + + // A missing artifact must not stop the gate. Falling back to the event base charges this unit + // every unmerged ancestor - stricter than the intended measurement, never looser - and the + // warning records which case ran. + const warnings = [] + const warn = console.warn + console.warn = (message) => warnings.push(message) + try { + assert.equal(readStackedMap(directory, '[{ "number": 3 }]', "absent.json"), "") + } finally { + console.warn = warn + } + assert.equal(warnings.length, 1) + assert.ok(warnings[0].includes("the artifact was not published")) + assert.deepEqual(parseStackedMap(""), []) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it("reports the stacked base in the job summary when the aligned reinstall fails", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "reinstall-summary-")) + const summaryPath = path.join(directory, "step-summary.md") + const previous = process.env.GITHUB_STEP_SUMMARY + process.env.GITHUB_STEP_SUMMARY = summaryPath + const warnings = [] + const warn = console.warn + console.warn = (message) => warnings.push(message) + try { + summarizeReinstallFailure( + { stackedOn: 7, baseSha: "1".repeat(40), diffHead: "2".repeat(40) }, + ["src/package.json", "pnpm-lock.yaml"], + new Error("pnpm install exited 1"), + ) + const summary = fs.readFileSync(summaryPath, "utf8") + // The whole point of the summary here: which base this unit was measured against, and the + // command that reproduces it - the parent head moves, the selected base is what is left. + assert.ok(summary.includes("Stacked unit: measured against the head of parent PR #7")) + assert.ok( + summary.includes(`node scripts/stryker-diff.mjs ci --base ${"1".repeat(40)} --head ${"2".repeat(40)}`), + ) + assert.ok(summary.includes("git checkout " + "2".repeat(40))) + assert.ok(summary.includes("src/package.json, pnpm-lock.yaml")) + assert.ok(summary.includes("pnpm install exited 1")) + assert.equal(warnings.length, 1) + } finally { + console.warn = warn + if (previous === undefined) delete process.env.GITHUB_STEP_SUMMARY + else process.env.GITHUB_STEP_SUMMARY = previous + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it("keeps the exit status when the reinstall writes nothing to stderr", () => { + // A killed or signal-terminated pnpm leaves stderr empty, and "" is a valid ?? operand: with ?? the + // report would end in a blank detail and lose the only fact that is left. + assert.equal(reinstallFailureDetail({ status: 1, stderr: "" }), "exit 1") + assert.equal(reinstallFailureDetail({ status: null, stderr: undefined }), "exit null") + assert.equal( + reinstallFailureDetail({ status: 1, stderr: " ERR_PNPM_OUTDATED_LOCKFILE " }), + "ERR_PNPM_OUTDATED_LOCKFILE", + ) + assert.equal( + reinstallFailureDetail({ status: null, stderr: "", error: new Error("spawnSync pnpm ENOENT") }), + "spawnSync pnpm ENOENT", + ) + }) + + it("degrades to the event base for an unparsable stacked map", () => { + assert.deepEqual(parseStackedMap("not json"), []) + assert.deepEqual(parseStackedMap('{"number": 1}'), []) + assert.deepEqual(parseStackedMap('[{"number": 1, "headSha": "abc"}]'), []) + assert.deepEqual(parseStackedMap('[{"number": 1, "headSha": "' + "a".repeat(40) + '"}]'), [ + { number: 1, headSha: "a".repeat(40) }, + ]) + + // The base ref must survive the parse: rejecting a parent that targets a different branch is + // only possible when the entry carries the ref it was filtered with. + assert.deepEqual( + parseStackedMap('[{"number": 1, "headSha": "' + "e".repeat(40) + '", "draft": false, "baseRef": "main"}]'), + [{ number: 1, headSha: "e".repeat(40), draft: false, baseRef: "main" }], + ) + + // gh api --paginate emits one array per page, so a parent unit on page 2 arrives as a second + // document rather than inside the first array. + assert.deepEqual( + parseStackedMap( + '[{"number": 1, "headSha": "' + + "b".repeat(40) + + '"}]' + + '[{"number": 2, "headSha": "' + + "c".repeat(40) + + '"}]', + ), + [ + { number: 1, headSha: "b".repeat(40) }, + { number: 2, headSha: "c".repeat(40) }, + ], + ) + // Trailing content cannot be consumed as a page, so the whole map is rejected rather than + // keeping a partial entry that could select a parent base. + assert.deepEqual(parseStackedMap('[{"number": 1, "headSha": "' + "d".repeat(40) + '"}]garbage'), []) + assert.deepEqual(parseStackedMap('[{"number": 1, "headSha": "' + "d".repeat(40) + '"}] [{"number": 2'), []) + }) + + it("the ci command picks the diff head from the commit graph", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + write("packages/core/src/unrelated.ts", "export const unrelated = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + // Stacked: base is the parent PR head and the diff head is the PR head, not the merge + // commit, so the base advance is not charged to this unit. + const stacked = resolveCiInvocation( + repository, + advancedBase, + mergeSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(stacked.baseSha, parentSha) + assert.equal(stacked.diffHead, childSha) + assert.equal(stacked.stackedOn, 1) + + // Non-stacked: the merge commit stays the diff head and the base stays the event base. + const plain = resolveCiInvocation(repository, advancedBase, mergeSha, childSha, [], "main") + assert.equal(plain.baseSha, advancedBase) + assert.equal(plain.diffHead, mergeSha) + assert.equal(plain.stackedOn, null) + + // --pr-head defaults to --head, so a plain pull request keeps the same decision. + const sameHead = resolveCiInvocation(repository, advancedBase, mergeSha, mergeSha, [], "main") + assert.equal(sameHead.diffHead, mergeSha) + assert.equal(sameHead.stackedOn, null) + + // A unit branch that is itself a merge commit is not a single unit delta, so the fallback + // stays the event base even when one of its parents is an open pull request head. + run("checkout", "--quiet", "-b", "unit-3", advancedBase) + write("packages/core/src/unit3.ts", "export const unit3 = () => 3\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 3") + run("merge", "--no-ff", "--quiet", "-m", "unit merge", parentSha) + const multiParentHead = run("rev-parse", "HEAD") + assert.equal( + run("rev-list", "--parents", "-n", "1", multiParentHead).trim().split(/\s+/).slice(1).length, + 2, + ) + const multi = resolveStackedUnitBase( + repository, + advancedBase, + multiParentHead, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(multi.baseSha, advancedBase) + assert.equal(multi.stackedOn, null) + assert.equal( + resolveCiInvocation( + repository, + advancedBase, + multiParentHead, + multiParentHead, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ).stackedOn, + null, + ) + + // The guard, not the map lookup, has to reject this merge: its FIRST parent is the open + // pull request head. Without the parents.length guard that match would win, the merge would + // be treated as a one-commit unit, and only the second-parent delta would be charged. + const mergeTree = run("rev-parse", `${parentSha}^{tree}`) + const firstParentOpenPrMerge = run( + "commit-tree", + mergeTree, + "-p", + parentSha, + "-p", + advancedBase, + "-m", + "unit merge with the open pull request head first", + ) + assert.equal( + run("rev-list", "--parents", "-n", "1", firstParentOpenPrMerge).trim().split(/\s+/).slice(1)[0], + parentSha, + ) + const firstParentLookup = resolveStackedUnitBase( + repository, + advancedBase, + firstParentOpenPrMerge, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(firstParentLookup.baseSha, advancedBase) + assert.equal(firstParentLookup.stackedOn, null) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("aligns the working tree to the diff head so selection and mutation see one tree", () => { + const { repository, eventBaseSha, parentSha, childSha, run, write } = createSyntheticStack() + + try { + run("checkout", "--quiet", "-b", "merge-branch", eventBaseSha) + // The base advance changes a file, so the merge tree differs from the unit tree. The + // selectors are derived from the unit tree, so mutation has to run on that same tree. + write("packages/core/src/unrelated.ts", "export const unrelated = () => 99\n") + run("add", ".") + run("commit", "--quiet", "-m", "base advance") + const advancedBase = run("rev-parse", "HEAD") + run("merge", "--no-ff", "--quiet", "-m", "merge", childSha) + const mergeSha = run("rev-parse", "HEAD") + + const invocation = resolveCiInvocation( + repository, + advancedBase, + mergeSha, + childSha, + [{ number: 1, headSha: parentSha, draft: false, baseRef: "main" }], + "main", + ) + assert.equal(invocation.diffHead, childSha) + assert.equal(run("rev-parse", "HEAD").toLowerCase(), mergeSha.toLowerCase()) + + assert.equal(alignExecutionTree(repository, invocation.diffHead), true) + assert.equal(run("rev-parse", "HEAD").toLowerCase(), childSha.toLowerCase()) + // The tree Stryker would mutate is now the unit tree, not the merge tree. + assert.equal( + fs.readFileSync(path.join(repository, "packages/core/src/unit2.ts"), "utf8").replace(/\r/g, ""), + "export const unit2 = () => 2\n", + ) + // Aligning again is a no-op. + assert.equal(alignExecutionTree(repository, invocation.diffHead), false) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) +}) + +describe("aligned tree dependency manifests", () => { + const createManifestStack = () => { + const repository = fs.mkdtempSync(path.join(os.tmpdir(), "mutation-manifests-")) + try { + const run = (...args) => execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim() + const write = (filePath, contents) => { + fs.mkdirSync(path.join(repository, path.dirname(filePath)), { recursive: true }) + fs.writeFileSync(path.join(repository, filePath), contents) + } + + run("init", "--quiet", "--initial-branch", "main") + run("config", "user.email", "gate@example.com") + run("config", "user.name", "Gate") + run("config", "commit.gpgsign", "false") + + write("pnpm-lock.yaml", "lockfileVersion: 9\n") + write("packages/core/package.json", '{ "name": "@roo-code/core" }\n') + write("packages/core/src/unit1.ts", "export const unit1 = () => 1\n") + run("add", ".") + run("commit", "--quiet", "-m", "parent unit") + const parentSha = run("rev-parse", "HEAD") + + return { repository, parentSha, run, write } + } catch (error) { + fs.rmSync(repository, { recursive: true, force: true }) + throw error + } + } + + it("reports no drift when the stacked unit changes no dependency declaration", () => { + const { repository, parentSha, run, write } = createManifestStack() + + try { + write("packages/core/src/unit2.ts", "export const unit2 = () => 2\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 2 source change") + const childSha = run("rev-parse", "HEAD") + + // The toolchain installed from the merge commit is the toolchain the aligned tree declares, + // so nothing has to be reinstalled. + assert.deepEqual(dependencyManifestDrift(repository, parentSha, childSha), []) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) + + it("reports every dependency declaration a stacked unit changes under any package", () => { + const { repository, parentSha, run, write } = createManifestStack() + + try { + write("packages/core/package.json", '{ "name": "@roo-code/core", "version": "0.0.1" }\n') + run("add", ".") + run("commit", "--quiet", "-m", "unit 2 manifest change") + const manifestSha = run("rev-parse", "HEAD") + assert.deepEqual(dependencyManifestDrift(repository, parentSha, manifestSha), [ + "packages/core/package.json", + ]) + + write("pnpm-lock.yaml", "lockfileVersion: 9\nimporters: {}\n") + run("add", ".") + run("commit", "--quiet", "-m", "unit 3 lockfile change") + const lockfileSha = run("rev-parse", "HEAD") + assert.deepEqual(dependencyManifestDrift(repository, parentSha, lockfileSha), [ + "packages/core/package.json", + "pnpm-lock.yaml", + ]) + } finally { + fs.rmSync(repository, { recursive: true, force: true }) + } + }) +}) + describe("parseNameStatus", () => { it("parses added, modified, and renamed paths", () => { assert.deepEqual( @@ -937,6 +1628,61 @@ describe("failure output", () => { assert.equal(new Set(summary.match(/Package\dMutator\d+/g)).size, 6 * MAX_MUTANTS) assert.ok(Buffer.byteLength(summary) < 1024 * 1024) }) + + it("names the parent pull request when the unit was measured against a stacked base", () => { + const rows = [ + { + id: "extension", + changedLines: 12, + valid: 3, + killed: 3, + timeout: 0, + survived: 0, + noCoverage: 0, + blocking: [], + result: "Pass", + }, + ] + const summary = formatSummary(rows, [], { stackedOn: 1914 }) + assert.ok(summary.includes("Stacked unit: measured against the head of parent PR #1914, not the event base.")) + + // A plain pull request has no stacked base, so the notice must not appear. + const plain = formatSummary(rows, [], { stackedOn: null }) + assert.ok(!plain.includes("Stacked unit")) + assert.ok(plain.includes("## Changed-code mutation testing")) + }) + + it("prints the pull-request-head checkout a stacked reproduction needs", () => { + const rows = [ + { + id: "extension", + changedLines: 12, + valid: 3, + killed: 3, + timeout: 0, + survived: 0, + noCoverage: 0, + blocking: [], + result: "Pass", + }, + ] + const baseSha = "a".repeat(40) + const prHeadSha = "b".repeat(40) + + // CI aligned the working tree to the pull request head before mutating, so the reproduction has + // to say so; otherwise the same command selects the unit's lines from the unit diff but mutates + // the merge-commit tree. + const stacked = formatSummary(rows, [], { stackedOn: 1914, baseSha, headSha: prHeadSha }) + assert.ok(stacked.includes("checked out at the pull request head")) + assert.ok(stacked.includes("git checkout " + prHeadSha)) + assert.ok(stacked.includes("node scripts/stryker-diff.mjs ci --base " + baseSha + " --head " + prHeadSha)) + + // A plain pull request was measured on the merge commit, which is what CI already has checked + // out, so no checkout step belongs in its reproduction. + const plain = formatSummary(rows, [], { stackedOn: null, baseSha, headSha: "c".repeat(40) }) + assert.ok(!plain.includes("git checkout")) + assert.ok(plain.includes("node scripts/stryker-diff.mjs ci --base " + baseSha + " --head " + "c".repeat(40))) + }) }) describe("report evaluation", () => {