Why output diffing does not work
Two runs of one failing command rarely produce identical bytes. Timestamps, absolute paths, durations, addresses, PIDs, ports, colour codes, test ordering: all of it churns. Byte comparison reports a difference every run.
Loosening it to “the output changed” is worse. A patch that guards a value at the wrong layer turns a TypeError deep in the stack into a downstream assertion failure. The output changed. The defect did not.
/** * Extracts a structural fingerprint from an executed command's output. * * Comparing raw output across a patch is useless: timestamps, absolute paths, * durations, object addresses and test ordering all churn between runs. The * signature normalises those away so that "did this specific failure go away" * becomes a real question with a real answer. * * The distinction that matters most is between a failure that is GONE and one * that merely CHANGED SHAPE. A patch that turns a TypeError into an * AssertionError has not fixed anything, but its output differs from the * original, so any comparison based on "output changed" would call it fixed. */
Credda names that FAILURE_MUTATED, so it is never absorbed into “fixed” or “unchanged”.
What a signature holds
export interface FailureSignature {
readonly command: string;
readonly exitCode: number | null;
/** e.g. "TypeError", "AssertionError", "ENOENT". Null when not classifiable. */
readonly errorClass: string | null;
/** Normalized message: absolute paths, timestamps, hex addresses stripped. */
readonly normalizedMessage: string | null;
/** Innermost stack frame within the repository under investigation, if any. */
readonly originFile: string | null;
readonly originLine: number | null;
readonly failingTestIds: readonly string[];
readonly hash: string;
}Every field is nullable in the honest way. errorClass is null when the failure is not classifiable rather than guessed at; originFile is null when no stack frame belonged to the repository under investigation.
What gets normalised, and what does not
Each substitution names something that provably varies between two runs of one failure:
.replace(/[A-Za-z]:[\\/][^\s:)'"]+/g, '<path>')
.replace(/(?<![\w<])\/(?:[\w.@-]+\/)+[\w.@-]+/g, '<path>')
.replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/g, '<ts>')
.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b/g, '<time>')
.replace(/\b\d+(?:\.\d+)?\s?(?:ms|s|sec|seconds)\b/gi, '<dur>')
.replace(/\b0x[0-9a-fA-F]+\b/g, '<addr>')
.replace(/\b[0-9a-f]{32,}\b/g, '<hash>')The workspace root is stripped first, so a signature captured in a sandbox compares with one captured anywhere else. The restraint is the design decision: no lowercasing, no general number stripping, no truncation.
/** * Deliberately conservative: it removes things that provably vary between runs * and leaves everything else alone. Over-normalising would collapse genuinely * different failures into one signature, which is the more dangerous error. */
The errors are not symmetric. Under-normalising costs a false FAILURE_MUTATED, which fails safe. Over-normalising puts two different failures on one hash, hiding a new bug inside an old fingerprint.
Picking the frame a human would look at
The origin is the first frame belonging to the repository, not the innermost frame, which is usually library code. Two exclusion rules:
const NON_REPOSITORY_PATH = /(?:^|[\\/])(?:node_modules|internal|node:)[\\/]?/; /** * V8 synthetic frames: `[eval]`, `[eval]-wrapper`, `<anonymous>`, `[stdin]`. * * These look like ordinary paths to a naive check but name no real file. Left * unfiltered they can be selected as a failure's origin, producing a signature * that points at a file nobody can open. */ const SYNTHETIC_FRAME = /^[[<]|^[^\\/.]+$/;
[eval], <anonymous> and [stdin] parse as good paths and name no file that exists. Left in, they produce an origin nobody can open, which is what a node -e one-liner reproduction always gives you.
One field is excluded from the hash on purpose:
/**
* The column number is deliberately excluded: a fix on the same line commonly
* shifts columns without changing the failure, and including it would report
* spurious FAILURE_MUTATED results.
*/
const canonical = JSON.stringify({
command: signature.command,
errorClass: signature.errorClass,
normalizedMessage: signature.normalizedMessage,
originFile: signature.originFile,
originLine: signature.originLine,
failingTestIds: signature.failingTestIds,
});A guard clause added above the failing line shifts every column on it. Including the column would report a mutation on almost every patch, and a signal that fires constantly stops being read.
Three outcomes, not two
export function compareSignatures(
before: FailureSignature | null,
after: FailureSignature | null,
): SignatureComparison {
if (before === null && after === null) return 'INCOMPARABLE';
if (before === null) return 'NEW_FAILURE';
if (after === null) return 'FAILURE_RESOLVED';
if (before.command !== after.command) return 'INCOMPARABLE';
if (before.hash === after.hash) return 'FAILURE_UNCHANGED';
return 'FAILURE_MUTATED';
}A null after means the command succeeded: FAILURE_RESOLVED is the only shape of “fixed” this function produces. A different command gives INCOMPARABLE, not a verdict. Everything else, a different error class, origin line, normalised message or set of failing test IDs, is FAILURE_MUTATED, and cannot become a verified fix:
// A mutated failure is never a verified fix, whatever the other signals say.
if (comparison === 'FAILURE_MUTATED' && verdict === 'VERIFIED') {
verdict = 'PARTIALLY_VERIFIED';
notes.push('Downgraded from VERIFIED because the failure signature changed shape rather than resolving.');
}This is the one place a verdict is overridden after deriveVerdict() runs, and it only moves conservatively.
Where the signature lands
A captured signature becomes evidence with an ID, a phase and a strength, outliving the sandbox that produced it:
Every material claim cites an evidence ID. Evidence outlives the sandbox that produced it.
| ID | Type | Phase | Strength | Summary |
|---|---|---|---|---|
| ev-01 | Reproduction | before | Strong | TypeError: Cannot read properties of undefined (reading ‘toUpperCase’) · exit 1 |
| ev-02 | Stack Trace | before | Strong | Origin frame inside the repository: src/tax.js:17:23 in rateForCountry |
| ev-03 | Code Reference | independent | Moderate | rateForCountry() calls country.toUpperCase() with no guard on country |
| ev-04 | Test Result | after | Strong | Existing suite: 10 / 10 passing against the patched tree |
The limits
Signatures are only as good as what the failure printed. A crash gives a clean fingerprint: the committed run records TypeError at src/tax.js:17. A silent wrong-value bug does not. For pagination-off-by-one, where a reporter’s assertion is the only thing that fails, the recorded detail is AssertionError at ?:?: the throw happens inside node:assert and no repository frame is in the stack. That case’s grading deliberately does not require an origin file.
The stack-frame pattern and the synthetic-frame filter are V8 shapes. Other runtimes need their frame grammars written.