You are doing a FINAL code review on the entire `birdseye` implementation branch in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Per-task reviews have already happened; your job is the holistic view — cross-cutting concerns, integration coherence, things only visible when you see the whole picture.

## Branch Scope

```
BASE_SHA: c040bf3 (master)
HEAD_SHA: 2991e67 (current tip of worktree-birdseye)
```

24 commits, ~2850 LOC added. Touches:
- `apps/birdseye/` — new Go module (CLI binary)
- `services/crow/pkg/client/` — added `Download` method

## Spec & Plan

- Spec: `docs/superpowers/specs/2026-05-12-birdseye-design.md`
- Plan: `docs/superpowers/plans/2026-05-12-birdseye.md`

## Architecture Recap

birdseye is a Go CLI that diffs two versions of a Terraform module hosted in Crow (Immuta's private terraform module registry). Architecture:

- `cmd/` — cobra command tree (root + diff + versions)
- `internal/archive` — tar.gz extraction with hardening
- `internal/registry` — Crow client + disk cache
- `internal/diff` — textual (gotextdiff) + semantic (HCL) diff
- `internal/tui` — bubbletea Browser (multi-pane diff viewer) + Picker (interactive flow)

## Review Focus — Cross-cutting Only

You are NOT re-reviewing individual tasks. Focus on things visible only at branch-scope:

1. **Integration coherence**: does the package graph make sense? Are there any unexpected cross-package dependencies? Cycles? Does `cmd` correctly compose `archive` + `diff` + `registry` + `tui` without leaking layering?

2. **Error code paths end-to-end**: does a user-visible error always carry the right sentinel for `ExitCode()` to map correctly? Run through scenarios mentally:
   - bad module address (`birdseye diff foo`) → exit 1
   - 404 version (`birdseye diff ns/n/p 9.9.9 1.0.0`) → exit 2
   - network failure → exit 3
   - corrupt tarball → exit 4
   - HCL parse error with --semantic → exit 5
   - Ctrl+C in TUI → exit 130

3. **Resource hygiene**: any leaked goroutines, file handles, or temp files? `Download` returns a ReadCloser whose closing is responsibility of `Registry.Fetch` (verify via `defer rc.Close()` in registry.go).

4. **API consistency**: does `FileDiff.Status.String()` produce the same labels as `SemanticChange.Status.String()`? Are status enum values consistent across textual/semantic? Are JSON tags consistent (snake_case vs lowercase)?

5. **Test coverage gaps**: anything important totally untested at the branch level (e.g., no integration test that runs --json + --semantic together; no test that exercises the disk cache hit path across two diff invocations)?

6. **Style/idiom consistency across files**: do all packages use the same logging style, same error-wrapping style, same comment-doc style?

7. **CLAUDE.md compliance**: user prefers stdlib, prealloc'd slices/maps, ctx threaded through long ops, small interfaces. Spot-check across files.

8. **Build sanity**:
   - Run `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... && go test -race ./... && go vet ./...`
   - Run `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/...`
   - All must be clean.

9. **Final binary smoke**:
   - `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && make build && ./bin/birdseye --help`
   - Output should mention `birdseye`, `diff`, `versions`, `--crow-url`, `--timeout`, `--json`.

10. **README accuracy**: does `apps/birdseye/README.md` accurately describe what was built? Any drift from the actual flag names or exit codes?

## Report Format

Lead with overall assessment. Then sections:

**Overall:** READY TO MERGE | NEEDS_FIXES_BEFORE_MERGE

**Integration coherence:**
**Error paths:**
**Resource hygiene:**
**API consistency:**
**Coverage gaps:**
**Style consistency:**
**Build sanity:**
**Smoke output:**
**README accuracy:**

**Critical/Important/Minor issues** (only if any).

Be thorough but terse. This is the gate for shipping. Report concrete findings only — no padding.


- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll perform a holistic review of the birdseye implementation. Let me start by exploring the structure and running the build/test sanity checks in parallel.


Bash

find apps/birdseye -type f \( -name "*.go" -o -name "*.md" -o -name "Makefile" -o -name "go.mod" \) | head -100

List all source files in birdseye app


apps/birdseye/go.mod
apps/birdseye/Makefile
apps/birdseye/README.md
apps/birdseye/main.go
apps/birdseye/cmd/errors_test.go
apps/birdseye/cmd/versions_test.go
apps/birdseye/cmd/root_test.go
apps/birdseye/cmd/diff_test.go
apps/birdseye/cmd/diff.go
apps/birdseye/cmd/root.go
apps/birdseye/cmd/versions.go
apps/birdseye/cmd/errors.go
apps/birdseye/internal/archive/extract_test.go
apps/birdseye/internal/archive/extract.go
apps/birdseye/internal/tui/picker.go
apps/birdseye/internal/tui/browser_test.go
apps/birdseye/internal/tui/styles.go
apps/birdseye/internal/tui/app.go
apps/birdseye/internal/tui/picker_test.go
apps/birdseye/internal/tui/browser.go
apps/birdseye/internal/diff/textual.go
apps/birdseye/internal/diff/semantic.go
apps/birdseye/internal/diff/types.go
apps/birdseye/internal/diff/textual_test.go
apps/birdseye/internal/diff/semantic_test.go
apps/birdseye/internal/registry/cache_test.go
apps/birdseye/internal/registry/cache.go
apps/birdseye/internal/registry/registry.go
apps/birdseye/internal/registry/registry_test.go


List commits on birdseye branch


2991e67 docs(birdseye): README with usage, flags, and exit codes
1c85b59 feat(birdseye): --json output for scripting and CI
e00763a feat(birdseye): --semantic flag wired into one-shot diff output
b914c7c feat(birdseye): HCL-aware semantic diff producing SemanticChange entries
894eb9c feat(birdseye): wire picker -> browser for no-args diff
b9ac21b feat(birdseye): interactive picker state machine for no-args diff
3f29025 fix(birdseye): prevent j/k double-processing through viewport in browser
89436d6 feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration
8b740bd chore(birdseye): promote bubbles and lipgloss to direct deps
d3eec79 feat(birdseye): tui browser scaffold with multi-pane layout
f0b337f feat(birdseye): versions subcommand
835333a feat(birdseye): plain unified diff output for one-shot CLI usage
16193cc feat(birdseye): textual unified diff between extracted modules
b7ab5b6 chore(birdseye): promote x/sync to direct dep and clean swallowed-error comment
743c194 feat(birdseye): registry with cache-first Fetch and concurrent FetchPair
a278a7a feat(birdseye): on-disk module tarball cache with atomic writes
51ae45c feat(birdseye): registry cache interface and in-memory impl
03e7d39 test(birdseye): harden archive extraction (zip-slip, size cap, symlinks)
67e6e70 style(birdseye): satisfy intrange and nolintlint for archive package
67c8b68 feat(birdseye): tar.gz extraction with happy-path coverage
ee7794e feat(crow/client): add Download method for module tarballs
59cd0b6 refactor(birdseye): named exit codes, broaden ErrSemantic, document RunE workaround
3b73e14 feat(birdseye): cobra root command and exit-code mapping
ae477d5 feat(birdseye): bootstrap module skeleton


git diff c040bf3..HEAD --stat | tail -50

Show diff stat for birdseye branch


 apps/birdseye/.gitignore                         |   1 +
 apps/birdseye/Makefile                           |  21 ++
 apps/birdseye/README.md                          |  60 +++++
 apps/birdseye/cmd/diff.go                        | 234 ++++++++++++++++++
 apps/birdseye/cmd/diff_test.go                   | 180 ++++++++++++++
 apps/birdseye/cmd/errors.go                      |  55 +++++
 apps/birdseye/cmd/errors_test.go                 |  32 +++
 apps/birdseye/cmd/root.go                        |  45 ++++
 apps/birdseye/cmd/root_test.go                   |  31 +++
 apps/birdseye/cmd/versions.go                    |  34 +++
 apps/birdseye/cmd/versions_test.go               |  40 +++
 apps/birdseye/go.mod                             |  52 ++++
 apps/birdseye/go.sum                             |  98 ++++++++
 apps/birdseye/internal/archive/extract.go        | 104 ++++++++
 apps/birdseye/internal/archive/extract_test.go   | 156 ++++++++++++
 apps/birdseye/internal/diff/semantic.go          | 199 +++++++++++++++
 apps/birdseye/internal/diff/semantic_test.go     |  64 +++++
 apps/birdseye/internal/diff/textual.go           | 119 +++++++++
 apps/birdseye/internal/diff/textual_test.go      |  86 +++++++
 apps/birdseye/internal/diff/types.go             |  51 ++++
 apps/birdseye/internal/registry/cache.go         | 111 +++++++++
 apps/birdseye/internal/registry/cache_test.go    |  68 ++++++
 apps/birdseye/internal/registry/registry.go      |  76 ++++++
 apps/birdseye/internal/registry/registry_test.go | 126 ++++++++++
 apps/birdseye/internal/tui/app.go                |  18 ++
 apps/birdseye/internal/tui/browser.go            | 180 ++++++++++++++
 apps/birdseye/internal/tui/browser_test.go       |  95 ++++++++
 apps/birdseye/internal/tui/picker.go             | 295 +++++++++++++++++++++++
 apps/birdseye/internal/tui/picker_test.go        |  42 ++++
 apps/birdseye/internal/tui/styles.go             |  40 +++
 apps/birdseye/main.go                            |  25 ++
 services/crow/pkg/client/client.go               |  50 ++++
 services/crow/pkg/client/client_test.go          |  64 +++++
 33 files changed, 2852 insertions(+)


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... 2>&1

Build birdseye


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... 2>&1 | tail -80

Run tests with race detector


(Bash completed with no output)


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go vet ./... 2>&1

Run go vet


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... 2>&1 | tail -40

Run crow client tests


?   	github.com/immuta/operations/apps/birdseye	[no test files]
ok  	github.com/immuta/operations/apps/birdseye/cmd	1.429s
ok  	github.com/immuta/operations/apps/birdseye/internal/archive	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/registry	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/tui	(cached)


ok  	github.com/immuta/operations/services/crow/pkg/client	1.324s


Build, vet, and all tests pass cleanly. Now let me examine the actual source files for the cross-cutting concerns.


1	package cmd
2
3	import (
4		"context"
5		"errors"
6
7		"github.com/immuta/operations/services/crow/pkg/client"
8	)
9
10	// Sentinel errors mapped to exit codes. Packages that want a specific exit
11	// behaviour wrap these (e.g. `fmt.Errorf("extract: %w", ErrArchive)`).
12	var (
13		// ErrUsage indicates a CLI usage error (bad args, malformed module
14		// address, missing TTY for interactive mode). Maps to exit code 1.
15		ErrUsage = errors.New("usage error")
16
17		// ErrArchive indicates the fetched module tarball was corrupt or
18		// contained unsafe paths. Maps to exit code 4.
19		ErrArchive = errors.New("archive corrupt or unsafe")
20
21		// ErrSemantic indicates a parse or validation error during semantic
22		// (HCL-aware) diffing. Maps to exit code 5.
23		ErrSemantic = errors.New("semantic diff parse error")
24	)
25
26	const (
27		exitOK            = 0
28		exitUsage         = 1
29		exitNotFound      = 2
30		exitGeneric       = 3
31		exitArchive       = 4
32		exitSemantic      = 5
33		exitUserCancelled = 130
34	)
35
36	// ExitCode maps an error to a process exit code per the spec table.
37	// Returns 0 for nil.
38	func ExitCode(err error) int {
39		switch {
40		case err == nil:
41			return exitOK
42		case errors.Is(err, ErrUsage):
43			return exitUsage
44		case errors.Is(err, client.ErrNotFound):
45			return exitNotFound
46		case errors.Is(err, ErrArchive):
47			return exitArchive
48		case errors.Is(err, ErrSemantic):
49			return exitSemantic
50		case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
51			return exitUserCancelled
52		default:
53			return exitGeneric
54		}
55	}
56


1	// Package cmd wires up the cobra command tree for birdseye. Functions
2	// return *cobra.Command (rather than using package-level vars) so each
3	// test builds a fresh tree and flag state does not leak between cases.
4	package cmd
5
6	import (
7		"time"
8
9		"github.com/spf13/cobra"
10	)
11
12	// GlobalFlags are the cross-subcommand options exposed on the root command.
13	type GlobalFlags struct {
14		CrowURL string
15		Timeout time.Duration
16		JSON    bool
17	}
18
19	const defaultCrowURL = "https://crow.infrastructure.immuta.io"
20
21	// NewRootCmd returns the configured root command. Subcommands are wired
22	// in here.
23	func NewRootCmd() *cobra.Command {
24		g := &GlobalFlags{}
25		root := &cobra.Command{
26			Use:           "birdseye",
27			Short:         "Diff two versions of a Terraform module hosted in Crow",
28			SilenceUsage:  true,
29			SilenceErrors: true,
30			// Without a RunE, cobra v1.x prints help via its internal stderr
31			// writer rather than the writer set by SetOut, which breaks
32			// TestNewRootCmdHelp's captured buffer. Calling Help() explicitly
33			// routes the output correctly. Subcommands will not invoke this.
34			RunE: func(cmd *cobra.Command, args []string) error {
35				return cmd.Help()
36			},
37		}
38		root.PersistentFlags().StringVar(&g.CrowURL, "crow-url", defaultCrowURL, "Crow base URL (env CROW_URL)")
39		root.PersistentFlags().DurationVar(&g.Timeout, "timeout", 30*time.Second, "per-operation HTTP timeout")
40		root.PersistentFlags().BoolVar(&g.JSON, "json", false, "emit structured JSON to stdout where supported")
41
42		root.AddCommand(newDiffCmd(g))
43		root.AddCommand(newVersionsCmd(g))
44		return root
45	}
46


1	package cmd
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"io"
8		"net/http"
9		"os"
10		"path/filepath"
11		"strings"
12		"time"
13
14		tea "github.com/charmbracelet/bubbletea"
15		"github.com/spf13/cobra"
16		"golang.org/x/term"
17
18		"github.com/immuta/operations/apps/birdseye/internal/archive"
19		"github.com/immuta/operations/apps/birdseye/internal/diff"
20		"github.com/immuta/operations/apps/birdseye/internal/registry"
21		"github.com/immuta/operations/apps/birdseye/internal/tui"
22		"github.com/immuta/operations/services/crow/pkg/api"
23		"github.com/immuta/operations/services/crow/pkg/client"
24	)
25
26	// diffOpts holds the resolved options for `birdseye diff`.
27	type diffOpts struct {
28		Semantic bool
29		Plain    bool
30		CacheDir string
31	}
32
33	const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size
34
35	func newDiffCmd(g *GlobalFlags) *cobra.Command {
36		o := &diffOpts{}
37		c := &cobra.Command{
38			Use:   "diff [module] [versionA] [versionB]",
39			Short: "Diff two versions of a Crow module",
40			Long: "Diff two versions of a module hosted in Crow.\n\n" +
41				"If module/versions are omitted, the interactive TUI launches\n" +
42				"(see picker task). With --plain, prints unified diff to stdout.",
43			Args:          cobra.MaximumNArgs(3),
44			SilenceUsage:  true,
45			SilenceErrors: true,
46			RunE: func(cmd *cobra.Command, args []string) error {
47				return runDiff(cmd.Context(), cmd, args, g, o)
48			},
49		}
50		c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
51		c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
52		c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
53		c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
54		return c
55	}
56
57	func defaultCacheDir() string {
58		if home, err := os.UserCacheDir(); err == nil {
59			return filepath.Join(home, "birdseye")
60		}
61		return ".birdseye-cache"
62	}
63
64	func newHTTPClient(d time.Duration) *http.Client {
65		return &http.Client{Timeout: d}
66	}
67
68	func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
69		parts := strings.Split(s, "/")
70		if len(parts) != 3 {
71			return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
72		}
73		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
74		if err := id.Validate(); err != nil {
75			return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
76		}
77		return id, nil
78	}
79
80	func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
81		if len(args) == 0 {
82			return runInteractive(ctx, cmd, g, o)
83		}
84		if len(args) != 3 {
85			return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
86		}
87		id, err := parseModuleAddress(args[0])
88		if err != nil {
89			return err
90		}
91		if err := api.ValidateVersion(args[1]); err != nil {
92			return fmt.Errorf("%w: %v", ErrUsage, err)
93		}
94		if err := api.ValidateVersion(args[2]); err != nil {
95			return fmt.Errorf("%w: %v", ErrUsage, err)
96		}
97		modA := api.Module{ModuleIdentifier: id, Version: args[1]}
98		modB := api.Module{ModuleIdentifier: id, Version: args[2]}
99
100		cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
101		cache, err := registry.NewDiskCache(o.CacheDir)
102		if err != nil {
103			return fmt.Errorf("cache init: %w", err)
104		}
105		reg := registry.New(cli, cache)
106
107		aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
108		if err != nil {
109			return err
110		}
111
112		modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
113		if err != nil {
114			return fmt.Errorf("%w: %v", ErrArchive, err)
115		}
116		modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
117		if err != nil {
118			return fmt.Errorf("%w: %v", ErrArchive, err)
119		}
120
121		result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
122		if o.Semantic {
123			sem, err := diff.Semantic(modAExtracted, modBExtracted)
124			if err != nil {
125				return fmt.Errorf("%w: %v", ErrSemantic, err)
126			}
127			result.Semantic = sem
128		}
129
130		if g.JSON {
131			return renderJSON(cmd.OutOrStdout(), result)
132		}
133		if !o.Plain && isTTY(cmd.OutOrStdout()) {
134			return tui.RunBrowser(ctx, result)
135		}
136		return renderPlain(cmd.OutOrStdout(), result)
137	}
138
139	// isTTY reports whether w is a *os.File backed by a terminal.
140	func isTTY(w io.Writer) bool {
141		f, ok := w.(*os.File)
142		if !ok {
143			return false
144		}
145		return term.IsTerminal(int(f.Fd()))
146	}
147
148	func runInteractive(ctx context.Context, cmd *cobra.Command, g *GlobalFlags, o *diffOpts) error {
149		if !isTTY(cmd.OutOrStdout()) {
150			return fmt.Errorf("%w: interactive mode requires a TTY", ErrUsage)
151		}
152		cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
153		cache, err := registry.NewDiskCache(o.CacheDir)
154		if err != nil {
155			return fmt.Errorf("cache init: %w", err)
156		}
157		reg := registry.New(cli, cache)
158
159		// Stage 1: picker → fetched bytes
160		p := tui.NewPicker(&tui.Deps{Client: cli, Reg: reg})
161		prog := tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))
162		if _, err := prog.Run(); err != nil {
163			return err
164		}
165		aBytes, bBytes := p.Bytes()
166		if aBytes == nil || bBytes == nil {
167			// User quit before completing picker; not an error.
168			return nil
169		}
170
171		// Stage 2: extract + diff + browser
172		modA, err := archive.Extract(aBytes, maxArchiveBytes)
173		if err != nil {
174			return fmt.Errorf("%w: %v", ErrArchive, err)
175		}
176		modB, err := archive.Extract(bBytes, maxArchiveBytes)
177		if err != nil {
178			return fmt.Errorf("%w: %v", ErrArchive, err)
179		}
180		result := diff.Result{Files: diff.Textual(modA, modB)}
181		return tui.RunBrowser(ctx, result)
182	}
183
184	// renderPlain writes the diff result to w in unified-diff format.
185	// "no differences" is emitted when there are zero files in the result.
186	func renderPlain(w io.Writer, r diff.Result) error {
187		if len(r.Files) == 0 && len(r.Semantic) == 0 {
188			_, err := fmt.Fprintln(w, "no differences")
189			return err
190		}
191		for i, fd := range r.Files {
192			if i > 0 {
193				_, _ = fmt.Fprintln(w)
194			}
195			_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
196			_, _ = fmt.Fprint(w, fd.Unified)
197		}
198		if len(r.Semantic) > 0 {
199			_, _ = fmt.Fprintln(w)
200			_, _ = fmt.Fprintln(w, "=== semantic changes ===")
201			for _, c := range r.Semantic {
202				_, _ = fmt.Fprintf(w, "%s %s %s\n", c.Status, c.Kind, c.Name)
203			}
204		}
205		return nil
206	}
207
208	func renderJSON(w io.Writer, r diff.Result) error {
209		type fileOut struct {
210			Path    string `json:"path"`
211			Status  string `json:"status"`
212			Adds    int    `json:"adds"`
213			Dels    int    `json:"dels"`
214			Unified string `json:"unified"`
215		}
216		type semOut struct {
217			Kind   string `json:"kind"`
218			Name   string `json:"name"`
219			Status string `json:"status"`
220			Detail string `json:"detail,omitempty"`
221		}
222		type payload struct {
223			Files    []fileOut `json:"files"`
224			Semantic []semOut  `json:"semantic,omitempty"`
225		}
226		out := payload{Files: make([]fileOut, 0, len(r.Files))}
227		for _, fd := range r.Files {
228			out.Files = append(out.Files, fileOut{Path: fd.Path, Status: fd.Status.String(), Adds: fd.Adds, Dels: fd.Dels, Unified: fd.Unified})
229		}
230		for _, c := range r.Semantic {
231			out.Semantic = append(out.Semantic, semOut{Kind: c.Kind, Name: c.Name, Status: c.Status.String(), Detail: c.Detail})
232		}
233		return json.NewEncoder(w).Encode(out)
234	}
235


