You are implementing Task 16 of the birdseye CLI implementation plan — HCL-aware semantic diff.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 15 (`894eb9c`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The existing textual diff covers any file, including .tf. This task adds an HCL-aware semantic diff that parses `.tf` files via `github.com/hashicorp/hcl/v2/hclparse`, builds a `ModuleSummary` (resources, variables, outputs, providers, locals, data, modules), and reports per-kind added/removed/modified blocks. Used by the `--semantic` flag (wired in Task 17).

The `SemanticChange` type already exists in `apps/birdseye/internal/diff/types.go` (Task 9). You add the `Semantic` function and a private `ModuleSummary` helper.

The block-equality check uses a hash of {labels, attribute keys, attribute byte-range offsets, nested block types+labels}. This is approximate but stable: any change to body bytes within a block shifts the offsets, so the hash changes. Renames of resources/variables/etc. surface as Removed+Added.

Strict TDD.

## Task Description (Verbatim)

### Task 16: `internal/diff` — semantic diff (ModuleSummary)

**Files:**
- Create: `apps/birdseye/internal/diff/semantic.go`
- Create: `apps/birdseye/internal/diff/semantic_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/diff/semantic_test.go`:

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

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/... -run TestSemantic -v`
Expected: compile error (`undefined: Semantic`).

- [ ] **Step 3: Implement `apps/birdseye/internal/diff/semantic.go`**

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

// 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]*hclsyntax.Block
	Variables map[string]*hclsyntax.Block
	Outputs   map[string]*hclsyntax.Block
	Providers map[string]*hclsyntax.Block
	Locals    map[string]*hclsyntax.Block
	Data      map[string]*hclsyntax.Block
	Modules   map[string]*hclsyntax.Block
}

// 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]*hclsyntax.Block{},
		Variables: map[string]*hclsyntax.Block{},
		Outputs:   map[string]*hclsyntax.Block{},
		Providers: map[string]*hclsyntax.Block{},
		Locals:    map[string]*hclsyntax.Block{},
		Data:      map[string]*hclsyntax.Block{},
		Modules:   map[string]*hclsyntax.Block{},
	}
	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] = blk
		}
	}
	return s, nil
}

func classify(blk *hclsyntax.Block, s *ModuleSummary) (string, map[string]*hclsyntax.Block) {
	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]*hclsyntax.Block) []SemanticChange {
	var out []SemanticChange
	for name, ablk := range a {
		bblk, ok := b[name]
		if !ok {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusRemoved})
			continue
		}
		if blockHash(ablk) != blockHash(bblk) {
			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 lexical span of a block. Equal hashes mean
// "byte-identical body" so we don't have to encode HCL semantic
// equivalence beyond Terraform's textual form.
func blockHash(blk *hclsyntax.Block) string {
	r := blk.Range()
	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(attrText(attr.Expr.Range(), r)))
		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))
}

// attrText returns a stable string for an expression's source range
// relative to its containing block.
func attrText(exprRange, blkRange hcl.Range) string {
	return fmt.Sprintf("%d:%d", exprRange.Start.Byte-blkRange.Start.Byte, exprRange.End.Byte-blkRange.Start.Byte)
}
```

- [ ] **Step 4: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All PASS (existing textual tests + 4 new semantic tests).

If `TestSemanticModifiedResource` fails because identical block bodies produce the same hash even when source text differs, that means the `attrText` byte-offset-based check is computing offsets relative to `blkRange.Start.Byte` — both versions have the same offset since the bucket attribute is at the same position in both blocks. In that case, modify `attrText` to hash the actual expression bytes by reading them from the underlying source. The plan's approximation is acknowledged to be coarse; if tests fail, you'll need to upgrade the hash to include the expression's source bytes:

```go
// attrText returns the actual source bytes of the expression's range.
// This relies on parser.Files()[blkRange.Filename] being available.
func attrText(file *hcl.File, exprRange hcl.Range) string {
	src := file.Bytes
	if exprRange.End.Byte > len(src) {
		return fmt.Sprintf("%d:%d", exprRange.Start.Byte, exprRange.End.Byte)
	}
	return string(src[exprRange.Start.Byte:exprRange.End.Byte])
}
```

You'll need to thread the `*hcl.File` (the parser caches them via `parser.Files()`) into `blockHash` — store the file in the `ModuleSummary` or in a wrapper struct alongside each block.

**If the simple offset-based approach already works for the test inputs, leave it.** Only do the upgrade if `TestSemanticModifiedResource` fails.

- [ ] **Step 5: 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 apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): HCL-aware semantic diff producing SemanticChange entries"
```

Drop go.mod/go.sum if unchanged.

## Notes & Style

- TDD: write the test, run it fail, then implement.
- The `hcl/v2` packages are listed in the original go.mod from Task 1, but `go mod tidy` may need to resolve `hclparse` and `hclsyntax` sub-packages on first import.
- Sort `out` in a stable order (kind first, then name) so test assertions and rendered output are deterministic.
- The block-equality check is intentionally byte-shift-sensitive. If TestSemanticModifiedResource passes WITHOUT the source-bytes upgrade, ship as-is. The plan's approximation note in the spec says this is acceptable for v1.

## Self-Review Checklist

- [ ] All 4 semantic tests pass: TestSemanticAddedResource, TestSemanticRemovedVariable, TestSemanticModifiedResource, TestSemanticParseError
- [ ] All previous diff tests still pass
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `hcl/v2` deps resolved (no // indirect when directly used)
- [ ] Output is stable-sorted by (Kind, Name)
- [ ] `ErrParseFailed` is returned as wrapped sentinel so `errors.Is` works
- [ ] Commit message exactly: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + race-clean
- Whether the simple offset-based blockHash worked, OR whether you had to upgrade to source-bytes (with details)
- Commit SHA
- Anything unexpected


- 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 implement Task 16 following strict TDD. Let me start by exploring the existing codebase.


Bash

List existing diff package files


.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 160 B  Tue May 12 23:44:54 2026 .
drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:55:20 2026 ..


Check go.mod for existing HCL dependencies


module github.com/immuta/operations/apps/birdseye

go 1.25.5

require (
	github.com/charmbracelet/bubbles v1.0.0
	github.com/charmbracelet/bubbletea v1.3.10
	github.com/charmbracelet/lipgloss v1.1.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
	golang.org/x/sync v0.20.0
	golang.org/x/term v0.43.0
)

require (
	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
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
	github.com/charmbracelet/x/term v0.2.2 // indirect
	github.com/clipperhouse/displaywidth v0.9.0 // indirect
	github.com/clipperhouse/stringish v0.1.1 // indirect
	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
	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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
	github.com/muesli/cancelreader v0.2.2 // indirect
	github.com/muesli/termenv v0.16.0 // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	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
	golang.org/x/sys v0.44.0 // indirect
	golang.org/x/text v0.34.0 // indirect
)

replace github.com/immuta/operations/services/crow => ../../services/crow

replace github.com/immuta/operations/libs/clog => ../../libs/clog


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


Good. Now let me check the archive package to understand the Module type:


find /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive -type f

List archive package files


grep -n "type Module" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/archive/extract.go

Check Module type definition


17:type Module struct {


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


Now I have all the context I need. Let me write the test file first (Step 1):


Write

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
}


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


Now Step 2 — run to confirm it fails with a compile error:


Bash

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

Run tests expecting compile error


# github.com/immuta/operations/apps/birdseye/internal/diff [github.com/immuta/operations/apps/birdseye/internal/diff.test]
internal/diff/semantic_test.go:13:18: undefined: Semantic
internal/diff/semantic_test.go:25:18: undefined: Semantic
internal/diff/semantic_test.go:37:18: undefined: Semantic
internal/diff/semantic_test.go:48:12: undefined: Semantic
internal/diff/semantic_test.go:52:21: undefined: ErrParseFailed
FAIL	github.com/immuta/operations/apps/birdseye/internal/diff [build failed]
FAIL


Good — compile error as expected. Now Step 3 — implement semantic.go. First, add the HCL dependency:


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go get github.com/hashicorp/hcl/v2@latest 2>&1

Add HCL v2 dependency


go: added github.com/agext/levenshtein v1.2.1
go: added github.com/apparentlymart/go-textseg/v15 v15.0.0
go: added github.com/hashicorp/hcl/v2 v2.24.0
go: added github.com/mitchellh/go-wordwrap v1.0.1
go: added github.com/zclconf/go-cty v1.16.3


HCL is currently marked `// indirect`. Now let me write semantic.go — I'll use the source-bytes approach for the blockHash to ensure TestSemanticModifiedResource works correctly (since "old" and "new" have the same byte offsets relative to block start):


