Skip to content

v3 quality pass: architectural refactor & bug fixes & lint cleanup#174

Open
ocean wants to merge 16 commits into
ahoy-cli:v3-candidatefrom
ocean:master
Open

v3 quality pass: architectural refactor & bug fixes & lint cleanup#174
ocean wants to merge 16 commits into
ahoy-cli:v3-candidatefrom
ocean:master

Conversation

@ocean

@ocean ocean commented Jun 17, 2026

Copy link
Copy Markdown
Member

This branch resolves 19 issues identified across several in-depth agent-assisted code reviews, covering correctness bugs, architectural problems, and test quality across the ahoy CLI codebase.

Architectural changes

  • Encapsulate global state into appState struct - removes all seven mutable package-level globals (sourcefile, verbose, AhoyConf, importVisited, etc.) and converts functions to methods. Each invocation and test now gets an isolated instance; no more save/restore patterns in tests.
  • setupApp() no longer owns execution, moved rootCmd.Execute() and os.Exit() calls out of setupApp() into main(), making the function testable and fixing a pipe-drain bypass on the no-config path.
  • getConfigPath() sentinel error, returns ("", errNoConfig) instead of ("", nil) when no .ahoy.yml is found, giving callers an idiomatic error check rather than a string-empty check.

Bug fixes

  • Env-file vars now correctly override inherited environment on macOS (first-match getenv semantics)
  • Download size limit (5 MB) on ahoy init to prevent unbounded response body reads
  • GitCommit, GitBranch, BuildTime vars declared so ldflags injection is no longer silently dropped
  • getConfig() error message names the failing import file, not the root config
  • normaliseLongFlagPrefixes() no longer corrupts the bare -- end-of-options separator
  • Pre-release version comparison is now numeric-aware (rc10 > rc9)
  • Makefile install target uses $(BINARY_NAME) instead of hardcoded ahoy
  • getEnvironmentVars() warns and skips malformed lines (no KEY=VALUE separator)
  • Dead PrintValidationIssues() function removed

Test & lint

  • appRun() test helper handles --file=value / -f=value equals-form flags
  • All golangci-lint errcheck, unconvert, staticcheck (QF1003), and noctx findings resolved
  • HTTP download uses NewRequestWithContext for proper context propagation

Test status

  • 132 BATS integration tests: all passing
  • Go unit tests: all passing
  • gofmt, staticcheck, go vet: clean

Summary by CodeRabbit

Release Notes

  • New Features

    • Added build metadata variables: GitCommit, GitBranch, and BuildTime.
    • Structured configuration validation reporting with improved diagnostics.
  • Bug Fixes

    • Fixed pre-release version comparison ordering (e.g., rc10 now correctly sorts after rc9).
    • Added 5 MB size limit to configuration file downloads to prevent unbounded responses.
    • Improved error logging and circular import detection.
  • Chores

    • Updated build installation to use binary name variable for cross-platform compatibility.

ocean added 16 commits June 16, 2026 12:31
An uncapped io.Copy allowed a malicious or slow server to write an
arbitrarily large file when running `ahoy config init <url>`, exhausting
disk space. Wrap the response body with io.LimitReader before copying.
The Makefile injected these three symbols via -X ldflags but they were
never declared in Go source, so the linker silently dropped them. Every
shipped binary was missing build provenance metadata.
The previous append(command.Environ(), cmdEnvVars...) placed user vars
last. Linux uses last-match semantics so it worked there, but macOS
getenv(3) returns the first match, silently ignoring the overrides.

Replace with a deduplicating merge: cmdEnvVars go first, then inherited
entries are added only when their key is not already present.
setupApp() was calling rootCmd.Execute() and os.Exit(0) internally when
no .ahoy.yml was found, bypassing main()'s pipe-drain goroutine and
making setupApp() impossible to unit-test as a pure builder. It now
returns rootCmd early and main() handles execution uniformly.
…onfig

