diff --git a/claudedocs/handoff-cli-docs-consolidation.md b/claudedocs/handoff-cli-docs-consolidation.md index e400420..4cb64e3 100644 --- a/claudedocs/handoff-cli-docs-consolidation.md +++ b/claudedocs/handoff-cli-docs-consolidation.md @@ -1,4 +1,4 @@ -# Handoff: cli-docs-consolidation — 2026-08-07 +# Handoff: cli-docs-consolidation — 2026-08-07 (r2) ## Goal Decide whether `civitai/cli`'s in-repo docs should be dropped in favour of the hosted @@ -40,7 +40,7 @@ table cells measure 60–2,263 chars (median ~400) against `Short` strings of 24 | #44 | docs | took the npm pin-freshness fetch out of the required `test-bridge` gate | | #42 | docs | **CLI reference 19 → 52 commands + global flags** | | #46 | docs | retired the hand-maintained flag tables from `site/guide/cli.md` | -| **issue #253** | cli | the `login --help` NUL byte (below) — **filed, unfixed** | +| **issue #253** | cli | the `login --help` NUL byte — filed here, **FIXED by #264** (`9cfe468`) | ### Verified live - Published reference documents `generate`, `workflows`, `download`, `models`, @@ -55,9 +55,123 @@ table cells measure 60–2,263 chars (median ~400) against `Short` strings of 24 - Forgejo downgrade (`civitai/civitai#3713`) **rolled out** — prod on `5.0.2249`, 6/6 pods. Released by another session. +## The Long/Annotations split — SCOPED AND PILOTED (2026-08-07, session 2) + +The premise of next-step 1 below was **half wrong, and the half that was wrong is +the half that mattered.** Measured on the whole tree at `aeceb6b`, 53 command +nodes, via a scratch test that dumps the real cobra tree (`Short`/`Long`/ +`Example`/`Annotations`) as JSON — not by parsing help text: + +| Fact | Measured | +|---|---| +| Nodes carrying an `Annotations` map | **0 of 53** | +| Nodes with NO `Long` at all | 8 | +| Nodes with a `Long` under 400 chars | 16 more | +| README table cells, 16 matched rows | 6,255 chars total | +| `Long` on those same 16 nodes | **19,017 chars** total | + +**`Long` is already 3× RICHER than the README table for the App-authoring +commands.** For 14 of the 16 matched rows the ratio is >1 (`app submit` 11×, +`app init` 9×). Only `whoami` is genuinely README-richer (590 vs 431). So +"migrate the README's prose into `Long`" is mostly ALREADY DONE where the handoff +assumed it was pending — and the real gap is the **public read-API group** +(`models`/`images`/`collections`/`creators`/`tags`/`users`/`model-versions`/ +`articles`), which owns 19 of the 24 empty-or-thin nodes and has **no table row +at all**. + +### 🔴 `Annotations` CANNOT reach the docs site. Measured, not inferred. + +The generator captures exactly two channels per node — `civitai --help` +and `civitai __complete ""` — and parses the text +(`gen-appblocks-cli.mjs:13-15`). Cobra's default help template does not render +`Annotations`. Probe: set `Annotations{"probe:visible-in-help": "ZZPROBEZZ"}` on +`workflows get` and rebuild — + +- `strings | grep -c ZZPROBEZZ` → **1** (the probe really applied) +- in `--help` → **0**; in `__complete` → **0** +- positive control, a word that IS in that help (`PRESIGNED`) → **1** +- `workflows get --help` is byte-identical with and without it (1512 both) + +So an `Annotations` half is a no-op for docs unless (a) a custom help template +renders them — at which point they are `Long` with extra steps and cost the same +terminal space, or (b) a `cli.json` export seam, which the gotchas below +**disprove**. **Recommendation: drop the `Annotations` half.** The split that +survives is `Long` (prose, budgeted) vs the *guide* (everything that does not fit). + +### 🔴 THE BLOCKER: the generator PARSES `Long` and then THROWS IT AWAY. + +`gen-appblocks-cli.mjs:781` — +`description: short || parseLongDescription(help).split('\n')[0] || ''` + +`parseLongDescription` captures the whole `Long` body, but it is only a +**fallback**, and even then only its **first line**. A subcommand always has a +`Short` from the parent's "Available Commands" block, so `short` always wins. +Measured end-to-end through the real generator with `CIVITAI_CLI_LIVE=1 +CIVITAI_CLI_BIN=`: the pilot added ~4,000 chars of `Long` and the +published `cli.json` gained **+926 chars, all of it `examples`**. The `Long` +reached `--help` and nothing else. + +**Nothing about this migration reaches developer.civitai.com until that line +changes** (emit `longDescription: parseLongDescription(help)` alongside +`description`, and render it in ``). That is a `civitai-developer-docs` +PR and it is the **prerequisite**, not a follow-up — do it before migrating any +further group. + +🔴 **AND THE FIX IS WORTH MORE THAN THE WHOLE MIGRATION.** Measured by adding that +one line to a scratch copy of the generator and running it against the pilot +binary: **52 of 52** commands gain a `longDescription`, publishing **43,460 chars +of prose that ALREADY EXISTS in the CLI today** — `cli.json` goes 42,784 → +88,747 chars (**+107%**). The largest are `generate` (5,726), `download` (3,742), +`app dev-token` (3,245), `app create` (2,385), `app validate` (2,293). None of +that required writing a word. Do this BEFORE authoring any more `Long`, or the +authoring is measured against a surface that discards it. + +### Pilot — `civitai app listing`, 7 nodes, on branch `zach/help-long-pilot` + +Chosen because it holds 3 of the 8 no-`Long` nodes, maps to the README's densest +single table row, and its missing facts (formats + per-kind byte caps) are **Go +constants**, so the help can be derived from the validator rather than duplicated. + +- set-icon / set-cover / add-screenshot gained a `Long` + `Example` (had neither). +- status / rm-screenshot / reorder gained an `Example`. +- Formats + caps now stated, **computed** from `maxIconBytes`/`maxCoverBytes`/ + `maxScreenshotBytes` through the same `humanBytes` the refusal message uses, so + `--help` predicts the error text. +- Four pre-existing lines over 80 columns rewrapped. + +**`--help` cost, base vs the committed pilot, all 54 captured nodes:** 47 nodes +byte-identical, 7 changed; tree 100,451 → 104,190 bytes (**+3.7%**) and +1,987 → 2,065 lines (**+3.9%**). Per node the group runs 16→32 lines (set-icon), +17→40 (add-screenshot, the largest). + +Three guards, none subsuming the others — caps-vs-constants (incl. cross-kind, +since icon and screenshot share a 2 MiB cap), completeness (Long+Short+Example on +every node, tree-walked with a count floor), budget (1400 chars / 80 columns). +**9/9 mutants killed by the intended guard, checksum-gated; a comment-only null +mutant survived.** `make ci` green, 18/18 packages. + +🔴 **Count RUNES, not bytes, in a column guard.** The first version used `len()` +and failed four PRE-EXISTING bodies that render inside 80 columns — em-dashes are +3 bytes, one column. + +### Where the remaining ~46 nodes stand + +| Bucket | Count | Nodes | +|---|---|---| +| No `Long` | 5 left | `collections get`, `creators search`, `model-versions get`, `models get`, `tags search` | +| Thin (<400) | 14 left | the rest of the read-API group | +| Mid (400–1200) | 17 left | mostly fine | +| Rich (>1200) | 11 | `generate` (5,726), `download` (3,742), root (4,203) — these need a BUDGET, not more prose | + ## Open investigations — live diagnosis state -### `civitai login --help` emits a raw NUL byte — filed as #253, still ships +### ~~`civitai login --help` emits a raw NUL byte — #253~~ — FIXED +Closed by `9cfe468` (#264). Re-verified on a clean build at `aeceb6b`: +`civitai login --help | tr -dc '\000' | wc -c` → **0** (positive control: +`printf 'a\000b' | tr -dc '\000' | wc -c` → 1, so the pipe is wired). +The original diagnosis is kept below for the mechanism. + +### `civitai login --help` NUL byte — the original diagnosis (mechanism only) - **Symptom + exact repro:** `civitai login --help > out.txt` yields a file git, grep and `file(1)` classify as binary. - **Observed (with values), on a CLEAN build from `origin/main`** (not a dirty tree): @@ -118,15 +232,29 @@ table cells measure 60–2,263 chars (median ~400) against `Short` strings of 24 ## Next steps (ranked) -1. **Migrate README command-reference prose into `Long` / `Annotations`** — the - actual answer to this session's question, and the only path that converges the - two surfaces without deleting the richer one. gh's - `Annotations["help:json-fields"]` is the working precedent for publishing - `--json` shapes from the command definition. -2. **Decide the IA wart** (above) — cheapest is option (c). -3. **Fix #253** in `civitai/cli` — one-line sentinel change + a regression test. -4. The two LOW items above (pins header comment; `parseSemver` build metadata). -5. **Prune `claudedocs/`** — dated handoffs that belong in neither the repo nor the +1. 🔴 **Publish `Long` from the generator** — **SHIPPED as docs PR #49** + (`zach/publish-long-description`), awaiting review. Emits + `longDescription: parseLongDescription(help)` alongside an untouched + `description`, and renders it in BOTH channels (`` and the + `.md`/LLM region — a Vue island's payload is invisible to the `.md` channel, + and `check:md-regions` blocks a PR that forgets it). Measured there: + `cli.json` 56,045 → 99,125 B (+76.9%), 52/52 commands gain a non-empty + `longDescription`, **0** commands change `description` or any other field. + 🔴 Residual it ships with, flagged not buried: **nothing in CI renders the + `.vue` SFC**, so deleting the render line leaves the whole node suite AND the + build green while the built page drops 44 → 0 blocks. Pre-existing (the + `.ab-example` block has the same gap); closing it needs a Vue test runner. +2. **Merge the pilot** (`zach/help-long-pilot` → cli PR #274) — it stands on its + own for terminal users, and now has a consumer once #49 lands. +3. **Then migrate the read-API group** (`models`/`images`/`collections`/ + `creators`/`tags`/`users`/`model-versions`/`articles`, 19 empty-or-thin nodes) + using the pilot's three guards as the template. Its source prose is + README lines 414–498, which has **no table row** — so unlike the App commands + this really is a migration rather than a re-statement. +4. **Do NOT add an `Annotations` half.** Measured disproof above. +5. **Decide the IA wart** (below) — cheapest is option (c). +6. The two LOW items below (pins header comment; `parseSemver` build metadata). +7. **Prune `claudedocs/`** — dated handoffs that belong in neither the repo nor the site. Coordinate: other sessions actively write here. ## Gotchas / decisions / dead-ends @@ -203,6 +331,24 @@ gh api repos/civitai/civitai-developer-docs/branches/main/protection --jq '.requ gh run list --repo civitai/civitai-developer-docs --workflow appblocks-drift.yml --limit 3 \ --json conclusion,event,createdAt -# #253 — the NUL bug still ships (expect 1 until it is fixed) -${R}/bin/civitai login --help | tr -dc '\000' | wc -c +# #253 — FIXED in #264; expect 0 now. The positive control matters: a pipe that +# reports 0 because it is wired to nothing looks identical to a fixed binary. +${R}/bin/civitai login --help | tr -dc '\000' | wc -c # expect 0 +printf 'a\000b' | tr -dc '\000' | wc -c # expect 1 (control) + +# the app-listing help pilot (branch zach/help-long-pilot) +git -C ${R} fetch origin -q +go test ./internal/cmd -run TestListingHelp -count=1 -v | grep -c -- '--- PASS' # expect 14 + +# 🔴 Annotations are invisible to the docs pipeline — re-measure before believing +# any plan that relies on them. Set one on any command, rebuild, then: +# strings | grep -c ZZPROBEZZ -> 1 (the probe applied) +# --help | grep -c ZZPROBEZZ -> 0 (it never reaches the channel) +# --help | grep -c -> 1 (control) + +# 🔴 The generator drops Long. This is the blocker, not a nicety. +command grep -n 'description: short ||' ${D}/scripts/gen-appblocks-cli.mjs +# End-to-end proof: generate from a binary with a fattened Long and diff cli.json — +# only `examples` moves. +CIVITAI_CLI_LIVE=1 CIVITAI_CLI_BIN= node ${D}/scripts/gen-appblocks-cli.mjs ``` diff --git a/internal/cmd/app_listing.go b/internal/cmd/app_listing.go index ea5910a..2cb3ffa 100644 --- a/internal/cmd/app_listing.go +++ b/internal/cmd/app_listing.go @@ -35,6 +35,27 @@ var ( scanPollTimeout = 120 * time.Second ) +// listingImageFormats is the accepted source set, mirroring what +// appapi.DecodeImageInfo will actually decode (png/jpeg/webp — the server's +// LISTING_ASSET_ALLOWED_MIME). Stated once so the six help bodies below cannot +// disagree with each other. +const listingImageFormats = "png, jpeg or webp" + +// listingSourceRule renders the one sentence describing what a source file for +// `kind` may be. +// +// 🔴 IT IS COMPUTED FROM THE CAP CONSTANTS AND MUST STAY THAT WAY — the number +// in the help has to be the number loadAndValidateImage enforces, and it is +// rendered through the SAME humanBytes the refusal message uses, so `--help` +// predicts the error text byte-for-byte ("larger than the 2.0 MB max") instead +// of quoting a differently-rounded prose figure. A hand-typed "2 MiB" here is a +// second copy of a constant: it reads fine, it survives every test that only +// greps for a cap being mentioned, and it goes stale the day a cap moves. +// TestListingHelpQuotesTheEnforcedCaps pins the coupling in both directions. +func listingSourceRule(kind mediaKind) string { + return fmt.Sprintf("%s, at most %s", listingImageFormats, humanBytes(int64(kindByteCap(kind)))) +} + // newAppListingCmd is the `civitai app listing` group: attach the store-listing // MEDIA (icon / cover / screenshots) an app needs to clear the publish floor, // without the browser. Operates on the app in the current directory (or --slug). @@ -46,18 +67,23 @@ func newAppListingCmd() *cobra.Command { a listing needs before it can publish. A store listing must have an ICON and a COVER before it can go live; screenshots -are optional. These commands ingest a local image, wait for the content scan, and -attach it to your listing — the same pipeline the web submit form uses. +are optional. These commands ingest a local image, wait for the content scan, +and attach it to your listing — the same pipeline the web submit form uses. For a listing that is already LIVE (approved), attaching media opens a REVISION that goes back to moderator review (the live listing is untouched until the revision is approved); pass --changelog to describe the change. The app is resolved from block.manifest.json in the current directory (or pass ---slug). Your store listing is created as a DRAFT when you run ` + "`civitai app submit`" + `, -so you can set its media WHILE your app is pending review — the media you attach -carries forward when a moderator approves it. Set it early to clear the publish -floor before you go live.`, +--slug). Your store listing is created as a DRAFT when you run +` + "`civitai app submit`" + `, so you can set its media WHILE your app is pending review +— the media you attach carries forward when a moderator approves it. Set it +early to clear the publish floor before you go live. + +Source files are checked locally BEFORE any upload — ` + listingImageFormats + `, at most +` + humanBytes(maxIconBytes) + ` for an icon, ` + humanBytes(maxCoverBytes) + ` for a cover, ` + humanBytes(maxScreenshotBytes) + ` for a screenshot. +A file in the wrong format, or over its cap, is refused before anything is +uploaded.`, Example: ` civitai app listing status civitai app listing set-icon ./assets/icon.png civitai app listing set-cover ./assets/cover.png @@ -151,12 +177,15 @@ func newAppListingStatusCmd() *cobra.Command { Long: `Show your store listing's attached media (icon, cover, screenshots) and what is still required before it can publish (an icon and a cover are mandatory). -Your store listing exists as a DRAFT from the moment you run ` + "`civitai app submit`" + `, -so this works while your app is still pending review. +Your store listing exists as a DRAFT from the moment you run +` + "`civitai app submit`" + `, so this works while your app is still pending review. Note: on a LIVE (approved) listing this opens an in-progress revision draft and reports ITS media (idempotent — it reuses any existing draft, and nothing is submitted for moderator review until you run a set-/add- command and confirm).`, + Example: ` civitai app listing status + civitai app listing status --slug my-app + civitai app listing status --dir ./my-app`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { client, err := newListingClient() @@ -262,7 +291,22 @@ func newAppListingSetIconCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set-icon ", Short: "Set the listing icon (a square-ish image)", - Args: cobra.ExactArgs(1), + Long: `Set your store listing's ICON — the small square-ish image shown beside your +app's name. An icon is MANDATORY: a listing cannot publish without one. + +The source file is validated locally first (` + listingSourceRule(kindIcon) + `), +then ingested, held until the content scan clears, and attached. Nothing is +uploaded if the local check fails. + +On a listing that is already LIVE this opens a REVISION for moderator re-review +instead of changing the live listing — pass --changelog to describe the change, +-y to skip the confirmation. On a DRAFT listing it attaches directly. + +Run ` + "`civitai app listing status`" + ` to see what the publish floor still needs.`, + Example: ` civitai app listing set-icon ./assets/icon.png + civitai app listing set-icon ./icon.png --slug my-app + civitai app listing set-icon ./icon.png --changelog "New brand mark" -y`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runSetMedia(cmd, kindIcon, args[0], "", lc, changelog, assumeYes) }, @@ -279,7 +323,22 @@ func newAppListingSetCoverCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set-cover ", Short: "Set the listing cover (a landscape hero image)", - Args: cobra.ExactArgs(1), + Long: `Set your store listing's COVER — the wide hero image at the top of the listing +page. A cover is MANDATORY: a listing cannot publish without one. + +The source file is validated locally first (` + listingSourceRule(kindCover) + `), +then ingested, held until the content scan clears, and attached. Nothing is +uploaded if the local check fails. + +On a listing that is already LIVE this opens a REVISION for moderator re-review +instead of changing the live listing — pass --changelog to describe the change, +-y to skip the confirmation. On a DRAFT listing it attaches directly. + +Run ` + "`civitai app listing status`" + ` to see what the publish floor still needs.`, + Example: ` civitai app listing set-cover ./assets/cover.png + civitai app listing set-cover ./cover.jpg --slug my-app + civitai app listing set-cover ./cover.png --changelog "Updated hero" -y`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runSetMedia(cmd, kindCover, args[0], "", lc, changelog, assumeYes) }, @@ -297,7 +356,30 @@ func newAppListingAddScreenshotCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add-screenshot ", Short: "Add a screenshot (up to 8) with an optional caption", - Args: cobra.ExactArgs(1), + Long: `Add a SCREENSHOT to your store listing's gallery. Screenshots are OPTIONAL: +they are not part of the publish floor. + +The source file is validated locally first (` + listingSourceRule(kindScreenshot) + `), +then ingested, held until the content scan clears, and appended to the gallery. +Nothing is uploaded if the local check fails. --caption adds a one-line caption. + +Each run appends one screenshot; there is no bulk add. Use +` + "`civitai app listing reorder`" + ` to change the order afterwards and +` + "`civitai app listing rm-screenshot`" + ` to drop one — both take the screenshot ids +that ` + "`civitai app listing status`" + ` prints. + +The gallery has a ceiling (8 at the time of writing) and it is the SERVER's, not +this CLI's: nothing here counts the gallery before uploading, so hitting the +ceiling surfaces as a server refusal after the ingest rather than as a local +usage error. + +On a listing that is already LIVE this opens a REVISION for moderator re-review +instead of changing the live listing — pass --changelog to describe the change, +-y to skip the confirmation. On a DRAFT listing it attaches directly.`, + Example: ` civitai app listing add-screenshot ./shot.png + civitai app listing add-screenshot ./grid.png --caption "Grid view" + civitai app listing add-screenshot ./shot.png --slug my-app`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runSetMedia(cmd, kindScreenshot, args[0], caption, lc, changelog, assumeYes) }, @@ -489,6 +571,8 @@ func newAppListingRmScreenshotCmd() *cobra.Command { Long: `Remove a screenshot from your listing by its screenshot id (the id shown by ` + "`civitai app listing status`" + `, e.g. alsc_...). Note: for a LIVE listing, direct screenshot edits are only possible while a revision is open.`, + Example: ` civitai app listing rm-screenshot alsc_01H8XYZ + civitai app listing rm-screenshot alsc_01H8XYZ --slug my-app`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, err := newListingClient() @@ -518,7 +602,13 @@ func newAppListingReorderCmd() *cobra.Command { Short: "Reorder screenshots (pass ALL current screenshot ids in the new order)", Long: `Reorder your listing's screenshots. Pass EXACTLY the current set of screenshot ids (from ` + "`civitai app listing status`" + `) in the desired order — a partial or -unknown set is rejected.`, +unknown set is rejected. + +Ordering is positional: the first id becomes the first screenshot in the +gallery. There is no "move one" form — read the current order out of +` + "`civitai app listing status`" + ` and pass the whole list back.`, + Example: ` civitai app listing reorder alsc_02 alsc_01 alsc_03 + civitai app listing reorder alsc_02 alsc_01 --slug my-app`, Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { client, err := newListingClient() diff --git a/internal/cmd/app_listing_help_test.go b/internal/cmd/app_listing_help_test.go new file mode 100644 index 0000000..a6d539c --- /dev/null +++ b/internal/cmd/app_listing_help_test.go @@ -0,0 +1,366 @@ +package cmd + +import ( + "bytes" + "image" + "image/jpeg" + "image/png" + "strings" + "testing" + "unicode/utf8" + + "github.com/civitai/cli/internal/appapi" + "github.com/spf13/cobra" +) + +// listingHelpNodes returns every command under `civitai app listing`, keyed by +// its leaf name, discovered by WALKING the real tree rather than from a +// hardcoded list. +// +// Walking matters: a hardcoded list turns a renamed or dropped command into a +// silently skipped row, and a skipped row is indistinguishable from a passing +// one. Every test below therefore also asserts a count, so a walk that found +// nothing cannot report a serene pass. +func listingHelpNodes(t *testing.T) map[string]*cobra.Command { + t.Helper() + var listing *cobra.Command + var find func(c *cobra.Command) + find = func(c *cobra.Command) { + if c.Name() == "listing" && c.Parent() != nil && c.Parent().Name() == "app" { + listing = c + return + } + for _, s := range c.Commands() { + find(s) + } + } + find(NewRootCmd()) + if listing == nil { + t.Fatal("could not find `civitai app listing` in the command tree") + } + out := map[string]*cobra.Command{"": listing} + for _, s := range listing.Commands() { + out[s.Name()] = s + } + return out +} + +// TestListingHelpQuotesTheEnforcedCaps pins the coupling between what `--help` +// PROMISES about a source file and what loadAndValidateImage actually ENFORCES. +// +// 🔴 THE POINT IS THE DIRECTION OF DERIVATION. The expected strings here are +// computed from maxIconBytes/maxCoverBytes/maxScreenshotBytes through the same +// humanBytes the refusal message uses — so moving a cap constant without moving +// the help reddens this test whether the help body computes its number or has a +// stale literal typed into it. A test that merely asserted "the help mentions a +// size" would pass over both. +// +// The cross-kind assertions are separate and are not redundant with the +// per-kind ones: icon and screenshot share a cap (2 MiB), so "set-cover quotes +// 4.0 MB" is satisfied by a body that ALSO quotes the icon's — the shape a +// copy-paste between the two bodies produces. +func TestListingHelpQuotesTheEnforcedCaps(t *testing.T) { + nodes := listingHelpNodes(t) + + cases := []struct { + cmd string + kind mediaKind + notCaps []string // caps that must NOT appear in this body + }{ + {"set-icon", kindIcon, []string{humanBytes(maxCoverBytes)}}, + {"set-cover", kindCover, []string{humanBytes(maxIconBytes)}}, + {"add-screenshot", kindScreenshot, []string{humanBytes(maxCoverBytes)}}, + } + if len(cases) != 3 { + t.Fatalf("expected 3 media commands under test, got %d", len(cases)) + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + c, ok := nodes[tc.cmd] + if !ok { + t.Fatalf("`app listing %s` is not in the command tree — a rename must "+ + "update this test, not silently drop the row", tc.cmd) + } + want := humanBytes(int64(kindByteCap(tc.kind))) + if !strings.Contains(c.Long, want) { + t.Errorf("`app listing %s` Long does not quote the cap it enforces (%s).\n"+ + "The help must state the SAME number loadAndValidateImage refuses on.\nLong:\n%s", + tc.cmd, want, c.Long) + } + if !strings.Contains(c.Long, listingImageFormats) { + t.Errorf("`app listing %s` Long does not state the accepted formats (%q)", + tc.cmd, listingImageFormats) + } + for _, bad := range tc.notCaps { + if bad == want { + continue // shared cap; nothing to distinguish + } + if strings.Contains(c.Long, bad) { + t.Errorf("`app listing %s` Long quotes %s, which is another kind's cap — "+ + "this is what a copy-paste between two media bodies looks like", tc.cmd, bad) + } + } + }) + } + + // 🔴 THE GROUP ASSERTION MUST BE CAP-IN-CONTEXT, NOT CAP-ALONE. + // + // `humanBytes(maxIconBytes)` and `humanBytes(maxScreenshotBytes)` are the SAME + // STRING today ("2.0 MB"), so three bare Contains checks over three strings — + // two of them byte-identical — enforce only TWO of the three caps. Measured: + // deleting the icon cap from the group body, and deleting the screenshot cap + // from it, BOTH survived a green suite, because the other one's identical + // text satisfied the check. Pairing each cap with its label is what makes the + // three assertions independent. + t.Run("group", func(t *testing.T) { + parent := nodes[""] + for _, want := range []string{ + humanBytes(maxIconBytes) + " for an icon", + humanBytes(maxCoverBytes) + " for a cover", + humanBytes(maxScreenshotBytes) + " for a screenshot", + listingImageFormats, + } { + if !strings.Contains(parent.Long, want) { + t.Errorf("`app listing` group Long does not state %q — the group page is where a "+ + "reader lands first, so all three caps belong there, each next to the kind "+ + "it applies to", want) + } + } + }) +} + +// TestListingHelpNamesTheKindItDescribes closes the other half of the shared-cap +// hole: a per-kind body asserted only to contain "2.0 MB" is satisfied by the +// OTHER 2 MiB kind's sentence. +// +// Measured: swapping `set-cover`'s `listingSourceRule(kindCover)` to `kindIcon` +// SURVIVED the original guard. Requiring the body to name its own kind, and NOT +// to name a sibling that has a DIFFERENT cap, is what makes such a swap +// observable — the cap string alone cannot. +// +// 🔴 THE ICON <-> SCREENSHOT SWAP IS A DECLARED **EQUIVALENT MUTANT**, NOT A +// SURVIVING HOLE, AND THE DIFFERENCE MATTERS TO WHOEVER EDITS THIS NEXT. +// `humanBytes(maxIconBytes)` and `humanBytes(maxScreenshotBytes)` are both +// "2.0 MB" today, so swapping `kindScreenshot` -> `kindIcon` produces a DIFFERENT +// BINARY and BYTE-IDENTICAL rendered help — measured, `cmp` on the two `--help` +// outputs, with the differing binaries as the negative control. There is nothing +// for any assertion to observe, which is why `foreign` deliberately names COVER +// (a differently-capped sibling) rather than the same-capped one: an assertion +// against a token that cannot vary is a guard that cannot fail. +// The protection against the swap MATTERING is elsewhere and was measured too — +// set maxScreenshotBytes to 3 MiB under that mutation and +// TestListingHelpQuotesTheEnforcedCaps reddens with "does not quote the cap it +// enforces (3.0 MB)". So the swap is invisible exactly while it is harmless. +func TestListingHelpNamesTheKindItDescribes(t *testing.T) { + nodes := listingHelpNodes(t) + cases := []struct { + cmd string + self string + // A sibling whose cap DIFFERS, so naming it is unambiguous evidence of a + // copy-paste rather than an incidental mention. + foreign string + }{ + {"set-icon", "ICON", "COVER"}, + {"set-cover", "COVER", "ICON"}, + {"add-screenshot", "SCREENSHOT", "COVER"}, + } + for _, tc := range cases { + t.Run(tc.cmd, func(t *testing.T) { + c, ok := nodes[tc.cmd] + if !ok { + t.Fatalf("`app listing %s` is not in the command tree", tc.cmd) + } + if !strings.Contains(c.Long, tc.self) { + t.Errorf("`app listing %s` Long never names %s — a body that does not say which "+ + "asset it is about cannot be distinguished from its sibling's", tc.cmd, tc.self) + } + if strings.Contains(c.Long, tc.foreign) { + t.Errorf("`app listing %s` Long names %s, a different asset with a different cap — "+ + "this is what a copy-paste between two media bodies looks like", tc.cmd, tc.foreign) + } + }) + } +} + +// TestListingImageFormatsMatchesTheDecoder is the drift check for the FOURTH +// thing this file states about images. +// +// 🔴 `listingImageFormats` is a prose MIRROR of what appapi.DecodeImageInfo will +// actually decode, and the caps guard above cannot see it: that assertion +// compares the constant against a Long built by interpolating the SAME constant, +// so it is a constant compared with itself and can never fail. Measured, both on +// a green suite: narrowing the constant to "png or jpeg" (the help then +// UNDERSTATES — webp is accepted) survived, and widening it to +// "png, jpeg or avif" (the help then LIES — avif is refused) survived. +// +// Unlike this repo's server-side mirrors there is a local counterpart to check +// against, so the drift is cheap to pin: decode a real header of each claimed +// format and refuse one that is not claimed. +func TestListingImageFormatsMatchesTheDecoder(t *testing.T) { + accepted := map[string][]byte{ + "png": minimalPNG(t), + "jpeg": minimalJPEG(t), + "webp": minimalWebP(t), + } + for name, data := range accepted { + t.Run("accepted/"+name, func(t *testing.T) { + if _, err := appapi.DecodeImageInfo(data); err != nil { + t.Fatalf("listingImageFormats claims %s is accepted, but DecodeImageInfo refused it: %v", + name, err) + } + if !strings.Contains(listingImageFormats, name) { + t.Errorf("DecodeImageInfo accepts %s but listingImageFormats (%q) does not name it — "+ + "the help UNDERSTATES what the CLI will take", name, listingImageFormats) + } + }) + } + // Negative control: a format the constant must NOT claim. Without this the + // test above is satisfied by a constant listing every format under the sun. + t.Run("refused/gif", func(t *testing.T) { + if _, err := appapi.DecodeImageInfo(minimalGIF(t)); err == nil { + t.Fatal("DecodeImageInfo accepted a GIF — this test's premise is stale, not the constant") + } + if strings.Contains(listingImageFormats, "gif") { + t.Errorf("listingImageFormats (%q) claims gif, which DecodeImageInfo refuses — "+ + "the help LIES about what it will take", listingImageFormats) + } + }) +} + +// Fixtures for the drift check above. They are REAL encoded images (or, for +// WebP, a real RIFF/VP8X container header), not magic-byte stubs — the point is +// to exercise the decoder the CLI actually calls, and a stub would only prove +// that a byte comparison works. +func minimalPNG(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatalf("png encode: %v", err) + } + return buf.Bytes() +} + +func minimalJPEG(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil); err != nil { + t.Fatalf("jpeg encode: %v", err) + } + return buf.Bytes() +} + +// minimalWebP is a VP8X (extended) container header — the sub-format that +// carries the canvas size directly, so a 30-byte header is a complete answer to +// DecodeImageInfo without encoding pixel data the stdlib cannot write. +func minimalWebP(*testing.T) []byte { + b := make([]byte, 30) + copy(b[0:4], "RIFF") + copy(b[8:12], "WEBP") + copy(b[12:16], "VP8X") + b[24], b[27] = 1, 1 // (width-1, height-1) little-endian -> 2x2 + return b +} + +// minimalGIF is hand-built, and 🔴 MUST NOT be replaced with `gif.Encode`. +// +// Importing `image/gif` anywhere in this package runs its init() and REGISTERS +// the GIF decoder process-wide, which changes how `civitai generate --image` +// refuses a GIF: it stops failing via `image.ErrFormat` and starts failing via +// the allowlist branch. That is not hypothetical — the first version of this +// file did import `image/gif` and reddened three unrelated tests, including +// TestSupportedImageFormats_DecoderRegistrationIsInLockstep, whose whole job is +// to catch exactly this ("the unreachability argument for the allowlist branch +// depends on this being the ErrFormat arm"). A test fixture that mutates global +// decoder state is a test that changes the thing it is measuring. +// +// These 13 bytes are a valid GIF87a header (magic + logical screen descriptor), +// which is all any header-only decoder would read. +func minimalGIF(*testing.T) []byte { + b := []byte("GIF87a") + b = append(b, 2, 0, 2, 0) // width=2, height=2 (little-endian uint16) + return append(b, 0x00, 0x00, 0x00) +} + +// TestListingHelpBodiesAreComplete is the pilot's own contract: every node in +// this group carries an authored Long AND an Example. +// +// Three of these commands (set-icon, set-cover, add-screenshot) shipped with +// NEITHER — cobra falls back to Short, so `--help` printed a single 41-character +// line and the docs generator, which parses that same help text, published a +// single line too. This test is what makes that state fail instead of pass. +// +// The floor is asserted so a walk that found nothing cannot read as clean. +func TestListingHelpBodiesAreComplete(t *testing.T) { + nodes := listingHelpNodes(t) + const wantNodes = 7 // the group + its six subcommands + if len(nodes) != wantNodes { + t.Fatalf("walked %d nodes under `app listing`, want %d — adding a command means "+ + "documenting it, so update this floor deliberately", len(nodes), wantNodes) + } + for name, c := range nodes { + label := "app listing " + name + t.Run(strings.TrimSpace(name), func(t *testing.T) { + if strings.TrimSpace(c.Long) == "" { + t.Errorf("%s has no Long — `--help` will print only its Short (%q), and the docs "+ + "generator publishes exactly what `--help` prints", label, c.Short) + } + if strings.TrimSpace(c.Example) == "" { + t.Errorf("%s has no Example", label) + } + if strings.TrimSpace(c.Short) == "" { + t.Errorf("%s has no Short", label) + } + }) + } +} + +// helpBodyBudget is the ceiling on a single leaf's rendered Long. +// +// It exists because the failure mode of "move the README's prose into Long" is +// NOT missing content — it is a `--help` nobody reads. The number is a budget, +// not a measurement: 1400 characters is roughly a terminal screen before the +// Usage/Flags blocks are added, and every body in this group is comfortably +// under it. Raise it deliberately, with a reason, rather than to make a new +// paragraph fit. +const helpBodyBudget = 1400 + +func TestListingHelpStaysWithinTheBudget(t *testing.T) { + nodes := listingHelpNodes(t) + if len(nodes) == 0 { + t.Fatal("no nodes walked") + } + var checked int + for name, c := range nodes { + body := strings.TrimSpace(c.Long) + if body == "" { + continue // TestListingHelpBodiesAreComplete owns that failure + } + checked++ + // 🔴 RUNES, NOT BYTES. These bodies are full of em-dashes (3 bytes, one + // column), so a byte count reports a 79-column line as 81 and fails prose + // that is fine. + // + // Measured on THIS tree: a byte count reddens 4 lines across 4 bodies + // (`app listing`, set-icon, set-cover, add-screenshot) that all render + // inside 80 columns. An earlier version of this comment called those four + // bodies PRE-EXISTING; that was wrong — three of them had no Long at all + // before this file was written. The 3 genuinely pre-existing over-80 + // COLUMN lines (two in the group body, one in `status`) are a separate + // set, and are rewrapped rather than excused. + if n := utf8.RuneCountInString(body); n > helpBodyBudget { + t.Errorf("`app listing %s` Long is %d chars, over the %d budget — "+ + "prose that does not fit a screen belongs in the guide, not in `--help`", + name, n, helpBodyBudget) + } + for _, line := range strings.Split(body, "\n") { + if n := utf8.RuneCountInString(line); n > 80 { + t.Errorf("`app listing %s` Long has an %d-column line (>80) — cobra does not wrap "+ + "Long, so it will hard-wrap in a standard terminal:\n%s", name, n, line) + } + } + } + if checked < 7 { + t.Fatalf("only %d bodies were measured, want 7 — a body that is empty is not a body "+ + "that is within budget", checked) + } +}