Write

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])
}


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


Now fix the go.mod to mark `github.com/hashicorp/hcl/v2` as a direct dependency:


Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.


Bash

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

Run go mod tidy to resolve direct vs indirect deps


(Bash completed with no output)


Check if HCL is now a direct dependency


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


Good — `go mod tidy` promoted it to a direct dependency (no `// indirect`). Now Step 4 — run the full test suite:


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

Run all 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)
=== 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	1.659s


All 9 tests pass (4 new semantic + 5 existing textual), race-clean. Now run vet and build:


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

Run go vet and go build


Clean. Now commit (Step 5):


Check git status


On branch worktree-birdseye
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   apps/birdseye/go.mod
	modified:   apps/birdseye/go.sum

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/mind.mv2.lock
	apps/birdseye/internal/diff/semantic.go
	apps/birdseye/internal/diff/semantic_test.go

no changes added to commit (use "git add" and/or "git commit -a")


git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "$(cat <<'EOF'
feat(birdseye): HCL-aware semantic diff producing SemanticChange entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit semantic diff implementation


[worktree-birdseye b914c7c] feat(birdseye): HCL-aware semantic diff producing SemanticChange entries
 4 files changed, 292 insertions(+)
 create mode 100644 apps/birdseye/internal/diff/semantic.go
 create mode 100644 apps/birdseye/internal/diff/semantic_test.go


Done.

---

**Status:** DONE

**Files created:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go`

**Modified:**
- `apps/birdseye/go.mod` — added `github.com/hashicorp/hcl/v2 v2.24.0` as a direct dependency (plus transitive deps: `levenshtein`, `go-textseg`, `go-wordwrap`, `go-cty`)
- `apps/birdseye/go.sum`

**Test count:** 9 total (4 new semantic + 5 existing textual), all PASS, race-clean.

**blockHash approach:** The simple offset-based approach from the plan was NOT used. Instead, I used source bytes (`exprText(src, attr.Expr.Range())`), which reads the actual expression text from the file's `[]byte` content. This is required because `resource "aws_s3_bucket" "alpha" { bucket = "old" }` and `{ bucket = "new" }` produce identical byte offsets relative to block start — only reading the actual source bytes differentiates them. A `blockEntry` wrapper struct pairs each `*hclsyntax.Block` with its source `[]byte`.

**Commit SHA:** `b914c7c`