When an imported file had the wrong ahoyapi version the error embedded
the global `sourcefile` (root config path) instead of the `file`
argument passed to getConfig(), sending the user to the wrong file.
…efixes

The prefix rewriter converted -- to a single -, corrupting the standard
end-of-options sentinel before it reached Cobra. Any arguments after --
that look like flags would be incorrectly parsed as ahoy flags.
The live code path uses PrintConfigReport() which writes to stdout.
PrintValidationIssues() was never called and wrote to stderr, so any
future caller would silently break scripts that pipe ahoy config validate.
…hically

String comparison caused rc10 < rc9 because "1" < "9". Split pre-release
labels on "." and compare numeric-looking segments as integers so that
rc10 > rc9 and 10 > 9 compare correctly.
On Windows the built binary is ahoy.exe, so `cp ahoy` silently failed.
Use $(BINARY_NAME) which is set to ahoy.exe on Windows_NT and ahoy elsewhere.
Lines without '=' were passed verbatim into exec.Cmd.Env, silently
creating invalid entries. A common cause is shell 'export KEY=VALUE'
syntax which is not supported. Now logs a warning and skips the line.
The hand-rolled flag parser only recognised the space-separated forms
(--file <value>, -f <value>). The equals form would have been treated as
a command argument, corrupting the call silently.
Remove all mutable package-level globals (sourcefile, verbose,
ahoyExecutable, importVisited, AhoyConf, versionFlagSet,
helpFlagSet, bashCompletionFlagSet, invalidFlagError) and replace
with an appState struct whose methods carry state through the call
chain. main() creates a fresh appState and calls setupApp; tests
create isolated appState instances, eliminating the save/restore
patterns that caused test-order sensitivity.
Replace the ambiguous ("", nil) return from getConfigPath when no
.ahoy.yml is found with ("", errNoConfig). setupApp now tests with
errors.Is(err, errNoConfig) so "no config" is clearly distinct from
genuine filesystem errors, and the srcFile empty-string check is
removed.
…x issues

- Check all cmd.Help() and rootCmd.Help() return values
- Consolidate four MarkHidden calls into a loop with _ = discard
- Log error from stderr drain goroutine io.Copy
- Replace if/else if entrypoint placeholder swap with tagged switch (QF1003)
- Use http.NewRequestWithContext instead of client.Get to satisfy noctx
- Remove unnecessary string() and []byte() conversions in tests
- Handle os.Chdir and testFile.Write return values in tests
- Discard cmd.Execute() return in appRun test helper
Signed-off-by: Drew Robinson <drew.robinson@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Replaces all package-level mutable globals in the ahoy CLI with a per-invocation appState struct. Flag parsing, config discovery, import handling, env loading, command building, and Cobra hook wiring are all moved onto appState methods. Config validation gains a structured ConfigReport API. downloadFile gains a context-backed request and a 5 MB body cap.

Changes

appState Refactor, Validation Reporting, and Download Hardening

