Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-bananas-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js": minor
---

stryker annotate also renders the GitHub annotations of reports/mutation/failure.json when a run failed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
title: A failure record is the only rendering of a failure; nothing next to it may restate it
date: 2026-10-02
category: best-practices
module: stryker-js failure reporting
problem_type: best_practice
component: tooling
severity: high
applies_when:
- adding a new failure path that raises a `RunFailure` or a tagged error with an `evidence` getter
- writing the `message` of an error class that also declares evidence
- adding stderr logging, CI summary text or annotations about a failed run
- asserting failure output in tests or e2e journeys
symptoms:
- "A gate refusal printed the new survivor id four times on stderr: a logError line, the record's evidence, the carrier's message and the carrier's stack header"
- "A failed dry run printed `Initial test run failed ...` from a logError before the record said the same thing"
- "A dry-run cause link dumped the whole `DryRunFailed` decision object, stack included, through the Formatter fallback"
- "The CI summary called a vacuous-property dry-run failure an infrastructure failure"
tags:
- failure-record
- diagnostics
- stderr
- cause-chain
---

# A failure record is the only rendering of a failure; nothing next to it may restate it

## Context

A failed run ends in one `FailureRecord.FailureRecord` from `@systemfsoftware/stryker-js-cli-contract`. That record is printed on stderr through `terminalTextOf`, emitted as the schema-3.0 `error` stream event, written to the run's `failure.json` under its mutation reports directory (`FAILURE_RECORD_FILE`), and turned into the CI summary and annotations by `markdownOf`/`annotationsOf` (`render-failure.ts`). Stack PRs #146-#151 introduced it after mutation run 36935602456 reported a vacuous in-source property in the dry run as an "infrastructure failure (missing binary, crashed run or timeout)".

The cutover worked on the first pass but still produced noisy, contradictory output, because old renderings survived next to the record.

## Guidance

1. **No log line about a failure the record carries.** A cell that fails with a `RunFailure` must not also `Effect.logError` the same fact. The removed offenders were `explainGateRefusal` in `run-request.cell.ts` and the `Initial test run failed. N of M test(s) failed` log in `dry-run.cell.ts`. Integration tests that captured those logs were pinning a duplicate; assert on the evidence instead.
2. **An error that declares evidence keeps a one-line summary `message`.** The record renders the evidence. If the message lists the same data, every cause link repeats it. `GateRejected.message` is now `stryker gate: N new survivor(s) absent from the committed baseline`, and the survivor list lives only in `NewSurvivors.survivors`.
3. **`RunFailure.cause` holds a real error, never the decision data.** `writeDryRunFailed` used to pass the `DryRunFailed` decision as the cause. `messageOf` in `conclude-run.ts` falls back to `Formatter.format` for non-Error objects, so the whole decision, stacks included, landed in the cause chain.
4. **A cause link's stack keeps only its `at` frames.** A V8 stack starts with `Name: message`, so storing it whole prints the message twice. `linkOf` in `conclude-run.ts` drops the lines before the first `at` frame.
5. **Stacks show on the terminal only when nothing else locates the failure.** `terminalTextOf` and `markdownOf` print cause stacks only for `CatalogGap` records, and a failed test's stack only when it has no `location`. `failure.json` keeps every stack.
6. **Paths in evidence are project-relative.** The runner reports paths inside `.stryker-tmp/sandbox-*`, which is deleted after the run. `projectTestOf` in `dry-run.cell.ts` relativizes the test `file` and strips the sandbox prefix from the stack. The Vitest runner names file-level failures by their project-relative path.
7. **CI text comes only from the record.** The mutation job's `buildSummary` and `buildRequireError` (`mutation-plan.ts`) render the stream's terminal record, or build `RecordMissing`, `JobTimedOut` or `BinaryMissing` through `recordFor` when no record exists. A summary label must never replace the outcome: "evaluated no mutants" is appended to "failure", never shown instead of it.

## Why This Matters

Each duplicate is a second, drifting description of the same failure. An agent reading stderr cannot tell which line is authoritative. Duplicated or missing data also breaks the exact-count assertions that consumers write: the gate test counts survivor ids, and annotations must cover exactly the located evidence. And output that claims a cause the run never had sends the reader to fix the wrong thing.

## When to Apply

- Any new code path that ends a run in failure, or any new error class with an `evidence` getter.
- Any change to `render-failure.ts`, `conclude-run.ts` (`linkOf`, `messageOf`), or the CI scripts' summary.

## Examples

Gate refusal on stderr, before (one id, four times):

```text
ERROR (#1): stryker gate: 1 new survivor(s) absent from the committed baseline:
src/sum.js:4 4d4d4d4d4d4d4d4d
...
GateRejected: stryker gate: 1 new survivor(s) absent from the committed baseline:
src/sum.js:4 4d4d4d4d4d4d4d4d
GateRejected: stryker gate: ... <- stack header repeating the message
```

After:

```text
NewSurvivors: Mutants the change can affect survived every test and are absent from the accepted survivor baseline.
survivors: 1 listed, 2 unchecked
src/sum.js:4 4d4d4d4d4d4d4d4d
GateRejected: stryker gate: 1 new survivor(s) absent from the committed baseline
next: killSurvivor
```

## Related

