Fix the semantic diff for `locals` blocks in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug

Currently in `apps/birdseye/internal/diff/semantic.go`, `locals` blocks are stored in `ModuleSummary.Locals` keyed by `filename#startLine`. When a `locals` block shifts position (a line above it changes) without its content changing, `diffMap` reports it as removed + added — two false-positive entries.

## Root Cause

Terraform's `locals` is a flat namespace. The blocks are just syntactic containers; the semantically meaningful unit is each individual local definition (an attribute inside a `locals` block). The current code treats the WHOLE BLOCK as the unit and synthesizes a position-based key to distinguish multiple blocks — which is brittle by design.

## The Fix

Change the semantic diff to track individual `local` definitions by their attribute name, not the enclosing block by line number.

### Changes to `apps/birdseye/internal/diff/semantic.go`

1. **New type `localEntry`** alongside `blockEntry`:

```go
// localEntry is a single attribute inside a locals block. Locals are a
// flat namespace in Terraform — name uniquely identifies the value
// regardless of which file or block it lives in.
type localEntry struct {
	expr *hclsyntax.Expression
	src  []byte
}
```

Wait, `*hclsyntax.Expression` is an interface pointer which is awkward. Use `hcl.Range` directly to get the source range for hashing:

```go
type localEntry struct {
	srcRange hcl.Range
	src      []byte
}
```

2. **Change `ModuleSummary.Locals` type:**

From:
```go
Locals map[string]blockEntry
```
To:
```go
Locals map[string]localEntry
```

3. **Update `summarize`** — locate where `classify` is called on each block; the "locals" case currently returns a synthetic filename#line key into `s.Locals`. Replace that handling so that for `locals` blocks, we iterate their attributes and add one entry per attribute name. The classify switch's `case "locals":` should be removed and replaced with inline handling in `summarize`.

Concretely, in `summarize`'s main loop:

```go
for _, blk := range syntaxBody.Blocks {
    if blk.Type == "locals" {
        for name, attr := range blk.Body.Attributes {
            s.Locals[name] = localEntry{
                srcRange: attr.Expr.Range(),
                src:      body,
            }
        }
        continue
    }
    key, target := classify(blk, &s)
    if target == nil {
        continue
    }
    target[key] = blockEntry{block: blk, src: body}
}
```

Remove the `case "locals":` arm from the `classify` function entirely.

4. **Initialize `Locals` map with the new type** in `summarize`:

```go
s := ModuleSummary{
    Resources: map[string]blockEntry{},
    Variables: map[string]blockEntry{},
    Outputs:   map[string]blockEntry{},
    Providers: map[string]blockEntry{},
    Locals:    map[string]localEntry{},  // CHANGED
    Data:      map[string]blockEntry{},
    Modules:   map[string]blockEntry{},
}
```

5. **Add a `diffLocals` function** alongside `diffMap`:

```go
func diffLocals(a, b map[string]localEntry) []SemanticChange {
    out := make([]SemanticChange, 0)
    for name, ae := range a {
        be, ok := b[name]
        if !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusRemoved})
            continue
        }
        if localText(ae) != localText(be) {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusModified})
        }
    }
    for name := range b {
        if _, ok := a[name]; !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusAdded})
        }
    }
    return out
}

// localText returns the source bytes for a local definition's
// expression. Two locals with identical text are equal.
func localText(e localEntry) string {
    return exprText(e.src, e.srcRange)
}
```

6. **Update `Semantic` to call `diffLocals`** instead of `diffMap` for locals. Find the existing line in `Semantic`:

```go
out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
```

Replace with:

```go
out = append(out, diffLocals(sa.Locals, sb.Locals)...)
```

### Tests to add in `apps/birdseye/internal/diff/semantic_test.go`

```go
func TestSemanticLocalsLineShiftIsNotAChange(t *testing.T) {
	// Same local definition, different surrounding whitespace pushes
	// the locals block to a different line. Must NOT report changes.
	a := mkArchive(map[string]string{"main.tf": `
locals {
  region = "us-east-1"
}
`})
	b := mkArchive(map[string]string{"main.tf": `
# new comment

variable "x" {}

locals {
  region = "us-east-1"
}
`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after line shift: %+v", c)
		}
	}
}

func TestSemanticLocalsValueChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-west-2" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "region", StatusModified) {
		t.Errorf("expected locals.region modified, got: %v", changes)
	}
}

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1"
  bucket = "logs"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusAdded) {
		t.Errorf("expected locals.bucket added, got: %v", changes)
	}
}

func TestSemanticLocalsRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
  bucket = "logs"
}`})
	b := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusRemoved) {
		t.Errorf("expected locals.bucket removed, got: %v", changes)
	}
}