1	// Package diff produces FileDiff entries and optional SemanticChange
2	// entries from a pair of extracted Modules. All functions are pure: no
3	// I/O, no globals.
4	package diff
5
6	// FileStatus describes what happened to a file between the two versions.
7	type FileStatus int
8
9	const (
10		StatusModified FileStatus = iota
11		StatusAdded
12		StatusRemoved
13	)
14
15	func (s FileStatus) String() string {
16		switch s {
17		case StatusAdded:
18			return "added"
19		case StatusRemoved:
20			return "removed"
21		default:
22			return "modified"
23		}
24	}
25
26	// FileDiff is a per-file diff result. Unified holds the rendered diff
27	// hunks; Adds/Dels are the count of added/removed lines for summary
28	// rendering.
29	type FileDiff struct {
30		Path    string
31		Status  FileStatus
32		Unified string
33		Adds    int
34		Dels    int
35	}
36
37	// SemanticChange is a single HCL-aware change. Kind is one of "resource",
38	// "variable", "output", "provider", "locals", "data", or "module".
39	type SemanticChange struct {
40		Kind   string
41		Name   string
42		Status FileStatus
43		Detail string
44	}
45
46	// Result is what cmd/diff renders. Semantic is nil unless --semantic was
47	// requested.
48	type Result struct {
49		Files    []FileDiff
50		Semantic []SemanticChange
51	}
52