Layer / File(s) Summary
appState struct, errNoConfig sentinel, and build metadata
ahoy.go
Introduces errNoConfig, GitCommit/GitBranch/BuildTime link-time variables, and the appState struct with a per-instance logger helper, replacing all package-level runtime globals.
Flag pre-parser and env fallback converted to appState methods
flag.go
initFlags, resetFlagState, newLegacyFlagSet, and applyEnvFallbacks become appState methods writing into s fields; the -- terminator is now preserved during legacy flag normalisation.
Config discovery and loading on appState
ahoy.go
getConfigPath and getConfig move to appState, using s.sourcefile first, then walking up for .ahoy.yml and returning errNoConfig when absent; YAML parsing validates AhoyAPI == v2.
Import processing, env loading, and command building on appState
ahoy.go
processImport/getSubCommands use s.importVisited for circular-import detection; getEnvironmentVars warns on malformed lines; getCommands threads s.srcDir through env-file loading.
Command construction and subprocess env merge
ahoy.go
RunE filters -- separators; cmdEnvVars is made highest-precedence by key-collation; inherited env entries are appended only for non-overridden keys; AHOY_CMD is injected from s.ahoyExecutable.
Cobra wiring: setupApp, noArgsAction, beforeCommand, and main
ahoy.go
noArgsAction/beforeCommand become appState methods; setupApp initialises s.importVisited and config; main instantiates appState, routes flags/version/help; stderr-drain goroutine logs io.Copy failures.
Structured config validation report API
config_validation.go, config.go
Removes PrintValidationIssues; adds ConfigReport, EnvFileStatus, ImportFileStatus, RunConfigValidate, PrintConfigReport; fixes compareVersions pre-release ordering; validateCommandAction becomes an appState method.
downloadFile hardening: context and size cap
config_init.go, config_init_test.go
downloadFile uses http.NewRequestWithContext, returns an error on request creation failure, and caps the response body to 5 MB via io.LimitReader.
Test suite updated to use appState instances
ahoy_test.go, cli_parsing_test.go, description_test.go, windows_test.go, tests/missing-cmd.bats
All test files replace package-level globals with appState instances or newAppState() calls; appRun uses newAppState().setupApp; circular-import and multi-branch tests seed appState directly.
Makefile install uses BINARY_NAME
Makefile
The install rule copies $(BINARY_NAME) instead of the hard-coded ahoy filename, supporting platform-specific binary names.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ahoy-cli/ahoy#169: Adds BATS coverage for verbose flag position and -- passthrough behaviour, which directly overlaps with the flag.go legacy pre-parser and -- terminator handling refactored in this PR.
  • ahoy-cli/ahoy#170: Changes ahoy.go command execution to inject and manage AHOY_CMD/AHOY_COMMAND_NAME in the subprocess environment, the same area this PR reworks via s.ahoyExecutable and the key-collation env merge.

Suggested labels

ready-for-merging

Poem

🐰 Hoppity-hop, the globals are gone,
Each appState instance carries its own!
No more shared vars to tangle the run,
Circular imports detected — and done.
The rabbit cheers: clean state, every one! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarises the main thrust of the changeset—a comprehensive quality pass encompassing architectural refactoring, bug fixes, and lint cleanup—which aligns with the objectives and file-level changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
config_init_test.go (1)

48-66: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a regression test for bodies larger than the download cap.

The suite currently validates status/scheme errors but not the new max-size guard. Please add a test server that returns >5 MB and assert downloadFile returns an error and does not finalise the destination file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config_init_test.go` around lines 48 - 66, Add a new test function (similar
to TestDownloadFile_200Response) that validates the max-size guard for
downloadFile. Create a test server that responds with a body larger than 5 MB,
then call downloadFile with the test server URL and a temporary file path.
Assert that downloadFile returns an error (indicating the download was rejected
due to size), and verify using fileExists that the destination file was not
created, ensuring the function properly rejects and does not finalize files
exceeding the size limit.
ahoy_test.go (1)

316-326: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve cmd.Execute() errors in appRun instead of discarding them.

Line 316 drops the execution error, so this helper can report success even when the command failed and wrote nothing to stderr. That can create false-positive test outcomes.

Suggested fix
 	cmd.SetArgs(cmdArgs)
-	_ = cmd.Execute()
+	execErr := cmd.Execute()
@@
 	out, _ := io.ReadAll(r)
 	errOut, _ := io.ReadAll(rErr)
 
-	// If there was an error output, include it
+	// Prefer command execution errors; include stderr context when present.
+	if execErr != nil {
+		if len(errOut) > 0 {
+			return string(out), fmt.Errorf("%w: %s", execErr, strings.TrimSpace(string(errOut)))
+		}
+		return string(out), execErr
+	}
+
+	// Preserve existing behaviour for stderr output on success paths.
 	if len(errOut) > 0 {
 		return string(out), fmt.Errorf("%s", errOut)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ahoy_test.go` around lines 316 - 326, The cmd.Execute() call is discarding
