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/humble-coats-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@systemfsoftware/stryker-js": minor
---

A failed run's record reaches every surface: the Mutation Server Protocol answers engine failures with the record in error.data and keeps serving, MCP rerun_mutant returns a RerunEngineUnusable refusal carrying it and the new get_failure tool reads reports/mutation/failure.json, a configured sarif reporter writes a SARIF log whose invocation is executionSuccessful false with one notification per record, and the run log no longer prints an orphan 'Possible causes' block or the bundled parser's WASI ExperimentalWarning.
2 changes: 1 addition & 1 deletion packages/stryker-js/etc/stryker-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export namespace ImportClosure {

// @public (undocumented)
export namespace Mcp {
export { McpServerOptions, MutantDetail, MutantUnusable, RerunMutantFailure, ShowMutantFailure, mcpServerLayer, mcpToolkit };
export { FailureLookup, GetFailure, McpServerOptions, MutantDetail, MutantUnusable, RerunEngineUnusable, RerunMutantFailure, ShowMutantFailure, mcpServerLayer, mcpToolkit };
}

// @public (undocumented)
Expand Down
81 changes: 67 additions & 14 deletions packages/stryker-js/src/Mcp/mcp-server.cell.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,45 @@
import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract'
import { FailureRecord, RunEvent } from '@systemfsoftware/stryker-js-cli-contract'
import { Mutant, type Options, Report } from '@systemfsoftware/stryker-js-plugin-interface'
import { McpProtocol, McpServer } from 'effect/ai'
import * as Arr from 'effect/Array'
import * as Cause from 'effect/Cause'
import * as Effect from 'effect/Effect'
import * as Exit from 'effect/Exit'
import * as FileSystem from 'effect/FileSystem'
import * as Layer from 'effect/Layer'
import * as Match from 'effect/Match'
import * as Option from 'effect/Option'
import * as Path from 'effect/Path'
import * as Queue from 'effect/Queue'
import * as Result from 'effect/Result'
import * as S from 'effect/Schema'

import { ReproducerSchema } from '../build-reproducers.workflow.js'
import { classifyRunOutcome, type RunOutcomeDecision } from '../classify-run-outcome.workflow.js'
import { runOutcomeCommandOf } from '../conclude-run.js'
import type { ConfigReadError } from '../ConfigError.schema.js'
import { recordFeedbackCell } from '../Feedback/Feedback.cell.js'
import { FeedbackUnusable } from '../Feedback/Feedback.schema.js'
import { readMutationReport, readSurfacedSurvivors } from '../Feedback/read-report.js'
import { ResolvedMode } from '../output-mode.schema.js'
import { MachineConsole } from '../reporting/machine-console.service.js'
import { mutantRerunAdmissionCell, RerunRefused } from '../Rerun/mod.js'
import { mutantRerunAdmissionCell, RerunEngineUnusable, RerunRefused } from '../Rerun/mod.js'
import { mutantDetailEventsOf } from '../Rerun/rerun-selection.js'
import type { MutationTestDone } from '../run/mutation-test.cell.js'
import { mutationTestCell } from '../run/run-stages.cell.js'
import { RunEnvironment, type RunEnvironmentShape } from '../run/RunEnvironment.service.js'
import type { EnginePorts } from '../run/StageServices.service.js'
import { FAILURE_RECORD_FILE } from '../stryker-outputs.js'
import { StrykerPackage } from '../stryker-package.schema.js'
import { mcpToolkit } from './mcp-tools.js'
import { MutantDetail, MutantUnusable, RerunUnusable } from './mcp-tools.schema.js'
import { FailureLookup, MutantDetail, MutantUnusable, RerunMutantFailure, RerunUnusable } from './mcp-tools.schema.js'

const MCP_RUN_ID = RunEvent.RunId.make('01ARZ3NDEKTSV4RRFFQ69G5FAM')

const REPRODUCERS_FILE = 'reports/mutation/reproducers.json'

const MCP_SERVER_INSTRUCTIONS =
'Stryker mutation testing. Use list_survivors to read the surfaced survivors of the finished report, show_mutant to inspect one, rerun_mutant to re-run one through the run pipeline, and report_usefulness to record whether a survivor was worth acting on.'
'Stryker mutation testing. Use list_survivors to read the surfaced survivors of the finished report, show_mutant to inspect one, rerun_mutant to re-run one through the run pipeline, report_usefulness to record whether a survivor was worth acting on, and get_failure to read the failure record the last failed run left behind.'

const MCP_MODE = ResolvedMode.make({ mode: 'machine', signal: 'tool', stdoutIsTTY: false })

Expand Down Expand Up @@ -123,10 +129,30 @@ const restrictedOptionsOf = ({
incremental: true,
})

const engineDecisionOf = <A, E>(exit: Exit.Exit<A, E>, basePath: string): RunOutcomeDecision =>
Result.getOrElse(
classifyRunOutcome(runOutcomeCommandOf({ exit, argv: [], cwd: basePath, traceId: null })),
(impossible) => impossible,
)

const engineRefusalOf = <A, E>(
exit: Exit.Exit<A, E>,
basePath: string,
): Option.Option<RerunEngineUnusable> =>
Match.value(engineDecisionOf(exit, basePath)).pipe(
Match.tag('RunFailed', (failed) =>
Option.some(RerunEngineUnusable.make({ code: failed.code, record: failed.record }))),
Match.orElse(() =>
Option.none<RerunEngineUnusable>()
),
)

const NO_RESULTS: MutationTestDone = { results: [], verdict: null }

const runRestricted = (
basePath: string,
options: Options.PartialStrykerOptions,
): Effect.Effect<MutationTestDone, never, EnginePorts> =>
): Effect.Effect<MutationTestDone, RerunEngineUnusable, EnginePorts> =>
Effect.gen(function*() {
const queue = yield* Queue.bounded<RunEvent.RunEvent, Cause.Done>(RunEvent.RunEvent.QUEUE_BOUND)
const environment: RunEnvironmentShape = {
Expand All @@ -141,13 +167,23 @@ const runRestricted = (
RunEnvironment.stage(environment, queue),
MachineConsole.captureLayer.pipe(Layer.provide(MachineConsole.layer)),
)
return yield* mutationTestCell
.run({ cliOptions: options, targetMutatePatterns: undefined })
.pipe(Effect.provide(runLayer), Effect.scoped, Effect.orDie)
const exit = yield* Effect.exit(
mutationTestCell
.run({ cliOptions: options, targetMutatePatterns: undefined })
.pipe(Effect.provide(runLayer), Effect.scoped),
)
return yield* Exit.match(exit, {
onSuccess: (done) => Effect.succeed(done),
onFailure: (cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Option.match(engineRefusalOf(Exit.failCause(cause), basePath), {
onNone: () => Effect.die(new Error('an engine failure did not classify as a run failure')),
onSome: (refusal) => Effect.fail(refusal),
}),
})
})

const NO_RESULTS: MutationTestDone = { results: [], verdict: null }

const resultsOf = (settled: void | MutationTestDone): ReadonlyArray<Mutant.RunMutantResult> =>
(settled ?? NO_RESULTS).results

Expand All @@ -162,14 +198,20 @@ const reportedOf = (
const reasonOfRerun = (error: RerunRefused | ConfigReadError): string =>
S.is(RerunRefused)(error) ? error.reason : error.message

const rerunFailureOf =
(id: Mutant.MutantId) => (error: RerunRefused | ConfigReadError | MutantUnusable): MutantUnusable | RerunUnusable =>
S.is(MutantUnusable)(error) ? error : RerunUnusable.make({ id, reason: reasonOfRerun(error) })
const isRerunRefusal = (error: unknown): error is MutantUnusable | RerunEngineUnusable =>
S.is(MutantUnusable)(error) || S.is(RerunEngineUnusable)(error)

const rerunFailureOf = (
id: Mutant.MutantId,
) =>
(
error: RerunRefused | ConfigReadError | MutantUnusable | RerunEngineUnusable,
): RerunMutantFailure => isRerunRefusal(error) ? error : RerunUnusable.make({ id, reason: reasonOfRerun(error) })

const rerunMutant = (
basePath: string,
id: Mutant.MutantId,
): Effect.Effect<MutantDetail, MutantUnusable | RerunUnusable, EnginePorts> =>
): Effect.Effect<MutantDetail, RerunMutantFailure, EnginePorts> =>
Effect.gen(function*() {
const settled = yield* mutantRerunAdmissionCell.run({
ids: [id],
Expand All @@ -196,13 +238,24 @@ const rerunMutant = (
}
})

const readFailure = (basePath: string): Effect.Effect<FailureLookup, never, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const text = yield* fs.readFileString(path.resolve(basePath, FAILURE_RECORD_FILE)).pipe(
Effect.orElseSucceed(() => ''),
)
return { failure: Option.getOrNull(S.decodeOption(FailureRecord.FailureRecordFile)(text)) }
})

const handlers = (basePath: string) =>
mcpToolkit.toLayer({
list_survivors: () => readSurfacedSurvivors(basePath),
show_mutant: ({ id }) => showMutant(basePath, id),
rerun_mutant: ({ id }) => rerunMutant(basePath, id),
report_usefulness: ({ id, judgment, reason }) =>
recordFeedbackCell({ basePath, id, judgment, reason: reason ?? null }),
get_failure: () => readFailure(basePath),
})

export const mcpServerLayer = ({ basePath }: McpServerOptions) =>
Expand Down
10 changes: 9 additions & 1 deletion packages/stryker-js/src/Mcp/mcp-tools.schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract'
import { Mutant } from '@systemfsoftware/stryker-js-plugin-interface'
import * as S from 'effect/Schema'

import { FeedbackUnusable } from '../Feedback/Feedback.schema.js'
import { RerunEngineUnusable } from '../Rerun/rerun.schema.js'

export const MutantDetail = S.Struct({
id: Mutant.MutantId,
Expand Down Expand Up @@ -31,6 +33,12 @@ export class RerunUnusable extends S.TaggedError<RerunUnusable>()('RerunUnusable
}
}

export const FailureLookup = S.Struct({ failure: S.NullOr(FailureRecord.FailureRecord) })

export type FailureLookup = typeof FailureLookup.Type

export const ShowMutantFailure = S.Union([MutantUnusable, FeedbackUnusable])

export const RerunMutantFailure = S.Union([MutantUnusable, RerunUnusable])
export const RerunMutantFailure = S.Union([MutantUnusable, RerunUnusable, RerunEngineUnusable])

export type RerunMutantFailure = typeof RerunMutantFailure.Type
11 changes: 9 additions & 2 deletions packages/stryker-js/src/Mcp/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { FeedbackUnusable } from '../Feedback/Feedback.schema.js'
import { GitDiff } from '../git-diff.service.js'
import { SurvivorRef } from '../surfacing.schema.js'
import { WorkerLauncher } from '../WorkerLauncher.service.js'
import { MutantDetail, RerunMutantFailure, ShowMutantFailure } from './mcp-tools.schema.js'
import { FailureLookup, MutantDetail, RerunMutantFailure, ShowMutantFailure } from './mcp-tools.schema.js'

const reportDependencies = [FileSystem.FileSystem, Path.Path]

Expand Down Expand Up @@ -63,4 +63,11 @@ export const ReportUsefulness = Tool.make('report_usefulness', {
dependencies: reportDependencies,
})

export const mcpToolkit = Toolkit.make(ListSurvivors, ShowMutant, RerunMutant, ReportUsefulness)
export const GetFailure = Tool.make('get_failure', {
description:
'Read the failure record the last finished run wrote to reports/mutation/failure.json: its stable code, evidence, reproduction and next action. Answers null when no failed run left a record.',
success: FailureLookup,
dependencies: reportDependencies,
})

export const mcpToolkit = Toolkit.make(ListSurvivors, ShowMutant, RerunMutant, ReportUsefulness, GetFailure)
11 changes: 9 additions & 2 deletions packages/stryker-js/src/Mcp/mod.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export { RerunEngineUnusable } from '../Rerun/rerun.schema.js'
export { mcpServerLayer, type McpServerOptions } from './mcp-server.cell.js'
export { mcpToolkit } from './mcp-tools.js'
export { MutantDetail, MutantUnusable, RerunMutantFailure, ShowMutantFailure } from './mcp-tools.schema.js'
export { GetFailure, mcpToolkit } from './mcp-tools.js'
export {
FailureLookup,
MutantDetail,
MutantUnusable,
RerunMutantFailure,
ShowMutantFailure,
} from './mcp-tools.schema.js'
3 changes: 2 additions & 1 deletion packages/stryker-js/src/Rerun/Rerun.cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { MutationTestDone } from '../run/mutation-test.cell.js'
import type { EnginePorts } from '../run/StageServices.service.js'
import { PriorReportDocument } from '../Survivors/Survivors.schema.js'
import { admitMutantRerun, type PriorMutantShape, RerunRefused } from './admit-mutant-rerun.workflow.js'
import type { RerunEngineUnusable } from './rerun.schema.js'

export type MutantRerunSettled = void | MutationTestDone

Expand All @@ -23,7 +24,7 @@ export interface MutantRerunRun {
}

export interface MutantRerunSettlement {
readonly runAdmitted: (run: MutantRerunRun) => Effect.Effect<MutantRerunSettled, never, EnginePorts>
readonly runAdmitted: (run: MutantRerunRun) => Effect.Effect<MutantRerunSettled, RerunEngineUnusable, EnginePorts>
}

export interface MutantRerunInput {
Expand Down
1 change: 1 addition & 0 deletions packages/stryker-js/src/Rerun/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export {
type MutantRerunSettled,
type MutantRerunSettlement,
} from './Rerun.cell.js'
export { RerunEngineUnusable } from './rerun.schema.js'
8 changes: 8 additions & 0 deletions packages/stryker-js/src/Rerun/rerun.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { FailureRecord } from '@systemfsoftware/stryker-js-cli-contract'
import { Plugin } from '@systemfsoftware/stryker-js-plugin-interface'
import * as S from 'effect/Schema'

export class RerunEngineUnusable extends S.TaggedClass<RerunEngineUnusable>()('RerunEngineUnusable', {
code: Plugin.ExitCode,
record: FailureRecord.FailureRecord,
}) {}
Loading
Loading