[object Object]

← 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

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 →