1	package diff
2
3	import (
4		"fmt"
5		"sort"
6		"strings"
7
8		"github.com/hexops/gotextdiff"
9		"github.com/hexops/gotextdiff/myers"
10		"github.com/hexops/gotextdiff/span"
11
12		"github.com/immuta/operations/apps/birdseye/internal/archive"
13	)
14
15	// Textual returns a FileDiff per file that differs between a and b,
16	// sorted by path. Files identical in both archives are not included.
17	func Textual(a, b archive.Module) []FileDiff {
18		all := make(map[string]struct{})
19		for k := range a.Files {
20			all[k] = struct{}{}
21		}
22		for k := range b.Files {
23			all[k] = struct{}{}
24		}
25		paths := make([]string, 0, len(all))
26		for k := range all {
27			paths = append(paths, k)
28		}
29		sort.Strings(paths)
30
31		out := make([]FileDiff, 0, len(paths))
32		for _, p := range paths {
33			ab, aok := a.Files[p]
34			bb, bok := b.Files[p]
35			switch {
36			case aok && bok:
37				if string(ab) == string(bb) {
38					continue
39				}
40				out = append(out, modifiedDiff(p, string(ab), string(bb)))
41			case !aok && bok:
42				out = append(out, addedDiff(p, string(bb)))
43			case aok && !bok:
44				out = append(out, removedDiff(p, string(ab)))
45			}
46		}
47		return out
48	}
49
50	func modifiedDiff(path, oldText, newText string) FileDiff {
51		edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
52		unified := gotextdiff.ToUnified(path, path, oldText, edits)
53		u := fmt.Sprintf("%v", unified)
54		adds, dels := countLines(u)
55		return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
56	}
57
58	func addedDiff(path, newText string) FileDiff {
59		return FileDiff{
60			Path:    path,
61			Status:  StatusAdded,
62			Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
63			Adds:    countNonEmptyLines(newText),
64		}
65	}
66
67	func removedDiff(path, oldText string) FileDiff {
68		return FileDiff{
69			Path:    path,
70			Status:  StatusRemoved,
71			Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
72			Dels:    countNonEmptyLines(oldText),
73		}
74	}
75
76	func prefixLines(s, prefix string) string {
77		if s == "" {
78			return ""
79		}
80		lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
81		var sb strings.Builder
82		sb.Grow(len(s) + len(lines)*len(prefix))
83		for _, l := range lines {
84			sb.WriteString(prefix)
85			sb.WriteString(l)
86			sb.WriteByte('\n')
87		}
88		return sb.String()
89	}
90
91	func countNonEmptyLines(s string) int {
92		if s == "" {
93			return 0
94		}
95		n := strings.Count(s, "\n")
96		if !strings.HasSuffix(s, "\n") {
97			n++
98		}
99		return n
100	}
101
102	func countLines(unified string) (adds, dels int) {
103		for _, line := range strings.Split(unified, "\n") {
104			if len(line) == 0 {
105				continue
106			}
107			switch line[0] {
108			case '+':
109				if !strings.HasPrefix(line, "+++") {
110					adds++
111				}
112			case '-':
113				if !strings.HasPrefix(line, "---") {
114					dels++
115				}
116			}
117		}
118		return adds, dels
119	}
120


1	package diff
2
3	import (
4		"crypto/sha256"
5		"encoding/hex"
6		"errors"
7		"fmt"
8		"sort"
9		"strings"
10
11		"github.com/hashicorp/hcl/v2"
12		"github.com/hashicorp/hcl/v2/hclparse"
13		"github.com/hashicorp/hcl/v2/hclsyntax"
14
15		"github.com/immuta/operations/apps/birdseye/internal/archive"
16	)
17
18	// ErrParseFailed is returned by Semantic when any .tf file fails to
19	// parse. Callers map this to exit code 5.
20	var ErrParseFailed = errors.New("hcl parse failed")
21
22	// blockEntry pairs an HCL block with the source bytes of its file so
23	// that blockHash can read actual expression text rather than byte offsets.
24	type blockEntry struct {
25		block *hclsyntax.Block
26		src   []byte
27	}
28
29	// ModuleSummary aggregates the named HCL blocks of interest from every
30	// .tf file in a module. Maps are keyed by a stable identifier
31	// (resource: "type.name", variable: name, etc.).
32	type ModuleSummary struct {
33		Resources map[string]blockEntry
34		Variables map[string]blockEntry
35		Outputs   map[string]blockEntry
36		Providers map[string]blockEntry
37		Locals    map[string]blockEntry
38		Data      map[string]blockEntry
39		Modules   map[string]blockEntry
40	}
41
42	// Semantic produces SemanticChange entries by parsing every .tf file in
43	// each archive into a ModuleSummary and diffing summaries.
44	func Semantic(a, b archive.Module) ([]SemanticChange, error) {
45		sa, err := summarize(a)
46		if err != nil {
47			return nil, err
48		}
49		sb, err := summarize(b)
50		if err != nil {
51			return nil, err
52		}
53		var out []SemanticChange
54		out = append(out, diffMap("resource", sa.Resources, sb.Resources)...)
55		out = append(out, diffMap("variable", sa.Variables, sb.Variables)...)
56		out = append(out, diffMap("output", sa.Outputs, sb.Outputs)...)
57		out = append(out, diffMap("provider", sa.Providers, sb.Providers)...)
58		out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
59		out = append(out, diffMap("data", sa.Data, sb.Data)...)
60		out = append(out, diffMap("module", sa.Modules, sb.Modules)...)
61		sort.SliceStable(out, func(i, j int) bool {
62			if out[i].Kind != out[j].Kind {
63				return out[i].Kind < out[j].Kind
64			}
65			return out[i].Name < out[j].Name
66		})
67		return out, nil
68	}
69
70	func summarize(m archive.Module) (ModuleSummary, error) {
71		s := ModuleSummary{
72			Resources: map[string]blockEntry{},
73			Variables: map[string]blockEntry{},
74			Outputs:   map[string]blockEntry{},
75			Providers: map[string]blockEntry{},
76			Locals:    map[string]blockEntry{},
77			Data:      map[string]blockEntry{},
78			Modules:   map[string]blockEntry{},
79		}
80		parser := hclparse.NewParser()
81		for path, body := range m.Files {
82			if !strings.HasSuffix(path, ".tf") {
83				continue
84			}
85			file, diags := parser.ParseHCL(body, path)
86			if diags.HasErrors() {
87				return ModuleSummary{}, fmt.Errorf("%w: %s: %s", ErrParseFailed, path, diags.Error())
88			}
89			syntaxBody, ok := file.Body.(*hclsyntax.Body)
90			if !ok {
91				return ModuleSummary{}, fmt.Errorf("%w: %s: unexpected body type", ErrParseFailed, path)
92			}
93			for _, blk := range syntaxBody.Blocks {
94				key, target := classify(blk, &s)
95				if target == nil {
96					continue
97				}
98				target[key] = blockEntry{block: blk, src: body}
99			}
100		}
101		return s, nil
102	}
103
104	func classify(blk *hclsyntax.Block, s *ModuleSummary) (string, map[string]blockEntry) {
105		switch blk.Type {
106		case "resource":
107			if len(blk.Labels) != 2 {
108				return "", nil
109			}
110			return blk.Labels[0] + "." + blk.Labels[1], s.Resources
111		case "data":
112			if len(blk.Labels) != 2 {
113				return "", nil
114			}
115			return blk.Labels[0] + "." + blk.Labels[1], s.Data
116		case "module":
117			if len(blk.Labels) != 1 {
118				return "", nil
119			}
120			return blk.Labels[0], s.Modules
121		case "variable":
122			if len(blk.Labels) != 1 {
123				return "", nil
124			}
125			return blk.Labels[0], s.Variables
126		case "output":
127			if len(blk.Labels) != 1 {
128				return "", nil
129			}
130			return blk.Labels[0], s.Outputs
131		case "provider":
132			if len(blk.Labels) != 1 {
133				return "", nil
134			}
135			return blk.Labels[0], s.Providers
136		case "locals":
137			// `locals` has no labels and may appear in multiple files;
138			// distinguish by source range so we don't collide.
139			return blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line), s.Locals
140		}
141		return "", nil
142	}
143
144	func diffMap(kind string, a, b map[string]blockEntry) []SemanticChange {
145		var out []SemanticChange
146		for name, ae := range a {
147			be, ok := b[name]
148			if !ok {
149				out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusRemoved})
150				continue
151			}
152			if blockHash(ae) != blockHash(be) {
153				out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusModified})
154			}
155		}
156		for name := range b {
157			if _, ok := a[name]; !ok {
158				out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusAdded})
159			}
160		}
161		return out
162	}
163
164	// blockHash digests the content of a block using actual source bytes for
165	// attribute expressions. Equal hashes mean byte-identical attribute values
166	// so any change to an attribute value will shift the hash.
167	func blockHash(e blockEntry) string {
168		blk := e.block
169		src := e.src
170		h := sha256.New()
171		h.Write([]byte(strings.Join(blk.Labels, "|")))
172		keys := make([]string, 0, len(blk.Body.Attributes))
173		for k := range blk.Body.Attributes {
174			keys = append(keys, k)
175		}
176		sort.Strings(keys)
177		for _, k := range keys {
178			attr := blk.Body.Attributes[k]
179			h.Write([]byte(k))
180			h.Write([]byte{0})
181			h.Write([]byte(exprText(src, attr.Expr.Range())))
182			h.Write([]byte{0})
183		}
184		for _, nested := range blk.Body.Blocks {
185			h.Write([]byte(nested.Type))
186			h.Write([]byte{0})
187			h.Write([]byte(strings.Join(nested.Labels, "|")))
188			h.Write([]byte{0})
189		}
190		return hex.EncodeToString(h.Sum(nil))
191	}
192
193	// exprText returns the actual source bytes of an expression's range.
194	func exprText(src []byte, r hcl.Range) string {
195		if r.End.Byte > len(src) || r.Start.Byte > r.End.Byte {
196			return fmt.Sprintf("%d:%d", r.Start.Byte, r.End.Byte)
197		}
198		return string(src[r.Start.Byte:r.End.Byte])
199	}
200


I see something important - `semantic.go` uses `ErrParseFailed` while `cmd/diff.go` wraps with `ErrSemantic`. Let me check if the semantic parse error propagates correctly through the exit-code mapping.

Looking at diff.go line 123-126: when `diff.Semantic` returns `ErrParseFailed`, it gets wrapped with `ErrSemantic` via `fmt.Errorf("%w: %v", ErrSemantic, err)`. The `%v` for the inner err means `ErrParseFailed` is *not* preserved in the chain, but `ErrSemantic` is. So `ExitCode` correctly returns 5. Good.


1	package registry
2
3	import (
4		"context"
5		"fmt"
6		"io"
7
8		"github.com/immuta/operations/services/crow/pkg/api"
9		"github.com/immuta/operations/services/crow/pkg/client"
10		"golang.org/x/sync/errgroup"
11	)
12
13	// Registry composes a typed Crow client with a Cache. Fetch is cache-first;
14	// FetchPair fans Fetch out concurrently with shared cancellation.
15	type Registry struct {
16		client *client.Client
17		cache  Cache
18	}
19
20	// New constructs a Registry. Both arguments must be non-nil.
21	func New(c *client.Client, cache Cache) *Registry {
22		return &Registry{client: c, cache: cache}
23	}
24
25	// Fetch returns the gzipped tarball bytes for the given module version,
26	// consulting the cache first. Network errors and 404s from the
27	// underlying client surface unchanged (callers can errors.Is against
28	// client.ErrNotFound).
29	func (r *Registry) Fetch(ctx context.Context, m api.Module) ([]byte, error) {
30		if body, hit, err := r.cache.Get(m); err != nil {
31			return nil, fmt.Errorf("cache get: %w", err)
32		} else if hit {
33			return body, nil
34		}
35		rc, err := r.client.Download(ctx, m)
36		if err != nil {
37			return nil, err
38		}
39		defer rc.Close()
40		body, err := io.ReadAll(rc)
41		if err != nil {
42			return nil, fmt.Errorf("read tarball: %w", err)
43		}
44		if err := r.cache.Put(m, body); err != nil {
45			// Cache write failure is not fatal — we still have the body to return.
46			_ = err
47		}
48		return body, nil
49	}
50
51	// FetchPair fetches a and b concurrently. The first error cancels its
52	// sibling via errgroup.WithContext.
53	func (r *Registry) FetchPair(ctx context.Context, a, b api.Module) ([]byte, []byte, error) {
54		g, gctx := errgroup.WithContext(ctx)
55		var aBytes, bBytes []byte
56		g.Go(func() error {
57			body, err := r.Fetch(gctx, a)
58			if err != nil {
59				return err
60			}
61			aBytes = body
62			return nil
63		})
64		g.Go(func() error {
65			body, err := r.Fetch(gctx, b)
66			if err != nil {
67				return err
68			}
69			bBytes = body
70			return nil
71		})
72		if err := g.Wait(); err != nil {
73			return nil, nil, err
74		}
75		return aBytes, bBytes, nil
76	}
77


