Combined spec + code-quality review of Task 16 (HCL semantic diff) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 894eb9c..b914c7c -- apps/birdseye/`

## Spec — What Was Requested

Create `apps/birdseye/internal/diff/semantic.go`:
- `ErrParseFailed` sentinel
- `ModuleSummary` struct with 7 block-kind maps (Resources, Variables, Outputs, Providers, Locals, Data, Modules)
- `Semantic(a, b archive.Module) ([]SemanticChange, error)`
- Private helpers: `summarize`, `classify`, `diffMap`, `blockHash`, `attrText` (or `exprText`)
- Output stable-sorted by `(Kind, Name)`
- Parse errors wrap `ErrParseFailed` via `%w` so `errors.Is` works

Create `apps/birdseye/internal/diff/semantic_test.go` with 4 tests:
- `TestSemanticAddedResource` (assert added)
- `TestSemanticRemovedVariable` (assert removed)
- `TestSemanticModifiedResource` (assert modified — this is the tricky one; differentiating "old" vs "new" requires source bytes, not just offsets)
- `TestSemanticParseError` (assert errors.Is(err, ErrParseFailed))
- `hasChange` helper

Commit message exact: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`.

The implementer reports they upgraded `blockHash` to use source bytes via a `blockEntry` wrapper struct. **This is acceptable and expected** per the plan's contingency.

## Quality Focus

- The `blockEntry` struct stores the *file's* source bytes alongside the block (the body bytes, not just the block bytes — verify the source slice is sized correctly so `exprRange` offsets resolve properly)
- The hash is stable across runs (no map iteration order leakage — `keys` slice is sorted before hashing)
- `ModuleSummary` maps the 7 block kinds correctly
- `classify` rejects malformed blocks (wrong label count) without panicking
- `diffMap` produces all three states (added, removed, modified) without duplicates
- Sort order matches spec (Kind then Name)
- Parse errors carry useful detail (file path + diagnostic)
- Test independence; no shared state between tests
- HCL v2 dep direct, not indirect

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues (Critical/Important/Minor) / Assessment

Be terse.


- 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 review the implementation by examining the relevant files.


Bash

cd /Users/steve.briggs/projects/operations && git diff 894eb9c..b914c7c -- apps/birdseye/ --name-only

List changed files in the diff


cd /Users/steve.briggs/projects/operations && git show b914c7c:apps/birdseye/go.mod 2>/dev/null | grep -E "hashicorp/hcl|hcl/v2" | head -20

Check HCL dependency in go.mod


