← back to Cli Printing Press
fix(cli): migrate 15 templates off flags.printJSON + dogfood regression guard (#428) (#447)
1e4798f1ed7a8b7782089e3e54b7cca1e9d167c9 · 2026-04-30 13:06:26 -0700 · Trevin Chow
* fix(cli): migrate 15 generator templates off flags.printJSON antipattern + dogfood guard
Closes #428.
Background:
PR #424 introduced printJSONFiltered(cmd.OutOrStdout(), v, flags) so
hand-written novel commands' JSON output honors --select, --compact,
--csv, and --quiet. SKILL.md principle 2 mandated its use. But the
generator's own novel-feature templates (jobs, feedback, share,
profile, doctor) were never migrated — every CLI we shipped from
those templates dropped flag handling silently.
Two parts:
1. Migrate 15 emitted call sites across 5 templates from
`flags.printJSON(cmd, v)` to `printJSONFiltered(cmd.OutOrStdout(),
v, flags)`:
- jobs.go.tmpl (3 sites)
- feedback.go.tmpl (3 sites)
- share_commands.go.tmpl (4 sites)
- profile.go.tmpl (4 sites)
- doctor.go.tmpl (1 site)
2. Add internal/pipeline/printjson_filtered_check.go: a dogfood check
that scans every printed CLI's internal/cli/*.go (excluding _test.go
and helpers.go) for the `flags.printJSON(cmd,` pattern. Each match
becomes a finding with file + line + trimmed snippet so a reviewer
can locate and fix it. Wired into DogfoodReport as
PrintJSONFilteredCheck and into the dogfood verdict rules as a
WARN-level signal.
Five tests cover the check: antipattern flagged with line+snippet,
clean CLI returns no findings, helpers.go and _test.go are skipped,
missing internal/cli returns Skipped, multiple findings across files
all surface.
Net effect:
- Existing library CLIs (recipe-goat, dub, espn, yahoo-finance,
postman-explore) regenerate with --select/--compact/--csv/--quiet
honored on every novel command path the generator emits.
- Future hand-written novel commands that regress to flags.printJSON
get caught at dogfood time with a remediation message pointing at
the right helper.
Per-CLI library backfill (the part of #428 that requires editing each
public-library CLI individually) is out of scope here; this PR makes
the next regen of each library CLI fix the bug automatically and adds
the long-term regression guard.
Golden fixtures refreshed for the doctor.go migration (new
printJSONFiltered call site) and the new print_json_filtered_check
field appearing in dogfood-verdict-matrix and generate-golden-api
emitted reports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): apply /simplify findings to printjson antipattern check
Findings from the three-agent /simplify pass on PR #447.
Reuse:
- Replace manual os.ReadDir + .go/_test.go filter with the existing
listGoFiles helper. Saves ~12 lines and pulls in deterministic
alphabetical ordering, so findings appear in the same order across
OSes (the manual ReadDir form returned filesystem-order entries).
Quality:
- Drop printJSONFilteredHelpersFile constant + comment. The skip was
dead defensive code AND the comment was factually wrong: the
flags.printJSON method is defined in root.go.tmpl (renders to
internal/cli/root.go), not helpers.go. The regex anchor (now
strings.Contains anchor) self-protects against the receiver-style
definition either way.
- Replace flagsPrintJSONRe regex with strings.Contains on the literal
"flags.printJSON(cmd,". gofmt normalizes spacing inside Go call
expressions; whitespace-flexible matching bought nothing. Drops the
regexp import and runs on a substring scan.
- Truncate Snippet at 120 runes (UTF-8 safe) with ellipsis suffix so
a multi-hundred-char struct-literal call site doesn't bloat
dogfood.json. New TestTruncateSnippet covers the rune boundary
cases.
- Trim PrintJSONFilteredCheckResult block comment from 13 lines to
4. The structural-vs-AST justification belonged in the issue body,
not the doc comment.
- Trim test function comments that just restated the test name.
Goldens refreshed: print_json_filtered_check.checked counts now
include helpers.go (3 instead of 2 in warn-priority.json,
32 instead of 31 in generate-golden-api/dogfood.json) since the
no-longer-skipped file is now scanned.
Skipped on closer review:
- Consolidating the infra-file maps across reimplementation_check,
source_client_check, and verify (reuse #2). Out of scope for this
PR; filed as #448.
- bufio.Scanner over strings.Split — false positive, file sizes
measured in low tens of KB.
- filepath.WalkDir for future-proofing nested internal/cli/
subdirectories. Templates don't emit there today; agent marked
not blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/templates/doctor.go.tmplM internal/generator/templates/feedback.go.tmplM internal/generator/templates/jobs.go.tmplM internal/generator/templates/profile.go.tmplM internal/generator/templates/share_commands.go.tmplM internal/pipeline/dogfood.goA internal/pipeline/printjson_filtered_check.goA internal/pipeline/printjson_filtered_check_test.goM testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.jsonM testdata/golden/expected/dogfood-verdict-matrix/pass.jsonM testdata/golden/expected/dogfood-verdict-matrix/warn-priority.jsonM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.goM testdata/golden/expected/generate-golden-api/scorecard.json
Diff
commit 1e4798f1ed7a8b7782089e3e54b7cca1e9d167c9
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu Apr 30 13:06:26 2026 -0700
fix(cli): migrate 15 templates off flags.printJSON + dogfood regression guard (#428) (#447)
* fix(cli): migrate 15 generator templates off flags.printJSON antipattern + dogfood guard
Closes #428.
Background:
PR #424 introduced printJSONFiltered(cmd.OutOrStdout(), v, flags) so
hand-written novel commands' JSON output honors --select, --compact,
--csv, and --quiet. SKILL.md principle 2 mandated its use. But the
generator's own novel-feature templates (jobs, feedback, share,
profile, doctor) were never migrated — every CLI we shipped from
those templates dropped flag handling silently.
Two parts:
1. Migrate 15 emitted call sites across 5 templates from
`flags.printJSON(cmd, v)` to `printJSONFiltered(cmd.OutOrStdout(),
v, flags)`:
- jobs.go.tmpl (3 sites)
- feedback.go.tmpl (3 sites)
- share_commands.go.tmpl (4 sites)
- profile.go.tmpl (4 sites)
- doctor.go.tmpl (1 site)
2. Add internal/pipeline/printjson_filtered_check.go: a dogfood check
that scans every printed CLI's internal/cli/*.go (excluding _test.go
and helpers.go) for the `flags.printJSON(cmd,` pattern. Each match
becomes a finding with file + line + trimmed snippet so a reviewer
can locate and fix it. Wired into DogfoodReport as
PrintJSONFilteredCheck and into the dogfood verdict rules as a
WARN-level signal.
Five tests cover the check: antipattern flagged with line+snippet,
clean CLI returns no findings, helpers.go and _test.go are skipped,
missing internal/cli returns Skipped, multiple findings across files
all surface.
Net effect:
- Existing library CLIs (recipe-goat, dub, espn, yahoo-finance,
postman-explore) regenerate with --select/--compact/--csv/--quiet
honored on every novel command path the generator emits.
- Future hand-written novel commands that regress to flags.printJSON
get caught at dogfood time with a remediation message pointing at
the right helper.
Per-CLI library backfill (the part of #428 that requires editing each
public-library CLI individually) is out of scope here; this PR makes
the next regen of each library CLI fix the bug automatically and adds
the long-term regression guard.
Golden fixtures refreshed for the doctor.go migration (new
printJSONFiltered call site) and the new print_json_filtered_check
field appearing in dogfood-verdict-matrix and generate-golden-api
emitted reports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): apply /simplify findings to printjson antipattern check
Findings from the three-agent /simplify pass on PR #447.
Reuse:
- Replace manual os.ReadDir + .go/_test.go filter with the existing
listGoFiles helper. Saves ~12 lines and pulls in deterministic
alphabetical ordering, so findings appear in the same order across
OSes (the manual ReadDir form returned filesystem-order entries).
Quality:
- Drop printJSONFilteredHelpersFile constant + comment. The skip was
dead defensive code AND the comment was factually wrong: the
flags.printJSON method is defined in root.go.tmpl (renders to
internal/cli/root.go), not helpers.go. The regex anchor (now
strings.Contains anchor) self-protects against the receiver-style
definition either way.
- Replace flagsPrintJSONRe regex with strings.Contains on the literal
"flags.printJSON(cmd,". gofmt normalizes spacing inside Go call
expressions; whitespace-flexible matching bought nothing. Drops the
regexp import and runs on a substring scan.
- Truncate Snippet at 120 runes (UTF-8 safe) with ellipsis suffix so
a multi-hundred-char struct-literal call site doesn't bloat
dogfood.json. New TestTruncateSnippet covers the rune boundary
cases.
- Trim PrintJSONFilteredCheckResult block comment from 13 lines to
4. The structural-vs-AST justification belonged in the issue body,
not the doc comment.
- Trim test function comments that just restated the test name.
Goldens refreshed: print_json_filtered_check.checked counts now
include helpers.go (3 instead of 2 in warn-priority.json,
32 instead of 31 in generate-golden-api/dogfood.json) since the
no-longer-skipped file is now scanned.
Skipped on closer review:
- Consolidating the infra-file maps across reimplementation_check,
source_client_check, and verify (reuse #2). Out of scope for this
PR; filed as #448.
- bufio.Scanner over strings.Split — false positive, file sizes
measured in low tens of KB.
- filepath.WalkDir for future-proofing nested internal/cli/
subdirectories. Templates don't emit there today; agent marked
not blocking.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/templates/doctor.go.tmpl | 2 +-
internal/generator/templates/feedback.go.tmpl | 8 +-
internal/generator/templates/jobs.go.tmpl | 6 +-
internal/generator/templates/profile.go.tmpl | 8 +-
.../generator/templates/share_commands.go.tmpl | 16 +-
internal/pipeline/dogfood.go | 48 +++--
internal/pipeline/printjson_filtered_check.go | 79 ++++++++
internal/pipeline/printjson_filtered_check_test.go | 218 +++++++++++++++++++++
.../fail-path-auth-dead.json | 3 +
.../expected/dogfood-verdict-matrix/pass.json | 3 +
.../dogfood-verdict-matrix/warn-priority.json | 3 +
.../expected/generate-golden-api/dogfood.json | 9 +-
.../printing-press-golden/internal/cli/doctor.go | 2 +-
.../expected/generate-golden-api/scorecard.json | 6 +-
14 files changed, 364 insertions(+), 47 deletions(-)
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index def4d047..fd6bfc80 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -324,7 +324,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
report["version"] = version
if flags.asJSON {
- if err := flags.printJSON(cmd, report); err != nil {
+ if err := printJSONFiltered(cmd.OutOrStdout(), report, flags); err != nil {
return err
}
return doctorExitForFailOn(failOn, report)
diff --git a/internal/generator/templates/feedback.go.tmpl b/internal/generator/templates/feedback.go.tmpl
index 63457513..1a45e19e 100644
--- a/internal/generator/templates/feedback.go.tmpl
+++ b/internal/generator/templates/feedback.go.tmpl
@@ -153,12 +153,12 @@ maintainer sees it.`,
}
if flags.asJSON {
- return flags.printJSON(cmd, map[string]any{
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
"recorded": true,
"truncated": truncated,
"upstream": upstreamResult,
"entry": entry,
- })
+ }, flags)
}
fmt.Fprintf(cmd.OutOrStdout(), "feedback recorded locally (%d chars%s)\n", len(text), func() string {
if truncated {
@@ -193,7 +193,7 @@ func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
if err != nil {
if os.IsNotExist(err) {
if flags.asJSON {
- return flags.printJSON(cmd, []FeedbackEntry{})
+ return printJSONFiltered(cmd.OutOrStdout(), []FeedbackEntry{}, flags)
}
return nil
}
@@ -214,7 +214,7 @@ func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
if limit > 0 && limit < len(entries) {
entries = entries[len(entries)-limit:]
}
- return flags.printJSON(cmd, entries)
+ return printJSONFiltered(cmd.OutOrStdout(), entries, flags)
},
}
cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of recent entries to return")
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
index 4bfde525..9a95944b 100644
--- a/internal/generator/templates/jobs.go.tmpl
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -243,7 +243,7 @@ func newJobsListCmd(flags *rootFlags) *cobra.Command {
rows = rows[:limit]
}
if flags.asJSON {
- return flags.printJSON(cmd, rows)
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
}
headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
tbl := make([][]string, 0, len(rows))
@@ -279,7 +279,7 @@ func newJobsGetCmd(flags *rootFlags) *cobra.Command {
for _, r := range rows {
if r.JobID == args[0] {
if flags.asJSON {
- return flags.printJSON(cmd, r)
+ return printJSONFiltered(cmd.OutOrStdout(), r, flags)
}
fmt.Fprintf(cmd.OutOrStdout(), "job_id: %s\nresource: %s/%s\nstatus: %s\nsubmitted: %s\nupdated: %s\n",
r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
@@ -341,7 +341,7 @@ func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
}
if flags.asJSON {
- return flags.printJSON(cmd, map[string]any{"pruned": pruned, "kept": len(kept)})
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"pruned": pruned, "kept": len(kept)}, flags)
}
fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
return nil
diff --git a/internal/generator/templates/profile.go.tmpl b/internal/generator/templates/profile.go.tmpl
index 7a785e46..66f736a8 100644
--- a/internal/generator/templates/profile.go.tmpl
+++ b/internal/generator/templates/profile.go.tmpl
@@ -201,7 +201,7 @@ present (other than --profile and --config).`,
return err
}
if flags.asJSON {
- return flags.printJSON(cmd, s.Profiles[name])
+ return printJSONFiltered(cmd.OutOrStdout(), s.Profiles[name], flags)
}
fmt.Fprintf(cmd.OutOrStdout(), "saved profile %q with %d values\n", name, len(values))
return nil
@@ -225,7 +225,7 @@ func newProfileUseCmd(flags *rootFlags) *cobra.Command {
return fmt.Errorf("profile %q not found", args[0])
}
if flags.asJSON {
- return flags.printJSON(cmd, p)
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
}
fmt.Fprintf(cmd.OutOrStdout(), "profile %q:\n", p.Name)
if p.Description != "" {
@@ -268,7 +268,7 @@ func newProfileListCmd(flags *rootFlags) *cobra.Command {
"field_count": len(p.Values),
})
}
- return flags.printJSON(cmd, out)
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
}
headers := []string{"NAME", "FIELDS", "DESCRIPTION"}
rows := make([][]string, 0, len(names))
@@ -294,7 +294,7 @@ func newProfileShowCmd(flags *rootFlags) *cobra.Command {
if p == nil {
return fmt.Errorf("profile %q not found", args[0])
}
- return flags.printJSON(cmd, p)
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
},
}
}
diff --git a/internal/generator/templates/share_commands.go.tmpl b/internal/generator/templates/share_commands.go.tmpl
index c913ac5b..378a7274 100644
--- a/internal/generator/templates/share_commands.go.tmpl
+++ b/internal/generator/templates/share_commands.go.tmpl
@@ -57,14 +57,14 @@ required to be a git repo — use this for offline handoffs or CI staging.`,
if err != nil {
return err
}
- return flags.printJSON(cmd, map[string]any{
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
"event": "share_export",
"repo": repoPath,
"generated_at": manifest.GeneratedAt,
"tables": len(manifest.Tables),
"cli_name": manifest.CLIName,
"schema_version": manifest.SchemaVersion,
- })
+ }, flags)
},
}
cmd.Flags().StringVar(&repoPath, "repo", "", "Directory to write the snapshot to (required)")
@@ -96,14 +96,14 @@ CI cache warming, or a manual restore from a known-good snapshot.`,
if err != nil {
return err
}
- return flags.printJSON(cmd, map[string]any{
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
"event": "share_import",
"repo": repoPath,
"changed": changed,
"generated_at": manifest.GeneratedAt,
"tables": len(manifest.Tables),
"schema_version": manifest.SchemaVersion,
- })
+ }, flags)
},
}
cmd.Flags().StringVar(&repoPath, "repo", "", "Directory to read the snapshot from (required)")
@@ -156,14 +156,14 @@ Runs idempotently — an unchanged snapshot produces no commit.`,
return err
}
}
- return flags.printJSON(cmd, map[string]any{
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
"event": "share_publish",
"repo": opts.RepoPath,
"remote": opts.Remote,
"committed": committed,
"generated_at": manifest.GeneratedAt,
"tables": len(manifest.Tables),
- })
+ }, flags)
},
}
cmd.Flags().StringVar(&repoPath, "repo", "", "Local repo checkout path (default: runstate dir)")
@@ -210,14 +210,14 @@ cache.enabled is set in the spec.`,
if err != nil {
return err
}
- return flags.printJSON(cmd, map[string]any{
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
"event": "share_subscribe",
"repo": opts.RepoPath,
"remote": opts.Remote,
"changed": changed,
"generated_at": manifest.GeneratedAt,
"tables": len(manifest.Tables),
- })
+ }, flags)
},
}
cmd.Flags().StringVar(&repoPath, "repo", "", "Local repo checkout path (default: runstate dir)")
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 09e67ff1..bd28ebd9 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -21,24 +21,25 @@ import (
)
type DogfoodReport struct {
- Dir string `json:"dir"`
- SpecPath string `json:"spec_path,omitempty"`
- Verdict string `json:"verdict"`
- PathCheck PathCheckResult `json:"path_check"`
- AuthCheck AuthCheckResult `json:"auth_check"`
- BrowserSessionCheck BrowserSessionCheckResult `json:"browser_session_check"`
- DeadFlags DeadCodeResult `json:"dead_flags"`
- DeadFuncs DeadCodeResult `json:"dead_functions"`
- PipelineCheck PipelineResult `json:"pipeline_check"`
- ExampleCheck ExampleCheckResult `json:"example_check"`
- WiringCheck WiringCheckResult `json:"wiring_check"`
- NovelFeaturesCheck NovelFeaturesCheckResult `json:"novel_features_check"`
- MCPSurfaceParityCheck MCPSurfaceResult `json:"mcp_surface_parity"`
- ReimplementationCheck ReimplementationCheckResult `json:"reimplementation_check"`
- SourceClientCheck SourceClientCheckResult `json:"source_client_check"`
- TestPresence TestPresenceResult `json:"test_presence"`
- NamingCheck NamingCheckResult `json:"naming_check"`
- Issues []string `json:"issues"`
+ Dir string `json:"dir"`
+ SpecPath string `json:"spec_path,omitempty"`
+ Verdict string `json:"verdict"`
+ PathCheck PathCheckResult `json:"path_check"`
+ AuthCheck AuthCheckResult `json:"auth_check"`
+ BrowserSessionCheck BrowserSessionCheckResult `json:"browser_session_check"`
+ DeadFlags DeadCodeResult `json:"dead_flags"`
+ DeadFuncs DeadCodeResult `json:"dead_functions"`
+ PipelineCheck PipelineResult `json:"pipeline_check"`
+ ExampleCheck ExampleCheckResult `json:"example_check"`
+ WiringCheck WiringCheckResult `json:"wiring_check"`
+ NovelFeaturesCheck NovelFeaturesCheckResult `json:"novel_features_check"`
+ MCPSurfaceParityCheck MCPSurfaceResult `json:"mcp_surface_parity"`
+ ReimplementationCheck ReimplementationCheckResult `json:"reimplementation_check"`
+ SourceClientCheck SourceClientCheckResult `json:"source_client_check"`
+ PrintJSONFilteredCheck PrintJSONFilteredCheckResult `json:"print_json_filtered_check"`
+ TestPresence TestPresenceResult `json:"test_presence"`
+ NamingCheck NamingCheckResult `json:"naming_check"`
+ Issues []string `json:"issues"`
}
// NamingCheckResult reports non-canonical command verbs and flag names
@@ -263,6 +264,7 @@ func RunDogfood(dir, specPath string, opts ...DogfoodOption) (*DogfoodReport, er
report.MCPSurfaceParityCheck = checkMCPSurfaceParity(dir)
report.ReimplementationCheck = checkReimplementation(dir, cfg.researchDir)
report.SourceClientCheck = checkSourceClients(dir)
+ report.PrintJSONFilteredCheck = checkPrintJSONFiltered(dir)
report.TestPresence = checkTestPresence(dir)
report.NamingCheck = checkNamingConsistency(dir)
report.Issues = collectDogfoodIssues(report, spec != nil)
@@ -1354,6 +1356,7 @@ var dogfoodVerdictRules = []dogfoodVerdictRule{
// Surface hand-rolled responses without hard-blocking early iteration.
{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.ReimplementationCheck.Suspicious) > 0 }},
{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.SourceClientCheck.Findings) > 0 }},
+ {"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.PrintJSONFilteredCheck.Findings) > 0 }},
}
func deriveDogfoodVerdict(report *DogfoodReport, hasSpec bool) string {
@@ -1442,6 +1445,15 @@ func collectDogfoodIssues(report *DogfoodReport, hasSpec bool) []string {
len(report.SourceClientCheck.Findings),
strings.Join(parts, "; ")))
}
+ if len(report.PrintJSONFilteredCheck.Findings) > 0 {
+ parts := make([]string, 0, len(report.PrintJSONFilteredCheck.Findings))
+ for _, f := range report.PrintJSONFilteredCheck.Findings {
+ parts = append(parts, fmt.Sprintf("%s:%d", f.File, f.Line))
+ }
+ issues = append(issues, fmt.Sprintf("%d call site(s) using flags.printJSON(cmd, v) — silently drops --select/--compact/--csv/--quiet, switch to printJSONFiltered(cmd.OutOrStdout(), v, flags): %s",
+ len(report.PrintJSONFilteredCheck.Findings),
+ strings.Join(parts, "; ")))
+ }
if len(report.TestPresence.MissingTests) > 0 {
issues = append(issues, fmt.Sprintf("pure-logic packages with no tests: %s",
strings.Join(report.TestPresence.MissingTests, ", ")))
diff --git a/internal/pipeline/printjson_filtered_check.go b/internal/pipeline/printjson_filtered_check.go
new file mode 100644
index 00000000..6de58490
--- /dev/null
+++ b/internal/pipeline/printjson_filtered_check.go
@@ -0,0 +1,79 @@
+package pipeline
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// PrintJSONFilteredCheckResult flags hand-written novel commands that
+// emit JSON via flags.printJSON(cmd, v) instead of
+// printJSONFiltered(cmd.OutOrStdout(), v, flags). The former silently
+// drops --select, --compact, --csv, and --quiet — agents requesting
+// narrower output still receive the full payload.
+type PrintJSONFilteredCheckResult struct {
+ Checked int `json:"checked"`
+ Findings []PrintJSONFilteredFinding `json:"findings,omitempty"`
+ Skipped bool `json:"skipped,omitempty"`
+}
+
+// PrintJSONFilteredFinding names a single antipattern call site.
+type PrintJSONFilteredFinding struct {
+ File string `json:"file"`
+ Line int `json:"line"`
+ Snippet string `json:"snippet"`
+}
+
+// printJSONFilteredAntipattern is the literal call shape we flag. gofmt
+// normalizes spacing inside Go call expressions, so a substring match
+// is sufficient — no need for whitespace-flexible regex.
+const printJSONFilteredAntipattern = "flags.printJSON(cmd,"
+
+// printJSONFilteredSnippetMax bounds the snippet stored per finding so
+// a multi-hundred-char struct-literal call site doesn't bloat
+// dogfood.json. Truncated snippets get an ellipsis suffix.
+const printJSONFilteredSnippetMax = 120
+
+func checkPrintJSONFiltered(cliDir string) PrintJSONFilteredCheckResult {
+ cliPkgDir := filepath.Join(cliDir, "internal", "cli")
+ if _, err := os.Stat(cliPkgDir); err != nil {
+ return PrintJSONFilteredCheckResult{Skipped: true}
+ }
+
+ result := PrintJSONFilteredCheckResult{}
+ for _, path := range listGoFiles(cliPkgDir) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ result.Checked++
+
+ // Walk line-by-line so each finding carries its own line number
+ // and snippet. The number of files is small (low tens at most)
+ // and per-file size is small (low thousands of bytes), so the
+ // strings.Split allocation is in the noise vs. the surrounding
+ // os.ReadFile cost.
+ for lineIdx, line := range strings.Split(string(data), "\n") {
+ if !strings.Contains(line, printJSONFilteredAntipattern) {
+ continue
+ }
+ result.Findings = append(result.Findings, PrintJSONFilteredFinding{
+ File: filepath.ToSlash(filepath.Join("internal", "cli", filepath.Base(path))),
+ Line: lineIdx + 1,
+ Snippet: truncateSnippet(strings.TrimSpace(line), printJSONFilteredSnippetMax),
+ })
+ }
+ }
+ return result
+}
+
+// truncateSnippet caps s at maxRunes runes (UTF-8 safe — splitting
+// mid-rune would corrupt the dogfood.json output) and appends an
+// ellipsis when truncation occurred.
+func truncateSnippet(s string, maxRunes int) string {
+ r := []rune(s)
+ if len(r) <= maxRunes {
+ return s
+ }
+ return string(r[:maxRunes]) + "…"
+}
diff --git a/internal/pipeline/printjson_filtered_check_test.go b/internal/pipeline/printjson_filtered_check_test.go
new file mode 100644
index 00000000..82a17c40
--- /dev/null
+++ b/internal/pipeline/printjson_filtered_check_test.go
@@ -0,0 +1,218 @@
+package pipeline
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestCheckPrintJSONFiltered_FlagsAntipattern asserts that the check
+// reports file + line + snippet for an offending call site. Without
+// these three fields a reviewer can't act on the finding.
+func TestCheckPrintJSONFiltered_FlagsAntipattern(t *testing.T) {
+ t.Parallel()
+
+ cliDir := t.TempDir()
+ cliPkg := filepath.Join(cliDir, "internal", "cli")
+ if err := os.MkdirAll(cliPkg, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ const novelGo = `package cli
+
+import "github.com/spf13/cobra"
+
+func newWidgetsCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "widgets",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rows := computeRows()
+ return flags.printJSON(cmd, rows)
+ },
+ }
+}
+`
+ if err := os.WriteFile(filepath.Join(cliPkg, "widgets.go"), []byte(novelGo), 0o644); err != nil {
+ t.Fatalf("write widgets.go: %v", err)
+ }
+
+ result := checkPrintJSONFiltered(cliDir)
+
+ if result.Skipped {
+ t.Fatalf("expected check to run, was skipped")
+ }
+ if result.Checked != 1 {
+ t.Errorf("Checked = %d, want 1", result.Checked)
+ }
+ if len(result.Findings) != 1 {
+ t.Fatalf("Findings = %d, want 1: %+v", len(result.Findings), result.Findings)
+ }
+ f := result.Findings[0]
+ if f.File != "internal/cli/widgets.go" {
+ t.Errorf("File = %q, want internal/cli/widgets.go", f.File)
+ }
+ if f.Line != 10 {
+ t.Errorf("Line = %d, want 10", f.Line)
+ }
+ if f.Snippet != "return flags.printJSON(cmd, rows)" {
+ t.Errorf("Snippet = %q, want clean trimmed source line", f.Snippet)
+ }
+}
+
+func TestCheckPrintJSONFiltered_CleanCLI(t *testing.T) {
+ t.Parallel()
+
+ cliDir := t.TempDir()
+ cliPkg := filepath.Join(cliDir, "internal", "cli")
+ if err := os.MkdirAll(cliPkg, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ const cleanGo = `package cli
+
+import "github.com/spf13/cobra"
+
+func newWidgetsCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "widgets",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rows := computeRows()
+ return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
+ },
+ }
+}
+`
+ if err := os.WriteFile(filepath.Join(cliPkg, "widgets.go"), []byte(cleanGo), 0o644); err != nil {
+ t.Fatalf("write widgets.go: %v", err)
+ }
+
+ result := checkPrintJSONFiltered(cliDir)
+
+ if len(result.Findings) != 0 {
+ t.Fatalf("expected no findings, got: %+v", result.Findings)
+ }
+ if result.Checked != 1 {
+ t.Errorf("Checked = %d, want 1", result.Checked)
+ }
+}
+
+// TestCheckPrintJSONFiltered_SkipsTestFiles confirms _test.go files
+// are excluded — regression-test fixtures may demonstrate the
+// antipattern intentionally.
+func TestCheckPrintJSONFiltered_SkipsTestFiles(t *testing.T) {
+ t.Parallel()
+
+ cliDir := t.TempDir()
+ cliPkg := filepath.Join(cliDir, "internal", "cli")
+ if err := os.MkdirAll(cliPkg, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ const testGo = `package cli
+
+import "testing"
+
+func TestRegression(t *testing.T) {
+ _ = flags.printJSON(cmd, struct{}{})
+}
+`
+ if err := os.WriteFile(filepath.Join(cliPkg, "regression_test.go"), []byte(testGo), 0o644); err != nil {
+ t.Fatalf("write regression_test.go: %v", err)
+ }
+
+ result := checkPrintJSONFiltered(cliDir)
+
+ if len(result.Findings) != 0 {
+ t.Fatalf("expected no findings (*_test.go skipped), got: %+v", result.Findings)
+ }
+}
+
+func TestCheckPrintJSONFiltered_SkipsWhenNoCliPkg(t *testing.T) {
+ t.Parallel()
+
+ result := checkPrintJSONFiltered(t.TempDir())
+
+ if !result.Skipped {
+ t.Errorf("expected Skipped=true when internal/cli is missing, got: %+v", result)
+ }
+}
+
+// TestCheckPrintJSONFiltered_MultipleFindingsAcrossFiles confirms the
+// check accumulates findings across files and across multiple call
+// sites within a single file. Findings are also sorted by file path
+// (via listGoFiles), so output is deterministic across OSes.
+func TestCheckPrintJSONFiltered_MultipleFindingsAcrossFiles(t *testing.T) {
+ t.Parallel()
+
+ cliDir := t.TempDir()
+ cliPkg := filepath.Join(cliDir, "internal", "cli")
+ if err := os.MkdirAll(cliPkg, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ const jobsGo = `package cli
+
+func A(flags *rootFlags, cmd *cobra.Command) error {
+ return flags.printJSON(cmd, "first")
+}
+
+func B(flags *rootFlags, cmd *cobra.Command) error {
+ return flags.printJSON(cmd, "second")
+}
+`
+ const feedbackGo = `package cli
+
+func C(flags *rootFlags, cmd *cobra.Command) error {
+ rows := []int{1, 2, 3}
+ return flags.printJSON(cmd, rows)
+}
+`
+ if err := os.WriteFile(filepath.Join(cliPkg, "jobs.go"), []byte(jobsGo), 0o644); err != nil {
+ t.Fatalf("write jobs.go: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(cliPkg, "feedback.go"), []byte(feedbackGo), 0o644); err != nil {
+ t.Fatalf("write feedback.go: %v", err)
+ }
+
+ result := checkPrintJSONFiltered(cliDir)
+
+ if result.Checked != 2 {
+ t.Errorf("Checked = %d, want 2", result.Checked)
+ }
+ if len(result.Findings) != 3 {
+ t.Fatalf("Findings = %d, want 3: %+v", len(result.Findings), result.Findings)
+ }
+ // listGoFiles sorts alphabetically: feedback.go before jobs.go.
+ if !strings.HasSuffix(result.Findings[0].File, "feedback.go") {
+ t.Errorf("first finding should be from feedback.go, got %q", result.Findings[0].File)
+ }
+}
+
+// TestTruncateSnippet covers the rune-safe truncation guard against
+// dogfood.json bloat. UTF-8 splitting at byte boundaries would corrupt
+// multi-byte characters; rune slicing keeps the output well-formed.
+func TestTruncateSnippet(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ in string
+ max int
+ want string
+ }{
+ {"under cap", "short line", 120, "short line"},
+ {"exact cap", "abcde", 5, "abcde"},
+ {"over cap", "abcdefghij", 5, "abcde…"},
+ {"multibyte preserved", "café日本語abcde", 6, "café日本…"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := truncateSnippet(tc.in, tc.max)
+ if got != tc.want {
+ t.Errorf("truncateSnippet(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json b/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
index ccb90a03..cb4f2924 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
@@ -63,6 +63,9 @@
"search_calls_domain": false,
"sync_calls_domain": true
},
+ "print_json_filtered_check": {
+ "checked": 3
+ },
"reimplementation_check": {
"checked": 0,
"exempted_via_store": 0,
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/pass.json b/testdata/golden/expected/dogfood-verdict-matrix/pass.json
index a8c94e1a..15d537d0 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/pass.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/pass.json
@@ -50,6 +50,9 @@
"search_calls_domain": false,
"sync_calls_domain": true
},
+ "print_json_filtered_check": {
+ "checked": 2
+ },
"reimplementation_check": {
"checked": 0,
"exempted_via_store": 0,
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json b/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
index a36733a7..d796a421 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
@@ -57,6 +57,9 @@
"search_calls_domain": false,
"sync_calls_domain": true
},
+ "print_json_filtered_check": {
+ "checked": 3
+ },
"reimplementation_check": {
"checked": 0,
"exempted_via_store": 0,
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index e4873704..20ee0049 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -15,10 +15,7 @@
"total": 17
},
"dead_functions": {
- "dead": 1,
- "items": [
- "printJSONFiltered"
- ],
+ "dead": 0,
"total": 49
},
"dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
@@ -30,7 +27,6 @@
"with_examples": 0
},
"issues": [
- "1 dead helper functions found",
"example check skipped: could not build CLI binary: exit status 1"
],
"mcp_surface_parity": {
@@ -57,6 +53,9 @@
"search_calls_domain": false,
"sync_calls_domain": true
},
+ "print_json_filtered_check": {
+ "checked": 32
+ },
"reimplementation_check": {
"checked": 0,
"exempted_via_store": 0,
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index a438366f..30274a16 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -202,7 +202,7 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
report["version"] = version
if flags.asJSON {
- if err := flags.printJSON(cmd, report); err != nil {
+ if err := printJSONFiltered(cmd.OutOrStdout(), report, flags); err != nil {
return err
}
return doctorExitForFailOn(failOn, report)
diff --git a/testdata/golden/expected/generate-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index 4e2e1034..9f2f4e95 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -14,7 +14,7 @@
"breadth": 5,
"cache_freshness": 5,
"data_pipeline_integrity": 7,
- "dead_code": 4,
+ "dead_code": 5,
"doctor": 10,
"error_handling": 10,
"insight": 8,
@@ -28,11 +28,11 @@
"mcp_tool_design": 5,
"output_modes": 10,
"path_validity": 0,
- "percentage": 81,
+ "percentage": 82,
"readme": 8,
"sync_correctness": 10,
"terminal_ux": 8,
- "total": 81,
+ "total": 82,
"type_fidelity": 3,
"vision": 8,
"workflows": 10
← ba22f30a fix(cli): retro #421 WU-4, WU-6, WU-7 — search empty JSON, l
·
back to Cli Printing Press
·
fix(cli): emit mcp:read-only + plumb body fields in promoted 6aee6205 →