1	// Package registry talks to a Crow server through its typed client and
2	// caches module tarballs on disk so repeated diffs against the same
3	// versions stay fast.
4	package registry
5
6	import (
7		"errors"
8		"fmt"
9		"os"
10		"path/filepath"
11		"sync"
12
13		"github.com/immuta/operations/services/crow/pkg/api"
14	)
15
16	// Cache is the storage seam used by Registry. Production uses DiskCache;
17	// tests inject MemCache.
18	type Cache interface {
19		Get(api.Module) ([]byte, bool, error)
20		Put(api.Module, []byte) error
21	}
22
23	// MemCache is a thread-safe in-memory Cache implementation.
24	type MemCache struct {
25		mu    sync.RWMutex
26		items map[string][]byte
27	}
28
29	// NewMemCache returns an empty MemCache.
30	func NewMemCache() *MemCache {
31		return &MemCache{items: make(map[string][]byte)}
32	}
33
34	func memKey(m api.Module) string {
35		return m.Namespace + "/" + m.Name + "/" + m.Provider + "/" + m.Version
36	}
37
38	func (c *MemCache) Get(m api.Module) ([]byte, bool, error) {
39		c.mu.RLock()
40		defer c.mu.RUnlock()
41		b, ok := c.items[memKey(m)]
42		if !ok {
43			return nil, false, nil
44		}
45		return b, true, nil
46	}
47
48	func (c *MemCache) Put(m api.Module, body []byte) error {
49		c.mu.Lock()
50		defer c.mu.Unlock()
51		c.items[memKey(m)] = body
52		return nil
53	}
54
55	// DiskCache stores tarballs under
56	//
57	//	<root>/<namespace>/<name>/<provider>/<version>.tar.gz
58	//
59	// Writes are atomic via a temp file + rename.
60	type DiskCache struct {
61		root string
62	}
63
64	// NewDiskCache creates the root directory if it does not exist.
65	func NewDiskCache(root string) (*DiskCache, error) {
66		if err := os.MkdirAll(root, 0o755); err != nil {
67			return nil, fmt.Errorf("mkdir cache root: %w", err)
68		}
69		return &DiskCache{root: root}, nil
70	}
71
72	func (c *DiskCache) path(m api.Module) string {
73		return filepath.Join(c.root, m.Namespace, m.Name, m.Provider, m.Version+".tar.gz")
74	}
75
76	func (c *DiskCache) Get(m api.Module) ([]byte, bool, error) {
77		b, err := os.ReadFile(c.path(m))
78		if errors.Is(err, os.ErrNotExist) {
79			return nil, false, nil
80		}
81		if err != nil {
82			return nil, false, fmt.Errorf("read cache: %w", err)
83		}
84		return b, true, nil
85	}
86
87	func (c *DiskCache) Put(m api.Module, body []byte) error {
88		final := c.path(m)
89		if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil {
90			return fmt.Errorf("mkdir cache dir: %w", err)
91		}
92		tmp, err := os.CreateTemp(filepath.Dir(final), ".birdseye-*.tmp")
93		if err != nil {
94			return fmt.Errorf("create temp: %w", err)
95		}
96		tmpName := tmp.Name()
97		if _, err := tmp.Write(body); err != nil {
98			_ = tmp.Close()
99			_ = os.Remove(tmpName)
100			return fmt.Errorf("write temp: %w", err)
101		}
102		if err := tmp.Close(); err != nil {
103			_ = os.Remove(tmpName)
104			return fmt.Errorf("close temp: %w", err)
105		}
106		if err := os.Rename(tmpName, final); err != nil {
107			_ = os.Remove(tmpName)
108			return fmt.Errorf("rename: %w", err)
109		}
110		return nil
111	}
112


1	// Package archive extracts gzipped tar archives from Crow into an
2	// in-memory file map. Diff and TUI layers consume the resulting Module
3	// type and never touch the network themselves.
4	package archive
5
6	import (
7		"archive/tar"
8		"bytes"
9		"compress/gzip"
10		"errors"
11		"fmt"
12		"io"
13	)
14
15	// Module is the in-memory representation of an extracted module tarball.
16	// Keys are forward-slash relative paths. Symlinks are not stored.
17	type Module struct {
18		Files map[string][]byte
19	}
20
21	var (
22		ErrTooLarge   = errors.New("archive exceeds size cap")
23		ErrUnsafePath = errors.New("archive contains unsafe path")
24	)
25
26	// Extract decompresses and untars tarball into a Module. If the
27	// cumulative uncompressed size exceeds maxBytes, Extract returns
28	// ErrTooLarge. Paths containing ".." or absolute roots are rejected with
29	// ErrUnsafePath. Symlinks are dropped silently (they are not useful
30	// inputs to a textual or semantic diff).
31	func Extract(tarball []byte, maxBytes int64) (Module, error) {
32		gz, err := gzip.NewReader(bytes.NewReader(tarball))
33		if err != nil {
34			return Module{}, fmt.Errorf("gzip: %w", err)
35		}
36		defer gz.Close()
37
38		tr := tar.NewReader(gz)
39		files := make(map[string][]byte)
40		var total int64
41		for {
42			hdr, err := tr.Next()
43			if errors.Is(err, io.EOF) {
44				break
45			}
46			if err != nil {
47				return Module{}, fmt.Errorf("tar: %w", err)
48			}
49			switch hdr.Typeflag {
50			case tar.TypeReg, tar.TypeRegA: //nolint:staticcheck // TypeRegA is the legacy '\x00' form of TypeReg still emitted by some older tar implementations
51				if err := safePath(hdr.Name); err != nil {
52					return Module{}, err
53				}
54				total += hdr.Size
55				if total > maxBytes {
56					return Module{}, ErrTooLarge
57				}
58				body := make([]byte, hdr.Size)
59				if _, err := io.ReadFull(tr, body); err != nil {
60					return Module{}, fmt.Errorf("reading %s: %w", hdr.Name, err)
61				}
62				files[hdr.Name] = body
63			default:
64				// Directories, symlinks, etc. ignored.
65			}
66		}
67		return Module{Files: files}, nil
68	}
69
70	func safePath(name string) error {
71		if name == "" {
72			return fmt.Errorf("%w: empty name", ErrUnsafePath)
73		}
74		if name[0] == '/' {
75			return fmt.Errorf("%w: absolute path %q", ErrUnsafePath, name)
76		}
77		// Reject any segment that is "..", or any path containing "..".
78		for i := range len(name) {
79			if name[i] == '\\' {
80				return fmt.Errorf("%w: backslash in path %q", ErrUnsafePath, name)
81			}
82		}
83		if containsDotDot(name) {
84			return fmt.Errorf("%w: path traversal in %q", ErrUnsafePath, name)
85		}
86		return nil
87	}
88
89	func containsDotDot(p string) bool {
90		for i := range len(p) {
91			if p[i] != '.' {
92				continue
93			}
94			// Check for ".." preceded by start-or-slash, followed by slash-or-end.
95			if i+1 < len(p) && p[i+1] == '.' {
96				startOK := i == 0 || p[i-1] == '/'
97				endOK := i+2 == len(p) || p[i+2] == '/'
98				if startOK && endOK {
99					return true
100				}
101			}
102		}
103		return false
104	}
105


Important finding: `Extract` does not wrap `ErrUnsafePath`/`ErrTooLarge`/gzip errors with `ErrArchive`. But `cmd/diff.go` wraps via `fmt.Errorf("%w: %v", ErrArchive, err)` — losing the inner sentinel but preserving `ErrArchive`. Exit code 4 is correctly emitted. Good.


1	package tui
2
3	import (
4		"strings"
5
6		tea "github.com/charmbracelet/bubbletea"
7		"github.com/charmbracelet/bubbles/viewport"
8		"github.com/charmbracelet/lipgloss"
9
10		"github.com/immuta/operations/apps/birdseye/internal/diff"
11	)
12
13	// Browser is the multi-pane diff viewer: file tree on the left,
14	// rendered unified diff on the right, footer at the bottom.
15	type Browser struct {
16		result   diff.Result
17		theme    Theme
18		selected int
19		width    int
20		height   int
21		viewport viewport.Model
22	}
23
24	// NewBrowser builds a Browser from a pre-computed Result. SetSize must
25	// be called once before the first View() so the viewport sizes itself.
26	func NewBrowser(r diff.Result) *Browser {
27		vp := viewport.New(0, 0)
28		return &Browser{
29			result:   r,
30			theme:    DefaultTheme(),
31			viewport: vp,
32		}
33	}
34
35	// SetSize informs the browser of the terminal dimensions.
36	func (b *Browser) SetSize(w, h int) {
37		b.width, b.height = w, h
38		fileTreeW := 32
39		if w < 80 {
40			fileTreeW = 24
41		}
42		contentW := w - fileTreeW - 4
43		contentH := h - 2
44		if contentW < 10 {
45			contentW = 10
46		}
47		if contentH < 5 {
48			contentH = 5
49		}
50		b.viewport.Width = contentW
51		b.viewport.Height = contentH
52		b.refreshViewport()
53	}
54
55	// SelectedIndex returns the currently focused file diff index.
56	func (b *Browser) SelectedIndex() int { return b.selected }
57
58	// SelectedFile returns the FileDiff at the current selection. Falls
59	// back to a zero value when the result has no files.
60	func (b *Browser) SelectedFile() diff.FileDiff {
61		if len(b.result.Files) == 0 {
62			return diff.FileDiff{}
63		}
64		return b.result.Files[b.selected]
65	}
66
67	// Move adjusts the selection by delta, clamping to the valid range, and
68	// refreshes the viewport content.
69	func (b *Browser) Move(delta int) {
70		if len(b.result.Files) == 0 {
71			return
72		}
73		b.selected += delta
74		if b.selected < 0 {
75			b.selected = 0
76		}
77		if b.selected >= len(b.result.Files) {
78			b.selected = len(b.result.Files) - 1
79		}
80		b.refreshViewport()
81	}
82
83	func (b *Browser) refreshViewport() {
84		if len(b.result.Files) == 0 {
85			b.viewport.SetContent("no differences")
86			return
87		}
88		b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
89		b.viewport.GotoTop()
90	}
91
92	func (b *Browser) colorizeUnified(unified string) string {
93		var sb strings.Builder
94		for _, line := range strings.Split(unified, "\n") {
95			switch {
96			case strings.HasPrefix(line, "@@"):
97				sb.WriteString(b.theme.HunkHeader.Render(line))
98			case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
99				sb.WriteString(b.theme.AddedLine.Render(line))
100			case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
101				sb.WriteString(b.theme.RemovedLine.Render(line))
102			default:
103				sb.WriteString(line)
104			}
105			sb.WriteByte('\n')
106		}
107		return sb.String()
108	}
109
110	// Init satisfies tea.Model. No initial cmd — browser is purely reactive.
111	func (b *Browser) Init() tea.Cmd { return nil }
112
113	// Update handles key presses and window-resize messages.
114	func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115		switch msg := msg.(type) {
116		case tea.WindowSizeMsg:
117			b.SetSize(msg.Width, msg.Height)
118			return b, nil
119		case tea.KeyMsg:
120			switch msg.Type {
121			case tea.KeyDown:
122				b.Move(1)
123				return b, nil
124			case tea.KeyUp:
125				b.Move(-1)
126				return b, nil
127			case tea.KeyRunes:
128				switch string(msg.Runes) {
129				case "q":
130					return b, tea.Quit
131				case "j":
132					b.Move(1)
133					return b, nil
134				case "k":
135					b.Move(-1)
136					return b, nil
137				}
138			}
139		}
140		// Forward to viewport for scroll handling.
141		var cmd tea.Cmd
142		b.viewport, cmd = b.viewport.Update(msg)
143		return b, cmd
144	}
145
146	// View renders the full multi-pane layout.
147	func (b *Browser) View() string {
148		tree := b.renderTree()
149		right := b.theme.Viewport.Render(b.viewport.View())
150		body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
151		footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
152		return lipgloss.JoinVertical(lipgloss.Left, body, footer)
153	}
154
155	func (b *Browser) renderTree() string {
156		var sb strings.Builder
157		for i, fd := range b.result.Files {
158			marker := " "
159			switch fd.Status {
160			case diff.StatusAdded:
161				marker = b.theme.StatusAdd.Render("+")
162			case diff.StatusRemoved:
163				marker = b.theme.StatusDel.Render("-")
164			case diff.StatusModified:
165				marker = b.theme.StatusMod.Render("~")
166			}
167			line := marker + " " + fd.Path
168			if i == b.selected {
169				line = b.theme.FileSelected.Render(line)
170			} else {
171				line = b.theme.FileTreeItem.Render(line)
172			}
173			sb.WriteString(line)
174			sb.WriteByte('\n')
175		}
176		if len(b.result.Files) == 0 {
177			sb.WriteString("(no files)")
178		}
179		return b.theme.FileTree.Render(sb.String())
180	}
181


