Skip to content

fix: emit [] instead of null for empty JSON arrays, and unify CLI input conventions - #14

Merged
machado144 merged 2 commits into
mainfrom
fix/empty-json-array-and-cli-dx
Aug 26, 2026
Merged

fix: emit [] instead of null for empty JSON arrays, and unify CLI input conventions#14
machado144 merged 2 commits into
mainfrom
fix/empty-json-array-and-cli-dx

Conversation

@machado144

Copy link
Copy Markdown
Contributor

The bug: null where [] belongs

diff files --output json filtered down to nothing printed null. Fed to a
GitHub Actions matrix, null errors the workflow; [] correctly skips the
job
— so a docs-only commit turned a green no-op run into a red build. Not
cosmetic.

Before / after

$ pipekit diff files --base HEAD~1 --head HEAD --include 'infra/**' --output json
- null
+ []

$ pipekit diff files --base HEAD~1 --head HEAD --include 'infra/**' --output json | pipekit matrix from-json
- {"item":null}
+ {"item":[]}

Why it matters, concretely:

jobs:
  plan:
    outputs:
      dirs: ${{ steps.diff.outputs.dirs }}
    steps:
      - id: diff
        run: pipekit diff dirs --include 'infra/**' --output json --to-github-output dirs

  apply:
    needs: plan
    strategy:
      matrix:
        dir: ${{ fromJSON(needs.plan.outputs.dirs) }}   # null → the job ERRORS
    steps:                                              # []   → the job is SKIPPED
      - run: terraform apply ${{ matrix.dir }}

Fix

Normalised at the source, not at the call site. services.emptyIfNil (new,
services/json_output.go) is applied inside FormatDiffOutput, so no branch of
that function can emit null for any diff subcommand or for
matrix shard --format json.

The audit found five more commands with the same latent bug, all of which
build a slice with var x []T + conditional append and then marshal it:

Command Empty case Was Now
matrix from-dirs DIR no subdirectories {"dir":null} {"dir":[]}
matrix from-files GLOB no matches {"file":null} {"file":[]}
matrix from-json --filter-* filter matches nothing {"item":null} {"item":[]}
http get --paginate API's only page is [] null []
archive list --json empty tar null []
changelog generate --format json empty commit range null []

Two of those are worth calling out. http get --paginate regressed its own
non-paginated path — the same endpoint printed [] without the flag and null
with it. And archive list --json disagreed with itself: listZip already
built with make() and returned [], while listTar returned nil.

