CLAUDE.md · git:20261017.59f175f · 2026-10-17 · sha256 36178ef4f6333cf5
CLAUDE.md git:20261017.59f175fA
Immutable. This exact content never changes and is served at /api/v1/blob/36178ef4f6333cf5.
# CLAUDE.md
Guidance for Claude Code in this repository. It lists commands and the
non-obvious rules that have caused real bugs. Feature behavior lives in
`specs/NNN-*/` and the code; this file points there instead of restating it.
## Critical Instructions
- **Do not run `git commit`** unless the user asks for a commit, directly or by
invoking a workflow whose purpose is a commit (for example `/pick-issue`).
- **Always run `make lint` and `make test`** before claiming success.
- **Do not modify `.golangci.yml`** without explicit approval.
- The constitution (`.specify/memory/constitution.md`) wins over any runtime
mode instruction (learning, explanatory). Principle VI forbids TODOs and
stubs. Record a conflict with `/speckit.revisit`.
## Project Overview
FinFocus Core is a CLI and plugin host that calculates cloud costs from Pulumi
(and Terraform state) definitions: projected estimates, actual history, and
Kubernetes allocation, all through gRPC plugins. Sibling repos:
`finfocus-spec` (protos, `pluginsdk`) and the `finfocus-plugin-*` plugins
(aws-public, azure-public, ...). Cross-repo changes follow the constitution's
protocol. Plugin domain knowledge stays in the plugin; core and spec get
generic channels only.
## Specs
Specs, plans, and tasks live in `specs/NNN-*/`, produced by **Spec Kit**
(`.specify/` and the `/speckit-*` skills). It is the only pipeline: output
from other planning tools must become a Spec Kit feature folder before
implementation.
## Commands
```bash
make tools # Install the toolchain pinned in mise.toml
make build # bin/finfocus
make test # Unit tests: internal/, pkg/, and the plugin modules
make test-race
make test-integration # Slower; see Integration Tests below
make test-e2e # Needs AWS credentials and Pulumi
make test-e2e-kind # kind cluster: run-rate, then historical (Prometheus)
make lint # golangci-lint, markdownlint, actionlint
make validate # go mod tidy -diff, go vet
make docs-lint
make docs-serve # Astro site at http://localhost:4321/finfocus/
make check-plugin-boundaries
make test-kubernetes # Also test-jev, test-prometheus (nested plugin modules)
make install-kubernetes # Also install-jev, install-prometheus, install-recorder
```
```bash
go test -run TestName ./internal/cli/
go test -race -shuffle=on -count=3 ./internal/<pkg>/... # Parallel-safety check
./bin/finfocus plugin list --output json
```
- `make lint` checks golangci-lint's version against `mise.toml` and runs
`$(HOME)/go/bin/golangci-lint`. When that binary lags the pin, run
`make lint GOLANGCI_LINT="$(mise which golangci-lint)"`.
- `pkill golangci-lint || true` clears a parallel-runner conflict. Lint can run
longer than five minutes.
- After a rebase, run `go vet ./...` before trusting `make test`. A widened
interface (for example `engine.ResourcePricer`) breaks test doubles that
another merged PR added, with no git conflict.
## Architecture
| Package | Role |
| --- | --- |
| `internal/cli/` | Cobra commands, routed through `ax.Execute` (ax-go) |
| `internal/engine/` | Cost orchestration, cluster allocation, rendering |
| `internal/pluginhost/` | gRPC plugin launch, connect, cleanup |
| `internal/registry/` | Discovery in `<home>/plugins/<name>/<version>/`, install |
| `internal/ingest/` | Pulumi plan and state parsing |
| `internal/proto/` | Adapter: descriptors, pre-flight validation |
| `internal/router/` | Plugin routing with priority and fallback |
| `internal/analyzer/` | Pulumi Analyzer gRPC server |
| `internal/tui/` | Bubble Tea v2 / Lipgloss v2 (`charm.land` imports) |
| `internal/config/` | Two-tier config |
| `plugins/{kubernetes,jev,prometheus}/` | Nested modules; never import core |
Entry point `cmd/finfocus/main.go`. Exit codes: 0, 1 (ax `ExitInternal`), and
the budget code from `cost --exit-code` (default 1), preserved through
`ax.Execute` by `cli.toAxExitError`. User-input errors (flag combinations,
malformed input files, bad dates) exit 2 through `toValidationError`
(`ax.ExitValidation`). Codes 3 and 4 (`ExitNetwork`, `ExitAuth`) are not used yet.
### Configuration Resolution
- Project settings (config, dismissals): `--project-dir`, then
`FINFOCUS_PROJECT_DIR`, then the nearest `Pulumi.yaml` (`$PROJECT/.finfocus/`),
then `~/.finfocus/`.
- Global resources (plugins, cache, logs): `FINFOCUS_HOME`, then
`PULUMI_HOME/finfocus`, then `~/.finfocus/`. With none of those, it is
`./.finfocus` (`config.UsesWorkingDirFallback`).
- Project `config.hujson` overrides global at the top-level key (shallow
merge). The global loader migrates a legacy `config.yaml` to Hujson on first
read; a legacy project `config.yaml` is read as an overlay and left as it is.
## Key Patterns
- `RunE`, not `Run`. `cmd.Printf`, not `fmt.Printf`. Defer cleanup right after
acquiring a resource. Dates accept `2006-01-02` and RFC3339.
- Pre-flight validation (`internal/proto/`, `pluginsdk` validators) returns a
`$0` placeholder with a `VALIDATION:` note (plugin errors use `ERROR:`) and
still calls the plugin for valid resources. A resource with `ref.*` tags and
no SKU uses `ValidateProjectedCostRequestLenient`.
- Logging: `--debug` > `FINFOCUS_LOG_LEVEL`/`FINFOCUS_LOG_FORMAT` > config >
info/console. `FINFOCUS_TRACE_ID` injects a trace id.
- `ax.Execute` mounts `--format json|human`, `--dry-run`, `--yes`, and
`--idempotency-key` on every command, plus `__schema` and `mcp-server`
(`--mcp` is the stdio alias). A command must not redeclare `--format`;
Cobra rejects it.
## Testing
- **Testify only** (`require` to stop, `assert` to continue); see `AGENTS.md`.
Never write `if got != want { t.Errorf(...) }` or raw `t.Fatalf`.
- **TUI changes**: render the view and read it, then run
`go test -run TestGolden ./internal/tui/...` (`UPDATE_GOLDEN=1` to
regenerate). `assert.Contains` alone is not enough.
- **Expected plugin failures**: log expected errors with `t.Logf`; fail only
when the required error is absent. `connection refused`, `context deadline
exceeded`, or `EOF` in logs of a passing test are normal.
- Coverage targets (80%, 95% on critical paths) are in the constitution.
### Parallel Tests
Call `t.Parallel()` at the top level and in each `t.Run`, except:
- Tests that touch process-wide state: `t.Setenv`, `t.Chdir`, `os.Setenv`,
package-level variables, `config.ResetGlobalConfigForTest`/`SetGlobalConfig`,
or anything that builds a root command (`NewRootCmd*` sets the resolved
project dir), including helpers such as `stubHome` and `WithEnv`.
- A test that writes a binary or script and then executes it. A concurrent
fork inherits the write fd and the exec fails with `text file busy`
(golang/go#22315).
- Mark them `//nolint:paralleltest // <reason>` above `func` (or on the
`t.Run`/`for` line for some subtests).
- Do not share a mutable fixture across subtests; production code mutates
protos in place. In a parent with parallel subtests use `t.Cleanup`, never
`defer`.
### E2E
- `test/e2e/` is its own module and must run the real `finfocus` binary;
never stub cost values. AWS E2E needs `PULUMI_CONFIG_PASSPHRASE` and
`~/.pulumi/bin` on `PATH`.
- The run-rate half of `make test-e2e-kind` installs aws-public into the real
`~/.finfocus`. `TestCostCluster_KindHistorical` uses a temp home: run
`test/e2e/kind/setup.sh`, apply `test/e2e/kind/prometheus.yaml`, then
`go test -tags e2e_kind -run '^TestCostCluster_KindHistorical$' ./...` from
`test/e2e` (its own module; a bare run from the repo root skips it) with
`FINFOCUS_BINARY` set.
- Real-stack checks use `../finfocus-demo` (stack `dev` in Pulumi Cloud, AWS
creds from an ESC environment attached with `pulumi config env add`; it
rewrites the tracked `Pulumi.dev.yaml`; never commit that change, since the
demo's CI has its own credentials). `pulumi preview` needs
no spend; web Cost and Recommendations read *deployed* state only, so they
show 0 until a `pulumi up`. Always `pulumi destroy` afterwards. Never
`pkill -f <name>` or `pgrep -f <name>` from a shell whose own command line
contains the name: it matches and kills the shell. Anchor the pattern.
- The aws-public router starts a per-region child
(`finfocus-plugin-aws-public-<region>`) that outlives the router when core
stops it. Check `ps` for orphans after local runs.
- Remote-write fixtures need `storage.tsdb.out_of_order_time_window` in the
Prometheus config and a window relative to now. A fixed date is rejected as
out of bounds once a newer sample exists, and later falls out of the 15-day
retention.
- `make test-e2e-kind` pins `E2E_AWS_PUBLIC_VERSION` (Renovate bumps it).
- Web plugin e2e uses the production seams `FINFOCUS_CATALOG_FILE` and
`FINFOCUS_GITHUB_API` (a stand-in catalog and GitHub API, not a supported
custom registry). `webEnvironment` strips every `FINFOCUS_*` value before
`fixtureEnv` is appended, so pass those two through `fixtureEnv`.
`GITHUB_TOKEN` is not stripped. Do not mention the seams in user docs.
### Integration Tests (`test/integration/`)
- `helpers.CLIHelper.Execute` must go through `ax.Execute`; a bare
`cli.NewRootCmd().Execute()` lacks `--format`, `--dry-run`, `--yes`, and
`--idempotency-key`.
- Tests built with `helpers.NewCLIHelper(t)` and no `WithEnv` read the real
`~/.finfocus/config.hujson`. Compare the failure *set* with a pre-change
baseline, not the count.
- Setting `HOME` for a `go`/`make` child moves Go's caches when `GOPATH` is not
exported (CI); cleanup then fails on read-only module files. Pass the real
`GOMODCACHE`/`GOCACHE` (see `scaffold_build_test.go`). Reproduce with
`env -u GOPATH go test ...`.
- `config.yaml` fixtures are intentional legacy input for auto-migration.
## Releases
- release-please manages `CHANGELOG.md` and versions; never edit them by hand.
A `feat` below 1.0 bumps the patch.
- A new nested plugin goes into `release-please-config.json` with
`initial-version` and its path in the root package's `exclude-paths`. Do
not add it to `.release-please-manifest.json`: a hand-written `0.1.0` makes
the first release `0.1.1`. The release PR adds the entry.
- `release-monorepo-plugin.yml` builds any `<name>-vX.Y.Z` tag from
`plugins/<name>/cmd`; a new plugin needs no workflow change. Plugin tag
prefixes must not start with `v`.
- Nested modules must pin the same `finfocus-spec` as the root `go.mod`; CI
compares them.
## Pulumi Integration
- `pulumi preview --json` nests resources under `newState`; ingestion must read
`inputs` and `type` there or plugins get `InvalidArgument`. A step has no
top-level `type`: call `PulumiStep.ResourceType()`, never read `step.Type`
(the overview showed no costs for preview-only resources until it did).
Test fixtures must carry the type only under `newState`, as Pulumi emits it.
- `parent` is the stack, not a pricing reference. Cross-resource refs follow
`propertyDependencies` only and become `ref.<property>.{urn,type,region,sku}`
tags (`specs/618-cross-resource-refs/`). The unknown sentinel
`04da6b54-80e4-46f7-96ec-b56ff0331ba9` is never sent as a tag, SKU, or
region. A referenced SKU is never copied into the child's `Sku`.
- **No credential or Pulumi secret reaches a plugin.** One rule each:
`config.IsCredentialName` (which `engine.isCredentialKey` calls) /
`skipDottedSegment` for names and `history.IsPulumiSecret`
for values. They apply to collapsed tags, dotted keys
(`specs/619-dotted-tag-keys/`), `ResourceDescriptor.attributes`
(`specs/621-k8s-workload-projected-cost/`), actual-cost tags, and
`EstimateCost` attributes. Do not add a second rule.
- Anything new sent to a plugin must be in the projected cache key; that is
why keys end in `/tags-`, `/refs-`, `/attrs-` digests and feature suffixes
(`/pricing-spec`, `/growth-v1`, `/penv-`). `/penv-` is the digest of the
clients that engine retained at construction. A settings or validate probe
must not update another engine's key. An empty digest omits the suffix.
Tests of the process-wide helper must not run in parallel and must call
`ResetPluginEnvDigestsForTest`.
- `__`-prefixed inputs (`__defaults`, `__provider`) are Pulumi internals; filter
them from anything shown to users.
- Analyzer import: `pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"`
(not `.../proto/go/pulumirpc`).
## Plugins
- **Recorder** (reference/contract plugin): `FINFOCUS_RECORDER_OUTPUT_DIR`
(default `./recorded_data`), `FINFOCUS_RECORDER_MOCK_RESPONSE`.
- **Jev scorer** (`plugins/jev/`): off unless `TYPESAFE_API_KEY` is set; the key
never appears in logs, errors, tests, or files. The recommendation id never
leaves the plugin (answers map back by position). `priority` is always one
record per request (batching drops rank quality). Scores rank only; never
gate an irreversible action on one.
- **Agent skills**: finfocus-specific skills live in `agent-skills/` here;
generic cost-workflow skills live in `rshade/agent-skills`.
## Package-Specific Gotchas
### Config singleton (`internal/config/integration.go`)
- `GetGlobalConfig()` initializes the singleton on its first call, without the
project overlay, and `InitGlobalConfigWithProject` then does nothing. The
`cost` commands call `config.GetDefaultOutputFormat()` while their flags are
built, so a project-tier `cost.budgets` (what the dashboard Config page
writes by default) is never applied. A global-tier budget is. Known and not
yet fixed; `config get cost.budgets.global.amount` also rejects the key
`config set` accepts.
### Plugin Host (`internal/pluginhost/`)
- Ports: allocate, hold, release, bind, retried by `StartWithRetry`. A port
stays in `pendingPorts` until its plugin binds. A skipped pending port's
listener is closed at once: the readiness probe is a bare TCP dial, so a
held listener would answer for another plugin.
- Always `cmd.Wait()` after `cmd.Process.Kill()`.
- Core never sets `PORT`; plugins use `--port` or `FINFOCUS_PLUGIN_PORT`.
- `NewClient` injects delivered settings before `Launcher.Start`. A descriptor
key uses `descriptor.env`. A key with no descriptor uses
`pluginhost.GenericPluginEnv` (`FINFOCUS_PLUGIN_<NAME>_<KEY>`). A parent
value already set, including empty, is left as it is. Skip
`FINFOCUS_PLUGIN_PORT`, the log-file variable, and the trace id. Do not
inject from redacted `pluginValue` or `config.effective()`: the config
loader shallow-replaces the whole `plugins` object.
- After `GetPluginInfo` (5s, request `*proto.Empty`), `NewClient` restarts
the process once when advertised settings change the injected pairs. That
restart happens before the version check, so stderr from the new process
can appear even when the client is then closed as incompatible. A second
change does not restart again. `Client.Close` is the func field from
`started.idempotentClose()`.
- Secrets never appear in logs, errors, or another plugin's environment.
Credential names stay on `config.IsCredentialName`.
- Dashboard settings apply on the next `NewClient`. The first reload must not
close the overview pipeline's original clients. Later reloads close only
the set the previous reload opened. `closeReloaded` runs before
`pipe.Cleanup`. Install and remove fail the operation when reopen fails.
A settings save still returns 200 and logs the reopen error.
- A dashboard HTTP request context has no plugin log writer. Plugin stderr is
then discarded when the process stderr is not a terminal. Short probes
(settings read, validate) use a child of the session context that is
canceled when the request ends. A reload that keeps clients uses the session
context itself. `exec.CommandContext` kills the plugin when its launch
context is canceled, so a request-scoped context must not own those clients.
### CLI (`internal/cli/`)
- `analyzer serve` prints only the port on stdout (Pulumi handshake); all logs
go to stderr.
- **Tests leak into the real `~/.finfocus`** unless they set
`t.Setenv("FINFOCUS_HOME", t.TempDir())`; this has produced tests that pass
only after a sibling seeded the store. A test that fails alone
(`HOME=$(mktemp -d) go test -run Name`) is order-dependent, not flaky. The
same leak makes `TestPluginListCmd_TableOutputUnchanged` fail when several
plugin versions are installed locally.
- Reject an unknown `--output` before loading state; early returns on empty
results otherwise exit 0 silently (see `config_routes.go`, `plugin_list.go`).
- `config validate` and every `cost` pre-run share `ValidateConfigSource`.
The on-disk budget field is `cost.budgets.global.amount`; flat
`cost.budgets.amount` is warned and ignored.
- Do not put `--plain` (or other accessibility flags) on the `cost` parent:
`cost history view` has its own `--plain`. Resolve modes with
`tui.DetectResolvedOutputMode`, never `DetectOutputModeFor`, which re-reads
`NO_COLOR` and undoes an explicit `--color`.
- Budget CLI flags (`--exit-on-threshold`, `--exit-code`) are
`BudgetFlagOverrides` on the command context, never written onto the global
config; only a copy of the global scope receives them.
- Budget notifications (`specs/625-budget-alert-notifications/`): stderr only,
never change stdout or the exit code. Only `${FINFOCUS_NOTIFY_*}` expands,
and a project-sourced destination with `${` is skipped without a lookup.
Sends are HTTPS only, follow no redirects, and time out after 10s. Failure
reasons redact every variable value, URL, and secret-looking header, and
never include a response body.
`ThresholdStatus.Notifications` is `json:"-"`; masking is display-only
because `Config.Save` marshals the struct. Tests swap the client with
`cli.SetNotificationClientForTest`.
- The overview docs page is `docs/src/content/docs/commands/overview.md`.
- `internal/webui` must not import `cli`, `registry`, or `pluginhost`. Plugin
page callbacks live in `internal/cli/web_plugins.go` and
`plugin_settings_web.go`. Install progress is a per-operation SSE stream
(`snapshot`, `progress`, `done`). Do not publish it on the overview bus:
that bus runs `RedactJSON` and drops slow subscribers. Settings GET uses
`writePlainJSON`. Secret values are absent from the body. `pluginMetadata`
does not copy `Settings`, so the settings handler calls `GetPluginInfo`
again.
- The dashboard Config page and `config set` patch `config.hujson` through
`config.PatchFile`. Do not call `Config.Save` for that write: it rewrites the
struct from `New()`, folds environment variables into the file, and drops
comments. `baseVersion` is the SHA-256 hex of the file bytes, or `absent`
when the file is missing. A legacy project `config.yaml` is hashed as raw
YAML and converted inside the patch, after that hash check, not before it.
Only the global loader rewrites a legacy file to `config.hujson`. `config set`
runs after that migration and passes an empty `baseVersion`. A request the
caller got wrong is a `config.InvalidChangeError` (400); any other write
error is logged and returned as a generic 500, because it can carry paths.
### Registry (`internal/registry/`)
- `Open` launches up to 8 plugins concurrently; clients keep
`ListLatestPlugins` order, sorted by name (router priority ties rely on it).
- With `region` in `plugin.metadata.json`, discovery looks for
`finfocus-plugin-<name>-<region>` first.
- Only a confirmed checksum mismatch is fatal; `--skip-checksum` exists.
The dashboard catalog path does not offer that flag. A missing
`checksums.txt` warns and continues on the CLI and returns
`ErrChecksumUnavailable` on the web path unless the person accepts it.
The web planner reports a zero-asset release as a fallback so confirmation
can name both versions. The confirmed install is an explicit `name@version`
of the fallback, so `AcceptFallbackVersion` stays unset. The installer
still does not swap an explicit version on its own.
- `FINFOCUS_CATALOG_FILE` overlays the embedded catalog for `GetPlugin` and
`GetAllPluginEntries` (read each call, not cached). `ListPluginsFromRegistry`
stays on the embedded catalog. `FINFOCUS_GITHUB_API` replaces the GitHub API
base. Both are test seams. A custom registry remains #1766.
- Monorepo plugins (`tag_prefix: "kubernetes-"`): "latest" is the highest
prefixed stable semver, never `/releases/latest`; installs use the canonical
version; the entry needs `asset_hints.asset_prefix`.
- A latest release with no assets yet (release-please publishes before the
archives upload) is replaced by the newest earlier release with assets. An
explicit version is never swapped; it returns `*NoAssetsError`
(`ErrReleaseHasNoAssets`), which the CLI's version fallback still handles.
- `DefaultPluginDir()` is `config.New().PluginDir` (`FINFOCUS_PLUGIN_DIR`,
then `plugin_dir`, then the plugins directory under the resolved home).
Install and discovery share it.
### Router (`internal/router/`)
- A `$0.00` result is valid and does not fall back; only nil/empty does.
- Higher priority number wins. No routing config means no router (all
plugins are queried).
- Provider means the billing cloud. `resourcetype.NormalizeProvider` holds the
only alias table (`aws-native`→`aws`, `azure-native`/`azurerm`→`azure`,
`google-native`/`google`→`gcp`); never split a type token for a provider
anywhere else. `resource_type` keeps the package, so `aws-native:*` patterns
still match.
### Engine (`internal/engine/`)
- `hoursPerMonth = 730`. Pricing-spec `per_day` uses 30 days (720 h). Do not
"fix" one against the other: both are AWS's own conventions.
- JSON/NDJSON error codes are `PLUGIN_ERROR`, `VALIDATION_ERROR`,
`TIMEOUT_ERROR`, and `NO_COST_DATA` (`types.go`); `NO_COST_DATA` is not an
error.
- A failed plugin call keeps the gRPC status as `<Code>: <message>`
(`pluginRPCError`). Only an empty projected result is `no cost data
available`. Core does not read `aws:region` from provider or stack config.
- Projected diff (`cost projected`): replacement steps sharing a URN are
collapsed into one `update` (`collapseReplacements`), or the old resource
counts twice. Both sides use the same basis and the same plan-wide `ref.*`
resolution. Unpriced `pulumi:` types are left out.
- Cost history (`internal/history`, `cost history *`): files are
`<project>@<stack>.history.db`, separate from the resource-observation
`history.db`. `collect` fails a checkpoint rather than store an inaccurate
total (#549). `internal/history` must not import `engine` or `ingest`.
History subcommands reuse the parent `--stack`. `cost history export` takes
`csv`/`ndjson` through the root `--format` by rewriting it in `Args` before
`ax.Execute` resolves the mode. `pulumi stack history` is read in pages of
100 until a page adds no new version; a short page is not the end, and the
CLI's default page is only 10 updates.
- `--explain`, `--pricing-spec-fallback`, and estimate-TUI discovery call
`GetPricingSpec`; disabled runs never call it, and each call has a 5s
deadline. A `$0` rate is a price; a plugin must answer `zero_cost` or an
error for "no price". Tiers are graduated, and tiers that do not cover the
whole quantity fall through to YAML.
- `cost forecast` (`specs/626-cost-forecast/`) does not change `cost projected`
totals. A blank currency is USD.
- `checkPluginSupports` caches per client, provider, type, region, SKU,
feature, and attributes digest. SKU must stay in the key, or one SKU-less
answer poisons every SKU in that region. The SDK's "not implemented" reason
is fail-open, like an RPC error.
- Plugin `expires_at` sets a per-entry TTL (capped at 7 days; a past time skips
caching). A plugin whose price depends on its own environment must send
`expires_at` = now, because the cache key cannot see it.
- Cache hits append "(cached)", with a leading space, to the adapter. A
corrupt cache is deleted and recreated. `cost.cache.lru_enabled` wraps
Bolt in `cache.TieredStore`.
- Budget health: OK < 80%, WARNING 80-89%, CRITICAL 90-100%, EXCEEDED > 100%;
aggregation takes the worst.
- Recommendations: `Recommendation.ID` is the dismissal id. The cache key hashes
inputs plus dismissed ids; `--include-dismissed` still sends excluded ids and
adds `/include-dismissed`.
- Scoring (`internal/scoring`, off by default) pseudonymizes identifiers by
default, only warns on failure, and never dismisses or hides anything.
Scorer-only plugins are dropped from cost features before `Supports`.
### Overview Field Semantics (`internal/engine/overview_*.go`)
| Field | Basis | Source |
| --- | --- | --- |
| `MTDCost` | Day 1 to today (spent so far) | Actual plugin |
| `MonthlyCost` | Full 730 h month | Projected plugin |
| `ExtrapolatedMonthly` | Calendar month from the MTD trend | `CalculateCostDrift` |
| `Delta` (drift) | `ExtrapolatedMonthly - MonthlyCost` | `CalculateCostDrift` |
- `MTDCost` and `MonthlyCost` are different units; never subtract them without
extrapolating first.
- Population by status:
| Status | Actual | Projected | Drift | PropertyDiffs |
| --- | --- | --- | --- | --- |
| Active | yes | yes | maybe | no |
| Updating / Replacing | yes | yes | maybe | yes |
| Creating | no | yes | no | no |
| Deleting | yes | no | no | no |
- The Delta column always comes from `CalculateRowDelta()`, which is
status-aware; never compute it inline. Update/replace is projected(after)
minus the baseline projected(current) (`GetBaselineProjectedMonthlyCost`),
the same basis. It falls back to extrapolated actual only without a
baseline, and shows no delta without property diffs. Creating is
`+projected`, deleting is `-extrapolatedActual`, active is the drift delta.
- `GetExtrapolatedActual()` and `ForceExtrapolateActual()` use 30 days
(stable deltas); `CalculateCostDrift()` uses calendar days. Do not unify
them.
- `CostDrift == nil` is normal: day 1-2, drift under 10%, new or deleted, or
created under 3 days ago.
- TUI diff values are truncated to 40 characters (`truncateDiffValue`). State-only
mode marks projected costs with `*`; `p` runs a preview and updates rows in
place.
### Cluster Allocation (`internal/engine/cluster*.go`, `plugins/`)
Specs 613-623 hold the details. Invariants:
- Core never interprets Kubernetes. Nodes arrive from `GetStats` as ordinary
descriptors and are priced by the engine; grouping is string-map aggregation.
- Run-rate prices with `GetProjectedCostWithErrors` and `Monthly`. A window
(`--from`/`--to`) prices with `GetWindowCost` and `TotalCost` only, labels the
period with `FormatWindow` (not `FormatPeriod`, which `cost actual` keeps),
and requires a source in historical mode (`ErrStatsModeMismatch`).
- A `$0` or missing price is unpriced (aws-public returns `$0` for unknown
types). All nodes unpriced is fatal. A stats warning starting `incomplete:`
sets `Incomplete`.
- Conservation is enforced in core (`VerifyConservation`, shared SDK rule)
even though the conformance suite checks it too.
- Nodes are keyed by cluster and node name; the priceable `id` stays the
Kubernetes node name.
- The idle row stays at `$0`: `ValidateAllocateResponse` needs one per priced
node. Fargate pods are their own priceables and never fold into idle.
- `plugins/kubernetes` and `plugins/prometheus` must not import core or each
other. Prometheus node identity copies the descriptor rules from
`plugins/kubernetes/usage/nodes.go`.
- Prometheus collection (`plugins/prometheus/`):
- Take `max by (node)` of kube-state-metrics capacity before integrating.
Replicas export the same node twice, and a sum doubles capacity.
- cAdvisor scraped from the kubelet often has no `node` label. The node
then comes from `kube_pod_info`, and matching must accept the empty label.
- Clusters are discovered from `kube_node_labels` or
`kube_node_status_allocatable` before any sum drops the label.
- Retention shorter than the window is detected store-wide
(`promql.StoreStart`), not per pod. A hole shorter than the 5m lookback is
not detected.
- The bearer token goes only to the configured scheme and host (a redirect
elsewhere would receive it otherwise), and URLs in errors and logs drop
userinfo and query.
- Pod label keys from kube-state-metrics are sanitized
(`app.kubernetes.io/name` becomes `app_kubernetes_io_name`).
### MCP Server (`internal/cli/mcp.go`, `output_mode.go`)
- ax-go re-executes the shared root once per `tools/call`, so persistent
pre/post run per call. Dispatched calls reuse the server's
`loggingSession` and never close it.
- One mutex serializes every call; a blocking command stalls the server. That
is why `analyzer serve` and `cost history collect` are excluded
(`mcpExcludedCommands`, `mcp.Exclude`). Never use `Hidden` to exclude a
group: ax-go prunes the whole subtree.
- `resolveOutputFormat`: explicit `--output`, then `--format`, then
`AGENT_MODE`. It ignores TTY detection on purpose. New commands with an
output flag must use it.
- Flag values reset per call only on a real server; assert cross-call
behavior in the integration test.
- `internal/cli/testdata/mcp/tools.golden` pins the tool list. A new command
must be exposed or excluded on purpose (`UPDATE_GOLDEN=1`).
- The MCP handshake rejects `dev`/`unknown`; build test binaries with
`-ldflags -X .../pkg/version.version=...`.
### Plugin Upgrade (`internal/pluginupgrade/`, `agent-skills/finfocus-plugin-upgrade/`)
- Bumping finfocus-spec needs a hop: `hops.go` must reach
`pluginsdk.SpecVersion` and have a `references/to-vX.Y.Z.md` guide (tests
enforce both), even for an additive release. Add the guide's row to
`SKILL.md` and rebuild the `.skill` zip (`SKILL.md` + `references/`).
- `SpecVersion` must be `v`-prefixed, or `GetPluginInfo` returns `Internal`.
- `plugin.manifest.json` is the flat `registry.Manifest`, not the pluginsdk
manifest; `plugin validate` needs name and version to match the install dir.
- Skill install runs `npx skills@1.7.0 add` in an empty temp dir, never the
plugin dir (its `.npmrc` and `node_modules` could run code), and writes
through an `os.Root`. Every failure is a warning. Tests never run npx.
## Recent Changes
- 629-web-plugin-page: Plugins page in `finfocus --web` (catalog, install, settings)
- 628-web-config-page: dashboard Config page and `config set` patch `config.hujson` in place
- 626-cost-forecast: `cost forecast` projects plugin GrowthType with finfocus-spec `pricing.ApplyGrowth`
- 625-budget-alert-notifications: Slack and generic HTTPS webhook budget alert destinations (stdlib `net/http`)
- 623-prometheus-usage-source: Prometheus usage source plugin and historical `cost cluster` windows
## Active Technologies
- Go 1.27.1 (`go.mod`), finfocus-spec v0.7.6 (`pluginsdk`, `WithSettings`), Cobra, ax-go, gRPC, zerolog, BoltDB (`go.etcd.io/bbolt`), Bubble Tea v2, testify; `prometheus/client_golang` and client-go in plugin modules only