1	package tui
2
3	import (
4		"context"
5		"fmt"
6		"strings"
7
8		"github.com/charmbracelet/bubbles/list"
9		"github.com/charmbracelet/bubbles/spinner"
10		"github.com/charmbracelet/bubbles/textinput"
11		tea "github.com/charmbracelet/bubbletea"
12
13		"github.com/immuta/operations/apps/birdseye/internal/registry"
14		"github.com/immuta/operations/services/crow/pkg/api"
15		"github.com/immuta/operations/services/crow/pkg/client"
16	)
17
18	// PickerState is the explicit state machine the picker walks.
19	type PickerState int
20
21	const (
22		StateAskModule PickerState = iota
23		StateAskVersionA
24		StateAskVersionB
25		StateLoading
26		StateBrowser
27		StateError
28	)
29
30	// Deps groups the picker's external dependencies. nil is acceptable for
31	// unit tests of the picker's input/state machinery (those tests never
32	// reach StateLoading).
33	type Deps struct {
34		Client *client.Client
35		Reg    *registry.Registry
36	}
37
38	type versionsLoadedMsg struct {
39		versions []string
40		err      error
41	}
42
43	type pairLoadedMsg struct {
44		a, b []byte
45		err  error
46	}
47
48	// versionItem implements bubbles/list.Item for a single version string.
49	type versionItem string
50
51	func (v versionItem) FilterValue() string { return string(v) }
52	func (v versionItem) Title() string       { return string(v) }
53	func (v versionItem) Description() string { return "" }
54
55	// Picker is a single bubbletea Model whose internal state enum drives
56	// which sub-widget is rendered.
57	type Picker struct {
58		deps       *Deps
59		state      PickerState
60		input      textinput.Model
61		moduleID   api.ModuleIdentifier
62		versionsA  list.Model
63		versionsB  list.Model
64		chosenA    string
65		chosenB    string
66		spinner    spinner.Model
67		browser    *Browser
68		width      int
69		height     int
70		errMessage string
71		pairA      []byte
72		pairB      []byte
73	}
74
75	// NewPicker constructs a picker. deps may be nil for input-only tests
76	// (no network dispatch occurs in that case).
77	func NewPicker(deps *Deps) *Picker {
78		ti := textinput.New()
79		ti.Placeholder = "namespace/name/provider"
80		ti.Focus()
81		sp := spinner.New()
82		sp.Spinner = spinner.Dot
83		return &Picker{
84			deps:    deps,
85			state:   StateAskModule,
86			input:   ti,
87			spinner: sp,
88		}
89	}
90
91	func (p *Picker) State() PickerState { return p.state }
92	func (p *Picker) HasError() bool     { return p.errMessage != "" }
93
94	// Bytes returns the fetched tarballs once StateBrowser is reached, so
95	// the cmd integration can extract + diff and build the browser.
96	func (p *Picker) Bytes() ([]byte, []byte) { return p.pairA, p.pairB }
97
98	// SetBrowser hands a pre-built Browser to the picker. Used by the cmd
99	// integration after FetchPair completes (the cmd does the extract+diff
100	// step and supplies the resulting browser here).
101	func (p *Picker) SetBrowser(b *Browser) {
102		p.browser = b
103		if p.width > 0 {
104			b.SetSize(p.width, p.height)
105		}
106		p.state = StateBrowser
107	}
108
109	func (p *Picker) Init() tea.Cmd { return textinput.Blink }
110
111	func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112		switch msg := msg.(type) {
113		case tea.WindowSizeMsg:
114			p.width, p.height = msg.Width, msg.Height
115			if p.browser != nil {
116				p.browser.SetSize(msg.Width, msg.Height)
117			}
118			return p, nil
119		case tea.KeyMsg:
120			if msg.Type == tea.KeyCtrlC ||
121				(msg.Type == tea.KeyRunes && string(msg.Runes) == "q" && p.state != StateAskModule) {
122				return p, tea.Quit
123			}
124		}
125		switch p.state {
126		case StateAskModule:
127			return p.updateAskModule(msg)
128		case StateAskVersionA:
129			return p.updateAskVersionA(msg)
130		case StateAskVersionB:
131			return p.updateAskVersionB(msg)
132		case StateLoading:
133			return p.updateLoading(msg)
134		case StateBrowser:
135			if p.browser == nil {
136				return p, tea.Quit
137			}
138			m, cmd := p.browser.Update(msg)
139			p.browser = m.(*Browser)
140			return p, cmd
141		}
142		return p, nil
143	}
144
145	func (p *Picker) updateAskModule(msg tea.Msg) (tea.Model, tea.Cmd) {
146		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
147			id, err := parseModuleAddressForPicker(p.input.Value())
148			if err != nil {
149				p.errMessage = err.Error()
150				return p, nil
151			}
152			p.errMessage = ""
153			p.moduleID = id
154			if p.deps == nil || p.deps.Client == nil {
155				// Test harness path: do not dispatch network cmd.
156				return p, nil
157			}
158			p.state = StateLoading
159			return p, p.loadVersionsCmd(id)
160		}
161		var cmd tea.Cmd
162		p.input, cmd = p.input.Update(msg)
163		return p, cmd
164	}
165
166	func (p *Picker) loadVersionsCmd(id api.ModuleIdentifier) tea.Cmd {
167		return func() tea.Msg {
168			versions, err := p.deps.Client.ListVersions(context.Background(), id)
169			return versionsLoadedMsg{versions: versions, err: err}
170		}
171	}
172
173	func (p *Picker) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) {
174		switch m := msg.(type) {
175		case versionsLoadedMsg:
176			if m.err != nil {
177				p.errMessage = m.err.Error()
178				p.state = StateError
179				return p, nil
180			}
181			items := make([]list.Item, 0, len(m.versions))
182			for _, v := range m.versions {
183				items = append(items, versionItem(v))
184			}
185			p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
186			p.versionsA.Title = "Select version A"
187			p.state = StateAskVersionA
188			return p, nil
189		case pairLoadedMsg:
190			if m.err != nil {
191				p.errMessage = m.err.Error()
192				p.state = StateError
193				return p, nil
194			}
195			p.pairA = m.a
196			p.pairB = m.b
197			// Stay in StateLoading until SetBrowser is called by the cmd
198			// integration. To make the bubbletea program quit (so the cmd
199			// code can run the second-stage Run), return tea.Quit here. The
200			// cmd integration will inspect Bytes() and either run the
201			// browser or report an error.
202			return p, tea.Quit
203		}
204		var cmd tea.Cmd
205		p.spinner, cmd = p.spinner.Update(msg)
206		return p, cmd
207	}
208
209	func (p *Picker) updateAskVersionA(msg tea.Msg) (tea.Model, tea.Cmd) {
210		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
211			item, ok := p.versionsA.SelectedItem().(versionItem)
212			if !ok {
213				return p, nil
214			}
215			p.chosenA = string(item)
216			items := make([]list.Item, 0, len(p.versionsA.Items()))
217			for _, it := range p.versionsA.Items() {
218				if v := it.(versionItem); string(v) != p.chosenA {
219					items = append(items, v)
220				}
221			}
222			p.versionsB = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
223			p.versionsB.Title = "Select version B"
224			p.state = StateAskVersionB
225			return p, nil
226		}
227		var cmd tea.Cmd
228		p.versionsA, cmd = p.versionsA.Update(msg)
229		return p, cmd
230	}
231
232	func (p *Picker) updateAskVersionB(msg tea.Msg) (tea.Model, tea.Cmd) {
233		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
234			item, ok := p.versionsB.SelectedItem().(versionItem)
235			if !ok {
236				return p, nil
237			}
238			p.chosenB = string(item)
239			if p.deps == nil || p.deps.Reg == nil {
240				return p, nil
241			}
242			p.state = StateLoading
243			return p, p.loadPairCmd(p.moduleID, p.chosenA, p.chosenB)
244		}
245		var cmd tea.Cmd
246		p.versionsB, cmd = p.versionsB.Update(msg)
247		return p, cmd
248	}
249
250	func (p *Picker) loadPairCmd(id api.ModuleIdentifier, va, vb string) tea.Cmd {
251		return func() tea.Msg {
252			a, b, err := p.deps.Reg.FetchPair(context.Background(),
253				api.Module{ModuleIdentifier: id, Version: va},
254				api.Module{ModuleIdentifier: id, Version: vb})
255			return pairLoadedMsg{a: a, b: b, err: err}
256		}
257	}
258
259	func (p *Picker) View() string {
260		switch p.state {
261		case StateAskModule:
262			errBlock := ""
263			if p.errMessage != "" {
264				errBlock = "\nerror: " + p.errMessage
265			}
266			return "module address (namespace/name/provider):\n" + p.input.View() + errBlock
267		case StateLoading:
268			return p.spinner.View() + " loading…"
269		case StateAskVersionA:
270			return p.versionsA.View()
271		case StateAskVersionB:
272			return p.versionsB.View()
273		case StateBrowser:
274			if p.browser != nil {
275				return p.browser.View()
276			}
277		case StateError:
278			return "error: " + p.errMessage + "\npress q to quit"
279		}
280		return ""
281	}
282
283	// parseModuleAddressForPicker mirrors cmd.parseModuleAddress without
284	// the import cycle. Returns a typed error for inline display in the TUI.
285	func parseModuleAddressForPicker(s string) (api.ModuleIdentifier, error) {
286		parts := strings.Split(s, "/")
287		if len(parts) != 3 {
288			return api.ModuleIdentifier{}, fmt.Errorf("module address must be namespace/name/provider, got %q", s)
289		}
290		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
291		if err := id.Validate(); err != nil {
292			return api.ModuleIdentifier{}, err
293		}
294		return id, nil
295	}
296


Important finding: The picker uses `context.Background()` in `loadVersionsCmd` and `loadPairCmd` instead of threading the cobra context through. This violates CLAUDE.md's "thread ctx through long ops" preference. Also, in `cmd/diff.go` `runInteractive`, after the picker quits, it calls `tui.RunBrowser(ctx, result)` directly bypassing the picker's SetBrowser path. Let me verify.


1	package tui
2
3	import (
4		"context"
5
6		tea "github.com/charmbracelet/bubbletea"
7
8		"github.com/immuta/operations/apps/birdseye/internal/diff"
9	)
10
11	// RunBrowser launches a full-screen bubbletea program rendering the
12	// supplied result. Returns when the user quits (q or Ctrl+C).
13	func RunBrowser(ctx context.Context, result diff.Result) error {
14		b := NewBrowser(result)
15		p := tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))
16		_, err := p.Run()
17		return err
18	}
19


1	package cmd
2
3	import (
4		"fmt"
5
6		"github.com/spf13/cobra"
7
8		"github.com/immuta/operations/services/crow/pkg/client"
9	)
10
11	func newVersionsCmd(g *GlobalFlags) *cobra.Command {
12		return &cobra.Command{
13			Use:           "versions <module>",
14			Short:         "List published versions of a Crow module",
15			Args:          cobra.ExactArgs(1),
16			SilenceUsage:  true,
17			SilenceErrors: true,
18			RunE: func(cmd *cobra.Command, args []string) error {
19				id, err := parseModuleAddress(args[0])
20				if err != nil {
21					return err
22				}
23				cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
24				versions, err := cli.ListVersions(cmd.Context(), id)
25				if err != nil {
26					return err
27				}
28				for _, v := range versions {
29					fmt.Fprintln(cmd.OutOrStdout(), v)
30				}
31				return nil
32			},
33		}
34	}
35