func TestSemanticLocalsAcrossFiles(t *testing.T) {
	// A local moved from main.tf to locals.tf without value change must
	// not produce any diff entries — locals is a flat namespace.
	a := mkArchive(map[string]string{
		"main.tf": `locals { region = "us-east-1" }`,
	})
	b := mkArchive(map[string]string{
		"main.tf":   ``,
		"locals.tf": `locals { region = "us-east-1" }`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after cross-file move: %+v", c)
		}
	}
}
```

## Verify

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

All previous diff tests still pass (especially `TestSemanticAddedResource`, `TestSemanticRemovedVariable`, `TestSemanticModifiedResource`, `TestSemanticDetectsNestedBlockAttributeChange`, `TestSemanticDetectsDeepNestedChange`, `TestSemanticIdenticalNestedBlocksUnchanged`, `TestSemanticParseError`) — these don't involve locals, so they should remain unaffected.

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): track locals by name across the module's flat namespace"
```

## Notes

- The `localEntry` shares the `exprText(src, range)` helper that already exists in `semantic.go` from prior tasks.
- `classify` no longer handles `locals`; only resource/data/module/variable/output/provider remain in the switch.
- `ModuleSummary.Locals` type changes from `map[string]blockEntry` to `map[string]localEntry`. The `Locals` field stays exported; consumers were not relying on its inner type (the only consumer is `diffMap`/`diffLocals` in this same file).
- If `classify` is called with `blk.Type == "locals"` somewhere else, search for it and remove. The only caller is in `summarize` which you'll have already updated.
- `hcl.Range` is in the existing `"github.com/hashicorp/hcl/v2"` import — no new imports needed.

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of structural changes
- Test count + race-clean
- Confirmation that previous non-locals tests still pass
- 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 start by reading the existing semantic.go and semantic_test.go files to understand the current structure before making changes.


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


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
66	func TestSemanticDetectsNestedBlockAttributeChange(t *testing.T) {
67		a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
68	  lifecycle {
69	    prevent_destroy = true
70	  }
71	}`})
72		b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
73	  lifecycle {
74	    prevent_destroy = false
75	  }
76	}`})
77		changes, err := Semantic(a, b)
78		if err != nil {
79			t.Fatalf("Semantic: %v", err)
80		}
81		if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
82			t.Errorf("expected resource modified due to nested lifecycle change:\n%v", changes)
83		}
84	}
85
86	func TestSemanticDetectsDeepNestedChange(t *testing.T) {
87		a := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
88	  inline_policy {
89	    name   = "p"
90	    policy = "old"
91	  }
92	}`})
93		b := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
94	  inline_policy {
95	    name   = "p"
96	    policy = "new"
97	  }
98	}`})
99		changes, err := Semantic(a, b)
100		if err != nil {
101			t.Fatalf("Semantic: %v", err)
102		}
103		if !hasChange(changes, "resource", "aws_iam_role.alpha", StatusModified) {
104			t.Errorf("expected resource modified due to deeply nested attribute change:\n%v", changes)
105		}
106	}
107
108	func TestSemanticIdenticalNestedBlocksUnchanged(t *testing.T) {
109		src := `resource "aws_s3_bucket" "alpha" {
110	  lifecycle {
111	    prevent_destroy = true
112	  }
113	}`
114		a := mkArchive(map[string]string{"main.tf": src})
115		b := mkArchive(map[string]string{"main.tf": src})
116		changes, err := Semantic(a, b)
117		if err != nil {
118			t.Fatalf("Semantic: %v", err)
119		}
120		if len(changes) != 0 {
121			t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
122		}
123	}
124


Now I have a clear picture of the existing code. Let me also check for the `mkArchive` helper to understand the test utilities.


Bash

grep -rn "mkArchive" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/ --include="*.go"

Find mkArchive definition in diff package tests


