CLAUDE.md · diff

git:20260331.2c27c39 to git:20260404.82b1c75

5 added, 4 removed. Audit A to A.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## CRITICAL INSTRUCTIONS
**DO NOT RUN `git commit`** - This is explicitly forbidden. Use `git add`, `git status`, `git diff`, and `git log` only. The user will commit manually.
**ALWAYS run `make lint` and `make test`** before claiming success.
**DO NOT modify `.golangci.yml`** without explicit approval.
## Project Overview
FinFocus Core is a CLI tool and plugin host system for calculating cloud infrastructure costs from Pulumi infrastructure definitions. It provides both projected cost estimates and actual historical cost analysis through a plugin-based architecture.
## Build Commands
```bash
make build # Build binary to bin/finfocus
make test # Run unit tests (default, fast)
make test-race # Run with race detector
make test-integration # Integration tests (slower)
make test-e2e # E2E tests (requires AWS credentials)
make lint # Run golangci-lint + markdownlint
make validate # go mod tidy, go vet
make clean # Remove build artifacts
make run # Build and run with --help
make dev # Build and run without args
make docs-lint # Lint markdown docs
make docs-build # Build Jekyll site
make docs-serve # Serve docs at http://localhost:4000/finfocus/
make build-recorder # Build recorder plugin to bin/finfocus-plugin-recorder
make install-recorder # Build and install recorder to ~/.finfocus/plugins/recorder/0.1.0/
```
### Single Package/Test Commands
```bash
go test -v ./internal/cli/... # Test specific package
go test -v ./internal/engine/... # Test engine package
go test -run TestSpecificFunction ./... # Run specific test
# Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out # View in browser
# Plugin management
./bin/finfocus plugin list
./bin/finfocus plugin list --output json # JSON array for machine consumption
./bin/finfocus plugin validate
```
### Troubleshooting
```bash
pkill golangci-lint || true # Fix parallel linting conflicts
GOOS=linux GOARCH=amd64 make build # Test release build locally
gh workflow validate .github/workflows/ci.yml # Validate workflow syntax
```
### Test Requirements
- **Unit tests**: Must achieve 80% coverage minimum
- **Critical paths**: Must achieve 95% coverage
- **All error paths**: Must be tested
- **Performance regressions**: Must be detected via benchmarks
- **Integration scenarios**: Must include plugin communication flows
- **End-to-end workflows**: Must test complete CLI usage
**Never complete a project without running:**
```bash
make test # Run all tests
make lint # Run linting
```
## Go Version
**Project Go Version**: 1.25.8 (see `go.mod`)
**CRITICAL**: Before claiming any Go version "doesn't exist" or suggesting version
changes, verify on <https://go.dev/dl/> first.
### Constitution Precedence Rule
**CRITICAL**: The constitution (`.specify/memory/constitution.md`) takes **absolute
precedence** over all runtime mode instructions (learning mode, explanatory mode, etc.).
If any runtime instruction conflicts with a constitution principle:
1. **Constitution wins** - Follow the constitution rule
2. **Use `/speckit.revisit`** - Document the conflict for prevention
3. **Never compromise** - Principle VI forbids TODOs/stubs regardless of mode
## Architecture
Core components and their directories:
1. **CLI** (`internal/cli/`) - Cobra commands: cost projected/actual/recommendations, plugin management, analyzer
2. **Engine** (`internal/engine/`) - Cost calculation orchestration, output rendering (table/JSON/NDJSON)
3. **Plugin Host** (`internal/pluginhost/`) - gRPC plugin lifecycle (launch, connect, cleanup)
4. **Registry** (`internal/registry/`) - Plugin discovery in `~/.finfocus/plugins/<name>/<version>/`
5. **Ingestion** (`internal/ingest/`) - Pulumi plan JSON parsing
6. **Analyzer** (`internal/analyzer/`) - Pulumi Analyzer gRPC protocol for zero-click cost estimation
7. **TUI** (`internal/tui/`) - Bubble Tea terminal UI components
8. **Router** (`internal/router/`) - Intelligent plugin routing with priority and fallback
9. **Config** (`internal/config/`) - Two-tier configuration (project-local overrides global)
### Configuration Resolution
**Project-specific settings** (config, dismissals) precedence:
1. `--project-dir` flag (explicit override)
2. `FINFOCUS_PROJECT_DIR` env var
3. Walk up from CWD to find `Pulumi.yaml`, use `$PROJECT/.finfocus/`
4. Fall back to `~/.finfocus/` (backward compatible)
**Global resources** (plugins, cache, logs) precedence:
1. `FINFOCUS_HOME` env var
2. `PULUMI_HOME/finfocus`
3. `~/.finfocus/`
**Config Merge**: Project `config.yaml` overrides global at the **top-level key** level
(shallow merge). Keys absent in project config inherit from global defaults.
## Key Patterns
### CLI Conventions
- Use `RunE` not `Run` for error handling
- Use `cmd.Printf()` for output (not `fmt.Printf()`)
- Defer cleanup functions immediately after obtaining resources
- Support multiple date formats: "2006-01-02", RFC3339
### Pre-Flight Request Validation
The adapter layer (`internal/proto/`) validates requests using `pluginsdk` validation
before gRPC calls. Key points:
- Validation happens in `GetProjectedCostWithErrors()` and `GetActualCostWithErrors()`
- Uses "VALIDATION:" prefix to distinguish from plugin errors ("ERROR:")
- Logs at WARN level with resource context for debugging
- Returns placeholder CostResult with $0 cost and descriptive Notes
- Invalid resources are skipped; valid resources still call the plugin
### Logging
```bash
# Enable debug output
finfocus cost projected --debug --pulumi-json plan.json
export FINFOCUS_LOG_LEVEL=debug
export FINFOCUS_LOG_FORMAT=json # json or console
export FINFOCUS_TRACE_ID=external-trace-123 # inject external trace ID
```
Precedence: CLI flags (`--debug`) > env vars > config file > default (info, console).
## Testing
### TUI Visual Verification
After modifying `internal/tui/` code, ALWAYS:
1. Render the affected view and read the full output (not just test pass/fail)
2. Verify column alignment, section ordering, and data population visually
3. Run golden file tests: `go test -run TestGolden ./internal/tui/...`
4. Regenerate golden files if layout intentionally changed:
`UPDATE_GOLDEN=1 go test -run TestGolden ./internal/tui/...`
String-only assertions (`assert.Contains`) are NOT sufficient for TUI testing.
### E2E Testing
**Location**: `test/e2e/` (separate Go module)
**Prerequisites**: AWS session or profile configured, Pulumi CLI, `make build`
```bash
export PATH="$HOME/.pulumi/bin:$PATH"
export PULUMI_CONFIG_PASSPHRASE="e2e-test-passphrase"
make test-e2e
```
**CRITICAL**: E2E tests MUST call actual finfocus CLI binary.
Never simulate cost values or stub CLI execution.
### Expected Failure Test Patterns
**IMPORTANT**: Tests that intentionally create failing plugin scenarios must follow
these patterns to avoid false CI failures:
- **Expected errors**: Use `t.Logf()` (informational, test passes), NOT `t.Errorf()`
- **Required errors**: Use `t.Fatalf("expected error")` only if error is absent
- **Common expected errors**: `context deadline exceeded`, `connection refused`,
`broken pipe`/`EOF`, `no such file or directory`
If CI shows these errors in logs but tests are marked PASS, the behavior is correct.
Only investigate if tests actually FAIL (exit code 1).
### Error Path Testing
When writing new code, always include tests for error conditions:
1. Test every error return path
2. Validate error messages with `assert.Contains(t, err.Error(), "expected text")`
3. Test boundary conditions: empty inputs, nil pointers, invalid ranges
4. Test partial failures in batch operations
5. Test resource cleanup runs even when errors occur (defer patterns)
### Testify Assertion Standards
**CRITICAL**: All Go tests MUST use testify's `require` and `assert` packages.
NEVER use manual `if x != y { t.Errorf(...) }` patterns.
**`require.*`** (stops test on failure): Setup operations, error checks where
continuing would panic, non-nil checks before use.
**`assert.*`** (continues on failure): Value comparisons, multiple property
checks, non-critical validations.
| Manual Pattern | Testify Replacement |
| --- | --- |
| `if err != nil { t.Fatal(err) }` | `require.NoError(t, err)` |
| `if err == nil { t.Error("expected error") }` | `require.Error(t, err)` |
| `if x != y { t.Errorf("got %v, want %v", x, y) }` | `assert.Equal(t, y, x)` |
| `if len(x) != n { t.Errorf(...) }` | `assert.Len(t, x, n)` |
| `if !strings.Contains(s, sub) { t.Errorf(...) }` | `assert.Contains(t, s, sub)` |
| `if x == nil { t.Fatal("nil") }` | `require.NotNil(t, x)` |
### Local Plugin Development
1. Clone the plugin repository (e.g., `finfocus-plugin-aws-public`)
2. Modify the plugin code (add logging, fix type mapping)
3. Build: `make build-region REGION=us-east-1`
4. Install: Copy binary to `~/.finfocus/plugins/<plugin>/<version>/`
5. Run Core E2E tests to verify
## Important Files
- `cmd/finfocus/main.go` - CLI entry point (semantic exit codes: 0=success, 1=error, 2=budget exceeded)
- `internal/engine/engine.go` - Core orchestration
- `internal/pluginhost/host.go` - Plugin client management
- `internal/ingest/pulumi_plan.go` - Pulumi plan parsing
- `.specify/memory/constitution.md` - Project principles and quality gates
- `examples/plans/aws-simple-plan.json` - Sample plan for testing
## Pulumi Integration Notes
### Plan JSON Parsing
The `pulumi preview --json` output nests resource details under `newState`.
Ingestion MUST inspect `newState` to extract `inputs` and `type`. Without this,
property extraction fails and plugins return `InvalidArgument` errors.
### Property Extraction
The adapter (`internal/proto/adapter.go`) relies on the `Inputs` map to extract:
- **SKU**: from `instanceType`, `type`, etc.
- **Region**: from `availabilityZone`, `region`
If ingestion fails to populate `Inputs`, these fields are empty.
### Resource Type Compatibility
Pulumi provides types like `aws:ec2/instance:Instance` (Type Token). Plugins may
expect `aws:ec2:Instance` or just `ec2`. Plugins should handle the standard
Pulumi format or normalize internally.
### Pulumi SDK Import Path
For Analyzer development, use the correct import:
```go
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
// NOT: github.com/pulumi/pulumi/sdk/v3/proto/go/pulumirpc
```
## Multi-Repository Ecosystem
FinFocus operates across three repositories:
- **finfocus** (this repo) - CLI tool, plugin host, orchestration
- **finfocus-spec** - Protocol buffer definitions, SDK generation
- **finfocus-plugin** - Plugin implementations (Kubecost, Vantage, etc.)
Cross-repo changes follow the protocol in `.specify/memory/constitution.md`.
### Agent Skills Placement
- **Tool-specific skills** (`finfocus-install`, `finfocus-diagnose`, etc.) → live in
`rshade/finfocus` under `agent-skills/` — these are product skills tightly coupled to
finfocus CLI commands, file paths, and architecture
- **Generic cost workflow skills** (`cost-check`, `cost-drift`, `cost-optimize`,
`budget-setup`) → live in `rshade/agent-skills` as multi-tool skills not tied to any
specific cost tool
## Common Error Types
- `ErrNoCostData`: No cost data available for a resource
- `ErrMixedCurrencies`: Multiple currencies detected in cross-provider aggregation
- `ErrInvalidGroupBy`: Invalid grouping type used for time-based aggregation
- `ErrEmptyResults`: Attempted aggregation on empty results
- `ErrInvalidDateRange`: Invalid date range (end date before start date)
- `ErrResourceValidation`: Internal resource validation failed
- `ErrConfigCorrupted`: Configuration file is malformed
Structured error codes for JSON/NDJSON output: `PLUGIN_ERROR`, `VALIDATION_ERROR`,
`TIMEOUT_ERROR`, `NO_COST_DATA` (see `internal/engine/types.go`).
## Recorder Plugin
Reference plugin for inspecting Core-to-plugin data shapes and contract testing.
| Variable | Default | Description |
| --- | --- | --- |
| `FINFOCUS_RECORDER_OUTPUT_DIR` | `./recorded_data` | Directory for recorded JSON files |
| `FINFOCUS_RECORDER_MOCK_RESPONSE` | `false` | Enable randomized mock responses |
## Package-Specific Gotchas
Non-obvious behaviors that can cause subtle bugs if you don't know about them.
### Plugin Host (`internal/pluginhost/`)
- **Port allocation**: Uses allocate→hold→release→bind pattern. Race window between
release and plugin bind is mitigated by `StartWithRetry`
- **Zombie prevention**: Always call `cmd.Wait()` after `cmd.Process.Kill()`
- **`PORT` env var removed**: Core does NOT set `PORT` (avoids Cloud Run conflicts).
Plugins must use `--port` flag or `FINFOCUS_PLUGIN_PORT`
### CLI (`internal/cli/`)
- **`analyzer serve` stdout**: Prints ONLY the port number to stdout (Pulumi handshake
protocol). ALL logging must go to stderr exclusively
- **DismissalStore**: Uses `GetResolvedProjectDir()` → project `dismissed.json`;
falls back to `~/.finfocus/dismissed.json`
- **`config init`**: Without `--global`, inside a Pulumi project creates
`$PROJECT/.finfocus/config.yaml` + `.gitignore`. Outside Pulumi project → global init
- **`config routes`**: `config routes list` shows effective routing source/path;
`config routes test <type> [region]` simulates per-feature plugin selection without loading plugin binaries
### Registry (`internal/registry/`)
- **Region-specific binaries**: When `plugin.metadata.json` has a `region` key, registry
looks for `finfocus-plugin-<name>-<region>` first, then falls back to standard names
- **Checksum verification**: Only a confirmed hash mismatch is fatal. Missing
`checksums.txt`, download failures, or unlisted assets produce warnings and continue
- **`--skip-checksum`** flag available on `plugin install` and `plugin update`
### Router (`internal/router/`)
- **Fallback chain**: `$0.00` cost is a VALID result (does NOT trigger fallback).
Only nil/empty results trigger fallback to the next plugin
- **Priority**: Higher number = higher priority (sorted descending by `sortByPriority`)
- **No config = no routing**: `createRouterForEngine()` returns nil if no routing config;
engine falls back to querying all plugins
### Engine (`internal/engine/`)
- **`hoursPerMonth = 730`** for monthly cost calculations
- **Budget health thresholds**: OK (<80%), WARNING (80-89%), CRITICAL (90-100%),
EXCEEDED (>100%). Aggregation uses worst-case status
- **Cache hits**: Append ` (cached)` to the Adapter field for visual feedback
- **Cache corruption**: Auto-detected and auto-recovered (delete + recreate)
- **Plugin TTL hints**: Plugins can set `expires_at` on responses to control per-entry
cache TTL. The engine extracts `ExpiresAt` from `CostResult`, calls
`cache.CalculatePluginTTL()`, and uses `SetWithTTL()` when a plugin hint is present.
Past timestamps skip caching entirely. TTLs exceeding `MaxTTLSeconds` (604800 = 7 days)
are capped. Debug logs record TTL overrides; warn logs record caps and skips
### Overview Field Semantics (`internal/engine/overview_*.go`)
Understanding what each field *means* prevents the most common overview bugs.
Every field on `OverviewRow` has a specific temporal basis, population rule,
and set of valid comparisons. Violating these invariants produces subtle bugs
(misleading deltas, nonsensical drift, UI garbage).
#### Cost Fields — Temporal Basis
| Field | Struct | Temporal Basis | Unit | Source |
| --- | --- | --- | --- | --- |
| `MTDCost` | `ActualCostData` | Partial month (day 1 → today) | Dollars spent so far | Actual cost plugin |
| `MonthlyCost` | `ProjectedCostData` | Full canonical month (730h) | Dollars if run all month | Projected cost plugin |
| `ExtrapolatedMonthly` | `CostDriftData` | Full calendar month (28-31d) | Projected from MTD trend | Calculated by `CalculateCostDrift` |
| `Delta` | `CostDriftData` | Full calendar month | ExtrapolatedMonthly - Projected | Calculated by `CalculateCostDrift` |
**Key rule**: `MTDCost` and `MonthlyCost` are **different units**. You cannot
subtract one from the other. To compare them, you must first extrapolate
`MTDCost` to a full month using `getExtrapolatedActual()` (30-day standard)
or `CalculateCostDrift()` (calendar-accurate).
#### When Fields Are Nil vs Populated
Fields start nil after merge and get populated during enrichment. What gets
populated depends on the resource's `Status`:
| Status | `ActualCost` | `ProjectedCost` | `CostDrift` | `PropertyDiffs` |
| --- | --- | --- | --- | --- |
| Active | Yes (has billing history) | Yes (current config) | Maybe (nil if < 10% or day < 3) | No (no changes) |
| Updating | Yes (still running) | Yes (new config pricing) | Maybe | Yes (what changed) |
| Replacing | Yes (old resource billing) | Yes (new resource pricing) | Maybe | Yes (what changed) |
| Creating | No (doesn't exist yet) | Yes (new resource pricing) | No (no history) | No |
| Deleting | Yes (still running) | No (will be removed) | No (no projection) | No |
This table is the **source of truth** for which cost computations are valid
per status. If a formula assumes a field is non-nil, check this table first.
#### Delta Column — What It Means Per Status
The "Delta" TUI column answers: "how will this change affect my monthly bill?"
Use `CalculateRowDelta()` — it encodes status-aware logic:
| Status | Delta Formula | Meaning |
| --- | --- | --- |
| Updating/Replacing | `projected - extrapolatedActual` | Cost impact of the config change |
| Creating | `+projected` | New cost being added |
| Deleting | `-extrapolatedActual` | Cost being removed |
| Active (with drift) | `CostDrift.Delta` | How much actual spend deviates from projection |
| Active (no drift) | `-` (no delta shown) | Spend is tracking projection (< 10% off) |
#### Extrapolation — Two Methods, Intentionally Different
| Function | Month Basis | Used For |
| --- | --- | --- |
| `getExtrapolatedActual()` | 30-day standard | Delta calculations (consistent cross-month) |
| `CalculateCostDrift()` | Calendar days (28-31) | Drift % (calendar-accurate precision) |
Do not unify these. Delta uses 30-day for stable comparisons across months.
Drift uses calendar days because a February drift % must account for 28 days.
#### Drift Nil Cases
`CostDrift` is nil (not populated) in these cases — all intentional:
- **Day 1-2 of month** (`driftMinDay = 3`): insufficient data
- **Drift < 10%** (`driftWarningThreshold`): not significant enough to show
- **New resource** (has projected, no actual): nothing to extrapolate from
- **Deleted resource** (has actual, no projected): nothing to compare against
- **Recently created** (`CreatedAt` within billing window, < 3 days old)
Code must always handle `row.CostDrift == nil` as a normal case, not an error.
#### Pulumi Plan Data — What to Filter
`PulumiStep.OldState.Inputs` and `NewState.Inputs` contain both user-specified
properties and Pulumi internal metadata. When displaying to users:
- **Filter keys prefixed with `__`** (e.g., `__defaults`, `__provider`) —
these are Pulumi SDK internals, not user properties
- **Truncate values** in TUI to prevent wrapping (max 40 chars via
`truncateDiffValue()`) — Pulumi inputs can contain large arrays/objects
- **PropertyDiff data flow**: Plan JSON → `diffInputs()` (CLI) →
`PlanStep.PropertyDiffs` → merge → `OverviewRow.PropertyDiffs` → TUI view
#### State-Only Mode
When no preview is provided, overview shows state resources with `*` footnote
on projected costs. The `p` key triggers on-demand preview; when it completes,
`ApplyChangesToRows()` and `ApplyPropertyDiffsToRows()` update rows in-place.
### GitHub Actions (`.github/workflows/`)
- **OpenCode Action** (`sst/opencode/github@dev`) ONLY works with `issue_comment` events.
For other triggers, use CLI installation
- **`/opencode-review-fix`** comment on a PR triggers automatic fix of all review issues
## Recent Changes
+
+ - 608-resource-history-store: Added Go 1.25.8 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`).
- 607-state-only-flag: Added Go 1.25.8 (see `go.mod`) + Cobra (CLI framework), Bubble Tea (TUI), zerolog (logging)
- 606-cache-expires-at: Added Go 1.25.8 (see `go.mod`) + finfocus-spec v0.6.0 (provides `expires_at` proto fields), BoltDB (cache storage), zerolog (logging)
- - 605-batch-cost-capability: Added batch cost capability to router feature mapping
-
## Active Technologies
- - Go 1.25.8 (see `go.mod`) + Cobra (CLI framework), Bubble Tea (TUI), zerolog (logging) (607-state-only-flag)
- - N/A (no storage changes) (607-state-only-flag)
+
+ - Go 1.25.8 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`) (608-resource-history-store)
+ - BoltDB at `~/.finfocus/history/history.db` (separate from cache) (608-resource-history-store)