- `docs/plans/2026-10-01-2306-feat-agent-ready-failure-diagnostics-plan.md`
- `docs/solutions/test-failures/stream-schema-must-not-carry-json-rest-records.md`
20 changes: 15 additions & 5 deletions packages/stryker-js/src/render-annotations.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class RenderAnnotationsCommand extends S.TaggedClass<RenderAnnotationsCom
report: Report.MutationTestResult,
survivors: S.Array(SurvivorRef),
baseline: S.Array(Mutant.MutantId),
failureAnnotations: S.String.pipe(S.Array, S.optional),
}) {
static readonly [Workflow.InstrumentationBrand] = {} as const
}
Expand Down Expand Up @@ -118,19 +119,28 @@ const lineOf = (entry: AnnotationEntry): string =>
].join(',')
}::${escapeMessage(messageOf(entry))}`

const linesOf = (command: RenderAnnotationsCommand): ReadonlyArray<string> => {
const byId = entriesByIdOf(command.report)
const baseline = HashSet.fromIterable(command.baseline)
const survivorLinesOf = (
report: Report.MutationTestResult,
survivors: ReadonlyArray<SurvivorRef>,
baseline: ReadonlyArray<Mutant.MutantId>,
): ReadonlyArray<string> => {
const byId = entriesByIdOf(report)
const baselineSet = HashSet.fromIterable(baseline)
return Arr.flatMap(
command.survivors,
survivors,
(ref) =>
Boolean.match(HashSet.has(baseline, ref.id), {
Boolean.match(HashSet.has(baselineSet, ref.id), {
onTrue: (): ReadonlyArray<string> => [],
onFalse: () => Arr.flatMap(Option.toArray(HashMap.get(byId, ref.id)), (entry) => [lineOf(entry)]),
}),
)
}

const linesOf = (command: RenderAnnotationsCommand): ReadonlyArray<string> => [
...survivorLinesOf(command.report, command.survivors, command.baseline),
...Option.fromUndefinedOr(command.failureAnnotations).pipe(Option.getOrElse((): ReadonlyArray<string> => [])),
]

const decide = (command: RenderAnnotationsCommand): RenderAnnotationsDecision => {
const lines = linesOf(command)
return Boolean.match(Arr.length(lines) === 0, {
Expand Down
20 changes: 20 additions & 0 deletions packages/stryker-js/src/run-request.cell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Cell, Sandwich } from '@systemfsoftware/effect-cell-types'
import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract'
import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract'
import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract'
import { Mutant, Report } from '@systemfsoftware/stryker-js-plugin-interface'
import type { Options } from '@systemfsoftware/stryker-js-plugin-interface'
import * as CliError from 'effect/cli/CliError'
Expand Down Expand Up @@ -61,6 +62,7 @@ import { mutationTestCell } from './run/run-stages.cell.js'
import { RunEnvironment } from './run/RunEnvironment.service.js'
import { serveMutationServer, type ServeRequest } from './Serve/Serve.cell.js'
import { StrykerError } from './stryker-error.schema.js'
import { FAILURE_RECORD_FILE } from './stryker-outputs.js'
import { annotationLinesOf, surfacedSurvivorsOf } from './surfacing.js'
import { type SurfacingCaps, SurfacingFields } from './surfacing.schema.js'
import type { SurvivorsAdmissionInput, SurvivorsSettlement } from './Survivors/mod.js'
Expand Down Expand Up @@ -385,6 +387,7 @@ const surfacingCapsOf = (report: Report.MutationTestResult): SurfacingCaps =>
Option.getOrElse(Option.map(surfacingFieldsOf(report), capsOf), () => SURFACING_DEFAULTS)

const decodeAnnotateReport = S.decodeUnknownResult(S.fromJsonString(Report.MutationTestResult))
const decodeAnnotateFailure = S.decodeUnknownOption(FailureRecord.FailureRecordFile)

const readAnnotateReport = (
file: string,
Expand All @@ -409,6 +412,21 @@ const readAnnotateReport = (
),
))

const readAnnotateFailureAnnotations = (
file: string,
): Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) =>
fs.readFileString(file).pipe(
Effect.asSome,
Effect.catchTag('PlatformError', () => Effect.succeed(Option.none<string>())),
Effect.map((text) =>
Option.flatMap(text, decodeAnnotateFailure).pipe(
Option.map(FailureRecord.annotationsOf),
Option.getOrElse((): ReadonlyArray<string> => []),
)
),
))

const readAnnotateBaseline = (
file: string,
): Effect.Effect<ReadonlyArray<Mutant.MutantId>, AnnotationsUnusable, FileSystem.FileSystem> =>
Expand All @@ -435,6 +453,7 @@ const annotateReport = (
const path = yield* Path.Path
const basePath = channel.environment.basePath
const report = yield* readAnnotateReport(path.resolve(basePath, GATE_REPORT_FILE))
const failureAnnotations = yield* readAnnotateFailureAnnotations(path.resolve(basePath, FAILURE_RECORD_FILE))
const baseline = yield* Effect.forEach(
Option.toArray(Option.fromUndefinedOr(annotate.baseline)),
(file) => readAnnotateBaseline(path.resolve(basePath, file)),
Expand All @@ -445,6 +464,7 @@ const annotateReport = (
report,
survivors: surfacedSurvivorsOf(report, surfacingCapsOf(report)),
baseline: baseline.flat(),
failureAnnotations,
}),
),
)
Expand Down
6 changes: 5 additions & 1 deletion scripts/deno.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"compilerOptions": {
"strict": true,
"noImplicitOverride": true
"noImplicitOverride": true,
"types": ["vitest/importMeta"]
},
"unstable": ["sloppy-imports"],
"minimumDependencyAge": {
"age": "P1D",
"exclude": ["npm:effect"]
Expand All @@ -19,6 +21,8 @@
"@std/fs/expand-glob": "jsr:@std/fs@1/expand-glob",
"@std/path": "jsr:@std/path@1",
"@std/yaml": "jsr:@std/yaml@1",
"@systemfsoftware/vitest": "npm:@systemfsoftware/vitest@^1.0.0",
"vitest": "npm:vitest@^5",
"effect": "npm:effect@^4.0.0",
"octokit": "npm:octokit@^4"
}
Expand Down
Loading
Loading