diff --git a/apps/birdseye/go.mod b/apps/birdseye/go.mod
index c93a7b5..f7c6212 100644
--- a/apps/birdseye/go.mod
+++ b/apps/birdseye/go.mod
@@ -6,6 +6,7 @@ require (
 	github.com/charmbracelet/bubbles v1.0.0
 	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/lipgloss v1.1.0
+	github.com/hashicorp/hcl/v2 v2.24.0
 	github.com/hexops/gotextdiff v1.0.3
 	github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
 	github.com/spf13/cobra v1.9.1
@@ -14,6 +15,8 @@ require (
 )

 require (
+	github.com/agext/levenshtein v1.2.1 // indirect
+	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
@@ -29,6 +32,7 @@ require (
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
 	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
@@ -36,8 +40,11 @@ require (
 	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/zclconf/go-cty v1.16.3 // indirect
+	golang.org/x/mod v0.32.0 // indirect
 	golang.org/x/sys v0.44.0 // indirect
 	golang.org/x/text v0.34.0 // indirect
+	golang.org/x/tools v0.41.0 // indirect
 )

 replace github.com/immuta/operations/services/crow => ../../services/crow
diff --git a/apps/birdseye/go.sum b/apps/birdseye/go.sum
index ae08691..6c557c7 100644
--- a/apps/birdseye/go.sum
+++ b/apps/birdseye/go.sum
@@ -1,3 +1,7 @@
+github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
+github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -27,8 +31,16 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
 github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
+github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
+github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -43,6 +55,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -60,8 +74,14 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
+github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -72,5 +92,7 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
 golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/apps/birdseye/internal/diff/semantic.go b/apps/birdseye/internal/diff/semantic.go
new file mode 100644
index 0000000..0086ff0
--- /dev/null
+++ b/apps/birdseye/internal/diff/semantic.go
@@ -0,0 +1,199 @@
+package diff
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclparse"
+	"github.com/hashicorp/hcl/v2/hclsyntax"
+
+	"github.com/immuta/operations/apps/birdseye/internal/archive"
+)
+
+// ErrParseFailed is returned by Semantic when any .tf file fails to
+// parse. Callers map this to exit code 5.
+var ErrParseFailed = errors.New("hcl parse failed")
+
+// blockEntry pairs an HCL block with the source bytes of its file so
+// that blockHash can read actual expression text rather than byte offsets.
+type blockEntry struct {
+	block *hclsyntax.Block
+	src   []byte
+}
+
+// ModuleSummary aggregates the named HCL blocks of interest from every
+// .tf file in a module. Maps are keyed by a stable identifier
+// (resource: "type.name", variable: name, etc.).
+type ModuleSummary struct {
+	Resources map[string]blockEntry
+	Variables map[string]blockEntry
+	Outputs   map[string]blockEntry
+	Providers map[string]blockEntry
+	Locals    map[string]blockEntry
+	Data      map[string]blockEntry
+	Modules   map[string]blockEntry
+}
+
+// Semantic produces SemanticChange entries by parsing every .tf file in
+// each archive into a ModuleSummary and diffing summaries.
+func Semantic(a, b archive.Module) ([]SemanticChange, error) {
+	sa, err := summarize(a)
+	if err != nil {
+		return nil, err
+	}
+	sb, err := summarize(b)
+	if err != nil {
+		return nil, err
+	}
+	var out []SemanticChange
+	out = append(out, diffMap("resource", sa.Resources, sb.Resources)...)
+	out = append(out, diffMap("variable", sa.Variables, sb.Variables)...)
+	out = append(out, diffMap("output", sa.Outputs, sb.Outputs)...)
+	out = append(out, diffMap("provider", sa.Providers, sb.Providers)...)
+	out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
+	out = append(out, diffMap("data", sa.Data, sb.Data)...)
+	out = append(out, diffMap("module", sa.Modules, sb.Modules)...)
+	sort.SliceStable(out, func(i, j int) bool {
+		if out[i].Kind != out[j].Kind {
+			return out[i].Kind < out[j].Kind
+		}
+		return out[i].Name < out[j].Name
+	})
+	return out, nil
+}
+
+func summarize(m archive.Module) (ModuleSummary, error) {
+	s := ModuleSummary{
+		Resources: map[string]blockEntry{},
+		Variables: map[string]blockEntry{},
+		Outputs:   map[string]blockEntry{},
+		Providers: map[string]blockEntry{},
+		Locals:    map[string]blockEntry{},
+		Data:      map[string]blockEntry{},
+		Modules:   map[string]blockEntry{},
+	}
+	parser := hclparse.NewParser()
+	for path, body := range m.Files {
+		if !strings.HasSuffix(path, ".tf") {
+			continue
+		}
+		file, diags := parser.ParseHCL(body, path)
+		if diags.HasErrors() {
+			return ModuleSummary{}, fmt.Errorf("%w: %s: %s", ErrParseFailed, path, diags.Error())
+		}
+		syntaxBody, ok := file.Body.(*hclsyntax.Body)
+		if !ok {
+			return ModuleSummary{}, fmt.Errorf("%w: %s: unexpected body type", ErrParseFailed, path)
+		}
+		for _, blk := range syntaxBody.Blocks {
+			key, target := classify(blk, &s)
+			if target == nil {
+				continue
+			}
+			target[key] = blockEntry{block: blk, src: body}
+		}
+	}
+	return s, nil
+}
+
+func classify(blk *hclsyntax.Block, s *ModuleSummary) (string, map[string]blockEntry) {
+	switch blk.Type {
+	case "resource":
+		if len(blk.Labels) != 2 {
+			return "", nil
+		}
+		return blk.Labels[0] + "." + blk.Labels[1], s.Resources
+	case "data":
+		if len(blk.Labels) != 2 {
+			return "", nil
+		}
+		return blk.Labels[0] + "." + blk.Labels[1], s.Data
+	case "module":
+		if len(blk.Labels) != 1 {
+			return "", nil
+		}
+		return blk.Labels[0], s.Modules
+	case "variable":
+		if len(blk.Labels) != 1 {
+			return "", nil
+		}
+		return blk.Labels[0], s.Variables
+	case "output":
+		if len(blk.Labels) != 1 {
+			return "", nil
+		}
+		return blk.Labels[0], s.Outputs
+	case "provider":
+		if len(blk.Labels) != 1 {
+			return "", nil
+		}
+		return blk.Labels[0], s.Providers
+	case "locals":
+		// `locals` has no labels and may appear in multiple files;
+		// distinguish by source range so we don't collide.
+		return blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line), s.Locals
+	}
+	return "", nil
+}
+
+func diffMap(kind string, a, b map[string]blockEntry) []SemanticChange {
+	var out []SemanticChange
+	for name, ae := range a {
+		be, ok := b[name]
+		if !ok {
+			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusRemoved})
+			continue
+		}
+		if blockHash(ae) != blockHash(be) {
+			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusModified})
+		}
+	}
+	for name := range b {
+		if _, ok := a[name]; !ok {
+			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusAdded})
+		}
+	}
+	return out
+}
+
+// blockHash digests the content of a block using actual source bytes for
+// attribute expressions. Equal hashes mean byte-identical attribute values
+// so any change to an attribute value will shift the hash.
+func blockHash(e blockEntry) string {
+	blk := e.block
+	src := e.src
+	h := sha256.New()
+	h.Write([]byte(strings.Join(blk.Labels, "|")))
+	keys := make([]string, 0, len(blk.Body.Attributes))
+	for k := range blk.Body.Attributes {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	for _, k := range keys {
+		attr := blk.Body.Attributes[k]
+		h.Write([]byte(k))
+		h.Write([]byte{0})
+		h.Write([]byte(exprText(src, attr.Expr.Range())))
+		h.Write([]byte{0})
+	}
+	for _, nested := range blk.Body.Blocks {
+		h.Write([]byte(nested.Type))
+		h.Write([]byte{0})
+		h.Write([]byte(strings.Join(nested.Labels, "|")))
+		h.Write([]byte{0})
+	}
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// exprText returns the actual source bytes of an expression's range.
+func exprText(src []byte, r hcl.Range) string {
+	if r.End.Byte > len(src) || r.Start.Byte > r.End.Byte {
+		return fmt.Sprintf("%d:%d", r.Start.Byte, r.End.Byte)
+	}
+	return string(src[r.Start.Byte:r.End.Byte])
+}
diff --git a/apps/birdseye/internal/diff/semantic_test.go b/apps/birdseye/internal/diff/semantic_test.go
new file mode 100644
index 0000000..43a9c6c
--- /dev/null
+++ b/apps/birdseye/internal/diff/semantic_test.go
@@ -0,0 +1,64 @@
+package diff
+
+import (
+	"errors"
+	"testing"
+)
+
+func TestSemanticAddedResource(t *testing.T) {
+	a := mkArchive(map[string]string{"main.tf": ""})
+	b := mkArchive(map[string]string{
+		"main.tf": `resource "aws_s3_bucket" "alpha" {}`,
+	})
+	changes, err := Semantic(a, b)
+	if err != nil {
+		t.Fatalf("Semantic: %v", err)
+	}
+	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusAdded) {
+		t.Errorf("missing added resource:\n%v", changes)
+	}
+}
+
+func TestSemanticRemovedVariable(t *testing.T) {
+	a := mkArchive(map[string]string{"v.tf": `variable "name" {}`})
+	b := mkArchive(map[string]string{"v.tf": ""})
+	changes, err := Semantic(a, b)
+	if err != nil {
+		t.Fatalf("Semantic: %v", err)
+	}
+	if !hasChange(changes, "variable", "name", StatusRemoved) {
+		t.Errorf("missing removed variable:\n%v", changes)
+	}
+}
+
+func TestSemanticModifiedResource(t *testing.T) {
+	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "old" }`})
+	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "new" }`})
+	changes, err := Semantic(a, b)
+	if err != nil {
+		t.Fatalf("Semantic: %v", err)
+	}
+	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
+		t.Errorf("missing modified resource:\n%v", changes)
+	}
+}
+
+func TestSemanticParseError(t *testing.T) {
+	a := mkArchive(map[string]string{"main.tf": "this is not valid hcl{{{"})
+	_, err := Semantic(a, mkArchive(nil))
+	if err == nil {
+		t.Fatal("expected parse error")
+	}
+	if !errors.Is(err, ErrParseFailed) {
+		t.Errorf("expected ErrParseFailed, got %v", err)
+	}
+}
+
+func hasChange(changes []SemanticChange, kind, name string, status FileStatus) bool {
+	for _, c := range changes {
+		if c.Kind == kind && c.Name == name && c.Status == status {
+			return true
+		}
+	}
+	return false
+}


