fix: emit [] instead of null for empty JSON arrays, and unify CLI input conventions - #14
Conversation
A filtered-to-empty result marshalled a nil slice, so `diff files --output json` printed `null`. Fed to a GitHub Actions matrix, `null` *errors* the workflow while `[]` correctly *skips* the job — so a docs-only commit turned a green no-op run into a red build. Normalised at the source via services.emptyIfNil, plus five other commands carrying the same latent bug: matrix from-dirs/from-files/from-json, http get --paginate, archive list --json, changelog generate --format json. `matrix from-json` also treats a literal `null` on stdin as an empty list. Developer experience, all strictly additive and backwards compatible: - Every body-taking `comment` subcommand now accepts stdin AND --body-file; `assert json-path` accepts stdin and a positional FILE alongside --file. - `comment --help` documents the input convention, a per-subcommand synopsis, `select`'s exit-1 create-vs-update branch, and the sticky-comment round-trip. `render --help` documents the Helm-style .Values / .Env namespace. - Every actions/*.go opens with a `// CLI: pipekit <name>` header, because the filename is not always the command (cache_key.go is `cache-key`, timecmd.go is `time`, misc.go is three commands). main_test.go enforces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ PR title follows the required formatCurrent title: |
CI note: the red check is a pre-existing
|
| Step | Result |
|---|---|
| Build candidate and install external quality gates | ✅ |
Run AxeForging/gauntlet@v0.1.0 |
✅ |
Vulnerability scan (make vuln) |
❌ |
| dogfood steps | ⏭️ skipped (job already failed) |
Running govulncheck ./... against pristine main (c35e359), with none of
this PR's code, reports the same six vulnerabilities:
Vulnerability #1: GO-2026-6218
Vulnerability #2: GO-2026-6090
Vulnerability #3: GO-2026-6088 encoding/xml → fixed in go1.25.13
Vulnerability #4: GO-2026-6061 google.golang.org/grpc v1.72.2 → fixed in v1.82.1
Vulnerability #5: GO-2026-5972 encoding/asn1 → fixed in go1.25.13
Vulnerability #6: GO-2026-5026 golang.org/x/net v0.53.0 → fixed in v0.55.0
net/http → fixed in go1.25.13
Every trace lands in code this PR does not touch (report_service.go,
probe_service.go, version_service.go, http_service.go,
assert_service.go, notify_service.go).
govulncheck queries a live database, so previously-green commits turn red as
new advisories land — which is what happened here. Clearing it means bumping the
pinned toolchain to go1.25.13 and grpc/x/net, i.e. a dependency-bump PR.
Mixing that into a behaviour fix would make both harder to review and to revert,
so I've deliberately left it out of scope.
Everything else was verified locally against the same commit:
| Gate | Result |
|---|---|
go test ./... |
403 pass (also -shuffle=on) |
go vet ./... |
clean |
structlint validate |
163 files, 0 violations |
golangci-lint run |
zero new issues (56 pre-existing on main, 56 here) |
gofmt |
all touched files clean |
dupehound |
duplication down 22.7% → 21.9% |
`make vuln` was red on this PR, and on `main`, and on every commit in between —
govulncheck queries a live database, so a previously-green commit turns red on
its own when new advisories land. Nothing here was introduced by the behaviour
fix, but a red gate that everyone learns to ignore is worse than no gate.
Four are standard-library and clear by moving the pinned toolchain to
go1.25.13 (net/url, crypto/tls, encoding/xml, encoding/asn1, net/http):
GO-2026-6218 net/url → go1.25.13
GO-2026-6090 crypto/tls → go1.25.13
GO-2026-6088 encoding/xml → go1.25.13
GO-2026-5972 encoding/asn1 → go1.25.13
GO-2026-6061 grpc v1.72.2 → v1.82.1
GO-2026-5026 x/net v0.53.0 → v0.55.0 (also net/http → go1.25.13)
The CI and release workflows pin the same version, so all three move together —
a toolchain bump in go.mod alone would leave CI scanning on the old one.
before: 6 vulnerabilities from 1 module and the Go standard library
after: No vulnerabilities found.
Also documents the `[]`-never-`null` guarantee in COMMANDS.md: it is a contract
callers script against, and it was only visible in the code.
go.sum picks up the transitive bumps grpc pulled in (genproto, protobuf,
x/sys, x/text). 350 tests pass, `-shuffle=on` too, `go vet` clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bug:
nullwhere[]belongsdiff files --output jsonfiltered down to nothing printednull. Fed to aGitHub Actions matrix,
nullerrors the workflow;[]correctly skips thejob — so a docs-only commit turned a green no-op run into a red build. Not
cosmetic.
Before / after
Why it matters, concretely:
Fix
Normalised at the source, not at the call site.
services.emptyIfNil(new,services/json_output.go) is applied insideFormatDiffOutput, so no branch ofthat function can emit
nullfor anydiffsubcommand or formatrix shard --format json.The audit found five more commands with the same latent bug, all of which
build a slice with
var x []T+ conditionalappendand then marshal it:matrix from-dirs DIR{"dir":null}{"dir":[]}matrix from-files GLOB{"file":null}{"file":[]}matrix from-json --filter-*{"item":null}{"item":[]}http get --paginate[]null[]archive list --jsonnull[]changelog generate --format jsonnull[]Two of those are worth calling out.
http get --paginateregressed its ownnon-paginated path — the same endpoint printed
[]without the flag andnullwith it. And
archive list --jsondisagreed with itself:listZipalreadybuilt with
make()and returned[], whilelistTarreturned nil.matrix from-jsonadditionally treats a literalnullon stdin as an emptylist (belt and braces —
encoding/jsondecodesnullinto a nil slice withouterroring, so someone else's tool could hand us one).
Prior art in the repo:
structdiff_service.go:FormatDiffJSONalready did thisinline for
[]DiffEntry.emptyIfNilis the generic version of that.Regression test
integration/empty_array_test.godrives the built binary through the exactreported reproduction (real git repo, docs-only commit,
--include 'infra/**',piped into
matrix from-json) plus every command in the table. Unit testscover each service function. All of them fail on
mainand pass here —verified by reverting the five service files and re-running.
DX 1 — one input convention (strictly additive)
Within
commentalone there were three conventions across four subcommands:renderandamendtook--body-file;payloadandfencetook stdin or apositional. Now every body-taking subcommand accepts both, stdin being the
default when the flag is absent.
amendtakes two inputs, so it inverts rather than guesses:--body-filesupplies the body and the existing comment comes from the positional
FILEorstdin (unchanged); drop
--body-fileand the existing comment must be thepositional
FILE, leaving stdin free for the new body.Audit of the other groups:
--body-fileexisted only incomment.Everything else (
env,config,parse,summary,matrix,json/yaml,report) already used positional-FILE-or-stdin uniformly. One outlier turnedup outside
comment:assert json-pathrequired--fileand could not readstdin. It now accepts stdin and a positional
FILEtoo;--filestill winsand still works (the CI dogfood step in
ci.yamluses it).Nothing was removed or repurposed.
render's positional argument is still thebody text, not a path — there is a test pinning that.
DX 2 — group-level
--helpthat teaches the interfacepipekit comment --helpused to list only--help; the real flags lived onelevel down. It now carries the input convention, a per-subcommand synopsis with
the flags each takes, and the sticky-comment round-trip — including the thing
that was invisible before:
selectexits 1 when the anchor is absent, andthat exit code is the create-vs-update branch.
One library quirk worth knowing about
urfave/cli v1's
SubcommandHelpTemplaterenders{{if .Description}}{{.Description}}{{else}}{{.Usage}}{{end}}on the NAME line,so giving a group a
Descriptionsilently replaces its one-line summary withthe whole block. A per-command
CustomHelpTemplatecannot fix it either:ShowCommandHelptakes thecommand == ""branch for<group> --helpandhardcodes the package template. So
main.gosetscli.SubcommandHelpTemplate = actions.GroupHelpTemplate, which keeps thesummary on NAME and gives
Descriptionits own section. Groups with noDescription render byte-identically to before — there's a test on
matrixasserting exactly that.
Also:
pipekit renderputs template values under.Values.*(Helm-style)and auto-populates
.Env.*. The help said neither, so a template written from--helpalone rendered<no value>for every field. It appeared only in onedocs/COMMANDS.mdexample.render --helpnow states it, with examples — and atest asserts the help is telling the truth (
{{ .name }}really is<no value>while
{{ .Values.name }}resolves).DX 3 — filenames that don't match CLI names
actions/cache_key.goimplementscache-key; reading the source tree invitesyou to write
pipekit cache_key, which does not exist. Everyactions/*.gonowopens with a
// CLI: pipekit <name>header. I chose headers over renamingbecause the repo's existing convention is already a doc comment naming the
command (
// PortCommand returns the port command group.), and renaming wouldchurn
git blameacross six open branches for no functional gain.Full audit — the four genuine mismatches:
cache_key.gocache-keytimecmd.gotimemisc.goport,uuid,randommiscjson.gojson,yamlyaml.goThe other 28 files match; they get a header too, so the answer is always in the
same place.
common.gosays// CLI: none.main_test.gokeeps this honest: it enumerates the registered command treeand fails if a command has no header, if a header claims a command that isn't
registered, or if two files claim the same one. This needed
main.go's commandslice extracted into a
commands()function — the only structural change there.annotate/lock/report— the findingNot dead code, and not an accidental non-registration. They don't exist at
v0.2.3at all —git ls-tree v0.2.3 actions/has noannotate.go,lock.goorreport.go. All three files and theirmain.goregistrationlanded together in #12 (
3f83a64), which merged after thev0.2.3tag.They are registered on
maintoday and work from amake build. It's purely arelease-timing artifact: the next tag ships them.
Nothing was wired up here, as asked.
Gates
go test ./...-shuffle=on)go build/make buildgo vet ./...structlint validategolangci-lint runmain, 56 here)gofmtgofumpt -l .main(17 pre-existing offenders, none new, none reformatted — out of scope churn)dupehoundgovulncheck ./...main— all pre-existing, none introduceddupehoundflagged that my first draft duplicated the integration test runner,so
runPipekitnow delegates to a dir-awarerunPipekitIninstead of the twocoexisting.
Docs
docs/COMMANDS.md(the[]-not-nullguarantee underdiffandmatrix; thecommentinput convention; theselectexit-code round-trip;amend's threeforms;
assert json-pathfrom stdin), plusdocs/CONTRIBUTING.mdanddocs/AI/README.md— the two conventions the tests now enforce, and the// CLI:header requirement in "adding a new command".