matrix from-json additionally treats a literal null on stdin as an empty
list (belt and braces — encoding/json decodes null into a nil slice without
erroring, so someone else's tool could hand us one).

Prior art in the repo: structdiff_service.go:FormatDiffJSON already did this
inline for []DiffEntry. emptyIfNil is the generic version of that.

Regression test

integration/empty_array_test.go drives the built binary through the exact
reported reproduction (real git repo, docs-only commit, --include 'infra/**',
piped into matrix from-json) plus every command in the table. Unit tests
cover each service function. All of them fail on main and pass here
verified by reverting the five service files and re-running.


DX 1 — one input convention (strictly additive)

Within comment alone there were three conventions across four subcommands:
render and amend took --body-file; payload and fence took stdin or a
positional. Now every body-taking subcommand accepts both, stdin being the
default when the flag is absent.

amend takes two inputs, so it inverts rather than guesses: --body-file
supplies the body and the existing comment comes from the positional FILE or
stdin (unchanged); drop --body-file and the existing comment must be the
positional FILE, leaving stdin free for the new body.

# all three 0.2.3 forms still work, byte-identical output
$ cat existing.md | pipekit comment amend --anchor ci --body-file new.md
$ pipekit comment amend --anchor ci --body-file new.md existing.md
# new
$ generate-report | pipekit comment amend --anchor ci existing.md

Audit of the other groups: --body-file existed only in comment.
Everything else (env, config, parse, summary, matrix, json/yaml,
report) already used positional-FILE-or-stdin uniformly. One outlier turned
up outside comment: assert json-path required --file and could not read
stdin.
It now accepts stdin and a positional FILE too; --file still wins
and still works (the CI dogfood step in ci.yaml uses it).

Nothing was removed or repurposed. render's positional argument is still the
body text, not a path — there is a test pinning that.

DX 2 — group-level --help that teaches the interface

pipekit comment --help used to list only --help; the real flags lived one
level 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: select exits 1 when the anchor is absent, and
that exit code is the create-vs-update branch.

One library quirk worth knowing about

urfave/cli v1's SubcommandHelpTemplate renders
{{if .Description}}{{.Description}}{{else}}{{.Usage}}{{end}} on the NAME line,
so giving a group a Description silently replaces its one-line summary with
the whole block. A per-command CustomHelpTemplate cannot fix it either:
ShowCommandHelp takes the command == "" branch for <group> --help and
hardcodes the package template. So main.go sets
cli.SubcommandHelpTemplate = actions.GroupHelpTemplate, which keeps the
summary on NAME and gives Description its own section. Groups with no
Description render byte-identically to before
— there's a test on matrix
asserting exactly that.

Also: pipekit render puts template values under .Values.* (Helm-style)
and auto-populates .Env.*. The help said neither, so a template written from
--help alone rendered <no value> for every field. It appeared only in one
docs/COMMANDS.md example. render --help now states it, with examples — and a
test 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.go implements cache-key; reading the source tree invites
you to write pipekit cache_key, which does not exist. Every actions/*.go now
opens with a // CLI: pipekit <name> header. I chose headers over renaming
because the repo's existing convention is already a doc comment naming the
command (// PortCommand returns the port command group.), and renaming would
churn git blame across six open branches for no functional gain.

Full audit — the four genuine mismatches:

File CLI name(s) Why it's confusing
cache_key.go cache-key underscore vs hyphen
timecmd.go time suffixed filename
misc.go port, uuid, random three commands, none called misc
json.go json, yaml there is no yaml.go

The other 28 files match; they get a header too, so the answer is always in the
same place. common.go says // CLI: none.

main_test.go keeps this honest: it enumerates the registered command tree
and 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 command
slice extracted into a commands() function — the only structural change there.


annotate / lock / report — the finding

Not dead code, and not an accidental non-registration. They don't exist at
v0.2.3 at all — git ls-tree v0.2.3 actions/ has no annotate.go,
lock.go or report.go. All three files and their main.go registration
landed together in #12 (3f83a64), which merged after the v0.2.3 tag.
They are registered on main today and work from a make build. It's purely a
release-timing artifact: the next tag ships them.

Nothing was wired up here, as asked.


Gates

Gate Result
go test ./... 403 pass (also with -shuffle=on)
go build / make build pass
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
gofumpt -l . identical list to main (17 pre-existing offenders, none new, none reformatted — out of scope churn)
dupehound duplication down 22.7% → 21.9% despite +1145 lines
govulncheck ./... identical findings to main — all pre-existing, none introduced

dupehound flagged that my first draft duplicated the integration test runner,
so runPipekit now delegates to a dir-aware runPipekitIn instead of the two
coexisting.

Docs

docs/COMMANDS.md (the []-not-null guarantee under diff and matrix; the
comment input convention; the select exit-code round-trip; amend's three
forms; assert json-path from stdin), plus docs/CONTRIBUTING.md and
docs/AI/README.md — the two conventions the tests now enforce, and the // CLI:
header requirement in "adding a new command".

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>
@github-actions

Copy link
Copy Markdown

✅ PR title follows the required format

Current title: fix: emit [] instead of null for empty JSON arrays, and unify CLI input conventions

@machado144

Copy link
Copy Markdown
Contributor Author

CI note: the red check is a pre-existing govulncheck failure, not this PR

The gauntlet job's own quality gate step — Run AxeForging/gauntlet@v0.1.0
(build, tests, structlint, dupehound) — passed.
The single failing step is
Vulnerability scan (make vuln):

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>
@machado144
machado144 merged commit 3f33238 into main Aug 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant