Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions internal/archtest/baseline/fmtprint.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Baseline for archtest rule "fmtprint".
# Each line is <file>:<line> of a known existing violation.
# Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/...
internal/brew/brew.go:128
internal/brew/brew.go:157
internal/brew/brew.go:186
internal/brew/brew_install.go:36
internal/brew/brew_install.go:58
internal/brew/brew_install.go:81
internal/brew/brew_install.go:104
internal/brew/brew_install.go:107
internal/brew/brew_install.go:142
internal/brew/brew_install.go:222
internal/brew/brew_install.go:232
internal/brew/brew_install.go:239
internal/brew/brew_install.go:271
internal/brew/brew_install.go:275
internal/brew/brew_install.go:277
internal/cli/install.go:368
internal/cli/install.go:372
internal/cli/install.go:393
internal/cli/install.go:405
internal/cli/install.go:477
internal/cli/install.go:484
internal/cli/root.go:86
internal/cli/snapshot.go:256
internal/cli/sync_helpers.go:45
internal/cli/sync_helpers.go:50
internal/cli/sync_helpers.go:54
internal/cli/sync_helpers.go:60
internal/cli/sync_helpers.go:62
internal/cli/sync_helpers.go:66
internal/cli/sync_helpers.go:72
internal/cli/sync_helpers.go:79
internal/cli/sync_helpers.go:81
internal/cli/sync_helpers.go:85
internal/cli/sync_helpers.go:86
internal/cli/sync_helpers.go:87
internal/cli/sync_helpers.go:95
internal/cli/update.go:104
internal/diff/format.go:14
internal/diff/format.go:16
internal/diff/format.go:19
internal/diff/format.go:97
internal/diff/format.go:99
internal/diff/format.go:102
internal/diff/format.go:105
internal/diff/format.go:107
internal/diff/format.go:116
internal/diff/format.go:118
internal/diff/format.go:122
internal/diff/format.go:125
internal/diff/format.go:127
internal/diff/format.go:136
internal/diff/format.go:138
internal/diff/format.go:142
internal/diff/format.go:146
internal/diff/format.go:149
internal/diff/format.go:158
internal/diff/format.go:160
internal/diff/format.go:164
internal/diff/format.go:167
internal/diff/format.go:169
internal/diff/format.go:176
internal/diff/format.go:182
internal/diff/format.go:190
internal/diff/format.go:193
internal/diff/format.go:204
internal/diff/format.go:209
internal/dotfiles/dotfiles.go:62
internal/dotfiles/dotfiles.go:79
internal/dotfiles/dotfiles.go:106
internal/dotfiles/dotfiles.go:116
internal/dotfiles/dotfiles.go:127
internal/dotfiles/dotfiles.go:130
internal/dotfiles/dotfiles.go:203
internal/dotfiles/dotfiles.go:207
internal/dotfiles/dotfiles.go:222
internal/dotfiles/dotfiles.go:271
internal/dotfiles/dotfiles.go:280
internal/dotfiles/dotfiles.go:361
internal/dotfiles/dotfiles.go:425
internal/dotfiles/dotfiles.go:437
internal/dotfiles/dotfiles.go:440
internal/dotfiles/dotfiles.go:444
internal/dotfiles/dotfiles.go:446
internal/installer/installer.go:48
internal/installer/installer.go:50
internal/installer/installer.go:54
internal/installer/installer.go:128
internal/installer/installer.go:134
internal/installer/installer.go:140
internal/installer/installer.go:151
internal/installer/installer.go:158
internal/installer/installer.go:183
internal/installer/installer.go:192
internal/installer/installer.go:204
internal/installer/installer.go:214
internal/installer/installer.go:216
internal/installer/installer.go:220
internal/installer/installer.go:231
internal/installer/installer.go:241
internal/installer/installer.go:302
internal/installer/installer.go:304
internal/installer/installer.go:307
internal/installer/installer.go:337
internal/installer/step_git.go:11
internal/installer/step_git.go:16
internal/installer/step_git.go:22
internal/installer/step_git.go:27
internal/installer/step_git.go:35
internal/installer/step_packages.go:76
internal/installer/step_packages.go:82
internal/installer/step_packages.go:133
internal/installer/step_packages.go:138
internal/installer/step_packages.go:158
internal/installer/step_packages.go:204
internal/installer/step_packages.go:206
internal/installer/step_packages.go:208
internal/installer/step_packages.go:226
internal/installer/step_packages.go:227
internal/installer/step_shell.go:20
internal/installer/step_shell.go:43
internal/installer/step_shell.go:61
internal/installer/step_shell.go:92
internal/installer/step_shell.go:117
internal/installer/step_shell.go:158
internal/installer/step_shell.go:168
internal/installer/step_shell.go:223
internal/installer/step_system.go:21
internal/installer/step_system.go:25
internal/installer/step_system.go:39
internal/installer/step_system.go:49
internal/installer/step_system.go:53
internal/installer/step_system.go:59
internal/installer/step_system.go:61
internal/installer/step_system.go:70
internal/installer/step_system.go:76
internal/installer/step_system.go:77
internal/installer/step_system.go:91
internal/installer/step_system.go:95
internal/installer/step_system.go:105
internal/installer/step_system.go:121
internal/installer/step_system.go:127
internal/installer/step_system.go:136
internal/installer/step_system.go:155
internal/installer/step_system.go:169
internal/installer/step_system.go:176
internal/installer/step_system.go:185
internal/installer/step_system.go:187
internal/installer/step_system.go:196
internal/installer/step_system.go:202
internal/installer/step_system.go:203
internal/installer/step_system.go:217
internal/installer/step_system.go:221
internal/macos/macos.go:94
internal/macos/macos.go:141
internal/macos/macos.go:153
internal/npm/npm.go:95
internal/npm/npm.go:115
internal/npm/npm.go:131
internal/npm/npm.go:134
internal/npm/npm.go:167
internal/npm/npm.go:185
internal/npm/npm.go:242
internal/shell/shell.go:57
internal/shell/shell.go:129
internal/shell/shell.go:147
internal/shell/shell.go:166
internal/shell/shell.go:199
internal/shell/shell.go:202
internal/shell/shell.go:258
internal/shell/shell.go:274
internal/shell/shell.go:298
internal/shell/shell.go:301
internal/updater/updater.go:189
internal/updater/updater.go:234
internal/updater/updater.go:239
internal/updater/updater.go:251
internal/updater/updater.go:255
112 changes: 112 additions & 0 deletions internal/archtest/fmtprint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package archtest

