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
10 changes: 5 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## What Changed

<!-- A short list of what landed in this PR. -->
<!-- A short list of what changed in this PR. -->

## Why

Expand All @@ -12,10 +12,10 @@

## Test Plan

- [ ] `npm run lint` — clean
- [ ] `npm run typecheck` — clean
- [ ] `npm test` — N passed, N skipped
- [ ] `npm run build` — clean
- [ ] `npm run lint`: clean
- [ ] `npm run typecheck`: clean
- [ ] `npm test`: N passed, N skipped
- [ ] `npm run build`: clean

## Notes

Expand Down
4 changes: 2 additions & 2 deletions docs/execa.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ When the wrapper synthesizes a result on replay, it populates every documented `
| `isTerminated` | Derived: `signal !== null` |
| `isForcefullyTerminated` | Stored on capture; defaults to `false` when absent |
| `isGracefullyCanceled` | Stored on capture; defaults to `false` when absent |
| `killed` | Derived: `signal !== null` |
| `killed` | Stored on capture from execa's `r.killed`; if absent (older cassettes), derived from `signal !== null` |
| `pipedFrom` | Always `[]` (`.pipe()` is stubbed; see below) |
| `ipcOutput` | Always `[]` (`ipc: true` is rejected at validation) |

Other fields (`stdout`, `stderr`, `exitCode`, `signal`, `command`, `escapedCommand`, `durationMs`, optional `all`) round-trip as before. The reject branch throws synthesized `ExecaError` when the resolved `failed` is `true` and `reject !== false` (matching execa's default). The fallback derivation lets cassettes recorded before the `failed` field was added auto-upgrade their replay correctness — aborted and signal-killed calls now throw on replay even when the cassette was recorded before the flag existed.
Other fields (`stdout`, `stderr`, `exitCode`, `signal`, `command`, `escapedCommand`, `durationMs`, optional `all`) round-trip as before. The reject branch throws synthesized `ExecaError` when the resolved `failed` is `true` and `reject !== false` (matching execa's default). The fallback derivations let cassettes recorded before the `failed` and `killed` fields were stored auto-upgrade their replay correctness; aborted and signal-killed calls throw on replay even when the cassette predates the flag.

`durationMs` is wall-clock measured by shell-cassette's wrapper around the real subprocess.

Expand Down
4 changes: 1 addition & 3 deletions docs/tinyexec.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,12 @@ The cassette schema is narrower than tinyexec's runtime result. One mapping stil

- **`signal` (string vs boolean)**: tinyexec exposes `killed: boolean` but not the actual signal name. We unconditionally store `'SIGTERM'` on kill. The real signal name is lost.

`aborted` is preserved through record/replay (captured from tinyexec's `aborted: true` when AbortSignal triggered, synthesized back to `aborted` on replay).
`aborted` and `killed` are preserved through record/replay. Both fields live as getters on the pre-await `ExecProcess`; the adapter's `realCall` snapshots them before the `await` resolves so the cassette captures real values rather than `undefined` reads from the awaited `Output`.

## Failed flag

Tinyexec's `Output` does not expose a `failed` boolean. shell-cassette derives `failed` on capture from `exitCode !== 0 || killed || aborted` and stores it in the cassette. Replay surfaces the stored value; older cassettes recorded before the field was stored re-derive at replay time via the same formula. The `throwOnError` reject branch keys on the resolved value, so signal-killed and aborted replays throw under `throwOnError: true` even when the cassette predates the field.

Tinyexec's awaited `Output` also drops the `aborted` and `killed` getters that live on the pre-await `ExecProcess`. As a result, tinyexec cassettes currently record `aborted: false` regardless of whether the original call was canceled. Tracked in [#126](https://github.com/slgoodrich/shell-cassette/issues/126); replay-side handling is correct.

## Supported tinyexec options

| Option | Status |
Expand Down
8 changes: 4 additions & 4 deletions src/cli-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type Finding = {
rule: string
/** Raw match (always populated; review --json without --include-match strips it before emit). */
match: string
/** `sha256:<64 hex>` — used for skip-set membership across runs. */
/** `sha256:<64 hex>`. Used for skip-set membership across runs. */
matchHash: string
matchLength: number
/** First-4 + ellipsis + last-4 if length >= 12, else full match. */
Expand All @@ -78,7 +78,7 @@ const CONTEXT_RADIUS = 2
* 1. Each finding includes context lines surrounding the match for
* terminal display.
* 2. Matches whose hash appears in any recording's `suppressed` array
* are skipped — the user already chose to skip them.
* are skipped (the user already chose to skip them).
*/
export function preScan(cassette: CassetteFile, config: Readonly<RedactConfig>): Finding[] {
const skipSet = collectSuppressedHashes(cassette)
Expand Down Expand Up @@ -273,7 +273,7 @@ export type ReviewState = {
/**
* Stack of cursors saved before each forward advance. `back` pops from
* here so multi-step jumps (e.g. `delete`'s contiguous-recording skip)
* unwind to the actual prior decision point — not just `cursor - 1`.
* unwind to the actual prior decision point, not just `cursor - 1`.
*/
readonly history: readonly number[]
readonly decisions: ReadonlyMap<string, Decision>
Expand Down Expand Up @@ -303,7 +303,7 @@ export type ReviewAction =
* - back: pop the prior cursor from history and rewind to it, removing
* every decision recorded in the unwound range so the user must
* re-decide. Empty history is a no-op (start of review or already
* fully unwound). Allowed from 'confirming' too — pops back to
* fully unwound). Allowed from 'confirming' too; pops back to
* 'reviewing' at the last decided finding.
* - quit: transition to 'aborted' (decisions discarded by caller).
*
Expand Down
45 changes: 45 additions & 0 deletions src/execa-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Result } from './types.js'

/**
* Internal: maps an execa raw result (or thrown error shape) to the cassette
* `Result` type. Lives in a non-exported module so it stays out of the
* `shell-cassette/execa` public sub-path's `.d.ts` and IDE autocomplete.
* Tests import this module directly via `src/execa-capture.js`.
*/
export function captureResult(raw: unknown, durationMs: number): Result {
const r = raw as {
stdout?: string | string[]
stderr?: string | string[]
all?: string | string[]
exitCode?: number
signal?: string | null
isCanceled?: boolean
killed?: boolean
failed?: boolean
timedOut?: boolean
isMaxBuffer?: boolean
isForcefullyTerminated?: boolean
isGracefullyCanceled?: boolean
}
return {
stdoutLines: toLines(r.stdout),
stderrLines: toLines(r.stderr),
allLines: r.all === undefined ? null : toLines(r.all),
exitCode: r.exitCode ?? 0,
signal: r.signal ?? null,
durationMs,
aborted: r.isCanceled === true,
killed: r.killed === true,
failed: r.failed === true,
timedOut: r.timedOut === true,
isMaxBuffer: r.isMaxBuffer === true,
isForcefullyTerminated: r.isForcefullyTerminated === true,
isGracefullyCanceled: r.isGracefullyCanceled === true,
}
}

function toLines(input: string | string[] | undefined): string[] {
if (input === undefined) return ['']
if (Array.isArray(input)) return [...input, '']
return input.split('\n')
}
70 changes: 27 additions & 43 deletions src/execa.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Options, ResultPromise } from 'execa'
import { MissingPeerDependencyError, UnsupportedOptionError } from './errors.js'
import { captureResult } from './execa-capture.js'
import { readInputFile } from './io.js'
import { validateOptions } from './options-execa.js'
import type { Call, Recording, Result } from './types.js'
import type { Call, Recording } from './types.js'
import { type RunnerHooks, runWrapped } from './wrapper.js'

// Reused across every synth call. `pipe` and async iteration always
Expand Down Expand Up @@ -69,11 +70,32 @@ export function execaNode(
const execaHooks: RunnerHooks<Options, unknown> = {
validate: (opts) => validateOptions(opts as Record<string, unknown> | undefined),
buildCall,
realCall: (file, args, options) => realExeca(file, args, options) as unknown as Promise<unknown>,
realCall,
captureResult,
synthesize,
}

// When buildCall already resolved `inputFile` into `Call.stdin`, swap the
// option so real execa consumes the resolved string via `input` instead of
// re-reading the file. The branch only fires on the record path
// (passthrough/no-session pass `resolvedStdin: undefined`). buildCall's
// contract: when inputFile is defined, stdin is always a string (possibly
// empty), never null, so a string-typeof check covers the optimization
// case without a redundant null branch.
function realCall(
file: string,
args: readonly string[],
options: Options,
resolvedStdin: string | null | undefined,
): Promise<unknown> {
let opts = options
if (typeof resolvedStdin === 'string' && options.inputFile !== undefined) {
const { inputFile: _, ...rest } = options as Options & { inputFile?: unknown }
opts = { ...rest, input: resolvedStdin } as Options
}
return realExeca(file, args, opts) as unknown as Promise<unknown>
}

async function buildCall(file: string, args: readonly string[], options: Options): Promise<Call> {
// Validator already rejected the invalid shapes (Uint8Array/Readable input,
// input+inputFile conflict). At this point `input` is either undefined,
Expand All @@ -96,46 +118,6 @@ async function buildCall(file: string, args: readonly string[], options: Options
}
}

function captureResult(raw: unknown, durationMs: number): Result {
const r = raw as {
stdout?: string | string[]
stderr?: string | string[]
all?: string | string[]
exitCode?: number
signal?: string | null
isCanceled?: boolean
failed?: boolean
timedOut?: boolean
isMaxBuffer?: boolean
isForcefullyTerminated?: boolean
isGracefullyCanceled?: boolean
}
return {
stdoutLines: toLines(r.stdout),
stderrLines: toLines(r.stderr),
allLines: r.all === undefined ? null : toLines(r.all),
exitCode: r.exitCode ?? 0,
signal: r.signal ?? null,
durationMs,
aborted: r.isCanceled === true,
failed: r.failed === true,
timedOut: r.timedOut === true,
isMaxBuffer: r.isMaxBuffer === true,
isForcefullyTerminated: r.isForcefullyTerminated === true,
isGracefullyCanceled: r.isGracefullyCanceled === true,
}
}

// Test-only export so unit tests can drive captureResult without spawning
// a real subprocess. Underscore prefix marks it as non-public API.
export const _captureResultForTesting = captureResult

function toLines(input: string | string[] | undefined): string[] {
if (input === undefined) return ['']
if (Array.isArray(input)) return [...input, '']
return input.split('\n')
}

// Resolve execa's `lines` option to per-stream booleans. The object form lets
// users set `lines` independently per fd, e.g. `{ stdout: true, stderr: false }`.
// Precedence matches execa's own resolution (see node_modules/execa/types/
Expand Down Expand Up @@ -211,7 +193,9 @@ function synthesize(rec: Recording, options: Options): unknown {
isTerminated,
isForcefullyTerminated: rec.result.isForcefullyTerminated ?? false,
isGracefullyCanceled: rec.result.isGracefullyCanceled ?? false,
killed: isTerminated, // NOTE: tracked as semantic bug in #129 — preserves current behavior
// Stored value when present; fall back to isTerminated for legacy
// cassettes recorded before `killed` was captured separately.
killed: rec.result.killed ?? isTerminated,

// pipedFrom: no chained subprocess on replay. ipcOutput: ipc: true
// is rejected at validation.
Expand Down
4 changes: 2 additions & 2 deletions src/redact-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function seedCountersFromCassette(cassette: CassetteFile): Map<string, nu

// Source 1: per-recording _redactions metadata. Each entry.count is the
// number of placeholder occurrences within that single recording. The
// cassette-wide ceiling is the SUM across recordings — counters are
// cassette-wide ceiling is the SUM across recordings; counters are
// monotonic and per-(source, rule) globally.
for (const rec of cassette.recordings) {
for (const entry of rec.redactions) {
Expand Down Expand Up @@ -326,7 +326,7 @@ function walkStringsForPlaceholders(
/**
* Synthetic rule name for the recorder's curated env-key-match path
* (env values whose key name matches the CURATED_ENV_KEYS or user-supplied
* envKeys list). Not in BUNDLED_PATTERNS — the env-key pathway bypasses the
* envKeys list). Not in BUNDLED_PATTERNS; the env-key pathway bypasses the
* regex pipeline since the whole value is sensitive.
*/
export const ENV_KEY_MATCH_RULE = 'env-key-match'
Expand Down
5 changes: 3 additions & 2 deletions src/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const REVIEW_WARNING =

/**
* Serialize a CassetteFile to JSON. Always emits SCHEMA_VERSION (currently 2)
* regardless of `file.version` — serialize is upgrade-on-write. A v1 cassette
* regardless of `file.version`; serialize is upgrade-on-write. A v1 cassette
* passed in is materialized as v2 on disk; deserialize handles the inverse
* by accepting v1 input and normalizing missing fields.
*
Expand Down Expand Up @@ -64,6 +64,7 @@ function orderRecording(rec: Recording) {
...(rec.result.isGracefullyCanceled !== undefined && {
isGracefullyCanceled: rec.result.isGracefullyCanceled,
}),
...(rec.result.killed !== undefined && { killed: rec.result.killed }),
},
_redactions: rec.redactions,
...(rec.suppressed.length > 0 ? { _suppressed: rec.suppressed } : {}),
Expand Down Expand Up @@ -144,7 +145,7 @@ export function deserialize(text: string): CassetteFile {
}
}

// Fields added after the v2 schema landed are optional on disk;
// Fields added after v2 schema initial release are optional on disk;
// normalizeRecording fills defaults.
type LegacyRecording = {
call: Recording['call']
Expand Down
43 changes: 43 additions & 0 deletions src/tinyexec-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Result } from './types.js'

/**
* Internal: maps a tinyexec raw result (with the OutputApi `aborted`/`killed`
* snapshot the wrapper takes pre-await) to the cassette `Result` type. Lives
* in a non-exported module so it stays out of the `shell-cassette/tinyexec`
* public sub-path's `.d.ts` and IDE autocomplete. Tests import this module
* directly via `src/tinyexec-capture.js`.
*
* tinyexec exposes only `killed: boolean` (not the actual signal name); we
* unconditionally record `signal: 'SIGTERM'` when killed. The real signal
* name is not recoverable from tinyexec's surface.
*/
export function captureResult(raw: unknown, durationMs: number): Result {
const r = raw as {
stdout?: string
stderr?: string
exitCode?: number
killed?: boolean
aborted?: boolean
}
// tinyexec.stdout/stderr is always a string per its type contract; the [''] fallback
// exists only for defensive narrowing on `unknown` raw input (e.g., a thrown error
// shape that happens to lack stdout/stderr fields).
const exitCode = r.exitCode ?? 0
const killed = r.killed === true
const aborted = r.aborted === true
return {
stdoutLines: typeof r.stdout === 'string' ? r.stdout.split('\n') : [''],
stderrLines: typeof r.stderr === 'string' ? r.stderr.split('\n') : [''],
allLines: null,
exitCode,
signal: killed ? 'SIGTERM' : null,
durationMs,
aborted,
killed,
// Derived because tinyexec does not expose a `failed` boolean. Covers
// the three known failure shapes (non-zero exit, signal kill, abort).
// timedOut and isMaxBuffer are intentionally not stored: tinyexec
// exposes neither; synth defaults each to false on replay.
failed: exitCode !== 0 || killed || aborted,
}
}
Loading
Loading