Quick — what does this call do?
v.validateDocument(raw, yaml.Unmarshal, "YAML", "dsl_version",
accepted, "control", true, "Fix control to match DSL schema", opts...)
What's the true? Is it strict? isYAML? overwrite? allowSymlink? You have to count parameters to find out. And if someone swaps the "YAML" and "dsl_version" strings, the compiler won't catch it — they're both string.
This is boolean blindness: when a function takes bool parameters whose meaning is invisible at the call site. The compiler sees true. The developer sees true. Neither sees "this enables YAML mode."
We found this pattern in five places and fixed each one differently.
1. Adjacent Bool Parameters — The File Safety Trap
Before: Three bools control security behavior
func SafeCreateFile(path string, perm os.FileMode, overwrite bool, allowSymlink bool) (*os.File, error) {
// ...
}
// At the call site:
f, err := fsutil.SafeCreateFile(outputPath, 0o600, false, false)
What do the two false values mean? The reader must look up the function signature. And if someone writes SafeCreateFile(path, 0o600, true, true), they've just enabled file overwriting AND symlink following — two security-sensitive behaviors activated by an unreadable pair of booleans.
After: Named options struct
type WriteOptions struct {
Perm os.FileMode
Overwrite bool
AllowSymlink bool
}
func DefaultWriteOpts() WriteOptions {
return WriteOptions{
Perm: 0o600,
Overwrite: false,
AllowSymlink: false,
}
}
func ConfigWriteOpts() WriteOptions {
return WriteOptions{
Perm: 0o644,
Overwrite: true,
AllowSymlink: false,
}
}
func SafeCreateFile(path string, opts WriteOptions) (*os.File, error) {
// ...
}
The call site is now self-documenting:
// Sensitive output: conservative defaults
f, err := fsutil.SafeCreateFile(outputPath, fsutil.DefaultWriteOpts())
// Config file: allow overwrite, broader permissions
f, err := fsutil.SafeCreateFile(configPath, fsutil.ConfigWriteOpts())
// Custom: explicit about what's enabled
f, err := fsutil.SafeCreateFile(path, fsutil.WriteOptions{
Perm: 0o600,
Overwrite: true, // explicitly named
AllowSymlink: false, // explicitly named
})
Named factory functions (DefaultWriteOpts, ConfigWriteOpts) capture common combinations. Most callers use a factory instead of constructing the struct — the boolean values are encapsulated behind a meaningful name.
Security benefit: A code reviewer sees ConfigWriteOpts() and knows it allows overwrite. They don't have to count booleans. If a new option is added (say, AppendMode), existing call sites don't change — they get the default value from the factory.
2. The quiet Bool — Wrong Abstraction Level
Before: Every renderer checks a boolean
func writeOutput(w io.Writer, format OutputFormat, quiet bool, delta Delta) error {
if quiet {
return nil
}
// ... render output
}
func RenderJSON(eval Evaluation, version string, w io.Writer, quiet bool) error {
if quiet {
return nil
}
// ... render JSON
}
func printScaffoldSummary(w, stderr io.Writer, req SummaryRequest, quiet bool) {
if quiet {
return
}
// ... print summary
}
Five renderers. Each takes quiet bool. Each has if quiet { return nil } at the top. The quiet behavior is identical in every renderer — but it's implemented five times.
After: Resolve the writer, not the boolean
func ResolveStdout(w io.Writer, quiet bool, format OutputFormat) io.Writer {
if quiet && !format.IsMachineReadable() {
return io.Discard
}
return w
}
The renderer no longer knows about quiet mode:
// BEFORE: renderer decides whether to write
func writeOutput(w io.Writer, format OutputFormat, quiet bool, delta Delta) error {
if quiet { return nil }
// ...
}
// AFTER: renderer writes to whatever it receives
func writeOutput(w io.Writer, format OutputFormat, delta Delta) error {
// ... always writes — if w is io.Discard, output is silently dropped
}
The caller resolves the writer once:
w := compose.ResolveStdout(cmd.OutOrStdout(), flags.Quiet, format)
writeOutput(w, format, delta)
Why this is better than removing the bool: The quiet decision is made once at the CLI boundary, not repeated in every renderer. The renderers are "dumb pipes" — they write to their writer without knowing or caring whether it's stdout or /dev/null. Adding a sixth renderer requires zero quiet-mode code.
The format-aware twist: ResolveStdout preserves JSON output even in quiet mode (IsMachineReadable()). A CI pipeline running stave apply --quiet --format json | jq .findings gets the JSON it needs. Only text output is silenced. This nuance was previously implemented inconsistently across the five renderers — one discarded JSON in quiet mode, breaking downstream tools.
3. Nine Positional Parameters — The Swap Hazard
Before: Strings and bools that look identical
func (v *Validator) validateDocument(
raw []byte,
unmarshal func([]byte, any) error,
formatName string, // "YAML" or "JSON"
versionField string, // "dsl_version" or "schema_version"
accepted []string, // ["ctrl.v1"] or ["obs.v0.1"]
kind string, // "control" or "observation"
isYAML bool, // true for YAML, false for JSON
defaultAction string, // "Fix control to match DSL schema"
opts ...Option,
) (*diag.Result, error) {
Nine parameters. Four are string. Swapping formatName with versionField compiles fine — both are string. The caller:
v.validateDocument(raw, yaml.Unmarshal, "YAML", "dsl_version",
accepted, "control", true, "Fix control to match DSL schema", opts...)
Which string is formatName? Which is kind? Which is defaultAction? You have to count. And if you miscount, the validator uses "YAML" as the version field name and "dsl_version" as the human-readable format label.
After: Config struct with named fields
type docConfig struct {
Unmarshal func([]byte, any) error
FormatName string
VersionField string
Accepted []string
Kind string
IsYAML bool
DefaultAction string
}
func (v *Validator) validateDocument(raw []byte, cfg docConfig, opts ...Option) (*diag.Result, error) {
// ...
}
The call site:
v.validateDocument(raw, docConfig{
Unmarshal: yaml.Unmarshal,
FormatName: "YAML",
VersionField: "dsl_version",
Accepted: []string{string(kernel.SchemaControl)},
Kind: string(schemas.KindControl),
IsYAML: true,
DefaultAction: "Fix control to match DSL schema",
}, opts...)
Every value is labeled. FormatName: "YAML" can't be confused with Kind: "control". Adding a new parameter requires no change to existing call sites — it gets the zero value default.
4. Two Adjacent Bools — The Silent Swap
Before: apply and force are next to each other
func resolveMode(apply, force bool, archiveDir string) (Mode, bool) {
if !apply || !force {
return ModePreview, false
}
if archiveDir != "" {
return ModeArchive, true
}
return ModePrune, true
}
At the call site:
mode, proceed := resolveMode(true, false, archiveDir)
Which true is apply? Which false is force? Swap them and the logic inverts — but it compiles fine.
After: Named parameters via struct
type ModeRequest struct {
Apply bool
Force bool
ArchiveDir string
}
func resolveMode(req ModeRequest) (Mode, bool) {
if !req.Apply || !req.Force {
return ModePreview, false
}
if req.ArchiveDir != "" {
return ModeArchive, true
}
return ModePrune, true
}
Call site:
mode, proceed := resolveMode(ModeRequest{
Apply: true,
Force: false,
ArchiveDir: archiveDir,
})
No swap possible. Apply: true is unambiguous. If someone adds a DryRun bool to ModeRequest, existing call sites don't change.
5. The FindingWriterFactory — Hidden Bool in a Function Type
Before: Bool parameter in a type alias
type FindingWriterFactory = func(OutputFormat, bool) (FindingMarshaler, error)
What's the bool? At every call site:
marshaler, err := factory(format, false)
Is false JSON mode? Pretty print? Strict validation? You have to read the factory implementation to find out.
After: Explicit at the call site
The fix here wasn't changing the type — it was eliminating the bool's reason for existing. The bool was isJSONMode, which duplicated information already in OutputFormat:
// BEFORE: redundant bool
marshaler, err := factory(format, cfg.IsJSONMode)
// AFTER: format already carries the information
marshaler, err := factory(format, false) // isJSONMode removed from config
The false literal is still there, but it's now always false — the JSON mode logic was consolidated into OutputFormat.IsJSON(). The next step is to remove the parameter entirely and update the function type.
The lesson: Before adding a parameter struct, ask whether the bool duplicates information that's already available. Sometimes the fix is removing the bool, not wrapping it.
The Decision Tree
Does the function take 2+ adjacent bools?
├── YES → Replace with an options struct
│
Does the function take 4+ parameters including bools and strings?
├── YES → Replace all params with a config struct
│
Is the bool controlling "output/no output"?
├── YES → Resolve the io.Writer at the boundary, not in the renderer
│
Does the bool duplicate info available elsewhere?
├── YES → Remove the bool, use the existing source
│
Is it a single bool with a clear name in a 2-3 param function?
└── YES → Keep it. Not everything needs a struct.
That last case matters. This is fine:
func (s Severity) Gte(other Severity) bool { return s >= other }
func (a Audience) IsExternal() bool { return a != AudiencePrivate }
func (ctl *ControlDefinition) IsEvaluatable() bool { ... }
Single bool return values and single bool parameters on short functions are readable. The problem is adjacent bools, positional bools, and bools that control behavior the caller should express differently.
These boolean blindness fixes were applied across Stave, a Go CLI for offline security evaluation. The WriteOptions struct replaced 3 security-sensitive bools with named fields and factory defaults. The quiet-to-io.Discard pattern eliminated 5 duplicate if quiet { return } checks. The docConfig struct replaced a 9-parameter function with labeled fields.
Top comments (0)