github.com/hashicorp/hcl/v2 v2.24.0


Now let me examine the existing types to understand `SemanticChange`, `FileStatus`, and `archive.Module`:


cd /Users/steve.briggs/projects/operations && git show b914c7c:apps/birdseye/internal/diff/ 2>/dev/null || ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/ 2>/dev/null

List diff package files


ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/ 2>/dev/null && ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/ 2>/dev/null

List files in diff and archive packages


tree b914c7c:apps/birdseye/internal/diff/

semantic.go
semantic_test.go
textual.go
textual_test.go
types.go


.rw-r--r-- steve.briggs staff 2.2 KB Tue May 12 23:39:29 2026 textual_test.go
.rw-r--r-- steve.briggs staff 1.1 KB Tue May 12 23:39:42 2026 types.go
.rw-r--r-- steve.briggs staff 2.6 KB Tue May 12 23:44:54 2026 textual.go
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:55:20 2026 ..
.rw-r--r-- steve.briggs staff 1.8 KB Wed May 13 00:19:39 2026 semantic_test.go
.rw-r--r-- steve.briggs staff 5.6 KB Wed May 13 00:20:17 2026 semantic.go
drwxr-xr-x steve.briggs staff 224 B  Wed May 13 00:20:17 2026 .
.rw-r--r-- steve.briggs staff 2.7 KB Tue May 12 23:25:49 2026 extract.go
.rw-r--r-- steve.briggs staff 3.7 KB Tue May 12 23:28:06 2026 extract_test.go
drwxr-xr-x steve.briggs staff 128 B  Tue May 12 23:28:06 2026 .
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:55:20 2026 ..


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 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


