What a roundtrip guarantees
A roundtrip property says that decoding an encoded value gives back the original: decode(encode(x)) equals x for every valid x. It is the single most useful invariant for serializers, escapers, URL and slug helpers, base converters and anything else that converts between two representations. When it breaks, data is silently corrupted: a name loses a character, a path changes meaning, a record round-trips into a different record.
It is also one of the easiest properties to state, which makes it a good first step into property based testing without writing tests. The catch is on the input side. The values that break codecs are rarely the friendly ones in your unit tests. They are the delimiter itself, the escape character, the empty string, a lone surrogate, or a character that your encoder treats specially and your decoder does not.
A codec that looks correct
Here is a tiny codec in the style of many real escaping helpers. The encoder replaces spaces with underscores so the result is safe in an identifier, and the decoder reverses it.
export function encodeBase(input: string): string {
return input.replace(/ /g, '_');
}
export function decodeBase(input: string): string {
return input.replace(/_/g, ' ');
}
Every example you would normally write passes. encodeBase('hello world') returns 'hello_world', and decodeBase('hello_world') returns the original. The bug only shows up for input that already contains the output alphabet. If the original text contains an underscore, the encoder passes it through untouched, and the decoder then turns it into a space. Two different inputs, ' ' and '_', encode to the same string, so no decoder could possibly tell them apart. That is the real shape of a roundtrip failure: the encoding is not injective.
How breakhound finds the partner function
A roundtrip check needs to know which function inverts which. breakhound infers this from names. It looks for pairs such as encodeX and decodeX, or toY and fromY, that share a suffix, then generates inputs for the encoder and feeds the result to the decoder. In this codec the pair is encodeBase and decodeBase.
Naming conventions are the whole mechanism, so it pays to follow them:
encodeUrlanddecodeUrlare paired.encodeUrlandparseUriare not.toHexandfromHexare paired through the sharedHexsuffix.- If an inverse exists under an unrelated name, the roundtrip oracle will not see it. Renaming it, or adding a thin alias export, brings it into scope.
Because the match is by name, a result is reported with medium confidence. Some codecs are intentionally lossy, for example a decoder that normalizes its output, so treat a finding as a strong lead and decide whether the loss is by design.
Running the roundtrip oracle
Point breakhound at the package or folder. To focus on this check and its close relative, restrict the rules:
npx breakhound ./my-lib --rules roundtrip,not-idempotent
On the buggy-lib fixture, which contains exactly this codec, the plain output reports both rules:
breakhound v0.1.0 buggy-lib@1.0.0 (local)
23 functions analyzed, 1 skipped | 4444 calls | 6 time zones | seed 42 | 6.8s
[roundtrip] decodeBase(encodeBase(x)) does not return x (confidence medium)
encodeBase('_') -> '_'
decodeBase(encodeBase('_')) -> ' '
[not-idempotent] slugify is not idempotent (confidence medium)
slugify('x y') -> 'x_y'
slugify(slugify('x y')) -> 'x-y'
The finding names the minimal input ('_'), shows what the encoder produced and what came back. It also ships a reproduction script and a ready Vitest test you can commit as a regression guard:
import { expect, test } from 'vitest';
import { encodeBase, decodeBase } from 'buggy-lib';
test("decodeBase reverses encodeBase", () => {
const input = '_';
const encoded = encodeBase('_');
expect(decodeBase(encoded)).toEqual(input);
});
Fixing the codec
The cure for a non-injective encoding is to escape the escape. Before you introduce a new meaning for a character, make sure the original character can still be represented. A percent-style escape does that, and it must be decoded in the reverse order it was applied:
export function encodeBase(input: string): string {
return input.replace(/%/g, '%25').replace(/_/g, '%5F').replace(/ /g, '_');
}
export function decodeBase(input: string): string {
return input.replace(/_/g, ' ').replace(/%5F/g, '_').replace(/%25/g, '%');
}
Now the underscore is encoded as %5F and the percent sign as %25, so the only bare underscore left in encoded text means a space. Run breakhound again with the same seed and the roundtrip finding disappears. If you can, prefer a standard codec such as encodeURIComponent over a hand-rolled one.
The idempotence sibling
The same fixture shows the related not-idempotent oracle, which targets normalize-like functions. A normalizer should be a fixed point of itself: slugify(slugify(x)) should equal slugify(x). In the output above, slugify('x y') returns 'x_y' but a second pass returns 'x-y', which means stored slugs drift every time they are re-normalized.
Roundtrip and idempotence often fail together, because both come from a transformation that creates the very characters it later treats as special. If your library mixes text with Unicode, also read unicode surrogate pair bugs, since encoders that cut strings by index frequently produce lone surrogates that no decoder can recover. For the broader workflow, see finding bugs automatically in JavaScript.
Add --fail-on medium in CI so a new roundtrip regression fails the build. Findings are saved to .breakhound/report.json unless you pass --no-save.
FAQ
How does breakhound know which functions are a pair?
It matches names: encodeX with decodeX, and toY with fromY. Functions that invert each other under unrelated names are not paired, so follow the naming convention or export an alias.
What if my codec is intentionally lossy?
Then decode(encode(x)) will legitimately differ from x, and the finding is a false positive for that function. Roundtrip findings carry medium confidence for this reason, so review each one against the intended contract.
Which inputs does the roundtrip check try?
Values generated from the parameter types, including edge cases from the edgegoat package such as empty strings, emoji, lone surrogates and unusual delimiters. The run is seeded (default 42) so results are reproducible.
Do I have to write the test myself?
No. Each finding includes a minimal reproduction script and a ready Vitest test, like the decodeBase reverses encodeBase example above.
Can I run only the roundtrip check?
Yes, use --rules roundtrip. You can also narrow the functions with --only and --exclude, which accept comma separated names with * wildcards.