1	// Command birdseye diffs two versions of a Terraform module hosted in
2	// Crow, Immuta's private module registry. See cmd/diff.go for the diff
3	// subcommand and cmd/versions.go for the versions subcommand.
4	package main
5
6	import (
7		"context"
8		"fmt"
9		"os"
10		"os/signal"
11		"syscall"
12
13		"github.com/immuta/operations/apps/birdseye/cmd"
14	)
15
16	func main() {
17		ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
18		defer stop()
19
20		root := cmd.NewRootCmd()
21		if err := root.ExecuteContext(ctx); err != nil {
22			fmt.Fprintln(os.Stderr, "birdseye:", err)
23			os.Exit(cmd.ExitCode(err))
24		}
25	}
26


1	package cmd
2
3	import (
4		"context"
5		"errors"
6		"fmt"
7		"testing"
8
9		"github.com/immuta/operations/services/crow/pkg/client"
10	)
11
12	func TestExitCode(t *testing.T) {
13		cases := []struct {
14			name string
15			err  error
16			want int
17		}{
18			{"nil", nil, 0},
19			{"not-found", client.ErrNotFound, 2},
20			{"wrapped-not-found", fmt.Errorf("fetch: %w", client.ErrNotFound), 2},
21			{"context-cancelled", context.Canceled, 130},
22			{"context-deadline", context.DeadlineExceeded, 130},
23			{"generic", errors.New("boom"), 3},
24		}
25		for _, c := range cases {
26			t.Run(c.name, func(t *testing.T) {
27				if got := ExitCode(c.err); got != c.want {
28					t.Errorf("ExitCode(%v) = %d, want %d", c.err, got, c.want)
29				}
30			})
31		}
32	}
33


1	// Package client is a reusable Crow HTTP client. It is used by apps/crowctl
2	// and is safe to use from tests (point it at an httptest.Server via
3	// WithHTTPClient or a localhost baseURL).
4	package client
5
6	import (
7		"context"
8		"encoding/json"
9		"errors"
10		"fmt"
11		"io"
12		"net/http"
13
14		"github.com/immuta/operations/services/crow/pkg/api"
15	)
16
17	type Client struct {
18		httpClient *http.Client
19		baseURL    string
20	}
21
22	type Option func(*Client)
23
24	// WithHTTPClient overrides the default *http.Client (useful for tests and
25	// for callers that want custom timeouts or transport).
26	func WithHTTPClient(h *http.Client) Option {
27		return func(c *Client) { c.httpClient = h }
28	}
29
30	// New constructs a Client pointed at baseURL (e.g. "https://crow.infrastructure.immuta.io").
31	// baseURL must not end in "/".
32	func New(baseURL string, opts ...Option) *Client {
33		c := &Client{baseURL: baseURL, httpClient: http.DefaultClient}
34		for _, opt := range opts {
35			opt(c)
36		}
37		return c
38	}
39
40	// Typed errors consumers can match with errors.Is.
41	var (
42		ErrAlreadyExists = errors.New("module version already exists")
43		ErrNotFound      = errors.New("module version not found")
44	)
45
46	// Upload PUTs the given gzipped-tar body for the module version. It returns
47	// ErrAlreadyExists on HTTP 409, nil on 201, and a generic error otherwise.
48	func (c *Client) Upload(ctx context.Context, m api.Module, body io.Reader) error {
49		if err := m.Validate(); err != nil {
50			return err
51		}
52		if err := api.ValidateVersion(m.Version); err != nil {
53			return err
54		}
55		req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL+m.UploadPath(), body)
56		if err != nil {
57			return err
58		}
59		req.Header.Set("Content-Type", "application/octet-stream")
60		resp, err := c.httpClient.Do(req)
61		if err != nil {
62			return err
63		}
64		defer resp.Body.Close()
65		switch resp.StatusCode {
66		case http.StatusCreated:
67			return nil
68		case http.StatusConflict:
69			return ErrAlreadyExists
70		default:
71			return fmt.Errorf("unexpected status %d uploading %s", resp.StatusCode, m.UploadPath())
72		}
73	}
74
75	// Exists returns (true, nil) on 200 or 204, (false, nil) on 404, and an
76	// error for any other status.
77	func (c *Client) Exists(ctx context.Context, m api.Module) (bool, error) {
78		if err := m.Validate(); err != nil {
79			return false, err
80		}
81		req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.baseURL+m.DownloadPath(), nil)
82		if err != nil {
83			return false, err
84		}
85		resp, err := c.httpClient.Do(req)
86		if err != nil {
87			return false, err
88		}
89		defer resp.Body.Close()
90		switch resp.StatusCode {
91		case http.StatusOK, http.StatusNoContent:
92			return true, nil
93		case http.StatusNotFound:
94			return false, nil
95		default:
96			return false, fmt.Errorf("unexpected status %d checking %s", resp.StatusCode, m.DownloadPath())
97		}
98	}
99
100	// ListVersions returns the module's version list in server order.
101	// Returns ErrNotFound when the module has no published versions.
102	func (c *Client) ListVersions(ctx context.Context, id api.ModuleIdentifier) ([]string, error) {
103		if err := id.Validate(); err != nil {
104			return nil, err
105		}
106		req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+id.VersionsPath(), nil)
107		if err != nil {
108			return nil, err
109		}
110		resp, err := c.httpClient.Do(req)
111		if err != nil {
112			return nil, err
113		}
114		defer resp.Body.Close()
115		switch resp.StatusCode {
116		case http.StatusOK:
117			var body api.ListVersionsResponse
118			if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
119				return nil, fmt.Errorf("decoding response: %w", err)
120			}
121			if len(body.Modules) == 0 {
122				return nil, ErrNotFound
123			}
124			out := make([]string, len(body.Modules[0].Versions))
125			for i, v := range body.Modules[0].Versions {
126				out[i] = v.Version
127			}
128			return out, nil
129		case http.StatusNotFound:
130			return nil, ErrNotFound
131		default:
132			return nil, fmt.Errorf("unexpected status %d listing %s", resp.StatusCode, id.VersionsPath())
133		}
134	}
135
136	// Download fetches the gzipped tarball bytes for the given module
137	// version. It follows the X-Terraform-Get redirect returned by Crow's
138	// 204 response (per the Terraform module registry protocol). The caller
139	// is responsible for closing the returned ReadCloser.
140	//
141	// Returns ErrNotFound on HTTP 404 from Crow.
142	func (c *Client) Download(ctx context.Context, m api.Module) (io.ReadCloser, error) {
143		if err := m.Validate(); err != nil {
144			return nil, err
145		}
146		req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+m.DownloadPath(), nil)
147		if err != nil {
148			return nil, err
149		}
150		resp, err := c.httpClient.Do(req)
151		if err != nil {
152			return nil, err
153		}
154
155		// The protocol uses 204 + X-Terraform-Get to point clients at the
156		// real artifact location (S3 presigned URL in production).
157		if resp.StatusCode == http.StatusNotFound {
158			_ = resp.Body.Close()
159			return nil, ErrNotFound
160		}
161		if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
162			_ = resp.Body.Close()
163			return nil, fmt.Errorf("unexpected status %d resolving %s", resp.StatusCode, m.DownloadPath())
164		}
165		location := resp.Header.Get("X-Terraform-Get")
166		_ = resp.Body.Close()
167		if location == "" {
168			return nil, fmt.Errorf("crow returned %d with no X-Terraform-Get header for %s", resp.StatusCode, m.DownloadPath())
169		}
170
171		blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
172		if err != nil {
173			return nil, err
174		}
175		blobResp, err := c.httpClient.Do(blobReq)
176		if err != nil {
177			return nil, fmt.Errorf("fetching presigned blob: %w", err)
178		}
179		if blobResp.StatusCode != http.StatusOK {
180			_ = blobResp.Body.Close()
181			return nil, fmt.Errorf("unexpected status %d fetching blob %s", blobResp.StatusCode, location)
182		}
183		return blobResp.Body, nil
184	}
185


