diff --git a/.claude/knowledge/architecture-boundaries.md b/.claude/knowledge/architecture-boundaries.md index 01dae23..e6be7f7 100644 --- a/.claude/knowledge/architecture-boundaries.md +++ b/.claude/knowledge/architecture-boundaries.md @@ -23,4 +23,5 @@ Key architectural decisions, service boundaries, data flow, integration points, - The taint engine emits a single rule ID per language, chosen by sink: `goTaintRuleID`/`pyTaintRuleID` return `security.ssrf.{go,python}` when the sink is an outbound-HTTP callee (`goHTTPSinkArgIndex`/`pyHTTPSinkArgIndex`), else the generic `security.taint.{go,python}`. To add an SSRF sink, add it to the arg-index map; the rule routing and OWASP A10 mapping follow automatically. - **Section dispatch is data-driven, not a hardcoded ladder.** `runner/checks/Build` iterates `sectionRegistry` (`runner/checks/registry.go`) — a `[]sectionDef{id, name, enabled(sc), run(ctx, sc, checkEnv)}`. To add a check section, append a `sectionDef`; do NOT reintroduce an `if cfg.X {...}` ladder. Every section runs through `safeRun` (in `checks.go`), which recovers panics into a `StatusWarn` SectionResult with a `checks.section.panic` finding — so a panic in one check degrades that section to a warning instead of aborting the whole scan. Preserve registry ordering; it determines output section order. - **Git subprocess hardening contract (threat model: untrusted PR).** `base_ref` from MCP/CLI is validated by `runnersupport.ValidateBaseRef` (rejects leading `-` to block git option injection; restricts charset; preserves the `stdin` sentinel) BEFORE reaching git, and `--end-of-options` is passed to git diff/show/worktree. All git/govulncheck subprocesses use `exec.CommandContext` with a contained timeout and read output through `io.LimitReader` (`maxGitOutputBytes`) to prevent hangs/OOM. The `//nolint:contextcheck` markers in the git layer (`runner/support`, `ai/fix`, `ai/semantic`, `checks/quality/quality.go`, `runner/runner.go`) are DELIBERATE — the contained timeout is the chosen design; threading caller `ctx` end-to-end is a tracked follow-up. Don't "fix" them by removing the nolint without actually threading ctx. `runner/support/MatchPattern` (glob→regex) uses `regexp.Compile` + a `sync.Map` cache and returns false (never panics) on a malformed config glob. +- **The usage menu makes one outbound call (opt-out).** Running `codeguard` with no args or `help` renders a "What's New" banner (`internal/whatsnew` + `internal/cli/whatsnew.go`) that queries the GitHub releases API for the latest version. It is disabled when `CODEGUARD_NO_UPDATE_CHECK` is truthy OR `$CI` is non-empty (so CI/automated runs make no surprise call), caches the result for 24h under `os.UserCacheDir()/codeguard/update-check.json`, uses the SSRF-guarded `ai/safehttp.Client` with a 1.5s timeout, and fails silent (falls back to stale cache, then to version-only). Changelog bullets come from `CHANGELOG.md` embedded via a **module-root package** (`/changelog.go`, `package codeguard`, imported as `root "github.com/devr-tools/codeguard"`) — `go:embed` cannot reference `../CHANGELOG.md`, so the embed directive must live at the repo root, not in `internal/`. - **Lint is enforced in CI.** `make lint` is `go vet`; `make lint-strict` / the CI `golangci-lint-action` step (now blocking — no `continue-on-error`) runs the 16-linter `.golangci.yml`. `golangci-lint run` must report 0 before pushing. The config excludes revive's documentation/naming style rules (`exported`, `package-comments`, `unused-parameter`) and excludes `tests/` from gosec/errcheck/prealloc/bodyclose (intentional fixtures). Cache/identity keys use sha256 (not sha1). Documenting the public `pkg/codeguard` API with doc comments is a known follow-up (revive `exported` is currently excluded rather than satisfied). diff --git a/Makefile b/Makefile index ed614f5..25f16b4 100644 --- a/Makefile +++ b/Makefile @@ -18,13 +18,19 @@ CI_CONFIG ?= .codeguard/codeguard.yaml BASE_REF ?= main CODEGUARD_BIN ?= ./dist/codeguard GOFILES := $(shell find cmd internal pkg tests -type f -name '*.go' 2>/dev/null) +# MENU_VERSION defaults to the current release-please version so `make menu` +# previews the banner with the real version (like a release build) instead of +# the source default; override with MENU_VERSION=x.y.z. +MANIFEST_VERSION := $(shell grep -oE '[0-9]+\.[0-9]+\.[0-9]+' .release-please-manifest.json 2>/dev/null | head -1) +MENU_VERSION ?= $(if $(MANIFEST_VERSION),$(MANIFEST_VERSION),$(VERSION)) +MENU_LDFLAGS := -X github.com/devr-tools/codeguard/internal/version.Number=v$(MENU_VERSION) export GOCACHE export GOMODCACHE .DEFAULT_GOAL := help -.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit table table-diff table-interactive clean +.PHONY: help fmt fmt-check lint lint-strict test codeguard-ci check ci build release release-snapshot release-check deploy commit menu table table-diff table-interactive clean help: @printf "\ncodeguard make targets\n\n" @@ -42,6 +48,7 @@ help: @printf " make release-snapshot Build local snapshot release artifacts\n" @printf " make deploy Alias for make release\n" @printf " make commit Create an interactive conventional commit\n" + @printf " make menu Preview the What's New banner and usage menu\n" @printf " make table Run a full scan and print the terminal table\n" @printf " make table-diff Run a diff scan and print the terminal table\n" @printf " make table-interactive Launch interactive terminal scanning\n" @@ -93,6 +100,10 @@ deploy: release commit: ./scripts/commit.sh +menu: + @printf "# codeguard menu preview (v%s); set CODEGUARD_NO_UPDATE_CHECK=1 to skip the update check\n\n" "$(MENU_VERSION)" + $(GO) run -ldflags "$(MENU_LDFLAGS)" ./cmd/codeguard + table: $(GO) run ./cmd/codeguard scan -config $(CONFIG) diff --git a/changelog.go b/changelog.go new file mode 100644 index 0000000..f612241 --- /dev/null +++ b/changelog.go @@ -0,0 +1,13 @@ +// Package codeguard exposes module-level assets that must live at the +// repository root. Currently it embeds CHANGELOG.md so the CLI can surface +// recent release highlights in its "What's New" banner without shipping a +// duplicate copy of the changelog. +package codeguard + +import _ "embed" + +// Changelog is the full contents of CHANGELOG.md, embedded at build time. +// It is generated by release-please in conventional-changelog format. +// +//go:embed CHANGELOG.md +var Changelog string diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 323e335..de90e33 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -9,6 +9,9 @@ import ( service "github.com/devr-tools/codeguard/pkg/codeguard" ) +// promptString and loadConfigWithProfile live here; the command menu is +// rendered by writeMenu in menu.go. + func promptString(reader *bufio.Reader, stdout io.Writer, label string, fallback string) (string, error) { if _, err := fmt.Fprintf(stdout, "%s [%s]: ", label, fallback); err != nil { return "", err @@ -24,28 +27,6 @@ func promptString(reader *bufio.Reader, stdout io.Writer, label string, fallback return line, nil } -func writeUsage(w io.Writer) { - _, _ = io.WriteString(w, `codeguard checks repositories for code quality, design, security, CI, prompt safety, and custom policy rules. - -Usage: - codeguard init [-output codeguard.yaml] [-interactive] [-profile startup|strict|enterprise|ai-safe] - codeguard validate [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe] - codeguard validate-patch [-config codeguard.yaml] [-format text|json|sarif|github] [-profile startup|strict|enterprise|ai-safe] [-ai] < patch.diff - codeguard scan [-config codeguard.yaml] [-mode full|diff] [-base-ref main] [-format text|json|sarif|github] [-interactive] [-profile startup|strict|enterprise|ai-safe] [-ai] [-allow-config-commands] [-allow-config-ai-endpoints] - codeguard scan-history [-config codeguard.yaml] [-path .] [-max-commits N] [-all] [-format text|json] # scan git history for committed secrets - codeguard fix [-config codeguard.yaml] [-mode full|diff] [-base-ref main] [-profile startup|strict|enterprise|ai-safe] [-rule rule.id] [-path rel/path] [-line N] -ai - codeguard baseline [-config codeguard.yaml] [-output codeguard-baseline.json] [-mode full|diff] [-base-ref main] [-profile startup|strict|enterprise|ai-safe] - codeguard report -slop-history [-config codeguard.yaml] [-limit N] [-profile startup|strict|enterprise|ai-safe] - codeguard rules [-config codeguard.yaml] - codeguard explain [-config codeguard.yaml] [-format text|agent] - codeguard owasp [-config codeguard.yaml] [-format text|json] [-profile startup|strict|enterprise|ai-safe] - codeguard serve --mcp [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe] - codeguard doctor [-config codeguard.yaml] [-profile startup|strict|enterprise|ai-safe] - codeguard profiles - codeguard version -`) -} - func loadConfigWithProfile(path string, profile string) (service.Config, error) { cfg, err := service.LoadConfigFile(path) if err != nil { diff --git a/internal/cli/menu.go b/internal/cli/menu.go new file mode 100644 index 0000000..ca5e18f --- /dev/null +++ b/internal/cli/menu.go @@ -0,0 +1,116 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/devr-tools/codeguard/internal/whatsnew" +) + +// menuItem is a single command row in the menu. +type menuItem struct { + name string + desc string +} + +// menuGroup is a titled cluster of related commands. +type menuGroup struct { + title string + items []menuItem +} + +// menuGroups defines the command menu layout, grouped by task. +var menuGroups = []menuGroup{ + { + title: "Get started", + items: []menuItem{ + {"init", "Create a codeguard config file"}, + {"validate", "Check that a config file is valid"}, + {"profiles", "List built-in policy profiles"}, + }, + }, + { + title: "Scan & baseline", + items: []menuItem{ + {"scan", "Scan the working tree or a diff for violations"}, + {"scan-history", "Scan git history for committed secrets"}, + {"validate-patch", "Scan a unified diff piped on stdin"}, + {"baseline", "Record current findings as an accepted baseline"}, + }, + }, + { + title: "Fix & report", + items: []menuItem{ + {"fix", "AI-assisted fix for a specific finding"}, + {"report", "Print the slop / AI-risk history report"}, + }, + }, + { + title: "Rules & policy", + items: []menuItem{ + {"rules", "List all rules and their metadata"}, + {"explain", "Explain a rule by id"}, + {"owasp", "Show OWASP category coverage"}, + }, + }, + { + title: "Serve & diagnose", + items: []menuItem{ + {"serve", "Run the Model Context Protocol (MCP) server"}, + {"doctor", "Diagnose config and environment"}, + {"version", "Print the codeguard version"}, + }, + }, +} + +// writeMenu renders the command menu: a short tagline, task-grouped commands +// with aligned descriptions, and a footer pointing at per-command help. Command +// names are styled in devr blue when w is a color-capable terminal. +func writeMenu(w io.Writer) { + color := whatsnew.ColorForWriter(w) + + _, _ = fmt.Fprintln(w, "codeguard — code quality, design, security, CI, and prompt-safety checks for your repository.") + _, _ = fmt.Fprintf(w, "\nUsage: %s %s %s\n\n", + whatsnew.BlueBold("codeguard", color), + whatsnew.Blue("", color), + whatsnew.Faint("[flags]", color)) + + width := menuNameWidth() + for _, group := range menuGroups { + _, _ = fmt.Fprintf(w, " %s\n", whatsnew.Faint(strings.ToUpper(group.title), color)) + for _, item := range group.items { + _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad(item.name, width), color), item.desc) + } + _, _ = fmt.Fprintln(w) + } + + _, _ = fmt.Fprintf(w, " %s\n", whatsnew.Faint("Common flags", color)) + _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-config", width), color), "Path to a config file or directory") + _, _ = fmt.Fprintf(w, " %s %s\n", whatsnew.Blue(pad("-profile", width), color), "startup | strict | enterprise | ai-safe") + _, _ = fmt.Fprintf(w, " %s %s\n\n", whatsnew.Blue(pad("-format", width), color), "text | json | sarif | github") + + _, _ = fmt.Fprintf(w, "Run %s to see all flags for a command.\n", + whatsnew.Blue("codeguard -h", color)) +} + +// menuNameWidth returns the column width for command/flag names: the longest +// name plus padding, so descriptions align. +func menuNameWidth() int { + longest := len("-profile") // keep the Common flags column aligned too. + for _, group := range menuGroups { + for _, item := range group.items { + if len(item.name) > longest { + longest = len(item.name) + } + } + } + return longest +} + +func pad(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index a9a17b3..2230faa 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -28,13 +28,15 @@ var commandCatalog = map[string]commandRunner{ func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { if len(args) == 0 { - writeUsage(stdout) + writeWhatsNew(stdout) + writeMenu(stdout) return 0 } command := args[0] if isHelpCommand(command) { - writeUsage(stdout) + writeWhatsNew(stdout) + writeMenu(stdout) return 0 } if command == "version" { @@ -46,7 +48,7 @@ func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int } _, _ = fmt.Fprintf(stderr, "unknown command %q\n\n", command) - writeUsage(stderr) + writeMenu(stderr) return 1 } diff --git a/internal/cli/whatsnew.go b/internal/cli/whatsnew.go new file mode 100644 index 0000000..ea99a4d --- /dev/null +++ b/internal/cli/whatsnew.go @@ -0,0 +1,20 @@ +package cli + +import ( + "context" + "io" + + root "github.com/devr-tools/codeguard" + "github.com/devr-tools/codeguard/internal/version" + "github.com/devr-tools/codeguard/internal/whatsnew" +) + +// writeWhatsNew renders the "What's New" banner above the usage menu: the +// running version, an upstream update notice (from a cached, opt-out release +// check), and the latest changelog highlights. Any failure degrades to showing +// less, never blocking the menu. +func writeWhatsNew(stdout io.Writer) { + rel, _ := whatsnew.LatestFromChangelog(root.Changelog) + latest, _ := whatsnew.DefaultChecker().LatestVersion(context.Background()) + whatsnew.Render(stdout, version.Number, latest, rel, whatsnew.ColorForWriter(stdout)) +} diff --git a/internal/version/version.go b/internal/version/version.go index 19ba839..1d81356 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,8 @@ package version -const Number = "0.1.0" +// Number is the codeguard version. It must be a var (not a const) so the +// release build can override it via the linker: GoReleaser injects the git tag +// with `-X github.com/devr-tools/codeguard/internal/version.Number=v{{.Version}}` +// (see .goreleaser.yaml). The linker's -X flag only sets string vars, so a +// const would silently leave released binaries reporting this default. +var Number = "0.1.0" diff --git a/internal/whatsnew/color.go b/internal/whatsnew/color.go new file mode 100644 index 0000000..8bb92f7 --- /dev/null +++ b/internal/whatsnew/color.go @@ -0,0 +1,56 @@ +package whatsnew + +import ( + "io" + "os" + "strings" +) + +// Brand ANSI SGR codes. devrBlue matches codeguard's brand blue (RGB +// 37,169,255) used by the report banner (internal/codeguard/report). +const ( + devrBlue = "38;2;37;169;255" + devrBlueBold = "1;38;2;37;169;255" + dim = "2" + reset = "\x1b[0m" +) + +// ColorForWriter reports whether ANSI color should be emitted to w: only when +// w is a terminal (char device), NO_COLOR is unset, and TERM is not "dumb". +// Writing to a pipe, file, or bytes.Buffer yields false, so redirected output +// and tests stay plain text. +func ColorForWriter(w io.Writer) bool { + if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" { + return false + } + if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// paint wraps s in the given SGR code when color is enabled, otherwise returns +// s unchanged. +func paint(color bool, code, s string) string { + if !color || code == "" { + return s + } + return "\x1b[" + code + "m" + s + reset +} + +// Blue renders s in codeguard's devr blue when color is enabled. +func Blue(s string, color bool) string { return paint(color, devrBlue, s) } + +// BlueBold renders s in bold devr blue when color is enabled. +func BlueBold(s string, color bool) string { return paint(color, devrBlueBold, s) } + +// Faint renders s dimmed when color is enabled. +func Faint(s string, color bool) string { return paint(color, dim, s) } diff --git a/internal/whatsnew/update.go b/internal/whatsnew/update.go new file mode 100644 index 0000000..bf12f23 --- /dev/null +++ b/internal/whatsnew/update.go @@ -0,0 +1,210 @@ +package whatsnew + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/devr-tools/codeguard/internal/codeguard/ai/safehttp" +) + +const ( + // defaultRepo is the GitHub owner/repo whose releases are checked. + defaultRepo = "devr-tools/codeguard" + // defaultCacheTTL bounds how often the upstream release API is queried. + defaultCacheTTL = 24 * time.Hour + // fetchTimeout bounds a single upstream release lookup so the banner never + // blocks the menu for long. + fetchTimeout = 1500 * time.Millisecond + // cacheFileName is the on-disk cache under the user cache directory. + cacheFileName = "update-check.json" + // maxReleaseBytes caps how much of the release API response is read. + maxReleaseBytes = 1 << 20 // 1 MiB + // disableEnv opts out of the upstream update check entirely. + disableEnv = "CODEGUARD_NO_UPDATE_CHECK" +) + +// FetchFunc returns the latest available version tag from upstream. +type FetchFunc func(ctx context.Context) (string, error) + +// UpdateChecker resolves the latest available codeguard version, caching the +// result on disk and degrading gracefully when offline or opted out. +type UpdateChecker struct { + // CacheDir is the directory holding the cache file. When empty, the check + // runs without persistence. + CacheDir string + // TTL is how long a cached result is considered fresh. + TTL time.Duration + // Now returns the current time (injectable for tests). + Now func() time.Time + // Disabled short-circuits the check, returning no version. + Disabled bool + // Fetch performs the upstream lookup when the cache is stale/missing. + Fetch FetchFunc +} + +// DefaultChecker builds the production update checker: disabled under CI or the +// opt-out env var, cached under the user cache directory, and backed by an +// SSRF-guarded GitHub releases lookup. +func DefaultChecker() *UpdateChecker { + c := &UpdateChecker{ + TTL: defaultCacheTTL, + Now: time.Now, + Disabled: updateCheckDisabled(), + Fetch: NewGitHubFetcher(safehttp.Client(fetchTimeout), "https://api.github.com", defaultRepo), + } + if dir, err := os.UserCacheDir(); err == nil { + c.CacheDir = filepath.Join(dir, "codeguard") + } + return c +} + +// updateCheckDisabled reports whether the environment opts out of update checks. +// It is disabled on the explicit env var or when a CI environment is detected, +// so codeguard never makes surprise outbound calls in automated pipelines. +func updateCheckDisabled() bool { + if isTruthy(os.Getenv(disableEnv)) { + return true + } + return strings.TrimSpace(os.Getenv("CI")) != "" +} + +func isTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "0", "false", "no", "off": + return false + default: + return true + } +} + +type cacheEntry struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` +} + +// LatestVersion returns the newest available version and whether one is known. +// It prefers a fresh cache entry, falls back to an upstream fetch, and returns +// a stale cached value if the fetch fails. It never returns an error: any +// failure simply yields ok=false so the banner degrades gracefully. +func (c *UpdateChecker) LatestVersion(ctx context.Context) (string, bool) { + if c == nil || c.Disabled { + return "", false + } + now := time.Now + if c.Now != nil { + now = c.Now + } + ttl := c.TTL + if ttl <= 0 { + ttl = defaultCacheTTL + } + + cached, haveCache := c.readCache() + if haveCache && now().Sub(cached.CheckedAt) < ttl { + return cached.LatestVersion, cached.LatestVersion != "" + } + + if c.Fetch == nil { + if haveCache { + return cached.LatestVersion, cached.LatestVersion != "" + } + return "", false + } + + version, err := c.Fetch(ctx) + if err != nil { + // Fetch failed (offline, rate-limited): fall back to any stale value. + if haveCache { + return cached.LatestVersion, cached.LatestVersion != "" + } + return "", false + } + version = NormalizeVersion(version) + c.writeCache(cacheEntry{CheckedAt: now(), LatestVersion: version}) + return version, version != "" +} + +func (c *UpdateChecker) cachePath() string { + if strings.TrimSpace(c.CacheDir) == "" { + return "" + } + return filepath.Join(c.CacheDir, cacheFileName) +} + +func (c *UpdateChecker) readCache() (cacheEntry, bool) { + path := c.cachePath() + if path == "" { + return cacheEntry{}, false + } + data, err := os.ReadFile(path) //nolint:gosec // path is derived from os.UserCacheDir, not user input. + if err != nil { + return cacheEntry{}, false + } + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return cacheEntry{}, false + } + return entry, true +} + +func (c *UpdateChecker) writeCache(entry cacheEntry) { + path := c.cachePath() + if path == "" { + return + } + if err := os.MkdirAll(c.CacheDir, 0o700); err != nil { + return + } + data, err := json.Marshal(entry) + if err != nil { + return + } + // Best effort: a failed cache write only means we re-check next time. + _ = os.WriteFile(path, data, 0o600) +} + +// NewGitHubFetcher returns a FetchFunc that reads the latest release tag from +// the GitHub releases API. baseURL is the API root (e.g. https://api.github.com) +// and repo is "owner/name". It is exported so callers and tests can supply a +// custom client or endpoint. +func NewGitHubFetcher(client *http.Client, baseURL, repo string) FetchFunc { + return func(ctx context.Context) (string, error) { + endpoint := fmt.Sprintf("%s/repos/%s/releases/latest", strings.TrimRight(baseURL, "/"), repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) //nolint:gosec // endpoint host is codeguard's own constant GitHub API root. + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "codeguard-update-check") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github releases: unexpected status %d", resp.StatusCode) + } + + var payload struct { + TagName string `json:"tag_name"` + } + body := io.LimitReader(resp.Body, maxReleaseBytes) + if err := json.NewDecoder(body).Decode(&payload); err != nil { + return "", err + } + tag := strings.TrimSpace(payload.TagName) + if tag == "" { + return "", fmt.Errorf("github releases: empty tag_name") + } + return tag, nil + } +} diff --git a/internal/whatsnew/whatsnew.go b/internal/whatsnew/whatsnew.go new file mode 100644 index 0000000..9ebd438 --- /dev/null +++ b/internal/whatsnew/whatsnew.go @@ -0,0 +1,191 @@ +// Package whatsnew renders codeguard's "What's New" banner: the current +// version, the latest release available upstream (when a cached update check +// has one), and a few highlights parsed from the embedded CHANGELOG.md. +package whatsnew + +import ( + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// maxHighlights caps how many changelog bullets the banner shows so it stays a +// glance-able banner rather than a full changelog dump. +const maxHighlights = 5 + +// Release describes a single changelog entry. +type Release struct { + Version string + Date string + Highlights []string +} + +var ( + // releaseHeading matches a release-please version heading, e.g. + // "## [0.6.1](https://…/compare/v0.6.0...v0.6.1) (2026-07-01)". + releaseHeading = regexp.MustCompile(`^##\s+\[([0-9][^\]]*)\][^)]*\)(?:\s+\((\d{4}-\d{2}-\d{2})\))?`) + // bulletPrefix matches a markdown list item marker. + bulletPrefix = regexp.MustCompile(`^[-*]\s+`) + // markdownLink strips trailing commit/PR reference links such as + // " ([34c7f87](https://…))" that release-please appends to each bullet. + markdownLink = regexp.MustCompile(`\s*\(\[[^\]]+\]\([^)]*\)\)`) + // boldMarker removes markdown bold emphasis around conventional-commit + // scopes, turning "**security:**" into "security:". + boldMarker = regexp.MustCompile(`\*\*`) +) + +// LatestFromChangelog parses the most recent release section out of a +// conventional-changelog document and returns its version, date, and a +// de-duplicated list of highlight bullets. ok is false when no release heading +// is found. +func LatestFromChangelog(markdown string) (Release, bool) { + lines := strings.Split(markdown, "\n") + + var rel Release + found := false + seen := make(map[string]bool) + + for _, line := range lines { + if m := releaseHeading.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + if found { + break // reached the previous release; stop collecting. + } + found = true + rel.Version = m[1] + rel.Date = m[2] + continue + } + if !found { + continue + } + if len(rel.Highlights) >= maxHighlights { + continue + } + if bullet, ok := cleanBullet(line); ok && !seen[bullet] { + seen[bullet] = true + rel.Highlights = append(rel.Highlights, bullet) + } + } + + return rel, found +} + +// cleanBullet turns a raw markdown bullet line into display text, returning +// ok=false for lines that are not bullets. +func cleanBullet(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if !bulletPrefix.MatchString(trimmed) { + return "", false + } + trimmed = bulletPrefix.ReplaceAllString(trimmed, "") + trimmed = markdownLink.ReplaceAllString(trimmed, "") + trimmed = boldMarker.ReplaceAllString(trimmed, "") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" { + return "", false + } + return trimmed, true +} + +// NormalizeVersion strips a leading "v" and surrounding whitespace so versions +// from git tags ("v0.6.1") and manifests ("0.6.1") compare equal. +func NormalizeVersion(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} + +// CompareVersions compares two dotted numeric versions, returning -1 if a < b, +// 0 if equal, and 1 if a > b. Non-numeric or pre-release suffixes on a segment +// are ignored for ordering; a version with more segments sorts higher when the +// shared prefix is equal (1.2 < 1.2.1). +func CompareVersions(a, b string) int { + as := strings.Split(NormalizeVersion(a), ".") + bs := strings.Split(NormalizeVersion(b), ".") + n := len(as) + if len(bs) > n { + n = len(bs) + } + for i := 0; i < n; i++ { + av := segmentValue(as, i) + bv := segmentValue(bs, i) + if av != bv { + if av < bv { + return -1 + } + return 1 + } + } + return 0 +} + +func segmentValue(segments []string, i int) int { + if i >= len(segments) { + return 0 + } + seg := segments[i] + // Trim any pre-release/build suffix, e.g. "1-rc1" -> "1". + if idx := strings.IndexAny(seg, "-+"); idx >= 0 { + seg = seg[:idx] + } + n, err := strconv.Atoi(seg) + if err != nil { + return 0 + } + return n +} + +// UpdateAvailable reports whether latest is a strictly newer version than +// current. It returns false when either value is empty. +func UpdateAvailable(current, latest string) bool { + if strings.TrimSpace(current) == "" || strings.TrimSpace(latest) == "" { + return false + } + return CompareVersions(latest, current) > 0 +} + +// Render writes the What's New banner to w. current is the running version, +// latest is the newest available version (may be empty when unknown/offline), +// rel holds the highlights to display, and color enables devr-blue ANSI +// styling (use ColorForWriter to decide). When there is nothing to show +// (no version and no highlights) Render writes nothing. +func Render(w io.Writer, current string, latest string, rel Release, color bool) { + current = strings.TrimSpace(current) + if current == "" && len(rel.Highlights) == 0 { + return + } + + const rule = "────────────────────────────────────────────────────────────" + frame := paint(color, devrBlue, "╭"+rule+"╮") + _, _ = fmt.Fprintln(w, frame) + + header := "codeguard" + if current != "" { + header = "codeguard v" + NormalizeVersion(current) + } + if UpdateAvailable(current, latest) { + notice := paint(color, devrBlueBold, header) + + " " + paint(color, devrBlue, "→ update available: v"+NormalizeVersion(latest)) + _, _ = fmt.Fprintf(w, " %s\n", notice) + hint := "upgrade: go install github.com/devr-tools/codeguard/cmd/codeguard@latest" + _, _ = fmt.Fprintf(w, " %s\n", paint(color, dim, hint)) + } else { + _, _ = fmt.Fprintf(w, " %s %s\n", paint(color, devrBlueBold, header), paint(color, dim, "(latest)")) + } + + if len(rel.Highlights) > 0 { + heading := "What's new" + if rel.Version != "" { + heading = "What's new in v" + NormalizeVersion(rel.Version) + } + if rel.Date != "" { + heading += fmt.Sprintf(" (%s)", rel.Date) + } + _, _ = fmt.Fprintf(w, " %s\n", paint(color, devrBlue, heading+":")) + for _, h := range rel.Highlights { + _, _ = fmt.Fprintf(w, " %s %s\n", paint(color, devrBlue, "•"), h) + } + } + + _, _ = fmt.Fprintln(w, paint(color, devrBlue, "╰"+rule+"╯")) +} diff --git a/tests/cli/whatsnew_test.go b/tests/cli/whatsnew_test.go new file mode 100644 index 0000000..b4fbba9 --- /dev/null +++ b/tests/cli/whatsnew_test.go @@ -0,0 +1,75 @@ +package cli_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/devr-tools/codeguard/internal/cli" +) + +func TestUsageMenuShowsWhatsNewBanner(t *testing.T) { + // Keep the test offline and deterministic: no upstream update check. + t.Setenv("CODEGUARD_NO_UPDATE_CHECK", "1") + + var stdout, stderr bytes.Buffer + code := cli.Run(nil, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "codeguard v") { + t.Fatalf("expected version banner in menu, got:\n%s", out) + } + if !strings.Contains(out, "What's new") { + t.Fatalf("expected What's new section in menu, got:\n%s", out) + } + // The usage text must still follow the banner. + if !strings.Contains(out, "Usage:") { + t.Fatalf("expected usage text after banner, got:\n%s", out) + } +} + +func TestMenuGroupsAndCommands(t *testing.T) { + t.Setenv("CODEGUARD_NO_UPDATE_CHECK", "1") + + var stdout, stderr bytes.Buffer + if code := cli.Run(nil, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + out := stdout.String() + + for _, group := range []string{"GET STARTED", "SCAN & BASELINE", "RULES & POLICY", "SERVE & DIAGNOSE"} { + if !strings.Contains(out, group) { + t.Errorf("menu missing group %q:\n%s", group, out) + } + } + for _, cmd := range []string{"scan", "scan-history", "explain", "serve", "doctor"} { + if !strings.Contains(out, cmd) { + t.Errorf("menu missing command %q", cmd) + } + } + if !strings.Contains(out, "Common flags") { + t.Errorf("menu missing Common flags section:\n%s", out) + } + if !strings.Contains(out, "codeguard -h") { + t.Errorf("menu missing per-command help hint:\n%s", out) + } + // Descriptions from the old flag-dump format should be gone. + if strings.Contains(out, "-base-ref main]") { + t.Errorf("menu still contains the old dense flag signatures:\n%s", out) + } +} + +func TestHelpCommandShowsBanner(t *testing.T) { + t.Setenv("CODEGUARD_NO_UPDATE_CHECK", "1") + + var stdout, stderr bytes.Buffer + code := cli.Run([]string{"help"}, strings.NewReader(""), &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "What's new") { + t.Fatalf("expected banner on help, got:\n%s", stdout.String()) + } +} diff --git a/tests/whatsnew/whatsnew_test.go b/tests/whatsnew/whatsnew_test.go new file mode 100644 index 0000000..328ec4a --- /dev/null +++ b/tests/whatsnew/whatsnew_test.go @@ -0,0 +1,326 @@ +package whatsnew_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devr-tools/codeguard/internal/whatsnew" +) + +const sampleChangelog = `# Changelog + +## [0.6.1](https://github.com/devr-tools/codeguard/compare/v0.6.0...v0.6.1) (2026-07-01) + + +### Bug Fixes + +* **security:** harden untrusted-input handling ([34c7f87](https://github.com/devr-tools/codeguard/commit/34c7f87)) + +## [0.6.0](https://github.com/devr-tools/codeguard/compare/v0.5.0...v0.6.0) (2026-06-30) + +### Features + +* earlier release bullet ([f2f6c61](https://github.com/devr-tools/codeguard/commit/f2f6c61)) +` + +func TestLatestFromChangelogParsesTopSection(t *testing.T) { + rel, ok := whatsnew.LatestFromChangelog(sampleChangelog) + if !ok { + t.Fatal("expected a release to be found") + } + if rel.Version != "0.6.1" { + t.Fatalf("version = %q, want 0.6.1", rel.Version) + } + if rel.Date != "2026-07-01" { + t.Fatalf("date = %q, want 2026-07-01", rel.Date) + } + if len(rel.Highlights) != 1 { + t.Fatalf("highlights = %v, want exactly the 0.6.1 bullet", rel.Highlights) + } + got := rel.Highlights[0] + if strings.Contains(got, "**") || strings.Contains(got, "([") || strings.Contains(got, "earlier release") { + t.Fatalf("highlight not cleaned/scoped: %q", got) + } + if got != "security: harden untrusted-input handling" { + t.Fatalf("highlight = %q", got) + } +} + +func TestLatestFromChangelogDedupesAndCaps(t *testing.T) { + var b strings.Builder + b.WriteString("## [1.0.0](https://x/compare/v0.9.0...v1.0.0) (2026-01-01)\n\n### Features\n\n") + b.WriteString("* dup bullet ([aaaaaaa](https://x/c/a))\n") + b.WriteString("* dup bullet ([bbbbbbb](https://x/c/b))\n") // same cleaned text -> deduped + for i := 0; i < 10; i++ { + b.WriteString("* unique bullet number " + string(rune('a'+i)) + "\n") + } + rel, ok := whatsnew.LatestFromChangelog(b.String()) + if !ok { + t.Fatal("expected release") + } + if len(rel.Highlights) > 5 { + t.Fatalf("expected highlights capped at 5, got %d", len(rel.Highlights)) + } + if rel.Highlights[0] != "dup bullet" { + t.Fatalf("first highlight = %q", rel.Highlights[0]) + } +} + +func TestLatestFromChangelogNoRelease(t *testing.T) { + if _, ok := whatsnew.LatestFromChangelog("# Changelog\n\nnothing here\n"); ok { + t.Fatal("expected ok=false when no release heading present") + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"0.6.1", "0.6.1", 0}, + {"v0.6.1", "0.6.1", 0}, + {"0.6.0", "0.6.1", -1}, + {"0.7.0", "0.6.1", 1}, + {"1.2", "1.2.1", -1}, + {"1.2.0", "1.2", 0}, + {"1.2.3-rc1", "1.2.3", 0}, + } + for _, c := range cases { + if got := whatsnew.CompareVersions(c.a, c.b); got != c.want { + t.Errorf("CompareVersions(%q,%q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestUpdateAvailable(t *testing.T) { + if !whatsnew.UpdateAvailable("0.6.1", "0.7.0") { + t.Error("expected update available for newer latest") + } + if whatsnew.UpdateAvailable("0.7.0", "0.6.1") { + t.Error("did not expect update when current is newer") + } + if whatsnew.UpdateAvailable("0.6.1", "") { + t.Error("empty latest must not report an update") + } + if whatsnew.UpdateAvailable("", "0.6.1") { + t.Error("empty current must not report an update") + } +} + +func TestRenderShowsUpdateNotice(t *testing.T) { + var buf bytes.Buffer + rel := whatsnew.Release{Version: "0.7.0", Date: "2026-08-01", Highlights: []string{"shiny new thing"}} + whatsnew.Render(&buf, "v0.6.1", "0.7.0", rel, false) + out := buf.String() + if !strings.Contains(out, "codeguard v0.6.1") { + t.Errorf("missing current version: %s", out) + } + if !strings.Contains(out, "update available: v0.7.0") { + t.Errorf("missing update notice: %s", out) + } + if !strings.Contains(out, "go install github.com/devr-tools/codeguard/cmd/codeguard@latest") { + t.Errorf("missing upgrade hint: %s", out) + } + if !strings.Contains(out, "• shiny new thing") { + t.Errorf("missing highlight: %s", out) + } +} + +func TestRenderNoUpdateShowsLatest(t *testing.T) { + var buf bytes.Buffer + whatsnew.Render(&buf, "0.6.1", "0.6.1", whatsnew.Release{Version: "0.6.1"}, false) + out := buf.String() + if !strings.Contains(out, "codeguard v0.6.1 (latest)") { + t.Errorf("expected (latest) marker: %s", out) + } + if strings.Contains(out, "update available") { + t.Errorf("did not expect update notice: %s", out) + } +} + +func TestRenderEmptyWritesNothing(t *testing.T) { + var buf bytes.Buffer + whatsnew.Render(&buf, "", "", whatsnew.Release{}, false) + if buf.Len() != 0 { + t.Errorf("expected no output, got: %q", buf.String()) + } +} + +func TestRenderColorUsesDevrBlue(t *testing.T) { + const devrBlue = "38;2;37;169;255" + + var colored bytes.Buffer + whatsnew.Render(&colored, "0.6.1", "0.6.1", whatsnew.Release{Version: "0.6.1", Highlights: []string{"a thing"}}, true) + if !strings.Contains(colored.String(), devrBlue) { + t.Fatalf("expected devr blue ANSI code in colored output:\n%q", colored.String()) + } + + var plain bytes.Buffer + whatsnew.Render(&plain, "0.6.1", "0.6.1", whatsnew.Release{Version: "0.6.1", Highlights: []string{"a thing"}}, false) + if strings.Contains(plain.String(), "\x1b[") { + t.Fatalf("expected no ANSI codes when color disabled:\n%q", plain.String()) + } +} + +func TestColorForWriterPlainForNonTerminal(t *testing.T) { + // A bytes.Buffer is not a terminal, so color must be disabled. + if whatsnew.ColorForWriter(&bytes.Buffer{}) { + t.Fatal("ColorForWriter must be false for a non-terminal writer") + } + // NO_COLOR forces plain even for os.Stdout. + t.Setenv("NO_COLOR", "1") + if whatsnew.ColorForWriter(os.Stdout) { + t.Fatal("ColorForWriter must honor NO_COLOR") + } +} + +func fixedNow(ts string) func() time.Time { + t, _ := time.Parse(time.RFC3339, ts) + return func() time.Time { return t } +} + +func TestLatestVersionFetchesAndCaches(t *testing.T) { + dir := t.TempDir() + calls := 0 + checker := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: 24 * time.Hour, + Now: fixedNow("2026-07-01T00:00:00Z"), + Fetch: func(context.Context) (string, error) { + calls++ + return "v0.7.0", nil + }, + } + got, ok := checker.LatestVersion(context.Background()) + if !ok || got != "0.7.0" { + t.Fatalf("LatestVersion = %q,%v; want 0.7.0,true", got, ok) + } + // Cache written; a second checker with the same fresh clock must not fetch. + checker2 := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: 24 * time.Hour, + Now: fixedNow("2026-07-01T01:00:00Z"), + Fetch: func(context.Context) (string, error) { + t.Fatal("fetch should not run when cache is fresh") + return "", nil + }, + } + got, ok = checker2.LatestVersion(context.Background()) + if !ok || got != "0.7.0" { + t.Fatalf("cached LatestVersion = %q,%v; want 0.7.0,true", got, ok) + } + if calls != 1 { + t.Fatalf("fetch calls = %d, want 1", calls) + } + if _, err := os.Stat(filepath.Join(dir, "update-check.json")); err != nil { + t.Fatalf("cache file not written: %v", err) + } +} + +func TestLatestVersionStaleTriggersRefetch(t *testing.T) { + dir := t.TempDir() + seed := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: 24 * time.Hour, + Now: fixedNow("2026-06-01T00:00:00Z"), + Fetch: func(context.Context) (string, error) { return "0.6.0", nil }, + } + seed.LatestVersion(context.Background()) + + refetched := false + stale := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: 24 * time.Hour, + Now: fixedNow("2026-07-01T00:00:00Z"), // a month later -> stale + Fetch: func(context.Context) (string, error) { + refetched = true + return "0.7.0", nil + }, + } + got, ok := stale.LatestVersion(context.Background()) + if !refetched { + t.Fatal("expected refetch when cache is stale") + } + if !ok || got != "0.7.0" { + t.Fatalf("stale refetch = %q,%v; want 0.7.0,true", got, ok) + } +} + +func TestLatestVersionFetchErrorFallsBackToStale(t *testing.T) { + dir := t.TempDir() + seed := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: time.Hour, + Now: fixedNow("2026-06-01T00:00:00Z"), + Fetch: func(context.Context) (string, error) { return "0.6.0", nil }, + } + seed.LatestVersion(context.Background()) + + offline := &whatsnew.UpdateChecker{ + CacheDir: dir, + TTL: time.Hour, + Now: fixedNow("2026-07-01T00:00:00Z"), + Fetch: func(context.Context) (string, error) { return "", errors.New("offline") }, + } + got, ok := offline.LatestVersion(context.Background()) + if !ok || got != "0.6.0" { + t.Fatalf("expected stale fallback 0.6.0,true; got %q,%v", got, ok) + } +} + +func TestLatestVersionDisabled(t *testing.T) { + checker := &whatsnew.UpdateChecker{ + Disabled: true, + Fetch: func(context.Context) (string, error) { t.Fatal("must not fetch when disabled"); return "", nil }, + } + if _, ok := checker.LatestVersion(context.Background()); ok { + t.Fatal("disabled checker must report ok=false") + } +} + +func TestNilCheckerIsSafe(t *testing.T) { + var checker *whatsnew.UpdateChecker + if _, ok := checker.LatestVersion(context.Background()); ok { + t.Fatal("nil checker must report ok=false") + } +} + +func TestGitHubFetcher(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/devr-tools/codeguard/releases/latest" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"tag_name":"v0.9.9","name":"ignored"}`)) + })) + defer srv.Close() + + fetch := whatsnew.NewGitHubFetcher(srv.Client(), srv.URL, "devr-tools/codeguard") + tag, err := fetch(context.Background()) + if err != nil { + t.Fatalf("fetch error: %v", err) + } + if tag != "v0.9.9" { + t.Fatalf("tag = %q, want v0.9.9", tag) + } +} + +func TestGitHubFetcherNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + fetch := whatsnew.NewGitHubFetcher(srv.Client(), srv.URL, "devr-tools/codeguard") + if _, err := fetch(context.Background()); err == nil { + t.Fatal("expected error on non-200 response") + } +}