|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## What this is |
| 6 | + |
| 7 | +`codeanalyzer-iac` (binary `caniac`, Cobra alias `codeanalyzer-iac`) is the CLDK Infrastructure-as-Code |
| 8 | +analyzer: a Go 1.26 CLI that turns a Helm chart tree (or an already-ingested Neo4j application) into |
| 9 | +one typed model and projects it as JSON, a Cypher script, or a reconciled Neo4j generation. Helm is the |
| 10 | +only dialect today; the frontend boundary is designed for more. It never shells out to `helm`, never |
| 11 | +touches a cluster or registry, never fetches dependencies, and never enables DNS during render. |
| 12 | + |
| 13 | +The README is the user-facing contract (flags, exit channels, levels, limitations) and is kept true by |
| 14 | +tests; read it before changing behavior it documents. |
| 15 | + |
| 16 | +## Commands |
| 17 | + |
| 18 | +```sh |
| 19 | +make build # ./caniac with -X main.version=$(VERSION); VERSION ?= 0.1.0-dev |
| 20 | +make test # go test ./... — offline, no services needed |
| 21 | +make race # go test -race ./... |
| 22 | +make vet # go vet ./... and go vet -tags=live ./... |
| 23 | +make schema-check # contract files == embedded copies, plus contract/emit tests |
| 24 | +make fuzz-smoke # 10s on each of the six parser fuzz targets |
| 25 | +make wheels VERSION=0.1.0rc0 # the five platform wheels (PEP 440 version required) |
| 26 | +gofmt -l cmd internal tests # CI fails on any output |
| 27 | +actionlint .github/workflows/*.yml |
| 28 | +``` |
| 29 | + |
| 30 | +Single test / package: |
| 31 | + |
| 32 | +```sh |
| 33 | +go test ./internal/dialects/helm -run 'TestResolveNestedChartFactsExactlyAndPreservesL1' -v |
| 34 | +go test ./internal/core -run TestOutputIndependentOfJobs -count=20 # determinism gate |
| 35 | +go test ./internal/dialects/helm -run '^$' -fuzz FuzzHelmTemplateNeverPanics -fuzztime=10m |
| 36 | +``` |
| 37 | + |
| 38 | +Graph gates (parity, e2e, reconcile integration) need a disposable Neo4j 5 and skip without it |
| 39 | +locally, but are **fatal when `CI` is set**: |
| 40 | + |
| 41 | +```sh |
| 42 | +docker run -d --name caniac-neo4j -p 7687:7687 -p 7474:7474 -e NEO4J_AUTH=neo4j/test-password neo4j:5 |
| 43 | +export NEO4J_TEST_URI=neo4j://localhost:7687 NEO4J_TEST_USERNAME=neo4j NEO4J_TEST_PASSWORD=test-password |
| 44 | +CI=true go test ./tests/... -count=1 |
| 45 | +go test ./internal/reconcile -run Integration -count=1 |
| 46 | +``` |
| 47 | + |
| 48 | +Live acceptance (network, `helm` **v4.2.4** exactly, `python3`, the same Neo4j) is behind the `live` |
| 49 | +build tag so `go test ./...` stays offline: |
| 50 | + |
| 51 | +```sh |
| 52 | +export CANIAC_SCHEMA_REPO=/path/to/codeanalyzer-schema # checkout at the pinned commit; no default |
| 53 | +make test-live # pinned DayTrader + Quarkus Coffee Shop revisions |
| 54 | +make test-live-head # default branches; fails visibly on drift, never rewrites expectations |
| 55 | +``` |
| 56 | + |
| 57 | +`scripts/check_iac.py` in the schema repo is a library with no `__main__`; invoking it directly exits 0 |
| 58 | +without checking anything. Drive it the way `tests/live/acceptance_test.go` does. |
| 59 | + |
| 60 | +Run the analyzer: |
| 61 | + |
| 62 | +```sh |
| 63 | +./caniac testdata/helm/profiles --app-name payments -a 3 --emit json | jq . |
| 64 | +./caniac <chart-root> --app-name demo --config .caniac.yaml --emit neo4j --neo4j-uri neo4j://localhost:7687 --neo4j-user neo4j --neo4j-password test-password |
| 65 | +./caniac --emit schema # embedded schema.neo4j.json, no input |
| 66 | +``` |
| 67 | + |
| 68 | +## Architecture |
| 69 | + |
| 70 | +**One model, two projections.** `internal/model` owns every wire type, canonical ID, `Delta`, `Apply`, |
| 71 | +and `Validate`. Dialects return `model.Delta`; they never marshal JSON, emit Cypher, or talk to Neo4j. |
| 72 | +Emitters (`internal/emit/json`, `internal/emit/neo4j`) consume only a validated `model.Analysis`. |
| 73 | +JSON honors `--analysis-level 1|2|3`; Cypher and Neo4j always analyze at L3. L4 is an explicit error. |
| 74 | + |
| 75 | +**Phase order** (`internal/core`): load → detect → parse (L1) → resolve (L2) → config/default profiles |
| 76 | +→ evaluate/render (L3) → validate. Workers never mutate the `Application`; each phase collects keyed |
| 77 | +deltas, sorts them, and applies them before the next phase reads the model. That is why `--jobs 1` and |
| 78 | +`--jobs N` are byte-identical, and `TestOutputIndependentOfJobs` enforces it. Any map iteration that |
| 79 | +reaches output must be sorted first. |
| 80 | + |
| 81 | +**Inputs** (`internal/ingest`): filesystem selections beneath one `--workspace-root` (traversal- and |
| 82 | +symlink-safe via `os.OpenRoot`; `.git` excluded by the walker), or a positional `neo4j://` URI that |
| 83 | +reads every `can://artifact/<app>/...` node page by page and verifies SHA-256 before any semantics. |
| 84 | +Graph mode never falls back to the host filesystem. `--app-name` is mandatory in graph mode and |
| 85 | +derived from the workspace-root directory otherwise. |
| 86 | + |
| 87 | +**Identity.** A file stays a neutral `Artifact` (`can://artifact/<app>/<path>`) forever; detection |
| 88 | +adds at most one dialect facet to it and never re-keys it. Contained semantics use `can://iac/...` |
| 89 | +IDs; alternative names are `IaCAlias` nodes with one `IAC_ALIAS_OF`. Percent-encoding is the accepted |
| 90 | +schema's uppercase `%HH` grammar (`internal/model/identity.go`), not `url.PathEscape`. `Artifact.source` |
| 91 | +is the only raw copy of any file; contained nodes carry spans (`bytes` are UTF-8 byte offsets, not |
| 92 | +character offsets). |
| 93 | + |
| 94 | +**Helm dialect** (`internal/dialects/helm`): `detect.go` classifies chart members and emits L1 edges; |
| 95 | +`chart.go`/`values.go`/`templates.go` parse source facts with spans; `resolve.go` computes membership, |
| 96 | +dependency closure, named-template winners, and value precedence at L2 with explicit uncertainty when |
| 97 | +Helm's own selection is ambiguous; `config.go` builds default and `--config` render profiles; |
| 98 | +`render.go`/`kubernetes.go` materialize a private temp chart, render through the pinned |
| 99 | +`helm.sh/helm/v4` SDK (`engine.Engine{}` literal, no client, DNS off, no external `$ref` in values |
| 100 | +schemas), and decode resources with hash-only Secrets. Provenance (`IAC_DERIVED_FROM`) is attributed |
| 101 | +only for single-region template files; multi-document files are reachable through |
| 102 | +`IAC_HAS_RESOURCE_TEMPLATE` instead. |
| 103 | + |
| 104 | +**Graph output** (`internal/emit/neo4j`, `internal/reconcile`): the projector's allowlist of labels, |
| 105 | +properties, and relationship endpoints is parsed from the embedded `schema.neo4j.json`; anything |
| 106 | +outside it fails projection. Relationships are identity-only. Default writes are non-destructive |
| 107 | +upserts; `--eager` removes only this producer's stale nodes, aliases, `iac_*`/`helm_*` properties, and |
| 108 | +`IAC_*` relationships, never `Artifact`, `ConfigKey`, `Package`, or foreign facts. One managed write |
| 109 | +transaction per generation; constraints are created through managed transactions so transient errors |
| 110 | +retry. Cypher text never interpolates input; labels and types come only from the catalog. |
| 111 | + |
| 112 | +**Contract pinning.** `schema.json` and `schema.neo4j.json` at the repo root are byte-for-byte copies of |
| 113 | +one accepted `codeanalyzer-schema` commit; `internal/contract` embeds them and `make schema-check` |
| 114 | +enforces parity. The pinned commit appears in the Schema decisions section below, `README.md`, and |
| 115 | +`.github/workflows/live.yml`; `TestSchemaPinIsTheSameCommitEverywhere` fails if they diverge. A change |
| 116 | +to either contract file is a schema-repo design change first, then a re-pin here. Decisions taken |
| 117 | +while implementing the contract are recorded in the Schema decisions section below; append there |
| 118 | +when a new one is made. |
| 119 | + |
| 120 | +## Conventions and gotchas |
| 121 | + |
| 122 | +- Branches are `<type>/issue-NNN-<short-title>` and one PR closes one issue. Commit subjects follow |
| 123 | + `feat(helm): ...`, `fix(reconcile): ...`, `test: ...`, `docs: ...`. |
| 124 | +- Tests are hand-written expectations compared exactly (`cmp.Diff`, exact sets); never a golden file |
| 125 | + generated by `caniac` itself. Every emitted delta in a test should pass `model.Validate` and the |
| 126 | + embedded schema, as existing tests do. |
| 127 | +- Diagnostics are part of the model: an unparsable file or a failed render is a diagnostic, not a |
| 128 | + process failure. Render diagnostics carry phase, code, and location only, never SDK error text |
| 129 | + (values can leak through it). Binary files are `IAC_SOURCE_NOT_TEXT` at `warning`, so `--strict` |
| 130 | + still passes on real repositories. |
| 131 | +- Stdout carries the document and nothing else; the standard logger is discarded at process start |
| 132 | + because Helm's value coalescing logs conflicting values. |
| 133 | +- Helm's `DefaultCapabilities` differ between test builds and production builds; profiles therefore |
| 134 | + store only configured `api_versions`/`kube_version` and the renderer supplies defaults at render time. |
| 135 | +- Timing-based scaling guards in `templates_test.go` compare ratios with best-of-3; if CI flakes, |
| 136 | + raise input sizes, do not loosen the ratio. |
| 137 | +- Release: tag `vX.Y.Z` (PEP 440 normalized, e.g. `v0.1.0rc1`) on `main`; `release.yml` verifies the |
| 138 | + version, runs the full gate against a Neo4j service, deletes the tag on any pre-publish failure, then |
| 139 | + publishes the GitHub Release, the PyPI wheels (`codeanalyzer-iac`, Trusted Publishing), and the |
| 140 | + Homebrew formula. The tag is the only version source: it stamps `__version__`, `main.version`, and |
| 141 | + the emitted `analyzer.version`. |
| 142 | + |
| 143 | +## Schema decisions |
| 144 | + |
| 145 | +This analyzer pins the accepted `codeanalyzer-schema` revision |
| 146 | +`b84428f1accebe2b259d32c15387f04a00167f2c`. |
| 147 | + |
| 148 | +- The analysis envelope is schema version `2.0.0`; the Neo4j graph catalog is |
| 149 | + schema version `1.0.0`. |
| 150 | +- A whole file retains neutral `Artifact` identity. IaC semantics are one |
| 151 | + optional discriminated facet on that artifact, rather than a replacement |
| 152 | + identity. |
| 153 | +- There is no emitted `IaCEntity` type or generic entity collection. Dialects |
| 154 | + own typed containment below their facet. |
| 155 | +- One artifact has at most one source dialect facet. Detection never replaces |
| 156 | + its `can://artifact/...` identity. |
| 157 | +- `schema.json` and `schema.neo4j.json` are byte-for-byte copies of the |
| 158 | + accepted schema revision. The latter is the complete, authoritative graph |
| 159 | + catalog and is embedded without edits. |
| 160 | + |
| 161 | +### Decisions taken while implementing 0.1.0 |
| 162 | + |
| 163 | +These follow from reading the accepted contract, not from preference. Each one |
| 164 | +is a place the schema decided the shape and the implementation complied. |
| 165 | + |
| 166 | +- **Node identities are `SemanticId` strings, so every derived identity is a |
| 167 | + path.** The accepted `SemanticId` pattern forces one opaque, percent-encoded |
| 168 | + handle per node, which is why a rendered Kubernetes resource is |
| 169 | + `<render-id>/kubernetes/<group>/<Kind>/<namespace>/<name>` rather than a node |
| 170 | + carrying separate group/kind/namespace/name merge keys. The same rule makes |
| 171 | + an anonymous construct `...@line:column` and a repeated address |
| 172 | + `...:<ordinal>`. Consumers treat IDs as opaque. |
| 173 | +- **The full render input hash is not persisted.** `HelmRender` has no field |
| 174 | + for it and forbids additional properties. The render identity therefore |
| 175 | + carries a 16-hex-character prefix of the input digest |
| 176 | + (`.../render/production@42a8b9b4c85722ed`) and nothing else records the |
| 177 | + inputs. `effective_values_sha256` remains a separate, schema-declared field. |
| 178 | +- **The configuration document is interpreted at L3 only.** `--config` selects |
| 179 | + an artifact, and the artifact is inventoried at every level, but the |
| 180 | + `CodeAnalyzerIaCConfig` facet and its render profiles are produced while |
| 181 | + profiles are built, which the level rule places at L3. At L1 and L2 the file |
| 182 | + is source and hash, with no facet. |
| 183 | +- **Configured `set:` entries use `--set-string` semantics.** The accepted |
| 184 | + `renders[].set` mapping is `map[string]string`, so a literal override is |
| 185 | + applied through `strvals.ParseIntoString`. Booleans and numbers are not |
| 186 | + expressible there; a values file listed under `values:` is. |
| 187 | +- **`plural` is emitted only for kinds the built-in scheme knows.** The field |
| 188 | + is optional, and the model carries no CRD-to-plural mapping, so a custom |
| 189 | + resource gets an empty plural instead of a guessed one. |
| 190 | +- **`IAC_DERIVED_FROM` is claimed only for unambiguous templates.** L1 records |
| 191 | + which source regions of a template file can emit resources, never how many |
| 192 | + documents each emits. A file with exactly one resource-bearing region is the |
| 193 | + only one whose rendered documents have a sound origin, so a multi-region file |
| 194 | + claims no provenance rather than an invented one. |
| 195 | +- **`HelmIgnore` publishes no patterns.** The facet has no field for them, so |
| 196 | + `.helmignore` is classified and its rules stay unpublished. |
| 197 | +- **`api_versions` records only configured extras.** Helm's default set differs |
| 198 | + between builds; persisting it would make the emitted model depend on how the |
| 199 | + analyzer was compiled. The pinned renderer supplies its own defaults at render |
| 200 | + time. |
| 201 | + |
| 202 | +### Decisions taken with the containment edges (schema `b84428f`) |
| 203 | + |
| 204 | +Recorded from the accepted spec `2026-09-05-iac-containment-edges` (D1-D4). |
| 205 | + |
| 206 | +- **D1: per-kind edge names.** `IAC_HAS_RESOURCE_TEMPLATE`, |
| 207 | + `IAC_HAS_LOOKUP_REFERENCE` and `IAC_HAS_ALIAS` rather than one generic |
| 208 | + `IAC_CONTAINS`. A single type would need fewer entries but would break the |
| 209 | + per-child pattern every other containment edge follows and would make the |
| 210 | + counterpart check label-dependent. |
| 211 | +- **D2: aliases are contained too.** `IAC_HAS_ALIAS` is included so every owned |
| 212 | + contained node has an inbound edge from its owner. Emitting only the two |
| 213 | + template edges would close the observed islands but leave an `IaCAlias` |
| 214 | + reachable only from itself. |
| 215 | +- **D3: the catalog `schema_version` stays 1.0.0.** The addition is purely |
| 216 | + additive, this analyzer pins by commit and byte-compares the catalog, and no |
| 217 | + consumer reads the version field yet. |
| 218 | +- **D4: the change lands before `v0.1.0`.** No release exists yet, so there is |
| 219 | + nothing to ship the additive edges in as a patch. |
| 220 | + |
| 221 | +Consequences in this repository: containment is emitted at L1 next to |
| 222 | +`iac_has_value_reference` and the chart alias, `internal/model/validate.go` |
| 223 | +enforces exactly one edge per nested child, and the Neo4j projector needed no |
| 224 | +change because its allowlist and endpoint families are parsed from the embedded |
| 225 | +catalog. |
0 commit comments