import (
"go/ast"
"testing"
)

// fmtPrintAllowedPaths lists packages that are allowed to use fmt.Print* calls
// directly. internal/ui owns the output helpers and internal/archtest itself
// uses fmt.Fprintf to write baselines.
var fmtPrintAllowedPaths = []string{
"internal/ui", // owns the output helpers
"internal/archtest", // uses fmt.Fprintf internally for baseline writes
}

// isOsStderr reports whether the AST expression represents os.Stderr.
// Matches the selector expression os.Stderr, regardless of how "os" is
// aliased in the import block (we check the local name via importedAs).
func isOsStderr(gf goFile, expr ast.Expr) bool {
sel, ok := expr.(*ast.SelectorExpr)
if !ok {
return false
}
if sel.Sel.Name != "Stderr" {
return false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
local := importedAs(gf.file, "os")
return local != "" && ident.Name == local
}

// findFmtPrint finds forbidden fmt.Print*/fmt.F* calls in gf.
// - fmt.Print, fmt.Println, fmt.Printf: always forbidden (stdout only).
// - fmt.Fprint, fmt.Fprintln, fmt.Fprintf: forbidden unless first argument
// is os.Stderr (stderr is acceptable for debug/error output).
func findFmtPrint(gf goFile) []callSite {
local := importedAs(gf.file, "fmt")
if local == "" {
return nil
}

// stdoutFuncs always write to stdout — always forbidden.
stdoutFuncs := map[string]bool{
"Print": true,
"Println": true,
"Printf": true,
}
// writerFuncs take an io.Writer as first arg — forbidden unless that writer
// is os.Stderr.
writerFuncs := map[string]bool{
"Fprint": true,
"Fprintln": true,
"Fprintf": true,
}

var out []callSite
ast.Inspect(gf.file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != local {
return true
}
fn := sel.Sel.Name
if stdoutFuncs[fn] {
p := gf.fset.Position(call.Pos())
out = append(out, callSite{file: gf.path, line: p.Line})
return true
}
if writerFuncs[fn] {
// Only flag if the first argument is NOT os.Stderr.
if len(call.Args) == 0 || !isOsStderr(gf, call.Args[0]) {
p := gf.fset.Position(call.Pos())
out = append(out, callSite{file: gf.path, line: p.Line})
}
return true
}
return true
})
return out
}

