Rewritten from the original placeholder. The observation below started as a question about JSON key
naming; researching it showed the key naming cannot be fixed on its own, so the issue now covers the
shape of the method.
The observation
JSONWriter.Table — writer.go:109-127:
func (w *JSONWriter) Table(headers []string, rows [][]string) {
records := make([]map[string]string, len(rows))
for i, row := range rows {
record := make(map[string]string, len(headers))
for j, header := range headers {
if j < len(row) {
record[header] = row[j]
}
}
records[i] = record
}
data, _ := json.Marshal(records)
fmt.Fprintln(w.stdout, string(data))
}
Three separate problems, none of which can be fixed without the others.
1. Every value is a string
The signature is rows [][]string, so the pretty renderer's display strings are the only thing the
JSON writer ever sees. A count arrives as "12", not 12; a boolean as "true"; a timestamp as
whatever the table decided to render it as. jq 'select(.count > 5)' cannot work on any of it, and a
consumer has to know which fields to tonumber back.
2. It emits one JSON array, but the format is documented as NDJSON
overview.md:293
describes -o json as "NDJSON lines" and then, in the same cell, "Table → JSON array to stdout".
Both cannot be true. An array also cannot be parsed until the closing bracket arrives, so a killed or
failed run leaves nothing readable — which is the property NDJSON exists to provide.
3. The header string doubles as the JSON key
The original observation. log_test.go asserts the consequence directly — "Name":"my-tpl",
"Version":"1.0.0" — capitalised keys, because Name and Version are what the pretty table prints
in its header row. One string is doing two jobs for two audiences: a column heading is prose for a
human and may be reworded freely; a JSON key is what a consumer's jq filter matches on. A
multi-word heading yields jq '.["Some heading"]', and rewording a heading silently starts returning
null downstream.
Single-word headings are why this has not bitten yet.
Proposal
Give the two audiences separate inputs, and the key-naming question answers itself: keys come from
json tags on a row struct, headings stay prose.
// Column describes one column of a pretty table: the heading a human reads,
// and how a row renders in that column.
type Column[T any] struct {
Header string
Cell func(T) string
}
func Col[T any](header string, cell func(T) string) Column[T]
// Table writes rows as the command's product: a bordered table for a human,
// one JSON object per row for a machine.
func Table[T any](w Writer, rows []T, cols ...Column[T])
Call site:
type templateRow struct {
Name string `json:"name"`
Version string `json:"version"`
Updates int `json:"updates_available"`
}
output.Table(w, rows,
output.Col("Name", func(r templateRow) string { return r.Name }),
output.Col("Version", func(r templateRow) string { return r.Version }),
output.Col("Updates available", func(r templateRow) string { return strconv.Itoa(r.Updates) }),
)
The Writer method underneath becomes WriteTable(TableData) where TableData carries Headers,
Cells and Records: the pretty writer renders the cells, the JSON writer marshals the records
one per line. Going through the generic constructor is also what guarantees every row has exactly one
cell per header — alignment a raw [][]string can silently get wrong today.
Result: {"name":"my-tpl","version":"1.0.0","updates_available":3} — a number stays a number, the
heading Updates available can be reworded without touching updates_available, and the stream is
genuinely NDJSON.
Open questions
- Are the current JSON keys a contract anyone depends on? They are capitalised
(Name, Version) and would change to snake_case json tags. -o json is not yet documented as
stable anywhere I can find, but this is the one genuinely breaking part and should be a deliberate
call — possibly worth a note in the release notes rather than a compatibility shim.
- Is the array → NDJSON change breaking in practice? Anything doing
specs template list -o json | jq '.[]' would need jq -s '.[]' or just jq .. Same question, same answer required.
- Should
WriteTable stay on the Writer interface at all, or should the generic Table be the only
public entry point?
Scope
Done when
Notes
Related: #113 (stdout/stderr split — a table is a product and stays on stdout under both issues) and
#116 (golden tests), which is worth landing first so the pretty rendering is pinned before the
signature changes underneath it.
Prior art, from the original body: specsnl/labelsync#51 solved the same three problems together. The
argument above stands on this repo's own behaviour; the link is kept only as a worked example.
The observation
JSONWriter.Table— writer.go:109-127:Three separate problems, none of which can be fixed without the others.
1. Every value is a string
The signature is
rows [][]string, so the pretty renderer's display strings are the only thing theJSON writer ever sees. A count arrives as
"12", not12; a boolean as"true"; a timestamp aswhatever the table decided to render it as.
jq 'select(.count > 5)'cannot work on any of it, and aconsumer has to know which fields to
tonumberback.2. It emits one JSON array, but the format is documented as NDJSON
overview.md:293
describes
-o jsonas "NDJSON lines" and then, in the same cell, "Table→ JSON array to stdout".Both cannot be true. An array also cannot be parsed until the closing bracket arrives, so a killed or
failed run leaves nothing readable — which is the property NDJSON exists to provide.
3. The header string doubles as the JSON key
The original observation.
log_test.goasserts the consequence directly —"Name":"my-tpl","Version":"1.0.0"— capitalised keys, becauseNameandVersionare what the pretty table printsin its header row. One string is doing two jobs for two audiences: a column heading is prose for a
human and may be reworded freely; a JSON key is what a consumer's
jqfilter matches on. Amulti-word heading yields
jq '.["Some heading"]', and rewording a heading silently starts returningnulldownstream.Single-word headings are why this has not bitten yet.
Proposal
Give the two audiences separate inputs, and the key-naming question answers itself: keys come from
jsontags on a row struct, headings stay prose.Call site:
The
Writermethod underneath becomesWriteTable(TableData)whereTableDatacarriesHeaders,CellsandRecords: the pretty writer renders the cells, the JSON writer marshals the recordsone per line. Going through the generic constructor is also what guarantees every row has exactly one
cell per header — alignment a raw
[][]stringcan silently get wrong today.Result:
{"name":"my-tpl","version":"1.0.0","updates_available":3}— a number stays a number, theheading
Updates availablecan be reworded without touchingupdates_available, and the stream isgenuinely NDJSON.
Open questions
(
Name,Version) and would change to snake_casejsontags.-o jsonis not yet documented asstable anywhere I can find, but this is the one genuinely breaking part and should be a deliberate
call — possibly worth a note in the release notes rather than a compatibility shim.
specs template list -o json | jq '.[]'would needjq -s '.[]'or justjq .. Same question, same answer required.WriteTablestay on theWriterinterface at all, or should the genericTablebe the onlypublic entry point?
Scope
internal/util/output/rows.go(new) —Column,Col,TableData, genericTableinternal/util/output/writer.go—Table(headers, rows)→WriteTable(TableData)on bothimplementations; JSON writes one object per line
internal/cmd— define a row struct withjsontags per tableinternal/util/output/log_test.go— the assertions on"Name"/"Version"keys are thecurrent behaviour being pinned; update them
docs/content/docs/architecture/overview.md:293— the row that contradicts itselfDone when
specs template list -o jsonemits one object per linejsontags, not from column headingsNotes
Related: #113 (stdout/stderr split — a table is a product and stays on stdout under both issues) and
#116 (golden tests), which is worth landing first so the pretty rendering is pinned before the
signature changes underneath it.
Prior art, from the original body: specsnl/labelsync#51 solved the same three problems together. The
argument above stands on this repo's own behaviour; the link is kept only as a worked example.