/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:10:func mkArchive(files map[string]string) archive.Module {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:30:	a := mkArchive(nil)
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:31:	b := mkArchive(map[string]string{"new.tf": "x\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:43:	a := mkArchive(map[string]string{"old.tf": "x\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:44:	b := mkArchive(nil)
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:56:	a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:57:	b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:69:	a := mkArchive(map[string]string{"m.tf": "x\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:70:	b := mkArchive(map[string]string{"m.tf": "x\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:77:	a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go:78:	b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:9:	a := mkArchive(map[string]string{"main.tf": ""})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:10:	b := mkArchive(map[string]string{
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:23:	a := mkArchive(map[string]string{"v.tf": `variable "name" {}`})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:24:	b := mkArchive(map[string]string{"v.tf": ""})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:35:	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "old" }`})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:36:	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "new" }`})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:47:	a := mkArchive(map[string]string{"main.tf": "this is not valid hcl{{{"})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:48:	_, err := Semantic(a, mkArchive(nil))
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:67:	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:72:	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:87:	a := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:93:	b := mkArchive(map[string]string{"main.tf": `resource "aws_iam_role" "alpha" {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:114:	a := mkArchive(map[string]string{"main.tf": src})
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/semantic_test.go:115:	b := mkArchive(map[string]string{"main.tf": src})


Good. Now I have everything I need. Let me make all the changes:

1. First, update `semantic.go` with the new `localEntry` type, updated `ModuleSummary`, updated `summarize`, remove `locals` from `classify`, and add `diffLocals`/`localText`.
2. Then add the new tests to `semantic_test.go`.


Edit

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

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

// localEntry is a single attribute inside a locals block. Locals are a
// flat namespace in Terraform — name uniquely identifies the value
// regardless of which file or block it lives in.
type localEntry struct {
	srcRange hcl.Range
	src      []byte
}


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)


	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{},
	}

	s := ModuleSummary{
		Resources: map[string]blockEntry{},
		Variables: map[string]blockEntry{},
		Outputs:   map[string]blockEntry{},
		Providers: map[string]blockEntry{},
		Locals:    map[string]localEntry{},
		Data:      map[string]blockEntry{},
		Modules:   map[string]blockEntry{},
	}


Now update the `summarize` loop to handle `locals` blocks inline and skip them in `classify`:


		for _, blk := range syntaxBody.Blocks {
			key, target := classify(blk, &s)
			if target == nil {
				continue
			}
			target[key] = blockEntry{block: blk, src: body}
		}

		for _, blk := range syntaxBody.Blocks {
			if blk.Type == "locals" {
				for name, attr := range blk.Body.Attributes {
					s.Locals[name] = localEntry{
						srcRange: attr.Expr.Range(),
						src:      body,
					}
				}
				continue
			}
			key, target := classify(blk, &s)
			if target == nil {
				continue
			}
			target[key] = blockEntry{block: blk, src: body}
		}


Now remove the `case "locals":` arm from `classify`:


	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
}

	case "provider":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Providers
	}
	return "", nil
}


Now update `Semantic` to call `diffLocals` instead of `diffMap` for locals:


out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)

out = append(out, diffLocals(sa.Locals, sb.Locals)...)


Now add the `diffLocals` and `localText` functions after `diffMap`. Also check if `fmt` is still needed after removing the `locals` case from `classify`:


func diffMap(kind string, a, b map[string]blockEntry) []SemanticChange {

func diffLocals(a, b map[string]localEntry) []SemanticChange {
	out := make([]SemanticChange, 0)
	for name, ae := range a {
		be, ok := b[name]
		if !ok {
			out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusRemoved})
			continue
		}
		if localText(ae) != localText(be) {
			out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusModified})
		}
	}
	for name := range b {
		if _, ok := a[name]; !ok {
			out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusAdded})
		}
	}
	return out
}

// localText returns the source bytes for a local definition's expression.
// Two locals with identical text are considered equal.
func localText(e localEntry) string {
	return exprText(e.src, e.srcRange)
}

func diffMap(kind string, a, b map[string]blockEntry) []SemanticChange {


Now check if `fmt` is still used (it was used in the removed `locals` case and in `exprText`):


Bash

Check remaining fmt usages in semantic.go


96:			return ModuleSummary{}, fmt.Errorf("%w: %s: %s", ErrParseFailed, path, diags.Error())
100:			return ModuleSummary{}, fmt.Errorf("%w: %s: unexpected body type", ErrParseFailed, path)
242:		return fmt.Sprintf("%d:%d", r.Start.Byte, r.End.Byte)


Good, `fmt` is still needed. Now add the tests to `semantic_test.go`:


<tool_use_error>No changes to make: old_string and new_string are exactly the same.</tool_use_error>

Error: No changes to make: old_string and new_string are exactly the same.


118			t.Fatalf("Semantic: %v", err)
119		}
120		if len(changes) != 0 {
121			t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
122		}
123	}
124


Edit

	if len(changes) != 0 {
		t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
	}
}

	if len(changes) != 0 {
		t.Errorf("expected no changes for identical nested blocks, got: %v", changes)
	}
}

func TestSemanticLocalsLineShiftIsNotAChange(t *testing.T) {
	// Same local definition, different surrounding whitespace pushes
	// the locals block to a different line. Must NOT report changes.
	a := mkArchive(map[string]string{"main.tf": `
locals {
  region = "us-east-1"
}
`})
	b := mkArchive(map[string]string{"main.tf": `
# new comment

variable "x" {}

locals {
  region = "us-east-1"
}
`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after line shift: %+v", c)
		}
	}
}

func TestSemanticLocalsValueChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-west-2" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "region", StatusModified) {
		t.Errorf("expected locals.region modified, got: %v", changes)
	}
}

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1"
  bucket = "logs"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusAdded) {
		t.Errorf("expected locals.bucket added, got: %v", changes)
	}
}

func TestSemanticLocalsRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
  bucket = "logs"
}`})
	b := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusRemoved) {
		t.Errorf("expected locals.bucket removed, got: %v", changes)
	}
}

func TestSemanticLocalsAcrossFiles(t *testing.T) {
	// A local moved from main.tf to locals.tf without value change must
	// not produce any diff entries — locals is a flat namespace.
	a := mkArchive(map[string]string{
		"main.tf": `locals { region = "us-east-1" }`,
	})
	b := mkArchive(map[string]string{
		"main.tf":   ``,
		"locals.tf": `locals { region = "us-east-1" }`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after cross-file move: %+v", c)
		}
	}
}


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)


Now let's run the tests to verify everything passes:


Bash

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

Run diff package tests with race detector


Error: Exit code 1
=== 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   TestSemanticLocalsLineShiftIsNotAChange
--- PASS: TestSemanticLocalsLineShiftIsNotAChange (0.00s)
=== RUN   TestSemanticLocalsValueChange
--- PASS: TestSemanticLocalsValueChange (0.00s)
=== RUN   TestSemanticLocalsAdded
    semantic_test.go:172: Semantic: hcl parse failed: main.tf: main.tf:1,30-2,1: Invalid single-argument block definition; An argument definition on the same line as its containing block creates a single-line block definition, which must also be closed on the same line. Place the block's closing brace immediately after the argument definition.
--- FAIL: TestSemanticLocalsAdded (0.00s)
=== RUN   TestSemanticLocalsRemoved
--- PASS: TestSemanticLocalsRemoved (0.00s)
=== RUN   TestSemanticLocalsAcrossFiles
--- PASS: TestSemanticLocalsAcrossFiles (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)
FAIL
FAIL	github.com/immuta/operations/apps/birdseye/internal/diff	0.643s
FAIL


One test has an HCL parse error — `TestSemanticLocalsAdded` uses a multi-line string literal embedded in a single-line block which is invalid HCL. I need to fix that test's HCL to be valid:


Edit

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1"
  bucket = "logs"
}`})

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
  bucket = "logs"
}`})