1	package client
2
3	import (
4		"bytes"
5		"context"
6		"encoding/json"
7		"errors"
8		"io"
9		"net/http"
10		"net/http/httptest"
11		"testing"
12		"time"
13
14		"github.com/immuta/operations/services/crow/pkg/api"
15	)
16
17	func TestNewDefaults(t *testing.T) {
18		c := New("https://crow.example")
19		if c == nil {
20			t.Fatal("New returned nil")
21		}
22		if c.baseURL != "https://crow.example" {
23			t.Errorf("baseURL = %q", c.baseURL)
24		}
25		if c.httpClient == nil {
26			t.Error("httpClient should default to non-nil")
27		}
28	}
29
30	func TestWithHTTPClient(t *testing.T) {
31		custom := &http.Client{Timeout: time.Second}
32		c := New("https://crow.example", WithHTTPClient(custom))
33		if c.httpClient != custom {
34			t.Error("WithHTTPClient did not apply")
35		}
36	}
37
38	func testModule() api.Module {
39		return api.Module{
40			ModuleIdentifier: api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"},
41			Version:          "1.0.0",
42		}
43	}
44
45	func TestExists(t *testing.T) {
46		cases := []struct {
47			name       string
48			status     int
49			wantExists bool
50			wantErr    bool
51		}{
52			{"204", http.StatusNoContent, true, false},
53			{"200", http.StatusOK, true, false},
54			{"404", http.StatusNotFound, false, false},
55			{"500", http.StatusInternalServerError, false, true},
56		}
57		for _, c := range cases {
58			t.Run(c.name, func(t *testing.T) {
59				srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60					if r.Method != http.MethodHead {
61						t.Errorf("expected HEAD, got %s", r.Method)
62					}
63					if r.URL.Path != "/v1/modules/iac/comply/aws/1.0.0/download" {
64						t.Errorf("unexpected path: %s", r.URL.Path)
65					}
66					w.WriteHeader(c.status)
67				}))
68				defer srv.Close()
69
70				cli := New(srv.URL, WithHTTPClient(srv.Client()))
71				exists, err := cli.Exists(context.Background(), testModule())
72				if (err != nil) != c.wantErr {
73					t.Errorf("err = %v, wantErr %v", err, c.wantErr)
74				}
75				if exists != c.wantExists {
76					t.Errorf("exists = %v, want %v", exists, c.wantExists)
77				}
78			})
79		}
80	}
81
82	func TestUpload(t *testing.T) {
83		cases := []struct {
84			name    string
85			status  int
86			wantErr error
87		}{
88			{"201", http.StatusCreated, nil},
89			{"409", http.StatusConflict, ErrAlreadyExists},
90			{"500", http.StatusInternalServerError, errors.New("unexpected status")},
91		}
92		for _, c := range cases {
93			t.Run(c.name, func(t *testing.T) {
94				srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95					if r.Method != http.MethodPut {
96						t.Errorf("expected PUT, got %s", r.Method)
97					}
98					if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/1.0.0" {
99						t.Errorf("unexpected path: %s", got)
100					}
101					if got := r.Header.Get("Content-Type"); got != "application/octet-stream" {
102						t.Errorf("unexpected content-type: %s", got)
103					}
104					body, _ := io.ReadAll(r.Body)
105					if string(body) != "fake-tarball" {
106						t.Errorf("unexpected body: %s", body)
107					}
108					w.WriteHeader(c.status)
109				}))
110				defer srv.Close()
111
112				cli := New(srv.URL, WithHTTPClient(srv.Client()))
113				err := cli.Upload(context.Background(), testModule(), bytes.NewReader([]byte("fake-tarball")))
114				switch {
115				case c.wantErr == nil && err != nil:
116					t.Errorf("unexpected error: %v", err)
117				case c.wantErr != nil && err == nil:
118					t.Error("expected error, got nil")
119				case errors.Is(c.wantErr, ErrAlreadyExists) && !errors.Is(err, ErrAlreadyExists):
120					t.Errorf("expected ErrAlreadyExists, got %v", err)
121				default:
122					// wantErr matched err, or both nil: pass.
123				}
124			})
125		}
126	}
127
128	func testIdentifier() api.ModuleIdentifier {
129		return api.ModuleIdentifier{Namespace: "iac", Name: "comply", Provider: "aws"}
130	}
131
132	func TestListVersionsOK(t *testing.T) {
133		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134			if r.Method != http.MethodGet {
135				t.Errorf("expected GET, got %s", r.Method)
136			}
137			if got := r.URL.Path; got != "/v1/modules/iac/comply/aws/versions" {
138				t.Errorf("unexpected path: %s", got)
139			}
140			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
141				Modules: []api.ModuleVersions{
142					{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
143				},
144			})
145		}))
146		defer srv.Close()
147
148		cli := New(srv.URL, WithHTTPClient(srv.Client()))
149		got, err := cli.ListVersions(context.Background(), testIdentifier())
150		if err != nil {
151			t.Fatalf("ListVersions: %v", err)
152		}
153		want := []string{"1.0.0", "1.1.0"}
154		if len(got) != len(want) {
155			t.Fatalf("len = %d, want %d (got %v)", len(got), len(want), got)
156		}
157		for i := range got {
158			if got[i] != want[i] {
159				t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
160			}
161		}
162	}
163
164	func TestListVersionsNotFound(t *testing.T) {
165		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
166			w.WriteHeader(http.StatusNotFound)
167		}))
168		defer srv.Close()
169
170		cli := New(srv.URL, WithHTTPClient(srv.Client()))
171		_, err := cli.ListVersions(context.Background(), testIdentifier())
172		if !errors.Is(err, ErrNotFound) {
173			t.Fatalf("expected ErrNotFound, got %v", err)
174		}
175	}
176
177	// A 200 with an empty Modules array is semantically "no versions" and must
178	// surface as ErrNotFound, same as a 404. The current server always uses 404,
179	// but the contract needs to hold regardless.
180	func TestListVersionsEmptyModulesIsNotFound(t *testing.T) {
181		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
182			_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{Modules: nil})
183		}))
184		defer srv.Close()
185
186		cli := New(srv.URL, WithHTTPClient(srv.Client()))
187		_, err := cli.ListVersions(context.Background(), testIdentifier())
188		if !errors.Is(err, ErrNotFound) {
189			t.Fatalf("expected ErrNotFound, got %v", err)
190		}
191	}
192
193	func TestDownloadOK(t *testing.T) {
194		mux := http.NewServeMux()
195		// Tarball server: serves the actual gzipped bytes.
196		tarball := []byte("fake-tarball-bytes")
197		tarSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
198			if r.URL.Path != "/blob/iac/comply/aws/1.0.0.tar.gz" {
199				t.Errorf("tarball server got unexpected path: %s", r.URL.Path)
200			}
201			_, _ = w.Write(tarball)
202		}))
203		defer tarSrv.Close()
204
205		// Crow server: returns 204 + X-Terraform-Get pointing at tarSrv.
206		mux.HandleFunc("/v1/modules/iac/comply/aws/1.0.0/download", func(w http.ResponseWriter, r *http.Request) {
207			if r.Method != http.MethodGet {
208				t.Errorf("expected GET, got %s", r.Method)
209			}
210			w.Header().Set("X-Terraform-Get", tarSrv.URL+"/blob/iac/comply/aws/1.0.0.tar.gz")
211			w.WriteHeader(http.StatusNoContent)
212		})
213		crowSrv := httptest.NewServer(mux)
214		defer crowSrv.Close()
215
216		cli := New(crowSrv.URL, WithHTTPClient(crowSrv.Client()))
217		rc, err := cli.Download(context.Background(), testModule())
218		if err != nil {
219			t.Fatalf("Download: %v", err)
220		}
221		defer rc.Close()
222		got, err := io.ReadAll(rc)
223		if err != nil {
224			t.Fatalf("read: %v", err)
225		}
226		if !bytes.Equal(got, tarball) {
227			t.Errorf("body = %q, want %q", got, tarball)
228		}
229	}
230
231	func TestDownloadNotFound(t *testing.T) {
232		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
233			w.WriteHeader(http.StatusNotFound)
234		}))
235		defer srv.Close()
236
237		cli := New(srv.URL, WithHTTPClient(srv.Client()))
238		_, err := cli.Download(context.Background(), testModule())
239		if !errors.Is(err, ErrNotFound) {
240			t.Fatalf("expected ErrNotFound, got %v", err)
241		}
242	}
243
244	func TestDownloadMissingHeader(t *testing.T) {
245		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
246			w.WriteHeader(http.StatusNoContent) // no X-Terraform-Get header
247		}))
248		defer srv.Close()
249
250		cli := New(srv.URL, WithHTTPClient(srv.Client()))
251		_, err := cli.Download(context.Background(), testModule())
252		if err == nil {
253			t.Fatal("expected error for missing X-Terraform-Get header")
254		}
255	}
256


1	# birdseye
2
3	A Go CLI for diffing two versions of a Terraform module hosted in
4	[Crow](../../services/crow/README.md), Immuta's private module registry.
5
6	## Build
7
8	```bash
9	make build
10	./bin/birdseye --help
11	```
12
13	## Usage
14
15	One-shot diff (multi-pane TUI on a TTY, unified diff to stdout otherwise):
16
17	```
18	birdseye diff <namespace>/<name>/<provider> <versionA> <versionB>
19	```
20
21	Interactive flow (TUI walks you through module → versions → diff):
22
23	```
24	birdseye diff
25	```
26
27	Common flags:
28
29	- `--semantic` — include HCL-aware resource/variable/output diff
30	- `--plain` / `--no-tui` — force plain unified output even on a TTY
31	- `--json` — emit structured JSON for scripts
32	- `--crow-url` — override Crow base URL (env `CROW_URL`)
33	- `--timeout` — per-HTTP-call timeout (default `30s`)
34	- `--cache-dir` — override the local tarball cache (default `~/.cache/birdseye`)
35
36	List versions for a module:
37
38	```
39	birdseye versions <namespace>/<name>/<provider>
40	```
41
42	## Exit codes
43
44	| Code | Meaning |
45	|---|---|
46	| 0 | success (including "no differences") |
47	| 1 | usage error |
48	| 2 | module version not found in registry |
49	| 3 | network or registry communication failure |
50	| 4 | archive corrupt or unsafe |
51	| 5 | HCL parse error (`--semantic` only) |
52	| 130 | user cancelled |
53
54	## Development
55
56	```
57	make test       # go test ./...
58	make test-race  # go test -race ./...
59	make lint       # go vet ./...
60	```
61


1	.PHONY: build test test-race lint clean
2
3	BIN := bin/birdseye
4
5	build:
6		@mkdir -p bin
7		go build -o $(BIN) .
8
9	test:
10		go test ./...
11
12	test-race:
13		go test -race ./...
14
15	lint:
16		go vet ./...
17
18	clean:
19		rm -rf bin
20
21	.DEFAULT_GOAL := build
22


1	module github.com/immuta/operations/apps/birdseye
2
3	go 1.25.5
4
5	require (
6		github.com/charmbracelet/bubbles v1.0.0
7		github.com/charmbracelet/bubbletea v1.3.10
8		github.com/charmbracelet/lipgloss v1.1.0
9		github.com/hashicorp/hcl/v2 v2.24.0
10		github.com/hexops/gotextdiff v1.0.3
11		github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
12		github.com/spf13/cobra v1.9.1
13		golang.org/x/sync v0.20.0
14		golang.org/x/term v0.43.0
15	)
16
17	require (
18		github.com/agext/levenshtein v1.2.1 // indirect
19		github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
20		github.com/atotto/clipboard v0.1.4 // indirect
21		github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
22		github.com/charmbracelet/colorprofile v0.4.1 // indirect
23		github.com/charmbracelet/x/ansi v0.11.6 // indirect
24		github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
25		github.com/charmbracelet/x/term v0.2.2 // indirect
26		github.com/clipperhouse/displaywidth v0.9.0 // indirect
27		github.com/clipperhouse/stringish v0.1.1 // indirect
28		github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
29		github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
30		github.com/inconshreveable/mousetrap v1.1.0 // indirect
31		github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
32		github.com/mattn/go-isatty v0.0.20 // indirect
33		github.com/mattn/go-localereader v0.0.1 // indirect
34		github.com/mattn/go-runewidth v0.0.19 // indirect
35		github.com/mitchellh/go-wordwrap v1.0.1 // indirect
36		github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
37		github.com/muesli/cancelreader v0.2.2 // indirect
38		github.com/muesli/termenv v0.16.0 // indirect
39		github.com/rivo/uniseg v0.4.7 // indirect
40		github.com/sahilm/fuzzy v0.1.1 // indirect
41		github.com/spf13/pflag v1.0.6 // indirect
42		github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
43		github.com/zclconf/go-cty v1.16.3 // indirect
44		golang.org/x/mod v0.32.0 // indirect
45		golang.org/x/sys v0.44.0 // indirect
46		golang.org/x/text v0.34.0 // indirect
47		golang.org/x/tools v0.41.0 // indirect
48	)
49
50	replace github.com/immuta/operations/services/crow => ../../services/crow
51
52	replace github.com/immuta/operations/libs/clog => ../../libs/clog
53


Now let me run the smoke test and check the runInteractive flow more carefully — the picker fetches the pair, then quits, then `runInteractive` immediately calls `RunBrowser` (not `SetBrowser`). So the picker flow is correct — picker stage 1, then a separate browser stage 2. Now let me check the README's `--crow-url` env claim and the smoke output.


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && make build 2>&1 && ./bin/birdseye --help 2>&1

Build and run help smoke test


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && ./bin/birdseye diff --help 2>&1

Show diff subcommand help


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && ./bin/birdseye diff foo 2>&1; echo "exit=$?"

