From d7bae5bb8efc81ccce8a86cf83a84945ff6697c1 Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 16:36:47 -0500 Subject: [PATCH 1/7] docs(cmd): give the `app listing` group real --help bodies, pinned to the caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pilot for the "move the README's command-reference prose into the command definitions" migration, scoped to ONE group before touching ~50 files. Measured on the tree at aeceb6b, across all 53 command nodes: - `Annotations` is set on ZERO nodes today. - 8 nodes have NO `Long` at all, 16 more are under 400 chars. - Three of the eight are in this group (set-icon, set-cover, add-screenshot): `--help` printed a single Short line, and the docs generator publishes exactly what `--help` prints. What changed, all in `app listing`: - set-icon / set-cover / add-screenshot get a Long and an Example (they had neither); status / rm-screenshot / reorder get an Example and, for reorder, the positional-ordering rule. - The accepted formats and the per-kind byte caps — which appeared NOWHERE in the help surface, only in the README — are now stated, and are COMPUTED from maxIconBytes/maxCoverBytes/maxScreenshotBytes through the same humanBytes the refusal message uses. `--help` now predicts the error text rather than quoting a separately-maintained figure. - Four pre-existing body lines that ran past 80 columns are rewrapped. `--help` cost, measured base vs HEAD over all 54 captured nodes: 7 nodes change and 47 are byte-identical; the tree grows 100,451 -> 104,190 bytes (+3.7%) and 1,987 -> 2,065 lines (+3.9%). Three guards, none subsuming the others: the caps guard pins help against the enforced constants (and cross-kind, since icon and screenshot share a cap); the completeness guard requires a Long, Short and Example on every node in the group, walking the real tree with a count floor; the budget guard caps a body at 1400 chars and 80 columns, counting RUNES — a byte count reported four 79-column em-dash lines as 81 and failed prose that was fine. Mutation-measured, checksum-gated so a no-op edit reports NOT-APPLIED rather than surviving: 9/9 mutants killed by the intended guard (stale literal + moved constant, dropped cap sentence, cross-kind copy-paste, group losing a cap, deleted Example, deleted Long, unregistered subcommand, over-wide line, blown budget); a comment-only null mutant survived. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/app_listing.go | 114 +++++++++++++-- internal/cmd/app_listing_help_test.go | 192 ++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 internal/cmd/app_listing_help_test.go diff --git a/internal/cmd/app_listing.go b/internal/cmd/app_listing.go index ea5910a..2447ce5 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 that fails those checks is refused as a usage error (exit 2) with nothing +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,23 @@ 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. The cover's cap is larger than the icon's +because a cover is stored at full resolution rather than inlined. + +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 +357,29 @@ 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 — and a listing holds up to 8. + +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 8-screenshot ceiling is NOT checked locally — this command does not count +the existing 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..1d04542 --- /dev/null +++ b/internal/cmd/app_listing_help_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "strings" + "testing" + "unicode/utf8" + + "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) + } + } + }) + } + + t.Run("group", func(t *testing.T) { + parent := nodes[""] + for _, want := range []string{ + humanBytes(maxIconBytes), + humanBytes(maxCoverBytes), + humanBytes(maxScreenshotBytes), + 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", want) + } + } + }) +} + +// 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: the first version of this guard reddened four + // PRE-EXISTING bodies that render inside 80 columns. + 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) + } +} From 1b30122d1e0833f17251e8b4aedc517ce9d8112b Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 16:38:20 -0500 Subject: [PATCH 2/7] docs(handoff): scope the Long/Annotations split from measurement, not assumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what the pilot measured, including two findings that reverse the handoff's own next-step 1: - `Long` is already 3x RICHER than the README table for the App-authoring commands (19,017 vs 6,255 chars over the 16 matched rows), so "migrate the README's prose into Long" was largely already done there. The real gap is the public read-API group, which has no table row at all. - `Annotations` cannot reach the docs site. The generator captures only `--help` and `__complete`, and cobra renders Annotations in neither — probed with a positive control. Recommendation: drop that half. - The blocker: `gen-appblocks-cli.mjs:781` parses the whole `Long` and then publishes only `short`. Measured end-to-end, the pilot's ~4,000 new chars of `Long` moved `cli.json` by +926 chars, all of it `examples`. Also marks #253 fixed (#264), re-verified with a positive control, and replaces the verify block's bare zero with the control that makes it mean something. Co-Authored-By: Claude Opus 5 (1M context) --- claudedocs/handoff-cli-docs-consolidation.md | 153 +++++++++++++++++-- 1 file changed, 142 insertions(+), 11 deletions(-) diff --git a/claudedocs/handoff-cli-docs-consolidation.md b/claudedocs/handoff-cli-docs-consolidation.md index e400420..4595cb7 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 @@ -55,8 +55,113 @@ 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. + +### 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 — #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` emits a raw NUL byte — filed as #253, still ships - **Symptom + exact repro:** `civitai login --help > out.txt` yields a file git, grep and `file(1)` classify as binary. @@ -118,14 +223,22 @@ 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). +1. 🔴 **Publish `Long` from the generator** (`civitai-developer-docs`) — emit + `longDescription: parseLongDescription(help)` at `gen-appblocks-cli.mjs:781` + and render it in ``. **Everything else in this workstream is + dead weight until this lands** — measured above, the pilot's 4,000 chars of + `Long` reached `--help` and 0 chars of `cli.json`. Cheap: the parser already + exists and is already called. +2. **Merge the pilot** (`zach/help-long-pilot`, 1 commit) — it stands on its own + for terminal users even before step 1. +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). 5. **Prune `claudedocs/`** — dated handoffs that belong in neither the repo nor the site. Coordinate: other sessions actively write here. @@ -203,6 +316,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 ``` From 870fefbea14272a4a34ccdf63e7c55f8c628d911 Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 16:38:31 -0500 Subject: [PATCH 3/7] docs(handoff): the #253 row still said unfixed Co-Authored-By: Claude Opus 5 (1M context) --- claudedocs/handoff-cli-docs-consolidation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claudedocs/handoff-cli-docs-consolidation.md b/claudedocs/handoff-cli-docs-consolidation.md index 4595cb7..4213bea 100644 --- a/claudedocs/handoff-cli-docs-consolidation.md +++ b/claudedocs/handoff-cli-docs-consolidation.md @@ -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`, From 0120106b1e0b3b2a1498347ab66ffdd76dc72274 Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 16:41:54 -0500 Subject: [PATCH 4/7] =?UTF-8?q?docs(handoff):=20quantify=20the=20generator?= =?UTF-8?q?=20fix=20=E2=80=94=2043,460=20chars=20already=20written?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added `longDescription: parseLongDescription(help)` to a scratch copy of gen-appblocks-cli.mjs and ran it against the pilot binary: 52/52 commands gain a longDescription and cli.json goes 42,784 -> 88,747 chars (+107%), publishing 43,460 chars of prose the CLI already carries. The one-line generator change is worth more than the migration it unblocks. Co-Authored-By: Claude Opus 5 (1M context) --- claudedocs/handoff-cli-docs-consolidation.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/claudedocs/handoff-cli-docs-consolidation.md b/claudedocs/handoff-cli-docs-consolidation.md index 4213bea..1b2636f 100644 --- a/claudedocs/handoff-cli-docs-consolidation.md +++ b/claudedocs/handoff-cli-docs-consolidation.md @@ -117,6 +117,15 @@ changes** (emit `longDescription: parseLongDescription(help)` alongside 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 From abbdf2645cc4434bc4920701dc81dda4c419084c Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 17:08:50 -0500 Subject: [PATCH 5/7] fix(cmd): correct two false help claims and close two guard holes an audit found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pre-merge audit of #274 falsified three things I had asserted. FALSE PROSE, now removed: - `set-cover` said its larger cap was "because a cover is stored at full resolution rather than inlined". `runSetMedia` sends cover AND screenshot down the identical IngestAssetFullRes path while the screenshot keeps the icon's 2 MiB cap, so the storage path does not explain the difference. An author reasoning from the stated rule would prepare a 3 MiB screenshot and be refused. The clause is deleted rather than reworded — the caps are the server's and do not follow from the path. - The group body hand-typed "(exit 2)", making a THIRD publishing surface for a contract `exitcodes_doc.go` exists to keep in one place, and dropping the exception both governed surfaces carry (an UNREADABLE file exits 1, not 2 — AGENTS.md item 24). Measured on the HEAD binary: oversize/empty/non-image/ directory/missing all exit 2, a mode-000 valid PNG exits 1. The sentence now names the checks and states no code. - `add-screenshot` promoted a hand-typed server constant ("up to 8") into two emphatic statements. It is now attributed to the server and dated. GUARD HOLES, now closed. Both were measured surviving a green suite: - `humanBytes(maxIconBytes)` and `humanBytes(maxScreenshotBytes)` are the SAME STRING ("2.0 MB"), so the group's three bare Contains checks enforced only two of three caps, and a per-kind body asserted to contain "2.0 MB" was satisfied by the other 2 MiB kind's sentence. Deleting the icon cap from the group, deleting the screenshot cap from it, and swapping add-screenshot's rule to kindIcon ALL survived. The group assertion is now cap-in-context and TestListingHelpNamesTheKindItDescribes requires each body to name its own asset and not a differently-capped sibling. - `listingImageFormats` was asserted against a Long built by interpolating the same constant — a constant compared with itself, which can never fail. Narrowing it to "png or jpeg" (understates) and widening it to claim avif (lies) both survived. TestListingImageFormatsMatchesTheDecoder now decodes a real PNG/JPEG/WebP header through appapi.DecodeImageInfo and carries a GIF negative control, so the mirror has a drift check. Also corrects the runes-vs-bytes comment's provenance: it said the byte-based first draft reddened "four PRE-EXISTING bodies", but three of those four bodies had no Long at all before this work. The genuinely pre-existing over-80-COLUMN lines number 3, not 4. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/app_listing.go | 16 +-- internal/cmd/app_listing_help_test.go | 162 +++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 14 deletions(-) diff --git a/internal/cmd/app_listing.go b/internal/cmd/app_listing.go index 2447ce5..2cb3ffa 100644 --- a/internal/cmd/app_listing.go +++ b/internal/cmd/app_listing.go @@ -82,7 +82,7 @@ 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 that fails those checks is refused as a usage error (exit 2) with nothing +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 @@ -328,8 +328,7 @@ 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. The cover's cap is larger than the icon's -because a cover is stored at full resolution rather than inlined. +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, @@ -357,8 +356,8 @@ func newAppListingAddScreenshotCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add-screenshot ", Short: "Add a screenshot (up to 8) with an optional caption", - Long: `Add a SCREENSHOT to your store listing's gallery. Screenshots are OPTIONAL — -they are not part of the publish floor — and a listing holds up to 8. + 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. @@ -369,9 +368,10 @@ Each run appends one screenshot; there is no bulk add. Use ` + "`civitai app listing rm-screenshot`" + ` to drop one — both take the screenshot ids that ` + "`civitai app listing status`" + ` prints. -The 8-screenshot ceiling is NOT checked locally — this command does not count -the existing gallery before uploading, so hitting the ceiling surfaces as a -server refusal after the ingest rather than as a local usage error. +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, diff --git a/internal/cmd/app_listing_help_test.go b/internal/cmd/app_listing_help_test.go index 1d04542..77c1136 100644 --- a/internal/cmd/app_listing_help_test.go +++ b/internal/cmd/app_listing_help_test.go @@ -1,10 +1,16 @@ package cmd import ( + "bytes" + "image" + "image/gif" + "image/jpeg" + "image/png" "strings" "testing" "unicode/utf8" + "github.com/civitai/cli/internal/appapi" "github.com/spf13/cobra" ) @@ -98,22 +104,159 @@ func TestListingHelpQuotesTheEnforcedCaps(t *testing.T) { }) } + // 🔴 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), - humanBytes(maxCoverBytes), - humanBytes(maxScreenshotBytes), + 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", want) + "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 `add-screenshot`'s `listingSourceRule(kindScreenshot)` to +// `kindIcon` SURVIVED the whole suite, because both render "2.0 MB". Requiring +// the body to name its own kind, and NOT to name a sibling that has a different +// cap, is what makes the swap observable — the cap string alone cannot. +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 +} + +func minimalGIF(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + if err := gif.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil); err != nil { + t.Fatalf("gif encode: %v", err) + } + return buf.Bytes() +} + // TestListingHelpBodiesAreComplete is the pilot's own contract: every node in // this group carries an authored Long AND an Example. // @@ -171,8 +314,15 @@ func TestListingHelpStaysWithinTheBudget(t *testing.T) { 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: the first version of this guard reddened four - // PRE-EXISTING bodies that render inside 80 columns. + // 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`", From 4429c8b367feed976059b377b829c7bfed65fab9 Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 17:10:52 -0500 Subject: [PATCH 6/7] docs: declare the icon<->screenshot swap an EQUIVALENT mutant, with the proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit reported it as a surviving hole. It is not: `humanBytes(maxIconBytes)` and `humanBytes(maxScreenshotBytes)` are both "2.0 MB", so the swap produces a DIFFERENT BINARY and BYTE-IDENTICAL rendered help — measured with `cmp` on the two `--help` outputs, with the differing binaries as the negative control. There is nothing for an assertion to observe, and asserting against a token that cannot vary would be a guard that cannot fail. What DOES protect it was measured separately: under that mutation, moving maxScreenshotBytes to 3 MiB reddens TestListingHelpQuotesTheEnforcedCaps with "does not quote the cap it enforces (3.0 MB)". The swap is invisible exactly while it is harmless. (The first attempt to test this said DIFFERS — because the baseline binary got built from the base clone rather than the worktree. The negative control that the two binaries are distinct builds is what makes the corrected answer evidence.) Also fixes the handoff: the next-steps list read 5,6,5 after an insertion, the retracted #253 heading still asserted "still ships", and step 1 now records that the generator fix shipped as docs PR #49 with its measured numbers and its one unguarded residual. Co-Authored-By: Claude Opus 5 (1M context) --- claudedocs/handoff-cli-docs-consolidation.md | 26 ++++++++++++-------- internal/cmd/app_listing_help_test.go | 22 ++++++++++++++--- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/claudedocs/handoff-cli-docs-consolidation.md b/claudedocs/handoff-cli-docs-consolidation.md index 1b2636f..4cb64e3 100644 --- a/claudedocs/handoff-cli-docs-consolidation.md +++ b/claudedocs/handoff-cli-docs-consolidation.md @@ -171,7 +171,7 @@ Closed by `9cfe468` (#264). Re-verified on a clean build at `aeceb6b`: `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` emits a raw NUL byte — filed as #253, still ships +### `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): @@ -232,14 +232,20 @@ The original diagnosis is kept below for the mechanism. ## Next steps (ranked) -1. 🔴 **Publish `Long` from the generator** (`civitai-developer-docs`) — emit - `longDescription: parseLongDescription(help)` at `gen-appblocks-cli.mjs:781` - and render it in ``. **Everything else in this workstream is - dead weight until this lands** — measured above, the pilot's 4,000 chars of - `Long` reached `--help` and 0 chars of `cli.json`. Cheap: the parser already - exists and is already called. -2. **Merge the pilot** (`zach/help-long-pilot`, 1 commit) — it stands on its own - for terminal users even before step 1. +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 @@ -248,7 +254,7 @@ The original diagnosis is kept below for the mechanism. 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). -5. **Prune `claudedocs/`** — dated handoffs that belong in neither the repo nor the +7. **Prune `claudedocs/`** — dated handoffs that belong in neither the repo nor the site. Coordinate: other sessions actively write here. ## Gotchas / decisions / dead-ends diff --git a/internal/cmd/app_listing_help_test.go b/internal/cmd/app_listing_help_test.go index 77c1136..1f538b3 100644 --- a/internal/cmd/app_listing_help_test.go +++ b/internal/cmd/app_listing_help_test.go @@ -134,10 +134,24 @@ func TestListingHelpQuotesTheEnforcedCaps(t *testing.T) { // hole: a per-kind body asserted only to contain "2.0 MB" is satisfied by the // OTHER 2 MiB kind's sentence. // -// Measured: swapping `add-screenshot`'s `listingSourceRule(kindScreenshot)` to -// `kindIcon` SURVIVED the whole suite, because both render "2.0 MB". Requiring -// the body to name its own kind, and NOT to name a sibling that has a different -// cap, is what makes the swap observable — the cap string alone cannot. +// 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 { From 953f1d9c173af5570bfb25ca3e51247e6e6e28a5 Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Fri, 7 Aug 2026 17:15:00 -0500 Subject: [PATCH 7/7] =?UTF-8?q?fix(test):=20hand-build=20the=20GIF=20contr?= =?UTF-8?q?ol=20=E2=80=94=20importing=20image/gif=20contaminated=20the=20p?= =?UTF-8?q?ackage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drift check's negative control used `gif.Encode`, which means importing `image/gif`. Its init() REGISTERS the GIF decoder process-wide, so a GIF stopped failing `civitai generate --image` via `image.ErrFormat` and started failing via the allowlist branch instead. That reddened three unrelated tests, one of which (TestSupportedImageFormats_DecoderRegistrationIsInLockstep) exists precisely to catch it: "the unreachability argument for the allowlist branch depends on this being the ErrFormat arm". A test fixture that mutates global decoder state changes the thing it measures. The control is now 13 hand-written GIF87a bytes (magic + logical screen descriptor) with the hazard documented at the fixture, and the three tests are green again. Verified after: `make ci` 18/18 packages ok, 0 FAIL; the GIF control still REFUSES (it is not passing vacuously); 29 subtests pass across the listing guards. Co-Authored-By: Claude Opus 5 (1M context) --- internal/cmd/app_listing_help_test.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/cmd/app_listing_help_test.go b/internal/cmd/app_listing_help_test.go index 1f538b3..a6d539c 100644 --- a/internal/cmd/app_listing_help_test.go +++ b/internal/cmd/app_listing_help_test.go @@ -3,7 +3,6 @@ package cmd import ( "bytes" "image" - "image/gif" "image/jpeg" "image/png" "strings" @@ -262,13 +261,24 @@ func minimalWebP(*testing.T) []byte { return b } -func minimalGIF(t *testing.T) []byte { - t.Helper() - var buf bytes.Buffer - if err := gif.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil); err != nil { - t.Fatalf("gif encode: %v", err) - } - return buf.Bytes() +// 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