Rearchitecture step 3 of 6. #54, #55, #48, #18 and #41 have all landed. This and #45 are the last two open.
Status: the panic half of this ticket already shipped
This ticket was filed at 19:01 UTC on 2026-08-07. Three hours later 4d5dc06 landed, closing #17 — and delivering this ticket's entire "Suggested fix" as a side effect. Nobody updated the body, so it read as unstarted work for three days.
At HEAD, all four fallible constructors already return (Assertion, error) — AssertHeaderMatch (assertions.go:89), AssertBodyMatch (:277), AssertRedirectMatch (:324) and AssertJQ (:164). regexp.MustCompile is gone from the source, and .golangci.yml now bans it outright via forbidigo. A bad pattern reports the way every other invalid flag value does:
$ http-assert --assert-body '[unclosed' https://example.com
Error: Invalid value for --assert-body flag: error parsing regexp: missing closing ]: `[unclosed`
[exit=71]
(The original ticket predicted 103; exit codes were collapsed to invocation/transport/assertion in 2f4b3e1 afterwards.)
Covered at both tiers: assertions_test.go:574, e2e_panic_test.go:18-19, e2e_assert_test.go:487-497.
Everything below is what actually remains.
Problem
Assertion is an opaque function returning error, so a failure can only ever be a string:
type Assertion func(res *httpResponse) error
There is nothing to serialize, which is why machine-readable output (#45) can't be added cheaply.
The error return also conflates two outcomes that are not the same thing:
- The assertion did not hold —
body: expected "x", got "y" (assertions.go:270 and ~15 siblings)
- The assertion could not be evaluated —
body: response is br-encoded and was not decoded (:121), a gojq runtime error (:209), a body that isn't JSON (:189)
A consumer of --json wants to tell those apart. Today nothing can.
Solution
Give Assertion an identity, and let the result carry the distinction the error return currently loses:
type Assertion interface {
Kind() string // "status", "header", "body", "redirect", "jq"
Check(res *httpResponse) (*Failure, error)
}
type Failure struct {
Kind string
Target string // header name, jq query, or ""
Expected any
Actual any
}
Check returns (nil, nil) when the assertion holds, (*Failure, nil) when it does not, and (nil, error) when it could not be evaluated at all. The earlier sketch of Check(res) *Failure was rejected: an operational error has no meaningful Expected/Actual, so a single return would either lose the distinction or fabricate fields.
The functional style is genuinely good and worth preserving where it can be — this is about the result carrying structure, not about abandoning composability.
Scope
Fourteen constructors in assertions.go, plus two consumers in main.go — mustCompileAssertion (:672, whose must prefix is now a leftover) and boolAssertion (:694).
Status
#45 is scheduled, which is what this structure exists for, so the work is done in #89. The refactor is behaviour-preserving: every failure message is byte-identical to before, verified by diffing both binaries across thirteen failing scenarios covering all seven kinds.
Related
Status: the panic half of this ticket already shipped
This ticket was filed at 19:01 UTC on 2026-08-07. Three hours later
4d5dc06landed, closing #17 — and delivering this ticket's entire "Suggested fix" as a side effect. Nobody updated the body, so it read as unstarted work for three days.At
HEAD, all four fallible constructors already return(Assertion, error)—AssertHeaderMatch(assertions.go:89),AssertBodyMatch(:277),AssertRedirectMatch(:324) andAssertJQ(:164).regexp.MustCompileis gone from the source, and.golangci.ymlnow bans it outright viaforbidigo. A bad pattern reports the way every other invalid flag value does:(The original ticket predicted
103; exit codes were collapsed to invocation/transport/assertion in2f4b3e1afterwards.)Covered at both tiers:
assertions_test.go:574,e2e_panic_test.go:18-19,e2e_assert_test.go:487-497.Everything below is what actually remains.
Problem
Assertionis an opaque function returningerror, so a failure can only ever be a string:There is nothing to serialize, which is why machine-readable output (#45) can't be added cheaply.
The
errorreturn also conflates two outcomes that are not the same thing:body: expected "x", got "y"(assertions.go:270and ~15 siblings)body: response is br-encoded and was not decoded(:121), a gojq runtime error (:209), a body that isn't JSON (:189)A consumer of
--jsonwants to tell those apart. Today nothing can.Solution
Give
Assertionan identity, and let the result carry the distinction theerrorreturn currently loses:Checkreturns(nil, nil)when the assertion holds,(*Failure, nil)when it does not, and(nil, error)when it could not be evaluated at all. The earlier sketch ofCheck(res) *Failurewas rejected: an operational error has no meaningfulExpected/Actual, so a single return would either lose the distinction or fabricate fields.The functional style is genuinely good and worth preserving where it can be — this is about the result carrying structure, not about abandoning composability.
Scope
Fourteen constructors in
assertions.go, plus two consumers inmain.go—mustCompileAssertion(:672, whosemustprefix is now a leftover) andboolAssertion(:694).Status
#45 is scheduled, which is what this structure exists for, so the work is done in #89. The refactor is behaviour-preserving: every failure message is byte-identical to before, verified by diffing both binaries across thirteen failing scenarios covering all seven kinds.
Related
4d5dc06, which is what delivered this ticket's first half