Exit code for malformed module


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && grep -n "CROW_URL" cmd/*.go 2>&1

Check if CROW_URL env var is actually wired


go build -o bin/birdseye .
Diff two versions of a Terraform module hosted in Crow

Usage:
  birdseye [flags]
  birdseye [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  diff        Diff two versions of a Crow module
  help        Help about any command
  versions    List published versions of a Crow module

Flags:
      --crow-url string    Crow base URL (env CROW_URL) (default "https://crow.infrastructure.immuta.io")
  -h, --help               help for birdseye
      --json               emit structured JSON to stdout where supported
      --timeout duration   per-operation HTTP timeout (default 30s)

Use "birdseye [command] --help" for more information about a command.


Diff two versions of a module hosted in Crow.

If module/versions are omitted, the interactive TUI launches
(see picker task). With --plain, prints unified diff to stdout.

Usage:
  birdseye diff [module] [versionA] [versionB] [flags]

Flags:
      --cache-dir string   module tarball cache directory (default "/Users/steve.briggs/Library/Caches/birdseye")
  -h, --help               help for diff
      --no-tui             alias for --plain
      --plain              force plain (non-TUI) output
      --semantic           include HCL-aware semantic diff alongside textual

Global Flags:
      --crow-url string    Crow base URL (env CROW_URL) (default "https://crow.infrastructure.immuta.io")
      --json               emit structured JSON to stdout where supported
      --timeout duration   per-operation HTTP timeout (default 30s)


birdseye: usage error: diff requires <module> <versionA> <versionB>
exit=1


cmd/root.go:38:	root.PersistentFlags().StringVar(&g.CrowURL, "crow-url", defaultCrowURL, "Crow base URL (env CROW_URL)")


Critical finding: `--crow-url` flag help advertises `(env CROW_URL)` and the README says "override Crow base URL (env `CROW_URL`)" — but the env var is NOT actually read. This is a documentation/UX bug.

Also `birdseye diff foo` returns exit 1 for "diff requires 3 args" before checking the malformed module address — but it's still exit 1 (`ErrUsage`), so it's correct.

Let me verify a few more concerns.


1	// Package tui hosts the bubbletea presentation for birdseye. Models are
2	// pure — they never make network calls or read the filesystem. The
3	// browser displays a pre-computed diff.Result; the picker (Task 14)
4	// dispatches network work as tea.Cmds.
5	package tui
6
7	import "github.com/charmbracelet/lipgloss"
8
9	// Theme keeps every lipgloss style in one place for easy tweaking.
10	type Theme struct {
11		FileTree     lipgloss.Style
12		FileTreeItem lipgloss.Style
13		FileSelected lipgloss.Style
14		StatusAdd    lipgloss.Style
15		StatusDel    lipgloss.Style
16		StatusMod    lipgloss.Style
17		Viewport     lipgloss.Style
18		Footer       lipgloss.Style
19		AddedLine    lipgloss.Style
20		RemovedLine  lipgloss.Style
21		HunkHeader   lipgloss.Style
22	}
23
24	// DefaultTheme returns a high-contrast theme that should look reasonable
25	// in both light and dark terminals.
26	func DefaultTheme() Theme {
27		return Theme{
28			FileTree:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
29			FileTreeItem: lipgloss.NewStyle(),
30			FileSelected: lipgloss.NewStyle().Bold(true).Reverse(true),
31			StatusAdd:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
32			StatusDel:    lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
33			StatusMod:    lipgloss.NewStyle().Foreground(lipgloss.Color("11")),
34			Viewport:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
35			Footer:       lipgloss.NewStyle().Faint(true),
36			AddedLine:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
37			RemovedLine:  lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
38			HunkHeader:   lipgloss.NewStyle().Foreground(lipgloss.Color("14")),
39		}
40	}
41


1	package diff
2
3	import (
4		"strings"
5		"testing"
6
7		"github.com/immuta/operations/apps/birdseye/internal/archive"
8	)
9
10	func mkArchive(files map[string]string) archive.Module {
11		out := archive.Module{Files: make(map[string][]byte, len(files))}
12		for k, v := range files {
13			out.Files[k] = []byte(v)
14		}
15		return out
16	}
17
18	func findFile(t *testing.T, ds []FileDiff, path string) FileDiff {
19		t.Helper()
20		for _, d := range ds {
21			if d.Path == path {
22				return d
23			}
24		}
25		t.Fatalf("no FileDiff for %s; got %d entries", path, len(ds))
26		return FileDiff{}
27	}
28
29	func TestTextualAdded(t *testing.T) {
30		a := mkArchive(nil)
31		b := mkArchive(map[string]string{"new.tf": "x\n"})
32		ds := Textual(a, b)
33		d := findFile(t, ds, "new.tf")
34		if d.Status != StatusAdded {
35			t.Errorf("status = %v, want Added", d.Status)
36		}
37		if d.Adds != 1 || d.Dels != 0 {
38			t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
39		}
40	}
41
42	func TestTextualRemoved(t *testing.T) {
43		a := mkArchive(map[string]string{"old.tf": "x\n"})
44		b := mkArchive(nil)
45		ds := Textual(a, b)
46		d := findFile(t, ds, "old.tf")
47		if d.Status != StatusRemoved {
48			t.Errorf("status = %v, want Removed", d.Status)
49		}
50		if d.Adds != 0 || d.Dels != 1 {
51			t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
52		}
53	}
54
55	func TestTextualModified(t *testing.T) {
56		a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
57		b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
58		ds := Textual(a, b)
59		d := findFile(t, ds, "m.tf")
60		if d.Status != StatusModified {
61			t.Errorf("status = %v, want Modified", d.Status)
62		}
63		if !strings.Contains(d.Unified, "-beta") || !strings.Contains(d.Unified, "+GAMMA") {
64			t.Errorf("unified missing expected lines:\n%s", d.Unified)
65		}
66	}
67
68	func TestTextualIdenticalReturnsEmpty(t *testing.T) {
69		a := mkArchive(map[string]string{"m.tf": "x\n"})
70		b := mkArchive(map[string]string{"m.tf": "x\n"})
71		if got := Textual(a, b); len(got) != 0 {
72			t.Errorf("expected 0 diffs, got %d", len(got))
73		}
74	}
75
76	func TestTextualSortedByPath(t *testing.T) {
77		a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
78		b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
79		ds := Textual(a, b)
80		if len(ds) != 2 {
81			t.Fatalf("got %d diffs, want 2", len(ds))
82		}
83		if ds[0].Path != "a.tf" || ds[1].Path != "z.tf" {
84			t.Errorf("expected sorted by path; got %s, %s", ds[0].Path, ds[1].Path)
85		}
86	}
87


1	package diff
2
3	import (
4		"errors"
5		"testing"
6	)
7
8	func TestSemanticAddedResource(t *testing.T) {
9		a := mkArchive(map[string]string{"main.tf": ""})
10		b := mkArchive(map[string]string{
11			"main.tf": `resource "aws_s3_bucket" "alpha" {}`,
12		})
13		changes, err := Semantic(a, b)
14		if err != nil {
15			t.Fatalf("Semantic: %v", err)
16		}
17		if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusAdded) {
18			t.Errorf("missing added resource:\n%v", changes)
19		}
20	}
21
22	func TestSemanticRemovedVariable(t *testing.T) {
23		a := mkArchive(map[string]string{"v.tf": `variable "name" {}`})
24		b := mkArchive(map[string]string{"v.tf": ""})
25		changes, err := Semantic(a, b)
26		if err != nil {
27			t.Fatalf("Semantic: %v", err)
28		}
29		if !hasChange(changes, "variable", "name", StatusRemoved) {
30			t.Errorf("missing removed variable:\n%v", changes)
31		}
32	}
33
34	func TestSemanticModifiedResource(t *testing.T) {
35		a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "old" }`})
36		b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "new" }`})
37		changes, err := Semantic(a, b)
38		if err != nil {
39			t.Fatalf("Semantic: %v", err)
40		}
41		if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
42			t.Errorf("missing modified resource:\n%v", changes)
43		}
44	}
45
46	func TestSemanticParseError(t *testing.T) {
47		a := mkArchive(map[string]string{"main.tf": "this is not valid hcl{{{"})
48		_, err := Semantic(a, mkArchive(nil))
49		if err == nil {
50			t.Fatal("expected parse error")
51		}
52		if !errors.Is(err, ErrParseFailed) {
53			t.Errorf("expected ErrParseFailed, got %v", err)
54		}
55	}
56
57	func hasChange(changes []SemanticChange, kind, name string, status FileStatus) bool {
58		for _, c := range changes {
59			if c.Kind == kind && c.Name == name && c.Status == status {
60				return true
61			}
62		}
63		return false
64	}
65


1	package cmd
2
3	import (
4		"archive/tar"
5		"bytes"
6		"compress/gzip"
7		"context"
8		"encoding/json"
9		"net/http"
10		"net/http/httptest"
11		"strings"
12		"testing"
13	)
14
15	// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
16	// (modified) and a new variables.tf.
17	func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
18		t.Helper()
19		mkTgz := func(files map[string]string) []byte {
20			var buf bytes.Buffer
21			gz := gzip.NewWriter(&buf)
22			tw := tar.NewWriter(gz)
23			for n, b := range files {
24				_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
25				_, _ = tw.Write([]byte(b))
26			}
27			_ = tw.Close()
28			_ = gz.Close()
29			return buf.Bytes()
30		}
31		v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
32		v2 := mkTgz(map[string]string{
33			"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
34			"variables.tf": "variable \"name\" { type = string }\n",
35		})
36
37		mux := http.NewServeMux()
38		blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39			switch r.URL.Path {
40			case "/blob/1.0.0":
41				_, _ = w.Write(v1)
42			case "/blob/1.1.0":
43				_, _ = w.Write(v2)
44			default:
45				http.NotFound(w, r)
46			}
47		}))
48		t.Cleanup(blob.Close)
49		mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
50			parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
51			if len(parts) != 2 || parts[1] != "download" {
52				http.NotFound(w, r)
53				return
54			}
55			w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
56			w.WriteHeader(http.StatusNoContent)
57		})
58		srv := httptest.NewServer(mux)
59		t.Cleanup(srv.Close)
60		return srv
61	}
62
63	func TestDiffPlainOutput(t *testing.T) {
64		srv := fakeCrowTwoVersions(t)
65		root := NewRootCmd()
66		var stdout, stderr bytes.Buffer
67		root.SetOut(&stdout)
68		root.SetErr(&stderr)
69		root.SetArgs([]string{
70			"diff",
71			"iac/comply/aws", "1.0.0", "1.1.0",
72			"--crow-url", srv.URL,
73			"--plain",
74			"--cache-dir", t.TempDir(),
75		})
76		if err := root.ExecuteContext(context.Background()); err != nil {
77			t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
78		}
79		out := stdout.String()
80		for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
81			if !strings.Contains(out, want) {
82				t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
83			}
84		}
85	}
86
87	func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
88		srv := fakeCrowTwoVersions(t)
89		root := NewRootCmd()
90		var stdout bytes.Buffer
91		root.SetOut(&stdout)
92		root.SetArgs([]string{
93			"diff",
94			"iac/comply/aws", "1.0.0", "1.0.0",
95			"--crow-url", srv.URL,
96			"--plain",
97			"--cache-dir", t.TempDir(),
98		})
99		if err := root.ExecuteContext(context.Background()); err != nil {
100			t.Fatalf("execute: %v", err)
101		}
102		if got := strings.TrimSpace(stdout.String()); got != "no differences" {
103			t.Errorf("output = %q, want \"no differences\"", got)
104		}
105	}
106
107	func TestDiffBadModuleAddress(t *testing.T) {
108		root := NewRootCmd()
109		var stderr bytes.Buffer
110		root.SetErr(&stderr)
111		root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
112		err := root.ExecuteContext(context.Background())
113		if err == nil {
114			t.Fatal("expected error for bad address")
115		}
116		if ExitCode(err) != 1 {
117			t.Errorf("exit code = %d, want 1", ExitCode(err))
118		}
119	}
120
121	func TestDiffJSONOutput(t *testing.T) {
122		srv := fakeCrowTwoVersions(t)
123		root := NewRootCmd()
124		var stdout bytes.Buffer
125		root.SetOut(&stdout)
126		root.SetArgs([]string{
127			"diff",
128			"iac/comply/aws", "1.0.0", "1.1.0",
129			"--crow-url", srv.URL,
130			"--plain",
131			"--json",
132			"--cache-dir", t.TempDir(),
133		})
134		if err := root.ExecuteContext(context.Background()); err != nil {
135			t.Fatalf("execute: %v", err)
136		}
137		var payload struct {
138			Files []struct {
139				Path   string `json:"path"`
140				Status string `json:"status"`
141			} `json:"files"`
142		}
143		if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
144			t.Fatalf("invalid json: %v\nraw:\n%s", err, stdout.String())
145		}
146		if len(payload.Files) == 0 {
147			t.Errorf("expected at least one file entry")
148		}
149	}
150
151	func TestDiffSemanticIncluded(t *testing.T) {
152		srv := fakeCrowTwoVersions(t)
153		root := NewRootCmd()
154		var stdout bytes.Buffer
155		root.SetOut(&stdout)
156		root.SetArgs([]string{
157			"diff",
158			"iac/comply/aws", "1.0.0", "1.1.0",
159			"--crow-url", srv.URL,
160			"--plain",
161			"--semantic",
162			"--cache-dir", t.TempDir(),
163		})
164		if err := root.ExecuteContext(context.Background()); err != nil {
165			t.Fatalf("execute: %v", err)
166		}
167		out := stdout.String()
168		for _, want := range []string{
169			"=== semantic changes ===",
170			"resource",
171			"aws_s3_bucket.alpha",
172			"aws_s3_bucket.beta",
173			"variable",
174			"name",
175		} {
176			if !strings.Contains(out, want) {
177				t.Errorf("semantic output missing %q\n--- output ---\n%s", want, out)
178			}
179		}
180	}
181