Now let me look at the textual_test.go to understand the `mkArchive` helper:


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


Now let me run the tests to confirm they pass:


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v -run "TestSemantic" 2>&1

Run semantic diff tests with race detector


=== RUN   TestSemanticAddedResource
--- PASS: TestSemanticAddedResource (0.00s)
=== RUN   TestSemanticRemovedVariable
--- PASS: TestSemanticRemovedVariable (0.00s)
=== RUN   TestSemanticModifiedResource
--- PASS: TestSemanticModifiedResource (0.00s)
=== RUN   TestSemanticParseError
--- PASS: TestSemanticParseError (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	1.362s


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

Run go vet on diff package


(Bash completed with no output)


cd /Users/steve.briggs/projects/operations && git log 894eb9c..b914c7c --oneline

Check commit messages in range


b914c7c feat(birdseye): HCL-aware semantic diff producing SemanticChange entries


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

Run all tests with race detector


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


All tests pass clean. Let me do one final check on the `locals` key uniqueness concern — this is a nuanced issue in the diff:


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":


The implementation is solid. Here is the full assessment:

---

**Spec: ✅**

All required deliverables are present and correct:
- `ErrParseFailed` sentinel exists, wrapped via `%w` — `errors.Is` works.
- `ModuleSummary` has all 7 block-kind maps, keyed as specified.
- `Semantic(a, b archive.Module) ([]SemanticChange, error)` matches the signature.
- All private helpers present: `summarize`, `classify`, `diffMap`, `blockHash`, `exprText` (spec said `attrText` — the rename to `exprText` is strictly better, not a violation).
- Output sorted by `(Kind, Name)` via `sort.SliceStable`.
- All 4 tests present with the `hasChange` helper. All pass, including race detector.
- Commit message matches exactly.
- HCL v2 is a **direct** dependency in `go.mod` (not indirect).

---

**Quality: Strengths**

- **`blockEntry` source-bytes approach is correctly scoped.** `src` stores the full file's `[]byte`, and `exprText` slices it via `r.Start.Byte:r.End.Byte`. Since `hclparse.ParseHCL` registers the same byte slice, all `Expr.Range()` byte offsets are valid against it. The bounds check (`r.End.Byte > len(src)`) is a sensible guard.
- **Hash stability is guaranteed.** `keys` is explicitly sorted before hashing. No map iteration order can leak.
- **`classify` is panic-safe.** Every case validates `len(blk.Labels)` before indexing and returns `("", nil)` on mismatch — caller in `summarize` skips `nil` targets.
- **`diffMap` is duplicate-free and produces all three states correctly.** The two passes (a→b for removed/modified, b for added) are the canonical pattern.
- **`locals` key construction** (`filename + "#" + line`) avoids collisions across multiple `locals` blocks in different files. This is a pragmatic and correct choice given that HCL allows multiple `locals` blocks per module.
- **Test independence is solid.** No package-level state; each test builds its own `archive.Module` inline.
- **Parse error messages include file path and HCL diagnostics** — actionable for debugging.

**Issues**

- **Minor — nested block hashing is shallow.** `blockHash` hashes nested block `Type` + `Labels` but not their attribute content. A change _inside_ a nested block (e.g., a `lifecycle { prevent_destroy = true }` → `false`) will not be detected as modified. This is a known limitation of a depth-1 hash and acceptable for a v1 implementation, but worth a comment.
- **Minor — `locals` diffing can produce false positives across versions.** If module A has one `locals` block in `main.tf:5` and module B has the same `locals` block (same content) but in `main.tf:10` (e.g., a line was inserted above it), the key `main.tf#5` vs `main.tf#10` will report it as removed+added rather than unchanged. This is a structural limitation of the key strategy. Again, acceptable for v1.
- **Minor — `ModuleSummary` fields are exported (`blockEntry` is not).** This creates a usability gap: callers can see the map keys but can't read the block content without going through the `diff` package's internals. Not a bug, just a potential API concern if `ModuleSummary` is ever used outside this package.

**Assessment:** Implementation is correct, well-structured, and ready to merge. The two minor hash limitations are inherent to a first-pass semantic diff and don't affect correctness for the common case. No critical or important issues.