// TestNoRawFmtPrint enforces the CLAUDE.md rule:
//
// UI output must always go through ui.* helpers; raw fmt.Print* calls
// are bugs in user-facing paths.
//
// fmt.Fprintf/Fprintln/Fprint to os.Stderr are exempt — stderr is acceptable
// for debug/error output.
func TestNoRawFmtPrint(t *testing.T) {
r := rule{
name: "fmtprint",
fix: "Use ui.* helpers (e.g. ui.Println, ui.Printf) instead of fmt.Print*. If writing to os.Stderr for debug/error output, use fmt.Fprintf(os.Stderr, ...) which is exempt.",
}
var violations []callSite
for _, gf := range productionFiles(t) {
if inAllowedPath(gf.path, fmtPrintAllowedPaths) {
continue
}
violations = append(violations, findFmtPrint(gf)...)
}
enforce(t, r, violations)
}
6 changes: 3 additions & 3 deletions internal/cli/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
)

func TestApplyEnvOverrides_SilentMode(t *testing.T) {
cfg := &config.Config{Silent: true}
cfg := &config.Config{InstallOptions: config.InstallOptions{Silent: true}}

t.Setenv("OPENBOOT_GIT_NAME", "Test User")
t.Setenv("OPENBOOT_GIT_EMAIL", "test@example.com")
Expand All @@ -26,7 +26,7 @@ func TestApplyEnvOverrides_SilentMode(t *testing.T) {
}

func TestApplyEnvOverrides_GitEnvIgnoredWhenNotSilent(t *testing.T) {
cfg := &config.Config{Silent: false}
cfg := &config.Config{InstallOptions: config.InstallOptions{Silent: false}}

t.Setenv("OPENBOOT_GIT_NAME", "Test User")
t.Setenv("OPENBOOT_GIT_EMAIL", "test@example.com")
Expand All @@ -48,7 +48,7 @@ func TestApplyEnvOverrides_UserFromEnv(t *testing.T) {
}

func TestApplyEnvOverrides_FlagTakesPrecedenceOverEnv(t *testing.T) {
cfg := &config.Config{Preset: "minimal", User: "bob"}
cfg := &config.Config{InstallOptions: config.InstallOptions{Preset: "minimal", User: "bob"}}

t.Setenv("OPENBOOT_PRESET", "full")
t.Setenv("OPENBOOT_USER", "alice")
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/snapshot_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func buildImportConfig(edited *snapshot.Snapshot, dryRun bool) *config.Config {
}
}

cfg := &config.Config{DryRun: dryRun}
cfg := &config.Config{InstallOptions: config.InstallOptions{DryRun: dryRun}}
cfg.SelectedPkgs = make(map[string]bool)

for _, name := range edited.Packages.Formulae {
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ func TestConfirmInstallation_DryRunSkipsPrompt(t *testing.T) {
func TestSaveSyncSourceIfRemote_NilRemoteConfigNoOp(t *testing.T) {
t.Setenv("HOME", t.TempDir())

c := &config.Config{RemoteConfig: nil}
c := &config.Config{}
// Should be a no-op — no panic, no file written.
saveSyncSourceIfRemote(c)

Expand All @@ -350,10 +350,12 @@ func TestSaveSyncSourceIfRemote_WithRemoteConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())

c := &config.Config{
User: "alice/dev-setup",
RemoteConfig: &config.RemoteConfig{
Username: "alice",
Slug: "dev-setup",
InstallOptions: config.InstallOptions{User: "alice/dev-setup"},
InstallState: config.InstallState{
RemoteConfig: &config.RemoteConfig{
Username: "alice",
Slug: "dev-setup",
},
},
}
saveSyncSourceIfRemote(c)
Expand Down
Loading
Loading