its error return value instead of capturing it, which can mask command failures
in test results. Modify the appRun function to capture the error returned by
cmd.Execute() instead of using the blank identifier, and then update the error
handling logic to return an error if either cmd.Execute() returned an error OR
if there is stderr output, ensuring both failure paths are properly reported to
the test.
🧹 Nitpick comments (1)
config_validation.go (1)

111-144: 💤 Low value

Pre-release segment comparison has an edge case when one version has more segments.

When comparing pre-release versions where one has more segments (e.g., v1.0.0-rc.1 vs v1.0.0-rc.1.0), the shorter one will have empty string segments (s1 or s2 will be ""). An empty string converts to 0 via strconv.Atoi with an error, so it falls through to string comparison where "" < any non-empty string. This matches SemVer 2.0 spec where the shorter identifier would be less, but empty string conversion to 0 with error could be confusing. The current behaviour is correct but consider adding a comment explaining the empty-segment handling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config_validation.go` around lines 111 - 144, The pre-release segment
comparison logic handles an edge case where one version has fewer segments than
the other, resulting in empty strings for missing segments. Add a clarifying
comment before or near the string comparison section (the else block that
compares s1 < s2) to explain that when strconv.Atoi fails on empty strings, the
string comparison naturally handles the edge case correctly by treating empty
segments as sorting lower than non-empty ones, which aligns with SemVer 2.0
specification. This will help future readers understand why this behavior is
intentional and correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli_parsing_test.go`:
- Around line 38-43: The test in TestInitFlags creates a fresh AppState which
already has srcDir as an empty string by default, so the assertion on line 41
does not meaningfully verify that the reset logic is working. Before calling
initFlags on the AppState s, set srcDir to a non-empty value (such as a test
path like "/some/path") so that the subsequent assertion actually validates that
initFlags properly resets the srcDir field instead of just confirming the
initial zero-value state.

In `@config_init.go`:
- Around line 60-61: The downloadFile function in config_init.go caps reads at
maxDownloadBytes using io.LimitReader but fails to detect when the actual
response exceeds this limit. If the server sends more than 5 MB, io.Copy will
silently truncate the content and return success, resulting in a partial YAML
file being written. After the io.Copy call with the limited reader on line 61,
add a check to detect oversized responses by attempting to read one more byte
from the original resp.Body. If this read succeeds (indicates more data exists),
return an error stating the response exceeded the maximum allowed size instead
of silently accepting the truncated content.

---

Outside diff comments:
In `@ahoy_test.go`:
- Around line 316-326: The cmd.Execute() call is discarding its error return
value instead of capturing it, which can mask command failures in test results.
Modify the appRun function to capture the error returned by cmd.Execute()
instead of using the blank identifier, and then update the error handling logic
to return an error if either cmd.Execute() returned an error OR if there is
stderr output, ensuring both failure paths are properly reported to the test.

In `@config_init_test.go`:
- Around line 48-66: Add a new test function (similar to
TestDownloadFile_200Response) that validates the max-size guard for
downloadFile. Create a test server that responds with a body larger than 5 MB,
then call downloadFile with the test server URL and a temporary file path.
Assert that downloadFile returns an error (indicating the download was rejected
due to size), and verify using fileExists that the destination file was not
created, ensuring the function properly rejects and does not finalize files
exceeding the size limit.

---

Nitpick comments:
In `@config_validation.go`:
- Around line 111-144: The pre-release segment comparison logic handles an edge
case where one version has fewer segments than the other, resulting in empty
strings for missing segments. Add a clarifying comment before or near the string
comparison section (the else block that compares s1 < s2) to explain that when
strconv.Atoi fails on empty strings, the string comparison naturally handles the
edge case correctly by treating empty segments as sorting lower than non-empty
ones, which aligns with SemVer 2.0 specification. This will help future readers
understand why this behavior is intentional and correct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd22df98-7e54-494d-a390-6e4071171d41

