diff --git a/docs/ARTIFACT_CONTRACT.md b/docs/ARTIFACT_CONTRACT.md index 8ac126a..a10d415 100644 --- a/docs/ARTIFACT_CONTRACT.md +++ b/docs/ARTIFACT_CONTRACT.md @@ -62,7 +62,7 @@ Some commands expose a `--raw` mode for source-faithful content where transforma JTK and CFL both use text-first output. The `-o json` resource surface has been removed from both tools (JTK earlier, then CFL via #392); CFL retains `-o table` and `-o plain` only. JSON is reserved for control-plane envelopes (`cfl set-credential --json`, `jtk set-credential --json`) and round-trip payloads (`jtk automation export`). -`jtk` already implements presenter-owned text output broadly. `cfl` is still migrating toward that boundary; its target command/output contract is defined in `tools/cfl/internal/cmd/OUTPUT_SPEC.md` and is implemented incrementally via the presenter work tracked in #271. +`jtk` and `cfl` both use presenter-owned text output for default CLI rendering. `cfl`'s command/output contract is defined in `tools/cfl/internal/cmd/OUTPUT_SPEC.md`; its presenter-boundary guidance and documented exceptions live in `tools/cfl/internal/present/README.md`. **Text output modes:** - Default = focused output for human and agent consumption (defined per-command) diff --git a/docs/proofs/271-436-cfl-diagnostics-advisories.md b/docs/proofs/271-436-cfl-diagnostics-advisories.md index e9d69b0..dba24d9 100644 --- a/docs/proofs/271-436-cfl-diagnostics-advisories.md +++ b/docs/proofs/271-436-cfl-diagnostics-advisories.md @@ -19,8 +19,8 @@ The migrated commands now: - keep `cfl init` wizard output outside this ticket Pagination advisories and the `page edit --legacy` warning were already moved -to presenters in earlier child tickets. `#435` contains the exact command and -live proof for the legacy-editor advisory. +to presenters in earlier child tickets. `docs/proofs/271-435-cfl-mutation-success.md` +contains the exact command and live proof for the legacy-editor advisory. ## Verification Commands @@ -169,6 +169,9 @@ Transcript directory: /tmp/cfl-436-proof.u3byyu ``` +This path was an intentionally ephemeral local capture directory. The redacted +durable excerpts needed for review are included below. + Executed: ```bash diff --git a/docs/proofs/271-437-final-enforcement.md b/docs/proofs/271-437-final-enforcement.md new file mode 100644 index 0000000..94c4a21 --- /dev/null +++ b/docs/proofs/271-437-final-enforcement.md @@ -0,0 +1,293 @@ +# Proof: #437 Final `cfl` Presenter Boundary Enforcement + +## Scope + +This proof covers the final enforcement pass for parent `#271`. + +The final state is: + +- default `cfl` text output is presenter/renderer based +- commands orchestrate and call presenters +- presenters own wording, labels, ordering, stream routing, and output shape +- `present.Emit` is the intended write point for presenter-rendered output +- remaining direct-output exceptions are explicit and enforced + +## Child Proof Index + +- `docs/proofs/271-431-cfl-me.md` +- `docs/proofs/271-432-cfl-list-search.md` +- `docs/proofs/271-433-cfl-detail-config.md` +- `docs/proofs/271-434-cfl-page-view.md` +- `docs/proofs/271-435-cfl-mutation-success.md` +- `docs/proofs/271-436-cfl-diagnostics-advisories.md` + +Temporary transcript directories named in child proofs are intentionally +ephemeral. The proof files contain durable redacted excerpts and any created or +deleted IDs needed to audit behavior after `/tmp` files are gone. + +## Enforcement + +Executable enforcement was added in: + +```text +tools/cfl/internal/cmd/root/presenter_boundary_test.go +``` + +It scans all non-test production Go files under `tools/cfl/internal/cmd` using +the Go AST and fails on: + +- legacy `v.Table`, `v.Success`, `v.RenderKeyValue`, `v.RenderKeyValues`, + `v.Info`, `v.Warning`, `v.Error`, `v.Println`, or `v.Render` outside the + `init` exception +- command-local `fmt.Fprint*` writes to `opts.Stdout`, `opts.Stderr`, `v.Out`, + `os.Stdout`, or `os.Stderr` outside prompt/init exceptions +- bare `fmt.Print`, `fmt.Printf`, or `fmt.Println` outside `init` +- import-alias variants of `fmt`, `io`, `log`, and `os` +- `io.WriteString` or direct `.Write` calls to command output streams +- `log.Print*`, `log.Fatal*`, or `log.Panic*` output outside `init` +- `view.ValidateFormat` +- `opts.View()` outside `init` +- direct `shared/view` imports outside root/init exceptions + +Allowed exceptions: + +- `tools/cfl/internal/cmd/init/**`: interactive wizard and migration UX +- root `Options.View()` bridge while `init` remains on `shared/view` +- one-shot delete/config confirmation prompt text on `opts.Stderr` + +## Verification Commands + +Executed: + +```bash +rtk go test ./tools/cfl/internal/cmd/root ./tools/cfl/internal/present ./shared/present +``` + +Result: + +```text +Go test: 102 passed in 3 packages +``` + +Executed: + +```bash +rtk go test ./tools/cfl/... ./shared/... +``` + +Result: + +```text +Go test: 1637 passed in 34 packages +``` + +Executed: + +```bash +rtk golangci-lint run ./tools/cfl/... ./shared/... +``` + +Result: + +```text +golangci-lint: No issues found +``` + +Executed: + +```bash +rtk proxy git diff --check +``` + +Result: + +```text + +``` + +## Grep Evidence + +Executed: + +```bash +rtk rg -n '\bv\.(Table|Success|RenderKeyValue|RenderKeyValues|Info|Warning|Error|Println|Render)\b' tools/cfl/internal/cmd --glob '!**/*_test.go' +``` + +Result: + +```text +tools/cfl/internal/cmd/init/init.go:117: v.Error("Cannot resolve the shared credential store path: %v", err) +tools/cfl/internal/cmd/init/init.go:118: v.Error("Set XDG_CONFIG_HOME to an absolute path (or unset it), then re-run cfl init.") +tools/cfl/internal/cmd/init/init.go:141: v.Error("Could not prepare secure credential storage: %v", err) +tools/cfl/internal/cmd/init/init.go:329: v.Error("Could not construct API client: %v", err) +tools/cfl/internal/cmd/init/init.go:337: v.Error("Connection failed: %v", err) +tools/cfl/internal/cmd/init/init.go:338: v.Error("Check your credentials and try again") +tools/cfl/internal/cmd/init/init.go:342: v.Success("Connected to %s", cfg.URL) +tools/cfl/internal/cmd/init/init.go:351: v.Info("Saving credentials affects jtk (shared default section); proceeding under --non-interactive.") +tools/cfl/internal/cmd/init/init.go:364: v.Info("Initialization cancelled. No changes saved.") +tools/cfl/internal/cmd/init/init.go:380: v.Error("Saved the non-secret config to %s, but could not store the API token in the keyring: %v", sharedPath, err) +tools/cfl/internal/cmd/init/init.go:381: v.Error("Recover by storing just the token (no need to re-run init): `cfl set-credential --ref atlassian-cli/default --key api_token --stdin --overwrite` (reads stdin; use --from-env VAR for env-driven setup).") +tools/cfl/internal/cmd/init/init.go:384: v.Success("Configuration saved to %s (token stored in the OS keyring)", sharedPath) +tools/cfl/internal/cmd/init/init.go:392: v.Info("Skipping cleanup of %s under --non-interactive; remove manually if desired.", lp) +tools/cfl/internal/cmd/init/init.go:407: v.Error("Could not remove %s: %v", lp, err) +tools/cfl/internal/cmd/init/init.go:409: v.Info("Removed %s", lp) +tools/cfl/internal/cmd/init/init.go:417: v.Println("") +tools/cfl/internal/cmd/init/init.go:423: v.Println("") +tools/cfl/internal/cmd/init/init.go:424: v.Println("You're all set! Try running:") +tools/cfl/internal/cmd/init/init.go:425: v.Println(" cfl space list") +tools/cfl/internal/cmd/init/init.go:426: v.Println(" cfl page list --space ") +tools/cfl/internal/cmd/init/init.go:429: v.Println("") +tools/cfl/internal/cmd/init/init.go:430: v.Info("To switch back to basic auth later, run: cfl init --auth-method basic") +tools/cfl/internal/cmd/init/reconcile.go:57: v.Error("Shared credential store at %s is unreadable: %v", sharedPath, relErr) +tools/cfl/internal/cmd/init/reconcile.go:58: v.Error("Refusing to overwrite. Fix or remove the file, then re-run cfl init.") +tools/cfl/internal/cmd/init/reconcile.go:60: v.Error("Shared credential store relocation check failed: %v", relErr) +tools/cfl/internal/cmd/init/reconcile.go:61: v.Error("Refusing to mutate anything. Reconcile the named file(s), then re-run cfl init.") +tools/cfl/internal/cmd/init/reconcile.go:68: v.Error("Shared credential store at %s is unreadable: %v", sharedPath, err) +tools/cfl/internal/cmd/init/reconcile.go:69: v.Error("Refusing to overwrite. Fix or remove the file, then re-run cfl init.") +tools/cfl/internal/cmd/init/reconcile.go:77: v.Error("Shared credential store at %s is unreadable: %v", sharedPath, err) +tools/cfl/internal/cmd/init/reconcile.go:78: v.Error("Refusing to overwrite. Fix or remove the file, then re-run cfl init.") +tools/cfl/internal/cmd/init/reconcile.go:88: v.Error("Legacy cfl config at %s is unreadable: %v", cflLegacyPath, cflErr) +tools/cfl/internal/cmd/init/reconcile.go:89: v.Error("Refusing to overwrite. Fix or remove the file, then re-run cfl init.") +tools/cfl/internal/cmd/init/reconcile.go:96: v.Info("Note: sibling jtk config at %s is unreadable; ignoring. (%v)", jtkLegacyPath, jtkErr) +tools/cfl/internal/cmd/init/reconcile.go:118: v.Error("Could not relocate the shared credential store: %v", aerr) +tools/cfl/internal/cmd/init/reconcile.go:127: v.Error("Shared credential store at %s is unreadable: %v", sharedPath, err) +``` + +All matches are in the documented `init` exception. + +Executed: + +```bash +rtk rg -n 'fmt\.F(print|printf|println)\((opts\.(Stdout|Stderr)|v\.Out|os\.Stderr),\s*"' tools/cfl/internal/cmd --glob '!**/*_test.go' +``` + +Result: + +```text +tools/cfl/internal/cmd/space/delete.go:63: _, _ = fmt.Fprintf(opts.Stderr, "About to delete space: %s (%s)\n", space.Name, space.Key) +tools/cfl/internal/cmd/space/delete.go:64: _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") +tools/cfl/internal/cmd/attachment/delete.go:62: _, _ = fmt.Fprintf(opts.Stderr, "About to delete attachment: %s (ID: %s)\n", attachment.Title, attachment.ID) +tools/cfl/internal/cmd/attachment/delete.go:63: _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") +tools/cfl/internal/cmd/page/delete.go:63: _, _ = fmt.Fprintf(opts.Stderr, "About to delete page: %s (ID: %s)\n", page.Title, page.ID) +tools/cfl/internal/cmd/page/delete.go:64: _, _ = fmt.Fprint(opts.Stderr, "Are you sure? [y/N]: ") +``` + +All matches are documented confirmation prompt exceptions. The stricter +executable enforcement test also covers computed prompt messages such as +`config clear`. + +Executed: + +```bash +rtk rg -n 'view\.ValidateFormat|opts\.View\(|github.com/open-cli-collective/atlassian-go/view' tools/cfl/internal/cmd --glob '!**/*_test.go' +``` + +Result: + +```text +tools/cfl/internal/cmd/root/root.go:18: "github.com/open-cli-collective/atlassian-go/view" +tools/cfl/internal/cmd/init/init.go:105: v := opts.View() +tools/cfl/internal/cmd/init/init.go:322: v := opts.View() +tools/cfl/internal/cmd/init/reconcile.go:7: "github.com/open-cli-collective/atlassian-go/view" +``` + +All matches are root/init transitional exceptions. + +## Live Smoke + +The binary was rebuilt: + +```bash +rtk go build -o ./bin/cfl ./tools/cfl/cmd/cfl +``` + +Live smoke used `ATLASSIAN_API_TOKEN` from the local keychain. The token value +was not written to any proof file. + +Transcript directory: + +```text +/tmp/cfl-437-smoke.cZWWs7 +``` + +This path is intentionally ephemeral; durable excerpts are below. + +Discovered smoke inputs: + +```text +space key: ~595553618 +page id: 33110 +scratch page id: 3555033104 +``` + +Representative read commands: + +```bash +bin/cfl --no-color me +bin/cfl --no-color space list --limit 2 +bin/cfl --no-color page list --space '~595553618' --limit 2 +bin/cfl --no-color search --type page --limit 1 +bin/cfl --no-color page view 33110 +bin/cfl --no-color attachment list --page 33110 --limit 2 +``` + +Observed excerpts, with identity fields redacted where needed: + +```text +me stdout: + | | + +space list stdout: +ID KEY TYPE NAME +33015 ~595553618 personal Konstantin N +65554 ~531183998 personal Brandon Rumburg + +space list stderr: +Next page: cfl space list --cursor "" + +page list stdout: +ID TITLE STATUS +33110 Konstantin current +33111 Sample Pages current + +page list stderr: +(showing first 2 results, use --limit to see more) + +search stdout: +ID TYPE SPACE TITLE +753683 page ENG Onboarding + +search stderr: +(showing 1 of 2386 results, use --limit to see more) + +page view stdout: +Title: Konstantin +ID: 33110 +Space: ~595553618 (ID: 33015) +Version: 3 + +attachment list stderr: +No attachments found. +``` + +Representative mutation command: + +```bash +bin/cfl --no-color page create --space '~595553618' --title "CFL Final Enforcement Scratch 20260624181253" --file /tmp/cfl-437-smoke.cZWWs7/scratch.md +bin/cfl --no-color page delete 3555033104 --force +``` + +Observed mutation output: + +```text +Created page: CFL Final Enforcement Scratch 20260624181253 +ID: 3555033104 +URL: https://monitproduct.atlassian.net/wiki/spaces/~595553618/pages/3555033104/CFL+Final+Enforcement+Scratch+20260624181253 + +Deleted page: CFL Final Enforcement Scratch 20260624181253 (ID: 3555033104) +``` + +Cleanup result: + +- scratch page `3555033104` was deleted with `--force` +- no scratch Confluence artifacts remain from this proof run diff --git a/tools/cfl/internal/cmd/configcmd/clear_test.go b/tools/cfl/internal/cmd/configcmd/clear_test.go index fbb2cd5..af2e1ba 100644 --- a/tools/cfl/internal/cmd/configcmd/clear_test.go +++ b/tools/cfl/internal/cmd/configcmd/clear_test.go @@ -188,31 +188,3 @@ func TestRunClear_All_KeyringUnavailableStillReportsPlanAndCleansPlaintext(t *te testutil.Contains(t, errBuf.String(), "Note: the keyring could not be opened") testutil.Contains(t, errBuf.String(), "plaintext artifacts will still be cleaned") } - -func TestConfigDiagnosticsPresenterBoundaryGrepGate(t *testing.T) { - t.Parallel() - - testSource, err := os.ReadFile("test.go") //nolint:gosec // test reads package source. - testutil.RequireNoError(t, err) - clearSource, err := os.ReadFile("clear.go") //nolint:gosec // test reads package source. - testutil.RequireNoError(t, err) - - combined := string(testSource) + "\n" + string(clearSource) - for _, phrase := range []string{ - "Testing connection", - "Troubleshooting", - "Authenticated as", - "No stored API token", - "Cancelled. Nothing was cleared", - "Removed key", - "Removed the shared keyring bundle", - "Warning: this is the SHARED token", - "Note: %s still set in the environment", - } { - testutil.NotContains(t, combined, phrase) - } - - testutil.Equal(t, 1, strings.Count(string(clearSource), "fmt.Fprint(")) - testutil.Contains(t, string(clearSource), `fmt.Fprint(opts.Stderr, promptText+" [y/N]: ")`) - testutil.NotContains(t, string(testSource), "fmt.Fprint") -} diff --git a/tools/cfl/internal/cmd/root/presenter_boundary_test.go b/tools/cfl/internal/cmd/root/presenter_boundary_test.go new file mode 100644 index 0000000..8a432f3 --- /dev/null +++ b/tools/cfl/internal/cmd/root/presenter_boundary_test.go @@ -0,0 +1,259 @@ +package root + +import ( + "bytes" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +func TestCFLPresenterBoundaryEnforcement(t *testing.T) { + t.Parallel() + + cmdRoot := filepath.Clean("..") + fset := token.NewFileSet() + var violations []string + + err := filepath.WalkDir(cmdRoot, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + + rel := filepath.ToSlash(path) + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + if imp.Path.Value == `"github.com/open-cli-collective/atlassian-go/view"` && !allowedViewImport(rel) { + violations = append(violations, rel+": direct shared/view import outside root/init exception") + } + } + imports := boundaryImports(file.Imports) + + file, err = parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + pos := fset.Position(call.Pos()) + if violation := presenterBoundaryViolation(fset, rel, pos.Line, call, imports); violation != "" { + violations = append(violations, violation) + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("unexpected cfl presenter-boundary violations:\n%s", strings.Join(violations, "\n")) + } +} + +type importNames struct { + fmt map[string]bool + io map[string]bool + log map[string]bool + os map[string]bool +} + +func presenterBoundaryViolation(fset *token.FileSet, rel string, line int, call *ast.CallExpr, imports importNames) string { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return "" + } + + receiver := exprString(fset, sel.X) + methodName := sel.Sel.Name + location := rel + ":" + itoa(line) + + if receiver == "v" && legacyViewHelper(methodName) && !allowedInitException(rel) { + return boundaryMessage(location, "legacy shared/view helper v."+methodName+" outside init exception") + } + if receiver == "view" && methodName == "ValidateFormat" { + return boundaryMessage(location, "view.ValidateFormat is not allowed in cfl command output paths") + } + if methodName == "View" && !allowedInitException(rel) { + return boundaryMessage(location, "opts.View() is only allowed for root/init transitional exceptions") + } + if imports.fmt[receiver] && fmtOutputCall(methodName) && len(call.Args) > 0 { + target := exprString(fset, call.Args[0]) + if outputWriteTarget(target, imports) && !allowedPromptWrite(fset, rel, call) && !allowedInitException(rel) { + return boundaryMessage(location, "command-local "+methodName+" write to "+target+" is not presenter-owned") + } + } + if imports.fmt[receiver] && fmtBareOutputCall(methodName) && !allowedInitException(rel) { + return boundaryMessage(location, "command-local fmt."+methodName+" writes to process stdout/stderr outside presenter boundary") + } + if imports.io[receiver] && methodName == "WriteString" && len(call.Args) > 0 { + target := exprString(fset, call.Args[0]) + if outputWriteTarget(target, imports) && !allowedInitException(rel) { + return boundaryMessage(location, "command-local io.WriteString write to "+target+" is not presenter-owned") + } + } + if outputWriteTarget(receiver, imports) && methodName == "Write" && !allowedInitException(rel) { + return boundaryMessage(location, "command-local Write call on "+receiver+" is not presenter-owned") + } + if imports.log[receiver] && logOutputCall(methodName) && !allowedInitException(rel) { + return boundaryMessage(location, "command-local log."+methodName+" output is not presenter-owned") + } + + return "" +} + +func boundaryMessage(location, problem string) string { + return location + ": " + problem + "; use present.Emit or a tools/cfl/internal/present presenter" +} + +func allowedViewImport(rel string) bool { + return rel == "../root/root.go" || strings.HasPrefix(rel, "../init/") +} + +func allowedInitException(rel string) bool { + return strings.HasPrefix(rel, "../init/") +} + +func allowedPromptWrite(fset *token.FileSet, rel string, call *ast.CallExpr) bool { + if len(call.Args) == 0 || exprString(fset, call.Args[0]) != "opts.Stderr" { + return false + } + if rel == "../configcmd/clear.go" { + return len(call.Args) == 2 && exprString(fset, call.Args[1]) == `promptText + " [y/N]: "` + } + if isDeleteCommandFile(rel) { + if len(call.Args) < 2 { + return false + } + arg := exprString(fset, call.Args[1]) + return strings.Contains(arg, "About to delete") || strings.Contains(arg, "Are you sure? [y/N]:") + } + return false +} + +func isDeleteCommandFile(rel string) bool { + switch rel { + case "../space/delete.go", "../page/delete.go", "../attachment/delete.go": + return true + } + return false +} + +func legacyViewHelper(name string) bool { + switch name { + case "Table", "Success", "RenderKeyValue", "RenderKeyValues", "Info", "Warning", "Error", "Println", "Render": + return true + } + return false +} + +func fmtOutputCall(name string) bool { + switch name { + case "Fprint", "Fprintf", "Fprintln": + return true + } + return false +} + +func outputWriteTarget(target string, imports importNames) bool { + switch target { + case "opts.Stdout", "opts.Stderr", "v.Out": + return true + } + for osName := range imports.os { + if target == osName+".Stdout" || target == osName+".Stderr" { + return true + } + } + return false +} + +func fmtBareOutputCall(name string) bool { + switch name { + case "Print", "Printf", "Println": + return true + } + return false +} + +func logOutputCall(name string) bool { + switch name { + case "Print", "Printf", "Println", "Fatal", "Fatalf", "Fatalln", "Panic", "Panicf", "Panicln": + return true + } + return false +} + +func boundaryImports(imports []*ast.ImportSpec) importNames { + names := importNames{ + fmt: map[string]bool{}, + io: map[string]bool{}, + log: map[string]bool{}, + os: map[string]bool{}, + } + for _, imp := range imports { + path := strings.Trim(imp.Path.Value, `"`) + name := importName(imp, path) + if name == "" { + continue + } + switch path { + case "fmt": + names.fmt[name] = true + case "io": + names.io[name] = true + case "log": + names.log[name] = true + case "os": + names.os[name] = true + } + } + return names +} + +func importName(imp *ast.ImportSpec, path string) string { + if imp.Name != nil { + if imp.Name.Name == "_" || imp.Name.Name == "." { + return "" + } + return imp.Name.Name + } + idx := strings.LastIndex(path, "/") + if idx >= 0 { + return path[idx+1:] + } + return path +} + +func exprString(fset *token.FileSet, expr ast.Expr) string { + var buf bytes.Buffer + _ = printer.Fprint(&buf, fset, expr) + return buf.String() +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + var digits [20]byte + i := len(digits) + for v > 0 { + i-- + digits[i] = byte('0' + v%10) + v /= 10 + } + return string(digits[i:]) +} diff --git a/tools/cfl/internal/present/README.md b/tools/cfl/internal/present/README.md index ef85675..78787c8 100644 --- a/tools/cfl/internal/present/README.md +++ b/tools/cfl/internal/present/README.md @@ -11,12 +11,12 @@ It accepts a shared `present.OutputModel`, renders it with the shared pure renderer, and writes the split stdout/stderr result through root options. `tools/cfl/internal/cmd/root.Options` already exposes `RenderMode()` and -`RenderStyle()`. During migration, legacy `Options.View()` and new presenter -paths must derive from that one root mode so cfl does not grow competing output -policy knobs. +`RenderStyle()`. Legacy `Options.View()` remains only for documented +exceptions, primarily `cfl init`, and derives from that same root mode so cfl +does not grow competing output policy knobs. -Most cfl commands still use transitional `shared/view` helpers. This guide -describes the target migration boundary for the remaining #271 work. +Default cfl text output is presenter-backed after #271. This guide documents +the boundary that new command work must preserve. ## Target Pipeline @@ -103,14 +103,24 @@ These exceptions are allowed when deliberate and tested: - one-shot confirmation prompts such as delete confirmations - editor handoffs for page create/edit - browser handoffs such as `page view --web` -- source-faithful content output such as `page view --raw` or `--content-only` +- root `Options.View()` plumbing while an allowed exception still needs + `shared/view` Exceptions should stay small and named in code review. They must not become a general escape hatch for command-local formatting. -## Migration Inventory +Source-faithful modes such as `page view --raw` and `--content-only` are not +presenter-boundary exceptions. They are presenter/projection-owned output modes +whose content selection is intentional. -Migrate by output shape rather than by scattered helper replacement. +Progress messages that intentionally complete later may use +`present.MessageSection{NoNewline: true}`. The wording and stream still belong +to the presenter; commands should only decide when to emit the progress model. + +## Presenter Inventory + +Keep output ownership grouped by output shape rather than by scattered helper +replacement. ### Table/List @@ -120,8 +130,8 @@ Migrate by output shape rather than by scattered helper replacement. - `search` - `attachment list` -List presenters own headers, row fields, empty states, and pagination hints. -Preserve `-o plain` TSV semantics until there is a presenter-backed TSV path. +List presenters own headers, row fields, empty states, pagination hints, and +`-o plain` TSV semantics through the shared renderer. ### Detail @@ -161,8 +171,12 @@ messages. ## Verification Gates -Use these greps during #271 migration PRs. They should trend toward only -documented exceptions. +`tools/cfl/internal/cmd/root/presenter_boundary_test.go` is the authoritative +package-wide enforcement gate. It scans production command files with Go's AST +and allowlists only documented exceptions. + +Use these greps as human-readable proof commands. Unexpected matches should be +explained by the enforcement allowlist or fixed. The target patterns include legacy helpers such as `v.Table`, `v.Success`, and `v.RenderKeyValue`, plus direct writes such as `fmt.Fprint`, `fmt.Fprintf`, and @@ -191,3 +205,16 @@ Presenter tests should assert exact `present.OutputModel` values. Renderer tests should assert exact stdout/stderr strings for representative cfl shapes. Command tests should stay lighter and verify wiring, mode selection, exceptions, and preserved control-plane JSON/raw behavior. + +## #271 Proof Index + +- `docs/proofs/271-431-cfl-me.md` +- `docs/proofs/271-432-cfl-list-search.md` +- `docs/proofs/271-433-cfl-detail-config.md` +- `docs/proofs/271-434-cfl-page-view.md` +- `docs/proofs/271-435-cfl-mutation-success.md` +- `docs/proofs/271-436-cfl-diagnostics-advisories.md` + +Proof transcript directories under `/tmp` are intentionally ephemeral. The +proof files contain the durable redacted excerpts and created/deleted IDs needed +to audit behavior after those temporary directories disappear.