Bash

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

Run diff package tests with race detector after fix


=== 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   TestSemanticLocalsLineShiftIsNotAChange
--- PASS: TestSemanticLocalsLineShiftIsNotAChange (0.00s)
=== RUN   TestSemanticLocalsValueChange
--- PASS: TestSemanticLocalsValueChange (0.00s)
=== RUN   TestSemanticLocalsAdded
--- PASS: TestSemanticLocalsAdded (0.00s)
=== RUN   TestSemanticLocalsRemoved
--- PASS: TestSemanticLocalsRemoved (0.00s)
=== RUN   TestSemanticLocalsAcrossFiles
--- PASS: TestSemanticLocalsAcrossFiles (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.543s


All 17 tests pass. Now run the full suite:


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

Run full test suite, vet, and build for birdseye


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


All clean. Now commit:


git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go && git commit -m "$(cat <<'EOF'
fix(birdseye): track locals by name across the module's flat namespace

Replace the brittle filename#startLine key for locals blocks with per-attribute
tracking keyed by local name. Locals are a flat Terraform namespace, so line
shifts and cross-file moves no longer produce false-positive removed+added pairs.

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

Commit semantic diff locals fix


[worktree-birdseye 6ea0909] fix(birdseye): track locals by name across the module's flat namespace
 2 files changed, 139 insertions(+), 7 deletions(-)


**Status: DONE**

**Structural changes:**

- `semantic.go`: Added `localEntry` struct (`srcRange hcl.Range`, `src []byte`). Changed `ModuleSummary.Locals` from `map[string]blockEntry` to `map[string]localEntry`. Updated `summarize` to handle `locals` blocks inline by iterating their attributes and keying by attribute name. Removed the `case "locals":` arm from `classify`. Added `diffLocals` function (compares by `localText`/`exprText`) and `localText` helper. Updated `Semantic` to call `diffLocals` instead of `diffMap` for locals.

- `semantic_test.go`: Added 5 new tests: `TestSemanticLocalsLineShiftIsNotAChange`, `TestSemanticLocalsValueChange`, `TestSemanticLocalsAdded`, `TestSemanticLocalsRemoved`, `TestSemanticLocalsAcrossFiles`. One test from the spec had a malformed HCL literal (multi-line content in a single-line block) — fixed it to use a properly formatted multi-line block.

**Test count:** 17 tests total, all pass, race-clean.

**Previous non-locals tests:** All 7 original tests still pass unaffected.

**Commit SHA:** `6ea0909`

**Unexpected:** `TestSemanticLocalsAdded` as written in the spec contained invalid HCL (a newline inside a single-line block definition). Fixed by converting the `b` fixture to a proper multi-line block — semantically identical to the intent.