Skip to content

Commit c41d9eb

Browse files
authored
Merge pull request #14 from codellm-devkit/docs/issue-13-schema-decisions-in-claude-md
docs: record schema decisions in CLAUDE.md and drop .claude
2 parents 927a45b + 1911b11 commit c41d9eb

4 files changed

Lines changed: 230 additions & 91 deletions

File tree

‎.claude/SCHEMA_DECISIONS.md‎

Lines changed: 0 additions & 83 deletions
This file was deleted.

‎.gitignore‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@
1313
!/packaging/python/src/codeanalyzer_iac/_bin/.gitignore
1414
.DS_Store
1515

16-
# SCHEMA_DECISIONS.md is repository content. A global ignore of `.claude`
17-
# would stop git descending into the directory at all, so the directory is
18-
# re-included here and everything else inside it stays ignored.
19-
!.claude/
20-
.claude/*
21-
!.claude/SCHEMA_DECISIONS.md
16+
# CLAUDE.md is repository content (the schema decisions live in it). A global
17+
# ignore of CLAUDE.md is common, so it is re-included here.
18+
!CLAUDE.md

‎CLAUDE.md‎

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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

Comments
 (0)