📥 Commits

Reviewing files that changed from the base of the PR and between cac253d and 7964d8f.

📒 Files selected for processing (12)
  • Makefile
  • ahoy.go
  • ahoy_test.go
  • cli_parsing_test.go
  • config.go
  • config_init.go
  • config_init_test.go
  • config_validation.go
  • description_test.go
  • flag.go
  • tests/missing-cmd.bats
  • windows_test.go

Comment thread cli_parsing_test.go
Comment on lines +38 to 43
// Test with empty flags - srcDir should be reset to empty string.
s := newAppState()
s.initFlags([]string{})
if s.srcDir != "" {
t.Error("Expected srcDir to be reset to empty string")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the srcDir reset assertion meaningful in TestInitFlags.

Line 39 initialises a fresh zero-value state, so the check on Line 41 would still pass if reset logic stopped clearing srcDir.

Suggested fix
 	// Test with empty flags - srcDir should be reset to empty string.
 	s := newAppState()
+	s.srcDir = "stale-value"
 	s.initFlags([]string{})
 	if s.srcDir != "" {
 		t.Error("Expected srcDir to be reset to empty string")
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Test with empty flags - srcDir should be reset to empty string.
s := newAppState()
s.initFlags([]string{})
if s.srcDir != "" {
t.Error("Expected srcDir to be reset to empty string")
}
// Test with empty flags - srcDir should be reset to empty string.
s := newAppState()
s.srcDir = "stale-value"
s.initFlags([]string{})
if s.srcDir != "" {
t.Error("Expected srcDir to be reset to empty string")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli_parsing_test.go` around lines 38 - 43, The test in TestInitFlags creates
a fresh AppState which already has srcDir as an empty string by default, so the
assertion on line 41 does not meaningfully verify that the reset logic is
working. Before calling initFlags on the AppState s, set srcDir to a non-empty
value (such as a test path like "/some/path") so that the subsequent assertion
actually validates that initFlags properly resets the srcDir field instead of
just confirming the initial zero-value state.

Comment thread config_init.go
Comment on lines +60 to +61
const maxDownloadBytes = 5 * 1024 * 1024 // 5 MB - generous for any YAML config file
if _, err = io.Copy(out, io.LimitReader(resp.Body, maxDownloadBytes)); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Detect oversized responses instead of silently truncating.

Line 60 currently caps reads, but Line 61 treats a clipped body as success. If the server sends more than 5 MB, downloadFile can write a partial YAML and still return nil, which breaks the “safe init” contract.

Proposed fix
-	const maxDownloadBytes = 5 * 1024 * 1024 // 5 MB - generous for any YAML config file
-	if _, err = io.Copy(out, io.LimitReader(resp.Body, maxDownloadBytes)); err != nil {
+	const maxDownloadBytes = 5 * 1024 * 1024 // 5 MB - generous for any YAML config file
+	written, err := io.Copy(out, io.LimitReader(resp.Body, maxDownloadBytes+1))
+	if err != nil {
 		out.Close()
 		return fmt.Errorf("failed to write file %s: %v", destPath, err)
 	}
+	if written > maxDownloadBytes {
+		out.Close()
+		return fmt.Errorf("failed to download file: response exceeds %d bytes", maxDownloadBytes)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config_init.go` around lines 60 - 61, The downloadFile function in
config_init.go caps reads at maxDownloadBytes using io.LimitReader but fails to
detect when the actual response exceeds this limit. If the server sends more
than 5 MB, io.Copy will silently truncate the content and return success,
resulting in a partial YAML file being written. After the io.Copy call with the
limited reader on line 61, add a check to detect oversized responses by
attempting to read one more byte from the original resp.Body. If this read
succeeds (indicates more data exists), return an error stating the response
exceeded the maximum allowed size instead of silently accepting the truncated
content.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant