type: user

role: user

content:
Fix a high-severity bug in birdseye's semantic diff. Worktree: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## Working Environment

- Branch: `worktree-birdseye`
- HEAD: `021e400`
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER `git add .` / `git add -A`.**

## The Bug (from bugbot)

`blockHash` in `apps/birdseye/internal/diff/semantic.go` only digests the Type + Labels of nested blocks. Their attributes and any further-nested content are NOT included in the hash. So changes inside nested blocks like `lifecycle { prevent_destroy = true → false }`, `dynamic`, `provisioner`, `inline_policy`, etc. are silently lost — `diffMap` reports the parent resource as unchanged when in fact its body has changed.

Current code (lines 184-189):

```go
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})
}
```

## The Fix

Refactor `blockHash` to recurse into nested blocks. Split into a recursive helper that hashes a block's labels + sorted attribute keys + attribute source bytes + recursively-hashed nested blocks.

In `apps/birdseye/internal/diff/semantic.go`:

Replace the existing `blockHash` function (lines 167-191) with:

```go
// blockHash digests the content of a block using actual source bytes for
// attribute expressions, recursing into nested blocks so changes anywhere
// in the block tree (e.g., lifecycle { prevent_destroy = X }, dynamic,
// provisioner) shift the hash.
func blockHash(e blockEntry) string {
	h := sha256.New()
	hashBlock(h, e.block, e.src)
	return hex.EncodeToString(h.Sum(nil))
}

// hashBlock writes a block's labels, sorted attribute key + source text
// pairs, and each nested block's (type, recursive hash) into h.
func hashBlock(h hash.Hash, blk *hclsyntax.Block, src []byte) {
	h.Write([]byte(strings.Join(blk.Labels, "|")))
	h.Write([]byte{0})
	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})
		hashBlock(h, nested, src)
		h.Write([]byte{0})
	}
}
```

Add `"hash"` to the import block (alphabetically with stdlib).

## Regression Tests

Append to `apps/birdseye/internal/diff/semantic_test.go`:

```go
func TestSemanticDetectsNestedBlockAttributeChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = true
  }
}`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = false
  }
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
		t.Errorf("expected resource modified due to nested lifecycle change:\n%v", changes)
	}
}

func TestSemanticDetectsDeepNestedChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
  inline_policy {
    name   = "p"
    policy = "old"
  }
}`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
  inline_policy {
    name   = "p"
    policy = "new"
  }
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_iam_role.alpha", StatusModified) {
		t.Errorf("expected resource modified due to deeply nested attribute change:\n%v", changes)
	}
}

func TestSemanticIdenticalNestedBlocksUnchanged(t *testing.T) {
	src := `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = true
  }
}`
	a := mkArchive(map[string]string{"main.tf": src})
	b := mkArchive(map[string]string{"main.tf": src})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if len(changes) != 0 {
		t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
	}
}
```

## Verify

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v
```

All previously-passing tests must still pass. The three new tests must pass.

Then:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./...
```

All clean.

## Commit

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go && git commit -m "fix(birdseye): recursively hash nested HCL blocks in semantic diff"
```

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of the fix
- Test count + race-clean
- Commit SHA
- Anything unexpected

userType: external

entrypoint: cli

gitBranch: worktree-birdseye


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

type: attachment


type: message

role: assistant

type: text

text: I'll fix the bug in birdseye's semantic diff. Let me start by reading the relevant files.

service_tier: standard

attributionAgent: general-purpose

type: assistant


Read

type: direct


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


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


text:
Now I have a clear picture. Let me also check the imports to understand what's already there, then make the fixes.

The current imports already have `"hash"` missing (the `hash.Hash` interface is needed for the new `hashBlock` helper). Let me check what's imported:

The imports at lines 3-16 show: `crypto/sha256`, `encoding/hex`, `errors`, `fmt`, `sort`, `strings` — no `"hash"` package. I need to add it.

Now let me apply the fix to `semantic.go`:


Edit

old_string:
import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strings"

new_string:
import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"hash"
	"sort"
	"strings"

speed: standard


content: The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic.go has been updated successfully. (file state is current in your context — no need to Read it back)


text: Now replace the `blockHash` function with the recursive implementation:


old_string:
// 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))
}

new_string:
// blockHash digests the content of a block using actual source bytes for
// attribute expressions, recursing into nested blocks so changes anywhere
// in the block tree (e.g., lifecycle { prevent_destroy = X }, dynamic,
// provisioner) shift the hash.
func blockHash(e blockEntry) string {
	h := sha256.New()
	hashBlock(h, e.block, e.src)
	return hex.EncodeToString(h.Sum(nil))
}

// hashBlock writes a block's labels, sorted attribute key + source text
// pairs, and each nested block's (type, recursive hash) into h.
func hashBlock(h hash.Hash, blk *hclsyntax.Block, src []byte) {
	h.Write([]byte(strings.Join(blk.Labels, "|")))
	h.Write([]byte{0})
	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})
		hashBlock(h, nested, src)
		h.Write([]byte{0})
	}
}


text: Now append the three regression tests to `semantic_test.go`:


old_string:
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
}

new_string:
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
}

func TestSemanticDetectsNestedBlockAttributeChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = true
  }
}`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = false
  }
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
		t.Errorf("expected resource modified due to nested lifecycle change:\n%v", changes)
	}
}

func TestSemanticDetectsDeepNestedChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
  inline_policy {
    name   = "p"
    policy = "old"
  }
}`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
  inline_policy {
    name   = "p"
    policy = "new"
  }
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_iam_role.alpha", StatusModified) {
		t.Errorf("expected resource modified due to deeply nested attribute change:\n%v", changes)
	}
}

func TestSemanticIdenticalNestedBlocksUnchanged(t *testing.T) {
	src := `resource "aws_s3_bucket" "alpha" {
  lifecycle {
    prevent_destroy = true
  }
}`
	a := mkArchive(map[string]string{"main.tf": src})
	b := mkArchive(map[string]string{"main.tf": src})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if len(changes) != 0 {
		t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
	}
}


content: The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go has been updated successfully. (file state is current in your context — no need to Read it back)


text: Now run the diff tests with the race detector:


Bash

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


content:
=== RUN   TestSemanticAddedResource
--- PASS: TestSemanticAddedResource (0.00s)
=== RUN   TestSemanticRemovedVariable
--- PASS: TestSemanticRemovedVariable (0.00s)
=== RUN   TestSemanticModifiedResource
--- PASS: TestSemanticModifiedResource (0.00s)
=== RUN   TestSemanticParseError
--- PASS: TestSemanticParseError (0.00s)
=== RUN   TestSemanticDetectsNestedBlockAttributeChange
--- PASS: TestSemanticDetectsNestedBlockAttributeChange (0.00s)
=== RUN   TestSemanticDetectsDeepNestedChange
--- PASS: TestSemanticDetectsDeepNestedChange (0.00s)
=== RUN   TestSemanticIdenticalNestedBlocksUnchanged
--- PASS: TestSemanticIdenticalNestedBlocksUnchanged (0.00s)
=== RUN   TestTextualAdded
--- PASS: TestTextualAdded (0.00s)
=== RUN   TestTextualRemoved
--- PASS: TestTextualRemoved (0.00s)
=== RUN   TestTextualModified
--- PASS: TestTextualModified (0.00s)
=== RUN   TestTextualIdenticalReturnsEmpty
--- PASS: TestTextualIdenticalReturnsEmpty (0.00s)
=== RUN   TestTextualSortedByPath
--- PASS: TestTextualSortedByPath (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	2.580s


text: All 12 tests pass. Now run the full suite:


command: cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./... 2>&1


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


text: All clean. Now commit:


command: git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go && git commit -m "fix(birdseye): recursively hash nested HCL blocks in semantic diff"


content:
[worktree-birdseye 474d56d] fix(birdseye): recursively hash nested HCL blocks in semantic diff
 2 files changed, 72 insertions(+), 6 deletions(-)


text:
**Status:** DONE

**Summary of the fix:**
- Added `"hash"` to the import block in `semantic.go`.
- Replaced the flat `blockHash` function with two functions: `blockHash` (entry point) and a new recursive `hashBlock` helper. The helper now digests labels, sorted attribute keys + source text, and for each nested block writes its type then recurses — so any change at any depth in the block tree shifts the hash.
- Previously, nested blocks like `lifecycle { prevent_destroy = true }` only contributed their type and labels to the hash; their attribute values were silently ignored, causing false "unchanged" reports.

**Test results:** 12 tests pass (7 semantic, 5 textual), all race-clean. Full suite (`./...`) passes with `go vet` and `go build` both clean.

**Commit:** `474d56d` — `fix(birdseye): recursively hash nested HCL blocks in semantic diff`

**Nothing unexpected** — the fix was straightforward and no existing tests were broken.