diff --git a/docs/glossary.md b/docs/glossary.md index c67af20..36a4a4e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -11,6 +11,7 @@ a binder that drafts a call or a loader that merges `allOf` is prose. |---|---| | **audit** | The credentialed stage that exercises a live API to learn its true behaviour — minimum and maximum valid configuration, field dependencies, value-conditional rules. `tfpfgen audit run`. The only stage that touches a network. | | **observation** | One recorded finding of an audit: what the live API actually accepted or rejected, with a redacted request/response excerpt as proof. Committed per entity in `audit/observations/.observations.json`, stamped with the spec hash it was observed against. Deliberately not replayable. | +| **request bodies** | The create request bodies a run got the API to accept, committed per entity in `audit/request_bodies/.request_bodies.json` with the status each was answered and the response it was answered with. An observation says something about one property; these say what a whole create looked like when it worked, which is the one thing a generated acceptance test cannot derive — the document describes what should be accepted, and only a run knows what was. Acceptance fixtures replay these values rather than deriving them again. Named for the request half deliberately: the response is carried as evidence of what the API echoed, and it is the request that a configuration has to reproduce. | | **correction** | One committed correction to the imported OpenAPI document: RFC 6902 operations plus a required justification and an optional evidence pointer to an observation. Lives in `spec/corrections/`; proposed ones await a human in `spec/corrections/proposed/`; rejected ones leave a marker in `spec/corrections/rejected/`. Kinds listed in config `audit.auto_accept` skip `proposed/` and land accepted directly, named with an `auto-NNN-` prefix. | | **revise** | To fold observations into proposed corrections and apply accepted ones — `tfpfgen spec revise`. The spec is revised based on audit observations; the output is the revised spec (`spec/revised.yaml`), the single source of truth for all generation. | | **import** | To pin the upstream OpenAPI document by hash — `tfpfgen spec import`. The imported document is immutable evidence of what the vendor published. | diff --git a/handoff.md b/handoff.md index 92bc54b..933303e 100644 --- a/handoff.md +++ b/handoff.md @@ -77,7 +77,7 @@ For `` under `internal/services/resources//v1//`: | File | Purpose | |---|---| | `resource_acceptance_test.go` | the live lifecycle: **step 1** apply minimal, **step 2** import + `ImportStateVerify`, **step 3** apply maximal | -| `tests/terraform/acceptance/resource_{minimal,maximal}.tf` | what the live steps apply — **built from recorded bodies** | +| `tests/terraform/acceptance/resource_{minimal,maximal}.tf` | what the live steps apply — **built from recorded request bodies** | | `tests/terraform/unit/resource_{minimal,maximal}.tf` | what the unit tests apply — **still derived from the document** | | `tests/responses/resource_{minimal,maximal}.json` | wire JSON the mocks answer with | | `mocks/responders.go` | httpmock responders built from those | @@ -94,7 +94,7 @@ Under the provider tree: | Path | What | |---|---| | `audit/observations/.observations.json` | one fact per property | -| `audit/bodies/.bodies.json` | **the accepted create bodies** — request, response, status | +| `audit/request_bodies/.request_bodies.json` | **the accepted create request bodies** — request, response, status | | `audit/inputs.json` | operator-supplied values the probe cannot invent (authored) | | `spec/corrections/*.correction.json` | accepted corrections (authored) | | `spec/corrections/proposed/` | awaiting a human decision | @@ -111,11 +111,11 @@ OpenAPI doc ──> specmodel ──> IR ──> sdkbind ──> emit ──> pr ^ | | v corrections <── revise <── observations <── audit (live probe) - + bodies + + request bodies ``` Presence (`required` / `optional` / `computed`) is corrected into the document -and re-derived. **Values are not.** Values come from `audit/bodies/` and are +and re-derived. **Values are not.** Values come from `audit/request_bodies/` and are replayed directly into acceptance fixtures. That split is the point: deriving values again from the document is what produced a year of one-field-at-a-time failures (`icon`, `match_type`, `filters` were all the same bug). @@ -127,8 +127,8 @@ failures (`icon`, `match_type`, `filters` were all the same bug). | Additive minimal search (add a field until 2xx) | `internal/audit/run/steps_create.go: searchMinimal` | | Subtractive maximal reduction (drop until 2xx) | `internal/audit/run/steps_create.go: reduceMaximal` | | Refusal grammar (what a 4xx names) | `internal/audit/run/adjust.go: classifyRefusal` | -| Recorded bodies artifact | `internal/audit/observe/bodies.go` | -| Replaying a body into a fixture | `internal/fixtures/fixtures.go: FromAcceptedBody` | +| Recorded request bodies artifact | `internal/audit/observe/request_bodies.go` | +| Replaying a request body into a fixture | `internal/fixtures/fixtures.go: FromAcceptedRequestBody` | | Per-run unique names | `internal/fixtures/fixtures.go: WithRunSuffix`, `RunSuffixBlock` | | Acceptance vs unit split | `internal/emit/render_fixtures.go: resourceFixtures` | | Probe step ordering | `internal/audit/strategy/program.go: buildProgram` | @@ -182,7 +182,7 @@ probe**. Everything else is downstream of that. (`tests_ftp_server`), a dashboard id (`dashboard_snapshot`), a discriminator `type` for `connectors_generic` / `operations_webhook`. 3. **The four inconsistent-result failures.** A value sent and echoed back - differently. `FromAcceptedBody` already drops what is never returned; this is + differently. `FromAcceptedRequestBody` already drops what is never returned; this is the narrower case of a value the API rewrites. 4. **`Invalid id` / `request never completed`** — one each, `templates_sharing_setting` (a singleton) and `stream` (`lastSuccess`/`lastFailure` declared `int64`, the diff --git a/internal/audit/infer/evidence.go b/internal/audit/infer/evidence.go index f7da935..9b75cd3 100644 --- a/internal/audit/infer/evidence.go +++ b/internal/audit/infer/evidence.go @@ -96,10 +96,10 @@ type ConditionalValue struct { type Evidence struct { // Entity is the classified entity key the evidence is about. Entity string - // AcceptedBodies is every create body the API accepted, resolved as + // AcceptedRequestBodies is every create body the API accepted, resolved as // sent. Their gate values and field sets are the positive half of // variant diffing. - AcceptedBodies []map[string]any + AcceptedRequestBodies []map[string]any // Adjustments is every body change the executor was forced to make. Adjustments []RequestAdjustment // CombinedRefusals lists field pairs a create was refused for carrying @@ -164,9 +164,9 @@ func gateOf(compiled *strategy.Strategy) gate { // value that body pinned, returning value -> set of field names present // (excluding the gate field itself). A body with no gate value lands under the // empty string. -func acceptedUnder(bodies []map[string]any, gateField string) map[string]map[string]bool { +func acceptedUnder(requestBodies []map[string]any, gateField string) map[string]map[string]bool { out := map[string]map[string]bool{} - for _, body := range bodies { + for _, body := range requestBodies { val := "" if gateField != "" { if raw, ok := body[gateField]; ok { diff --git a/internal/audit/infer/infer.go b/internal/audit/infer/infer.go index ef21e86..fa2f2ef 100644 --- a/internal/audit/infer/infer.go +++ b/internal/audit/infer/infer.go @@ -92,7 +92,7 @@ type model struct { func newModel(ev Evidence, compiled *strategy.Strategy) *model { g := gateOf(compiled) - accepted := acceptedUnder(ev.AcceptedBodies, g.field) + accepted := acceptedUnder(ev.AcceptedRequestBodies, g.field) created := make([]string, 0, len(accepted)) for v := range accepted { if v != "" { @@ -466,7 +466,7 @@ func (m *model) removedValues(f string) []string { // acceptedAlone reports whether field a appeared in an accepted body that did // not also carry field b. func (m *model) acceptedAlone(a, b string) bool { - for _, body := range m.ev.AcceptedBodies { + for _, body := range m.ev.AcceptedRequestBodies { if _, hasA := body[a]; !hasA { continue } diff --git a/internal/audit/infer/infer_test.go b/internal/audit/infer/infer_test.go index 2f1eaf7..8baffc7 100644 --- a/internal/audit/infer/infer_test.go +++ b/internal/audit/infer/infer_test.go @@ -33,7 +33,7 @@ func monitorEvidence() Evidence { } return Evidence{ Entity: "monitor", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"kind": "ping", "interval": 5.0, "target_host": "h", "name": "n"}, {"kind": "web", "interval": 5.0, "web": map[string]any{"url": "u"}, "name": "n"}, {"kind": "dns", "interval": 5.0, "domain": "d", "dnssec": true, "name": "n"}, @@ -176,7 +176,7 @@ func TestUnit_Infer_PlantedFalseEdgeIsInconclusive(t *testing.T) { } ev := Evidence{ Entity: "monitor", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"kind": "ping", "foo": "x"}, {"kind": "dns", "foo": "y"}, // foo accepted under BOTH values: not gated }, @@ -203,8 +203,8 @@ func TestUnit_Infer_LoneAmbiguousRemovalAssertsNothing(t *testing.T) { Variants: []strategy.Variant{{}, {GateField: "kind", GateValue: "ping"}, {GateField: "kind", GateValue: "dns"}}, } ev := Evidence{ - Entity: "monitor", - AcceptedBodies: []map[string]any{{"kind": "ping"}}, + Entity: "monitor", + AcceptedRequestBodies: []map[string]any{{"kind": "ping"}}, Adjustments: []RequestAdjustment{ {Entity: "monitor", Action: AdjustRemove, Field: "bar", GateField: "kind", GateValue: "dns"}, }, @@ -223,8 +223,8 @@ func TestUnit_Infer_ConflictingAcceptanceAndRemovalIsNoEdge(t *testing.T) { Variants: []strategy.Variant{{}, {GateField: "kind", GateValue: "a"}, {GateField: "kind", GateValue: "b"}}, } ev := Evidence{ - Entity: "e", - AcceptedBodies: []map[string]any{{"kind": "a", "foo": 1.0}}, + Entity: "e", + AcceptedRequestBodies: []map[string]any{{"kind": "a", "foo": 1.0}}, Adjustments: []RequestAdjustment{ {Entity: "e", Action: AdjustRemove, Field: "foo", GateField: "kind", GateValue: "a"}, }, @@ -240,7 +240,7 @@ func TestUnit_Infer_MutuallyExclusive(t *testing.T) { t.Parallel() ev := Evidence{ Entity: "widget", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"a": 1.0, "name": "n"}, {"b": 2.0, "name": "n"}, }, @@ -262,9 +262,9 @@ func TestUnit_Infer_MutuallyExclusive(t *testing.T) { func TestUnit_Infer_MutuallyExclusiveNeedsBothAlone(t *testing.T) { t.Parallel() ev := Evidence{ - Entity: "widget", - AcceptedBodies: []map[string]any{{"a": 1.0, "b": 2.0}}, // never a without b - CombinedRefusals: []FieldPair{{A: "a", B: "b"}}, + Entity: "widget", + AcceptedRequestBodies: []map[string]any{{"a": 1.0, "b": 2.0}}, // never a without b + CombinedRefusals: []FieldPair{{A: "a", B: "b"}}, } if o := find(Infer(ev, &strategy.Strategy{Entity: "widget"}), "", observe.KindMutuallyExclusive); o != nil { t.Fatalf("asserted %+v without evidence each is valid alone", o) @@ -276,8 +276,8 @@ func TestUnit_Infer_MutuallyExclusiveNeedsBothAlone(t *testing.T) { func TestUnit_Infer_FlatResourceHasNoVariantEdges(t *testing.T) { t.Parallel() ev := Evidence{ - Entity: "assignment", - AcceptedBodies: []map[string]any{{"name": "n", "agent_id": "agent-1"}}, + Entity: "assignment", + AcceptedRequestBodies: []map[string]any{{"name": "n", "agent_id": "agent-1"}}, Adjustments: []RequestAdjustment{ {Entity: "assignment", Action: AdjustBorrow, Field: "agent_id", GateField: "agent"}, }, @@ -305,7 +305,7 @@ func TestUnit_Infer_ValidConfigurationNeedsADistinguishingField(t *testing.T) { } ev := Evidence{ Entity: "e", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"kind": "a", "name": "n"}, {"kind": "b", "name": "n"}, }, @@ -327,8 +327,8 @@ func TestUnit_Infer_HypothesisProvenanceIsCarried(t *testing.T) { }, } ev := Evidence{ - Entity: "e", - AcceptedBodies: []map[string]any{{"kind": "a", "foo": 1.0}, {"kind": "b"}}, + Entity: "e", + AcceptedRequestBodies: []map[string]any{{"kind": "a", "foo": 1.0}, {"kind": "b"}}, Adjustments: []RequestAdjustment{ {Entity: "e", Action: AdjustRemove, Field: "foo", GateField: "kind", GateValue: "b"}, }, @@ -454,7 +454,7 @@ func TestUnit_Infer_ValueConditionalConfiguration(t *testing.T) { } ev := Evidence{ Entity: "stream", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"format": "avro", "mode": "streaming", "name": "n"}, {"format": "json", "mode": "batch", "name": "n"}, }, @@ -486,7 +486,7 @@ func TestUnit_Infer_ValueConditionalNeedsBothDirections(t *testing.T) { } ev := Evidence{ Entity: "stream", - AcceptedBodies: []map[string]any{ + AcceptedRequestBodies: []map[string]any{ {"format": "avro", "mode": "streaming", "name": "n"}, {"format": "json", "mode": "streaming", "name": "n"}, }, @@ -511,8 +511,8 @@ func TestUnit_Infer_ValueConditionalNeedsTwoCreatedValues(t *testing.T) { Variants: []strategy.Variant{{}, {GateField: "format", GateValue: "avro"}, {GateField: "format", GateValue: "json"}}, } ev := Evidence{ - Entity: "stream", - AcceptedBodies: []map[string]any{{"format": "avro", "mode": "streaming", "name": "n"}}, + Entity: "stream", + AcceptedRequestBodies: []map[string]any{{"format": "avro", "mode": "streaming", "name": "n"}}, ConditionalValues: []ConditionalValue{ {GateField: "format", GateValue: "avro", Field: "mode", Value: "batch", Accepted: false}, {GateField: "format", GateValue: "json", Field: "mode", Value: "batch", Accepted: true}, diff --git a/internal/audit/observe/bodies.go b/internal/audit/observe/request_bodies.go similarity index 64% rename from internal/audit/observe/bodies.go rename to internal/audit/observe/request_bodies.go index 286b256..6c8415c 100644 --- a/internal/audit/observe/bodies.go +++ b/internal/audit/observe/request_bodies.go @@ -16,34 +16,36 @@ import ( "sort" ) -// encodeBodies renders one entity's record deterministically, matching the -// observations beside it: sorted map keys, no HTML escaping, two-space indent. -func encodeBodies(b Bodies) ([]byte, error) { +// encodeRequestBodies renders one entity's record deterministically, +// matching the observations beside it: sorted map keys, no HTML escaping, +// two-space indent. +func encodeRequestBodies(b RequestBodies) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(b); err != nil { - return nil, fmt.Errorf("encoding bodies for %s: %w", b.Entity, err) + return nil, fmt.Errorf("encoding request bodies for %s: %w", b.Entity, err) } return buf.Bytes(), nil } -// BodiesSuffix is the committed file naming, one file per entity, matching -// the observations beside it. -const BodiesSuffix = ".bodies.json" +// RequestBodiesSuffix is the committed file naming, one file per entity, +// matching the observations beside it. +const RequestBodiesSuffix = ".request_bodies.json" -// Bodies is what one entity's creates looked like when the API accepted them. -type Bodies struct { +// RequestBodies is what one entity's creates looked like when the API +// accepted them. +type RequestBodies struct { Entity string `json:"entity"` // Minimal is the smallest create the run got accepted, and Maximal the // fullest. Either may be absent when no create of that shape succeeded. - Minimal *AcceptedBody `json:"minimal,omitempty"` - Maximal *AcceptedBody `json:"maximal,omitempty"` + Minimal *AcceptedRequestBody `json:"minimal,omitempty"` + Maximal *AcceptedRequestBody `json:"maximal,omitempty"` } -// AcceptedBody is one create the API answered 2xx to. -type AcceptedBody struct { +// AcceptedRequestBody is one create the API answered 2xx to. +type AcceptedRequestBody struct { // Status is the code the API answered, kept so a reader can see that this // was an acceptance rather than an assumption. Status int `json:"status"` @@ -61,7 +63,7 @@ type AcceptedBody struct { // A field the API accepts and never echoes cannot appear in a generated // configuration: terraform compares what it planned against what the provider // answers, and a value that never comes back reads as the provider losing it. -func (b *AcceptedBody) Echoed(wire string) bool { +func (b *AcceptedRequestBody) Echoed(wire string) bool { if b == nil || b.Response == nil { return false } @@ -69,15 +71,15 @@ func (b *AcceptedBody) Echoed(wire string) bool { return ok } -// WriteBodies commits one .bodies.json per entity under dir. -// Encoding matches the observations: sorted keys, stable bytes, so a re-run -// that learned nothing new rewrites nothing. -func WriteBodies(dir string, bodies []Bodies) error { - if len(bodies) == 0 { +// WriteRequestBodies commits one .request_bodies.json per entity +// under dir. Encoding matches the observations: sorted keys, stable bytes, +// so a re-run that learned nothing new rewrites nothing. +func WriteRequestBodies(dir string, requestBodies []RequestBodies) error { + if len(requestBodies) == 0 { return nil } - byEntity := map[string]Bodies{} - for _, b := range bodies { + byEntity := map[string]RequestBodies{} + for _, b := range requestBodies { if b.Entity == "" || (b.Minimal == nil && b.Maximal == nil) { continue } @@ -91,7 +93,7 @@ func WriteBodies(dir string, bodies []Bodies) error { encoded := make(map[string][]byte, len(entities)) for _, entity := range entities { - raw, err := encodeBodies(byEntity[entity]) + raw, err := encodeRequestBodies(byEntity[entity]) if err != nil { return err } @@ -101,7 +103,7 @@ func WriteBodies(dir string, bodies []Bodies) error { return fmt.Errorf("creating %s: %w", dir, err) } for _, entity := range entities { - path := filepath.Join(dir, entity+BodiesSuffix) + path := filepath.Join(dir, entity+RequestBodiesSuffix) if err := os.WriteFile(path, encoded[entity], 0o644); err != nil { return fmt.Errorf("writing %s: %w", path, err) } @@ -109,11 +111,12 @@ func WriteBodies(dir string, bodies []Bodies) error { return nil } -// ReadBodies loads every recorded body under dir, keyed by entity. A missing -// directory is not an error: an entity the probe never cleared has none, and -// generation falls back to deriving values from the document. -func ReadBodies(dir string) (map[string]Bodies, error) { - out := map[string]Bodies{} +// ReadRequestBodies loads every recorded request body under dir, keyed by +// entity. A missing directory is not an error: an entity the probe never +// cleared has none, and generation falls back to deriving values from the +// document. +func ReadRequestBodies(dir string) (map[string]RequestBodies, error) { + out := map[string]RequestBodies{} entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { @@ -129,7 +132,7 @@ func ReadBodies(dir string) (map[string]Bodies, error) { if err != nil { return nil, fmt.Errorf("reading %s: %w", e.Name(), err) } - var b Bodies + var b RequestBodies if err := json.Unmarshal(raw, &b); err != nil { return nil, fmt.Errorf("reading %s: %w", e.Name(), err) } diff --git a/internal/audit/observe/bodies_test.go b/internal/audit/observe/request_bodies_test.go similarity index 73% rename from internal/audit/observe/bodies_test.go rename to internal/audit/observe/request_bodies_test.go index b6397a4..ea76981 100644 --- a/internal/audit/observe/bodies_test.go +++ b/internal/audit/observe/request_bodies_test.go @@ -5,16 +5,16 @@ import ( "testing" ) -// TestUnit_Observe_RecordedBodiesRoundTrip proves a recorded body survives the +// TestUnit_Observe_RecordedRequestBodiesRoundTrip proves a recorded body survives the // trip to disk unchanged. A generated configuration is built from these, so a // value that shifts in the file is a configuration that no longer matches the // request the API accepted. -func TestUnit_Observe_RecordedBodiesRoundTrip(t *testing.T) { +func TestUnit_Observe_RecordedRequestBodiesRoundTrip(t *testing.T) { dir := t.TempDir() - in := []Bodies{ + in := []RequestBodies{ { Entity: "tag", - Minimal: &AcceptedBody{ + Minimal: &AcceptedRequestBody{ Status: 201, Request: map[string]any{"key": "branch", "value": "sfo"}, Response: map[string]any{"key": "branch", "value": "sfo", "id": "7"}, @@ -22,18 +22,18 @@ func TestUnit_Observe_RecordedBodiesRoundTrip(t *testing.T) { }, { Entity: "role", - Maximal: &AcceptedBody{Status: 200, Request: map[string]any{"name": "n"}}, + Maximal: &AcceptedRequestBody{Status: 200, Request: map[string]any{"name": "n"}}, }, // Neither shape accepted: nothing to record, and nothing written. {Entity: "user"}, } - if err := WriteBodies(dir, in); err != nil { - t.Fatalf("WriteBodies: %v", err) + if err := WriteRequestBodies(dir, in); err != nil { + t.Fatalf("WriteRequestBodies: %v", err) } - out, err := ReadBodies(dir) + out, err := ReadRequestBodies(dir) if err != nil { - t.Fatalf("ReadBodies: %v", err) + t.Fatalf("ReadRequestBodies: %v", err) } if len(out) != 2 { t.Fatalf("read %d entities, want the two that had an accepted create", len(out)) @@ -57,7 +57,7 @@ func TestUnit_Observe_RecordedBodiesRoundTrip(t *testing.T) { // depends on: a property the API took and never returned cannot be held in // terraform state. func TestUnit_Observe_EchoedReadsTheResponse(t *testing.T) { - b := &AcceptedBody{ + b := &AcceptedRequestBody{ Request: map[string]any{"name": "n", "matchType": "and"}, Response: map[string]any{"name": "n"}, } @@ -68,17 +68,17 @@ func TestUnit_Observe_EchoedReadsTheResponse(t *testing.T) { t.Error("a property the response omitted reads as echoed") } // No response recorded says nothing about any property. - var none *AcceptedBody + var none *AcceptedRequestBody if none.Echoed("name") { t.Error("an absent record claimed an echo") } } -// TestUnit_Observe_ReadBodiesToleratesNoRun proves a tree the probe has never +// TestUnit_Observe_ReadRequestBodiesToleratesNoRun proves a tree the probe has never // run against reads as empty rather than as an error: generation falls back to // deriving values from the document. -func TestUnit_Observe_ReadBodiesToleratesNoRun(t *testing.T) { - out, err := ReadBodies(filepath.Join(t.TempDir(), "never-written")) +func TestUnit_Observe_ReadRequestBodiesToleratesNoRun(t *testing.T) { + out, err := ReadRequestBodies(filepath.Join(t.TempDir(), "never-written")) if err != nil { t.Fatalf("a missing directory is a normal state: %v", err) } diff --git a/internal/audit/run/adaptive_test.go b/internal/audit/run/adaptive_test.go index e812784..57246a0 100644 --- a/internal/audit/run/adaptive_test.go +++ b/internal/audit/run/adaptive_test.go @@ -528,8 +528,8 @@ func TestUnit_Adaptive_TheReductionKeepsWhatTheCreateNeeded(t *testing.T) { _, sum := mustRun(t, testOptions(t, s, p, testEnv(), nil)) - var recorded *observe.AcceptedBody - for _, b := range sum.Bodies { + var recorded *observe.AcceptedRequestBody + for _, b := range sum.RequestBodies { if b.Entity == "thing" { recorded = b.Maximal } diff --git a/internal/audit/run/entity.go b/internal/audit/run/entity.go index dd1eef1..f263b85 100644 --- a/internal/audit/run/entity.go +++ b/internal/audit/run/entity.go @@ -65,35 +65,35 @@ func (r *runner) runEntity(ctx context.Context, ep *plan.EntityPlan) { r.finalizeEvidence(ent) r.evidence[ep.Entity] = &infer.Evidence{ - Entity: ep.Entity, - AcceptedBodies: ent.ev.acceptedBodies, - ListBodies: ent.ev.listBodies, - CombinedRefusals: ent.ev.combinedRefusals, - ConditionalValues: ent.ev.conditionalValues, - IdentifierProperty: ent.ev.idField, - } - r.summary.Bodies = append(r.summary.Bodies, recordedBodies(ep.Entity, ent)) + Entity: ep.Entity, + AcceptedRequestBodies: ent.ev.acceptedRequestBodies, + ListBodies: ent.ev.listBodies, + CombinedRefusals: ent.ev.combinedRefusals, + ConditionalValues: ent.ev.conditionalValues, + IdentifierProperty: ent.ev.idField, + } + r.summary.RequestBodies = append(r.summary.RequestBodies, recordedRequestBodies(ep.Entity, ent)) r.summary.Entities = append(r.summary.Entities, EntityResult{ Entity: ep.Entity, Status: ent.status, Reason: ent.reason, }) r.log.Info().Str("entity", ep.Entity).Str("status", ent.status).Str("reason", ent.reason).Int("requests", ent.requests).Msg("entity finished") } -// recordedBodies is what this entity's accepted creates looked like, for the -// generated acceptance tests to be built from. +// recordedRequestBodies is what this entity's accepted creates looked like, +// for the generated acceptance tests to be built from. // // A create the API refused is not here: the point of the record is that these // are requests it took, so a configuration replaying one is a configuration // known to apply. -func recordedBodies(entity string, ent *entityState) observe.Bodies { - out := observe.Bodies{Entity: entity} +func recordedRequestBodies(entity string, ent *entityState) observe.RequestBodies { + out := observe.RequestBodies{Entity: entity} if ent.ev.sent != nil { - out.Minimal = &observe.AcceptedBody{ + out.Minimal = &observe.AcceptedRequestBody{ Status: ent.ev.sentStatus, Request: ent.ev.sent, Response: ent.ev.got, } } if ent.ev.maximalSent != nil { - out.Maximal = &observe.AcceptedBody{ + out.Maximal = &observe.AcceptedRequestBody{ Status: ent.ev.maximalStatus, Request: ent.ev.maximalSent, Response: ent.ev.maximalGot, } } diff --git a/internal/audit/run/evidence.go b/internal/audit/run/evidence.go index 7972734..ef7d440 100644 --- a/internal/audit/run/evidence.go +++ b/internal/audit/run/evidence.go @@ -62,15 +62,15 @@ type evidence struct { readProof *observe.Excerpt // The raw material the triangulating inference reads, gathered across - // every step. acceptedBodies is every create body the API accepted, + // every step. acceptedRequestBodies is every create body the API accepted, // resolved as sent — the positive half of variant diffing. listBodies is // the collection responses the pre-flight captured, for the list-shape // finding. combinedRefusals holds any field pairs a create was refused // for carrying together — the mutual-exclusion signal, empty unless the // refusal grammar names one. - acceptedBodies []map[string]any - listBodies [][]byte - combinedRefusals []infer.FieldPair + acceptedRequestBodies []map[string]any + listBodies [][]byte + combinedRefusals []infer.FieldPair // conditionalValues records the value-cycling outcomes the executor // gathered healing free-form conditional refusals — each (discriminator // value, sibling field, sibling value) the API accepted or refused — the diff --git a/internal/audit/run/run.go b/internal/audit/run/run.go index 234abc0..eabefee 100644 --- a/internal/audit/run/run.go +++ b/internal/audit/run/run.go @@ -155,10 +155,10 @@ type Summary struct { Blocked int `json:"blocked"` TimedOut int `json:"timedOut"` Skipped int `json:"skipped"` - // Bodies is what each entity's accepted creates looked like. A generated + // RequestBodies is what each entity's accepted creates looked like. A generated // acceptance test is built from these rather than from values derived // again from the document, because only these were actually accepted. - Bodies []observe.Bodies `json:"-"` + RequestBodies []observe.RequestBodies `json:"-"` // SkippedEntities is every entity the plan left out, with its reason. // Carried because the count alone cannot be acted on: it does not // distinguish a run that covered the API from one that skipped most of it. @@ -303,7 +303,7 @@ type runner struct { // non-strategy run, which is what makes such a run skip inference. strategies map[string]*strategy.Strategy // evidence accumulates, per entity, the raw record the inference reads — - // accepted bodies, forced adjustments, list responses. + // accepted request bodies, forced adjustments, list responses. evidence map[string]*infer.Evidence // borrowed caches one real id per collection the run has borrowed a // reference from, so a second create needing it costs no extra request. diff --git a/internal/audit/run/run_test.go b/internal/audit/run/run_test.go index 05affc8..77c1564 100644 --- a/internal/audit/run/run_test.go +++ b/internal/audit/run/run_test.go @@ -115,7 +115,7 @@ func TestUnit_Run_HappyPathDerivesTheExpectedObservations(t *testing.T) { if d, err := time.ParseDuration(ra.Value.(string)); err != nil || d > time.Second { t.Errorf("readAfterWrite = %v, want a small duration", ra.Value) } - // PUT preserved what the update bodies omitted. + // PUT preserved what the update request bodies omitted. if o := wantConfirmed(t, obs, "thing", "", observe.KindUpdateStyle); o.Value != "patch-merge" { t.Errorf("updateStyle = %v, want patch-merge", o.Value) } diff --git a/internal/audit/run/steps_create.go b/internal/audit/run/steps_create.go index 98ca180..8999573 100644 --- a/internal/audit/run/steps_create.go +++ b/internal/audit/run/steps_create.go @@ -50,7 +50,7 @@ func (r *runner) runCreateMinimal(ctx context.Context, ent *entityState, step *p ent.ev.sent = sent ent.ev.sentStatus = rr.res.status ent.ev.createProof = &rr.res.excerpt - ent.ev.acceptedBodies = append(ent.ev.acceptedBodies, cloneAnyMap(rr.body)) + ent.ev.acceptedRequestBodies = append(ent.ev.acceptedRequestBodies, cloneAnyMap(rr.body)) return nil } if _, exists := r.registry[ent.plan.Entity]; exists { @@ -92,7 +92,7 @@ func (r *runner) runCreateMaximal(ctx context.Context, ent *entityState, step *p ent.ev.maximalSent = sent ent.ev.maximalGot = rr.res.object() ent.ev.maximalStatus = rr.res.status - ent.ev.acceptedBodies = append(ent.ev.acceptedBodies, cloneAnyMap(rr.body)) + ent.ev.acceptedRequestBodies = append(ent.ev.acceptedRequestBodies, cloneAnyMap(rr.body)) _, _ = r.deleteObject(ctx, ent, ent.recipe, rr.obj) return nil } @@ -104,7 +104,7 @@ func (r *runner) runCreateMaximal(ctx context.Context, ent *entityState, step *p if err := r.bisectMaximal(ctx, ent, step, rr.res); err != nil { return err } - return r.reduceMaximal(ctx, ent, step, rr.body, rr.res) + return r.reduceMaximal(ctx, ent, rr.body, rr.res) } // bisectMaximal narrows a refused maximal create to the optional field @@ -271,7 +271,7 @@ func (r *runner) runCreatePerEnumValue(ctx context.Context, ent *entityState, st } accepted := obj != nil if accepted { - ent.ev.acceptedBodies = append(ent.ev.acceptedBodies, cloneAnyMap(rr.body)) + ent.ev.acceptedRequestBodies = append(ent.ev.acceptedRequestBodies, cloneAnyMap(rr.body)) _, _ = r.deleteObject(ctx, ent, ent.recipe, obj) } else if !res.refused() { return nil @@ -436,7 +436,7 @@ func (r *runner) searchCandidates(ent *entityState, body map[string]any, refusal // one removes. What survives is the fullest create this run could get taken, // which is what a generated maximal configuration has to be — every field in // it is one the API demonstrably tolerates alongside the others. -func (r *runner) reduceMaximal(ctx context.Context, ent *entityState, step *plan.Step, body map[string]any, refusal *httpResult) error { +func (r *runner) reduceMaximal(ctx context.Context, ent *entityState, body map[string]any, refusal *httpResult) error { minimal := ent.recipe.minimalBody allowance := searchAllowance(len(body)) last := refusal @@ -462,7 +462,7 @@ func (r *runner) reduceMaximal(ctx context.Context, ent *entityState, step *plan ent.ev.maximalSent = sent ent.ev.maximalGot = res.object() ent.ev.maximalStatus = res.status - ent.ev.acceptedBodies = append(ent.ev.acceptedBodies, cloneAnyMap(body)) + ent.ev.acceptedRequestBodies = append(ent.ev.acceptedRequestBodies, cloneAnyMap(body)) _, _ = r.deleteObject(ctx, ent, ent.recipe, obj) return nil } diff --git a/internal/audit/run/strategize.go b/internal/audit/run/strategize.go index 7a3e43f..0e0d9cf 100644 --- a/internal/audit/run/strategize.go +++ b/internal/audit/run/strategize.go @@ -7,7 +7,7 @@ package run // shaped by that strategy: creates and reads repeated per variant, the widest // body per variant, the negatives and per-value creates the gates imply, all // under a complexity-scaled budget. The addressing is reused verbatim; only -// the program, the bodies and the per-entity request budget change. +// the program, the request bodies and the per-entity request budget change. // // Field values are synthesised here, at run start, from the strategy's // per-field SynthHints — never baked into the compiled strategy, which stays a @@ -144,7 +144,7 @@ func addressingOf(ep *plan.EntityPlan) addressing { } // translateProgram turns a strategy's ordered, value-free program into -// executable steps: addressing from addr, bodies synthesised from the variant +// executable steps: addressing from addr, request bodies synthesised from the variant // skeletons and per-field hints. func translateProgram(compiled *strategy.Strategy, addr addressing, entity, prefix string) []plan.Step { baseMinimal := map[string]any{} diff --git a/internal/audit/strategy/strategy_test.go b/internal/audit/strategy/strategy_test.go index 52ee43b..cafd435 100644 --- a/internal/audit/strategy/strategy_test.go +++ b/internal/audit/strategy/strategy_test.go @@ -775,7 +775,7 @@ func TestUnit_Strategy_TheMaximalCreateFollowsTheDelete(t *testing.T) { maximal := posOf("createMaximal") cleanup := posOf("cleanupDelete") - if !(create < del && del < maximal && maximal < cleanup) { + if create >= del || del >= maximal || maximal >= cleanup { t.Errorf("program order is createMinimal=%d delete=%d createMaximal=%d cleanup=%d; "+ "the maximal create must sit between the delete and the cleanup", create, del, maximal, cleanup) diff --git a/internal/cli/audit.go b/internal/cli/audit.go index ce38544..ef8cb21 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -100,12 +100,12 @@ func newAuditRunCommand() *cobra.Command { return writeErr } } - // The accepted bodies sit beside the observations: an observation + // The accepted request bodies sit beside the observations: an observation // says something about a property, these say what a whole create // looked like when the API took it. - if len(sum.Bodies) > 0 { - bodiesDir := filepath.Join(filepath.Dir(out), "bodies") - if writeErr := observe.WriteBodies(bodiesDir, sum.Bodies); writeErr != nil { + if len(sum.RequestBodies) > 0 { + requestBodiesDir := filepath.Join(filepath.Dir(out), "request_bodies") + if writeErr := observe.WriteRequestBodies(requestBodiesDir, sum.RequestBodies); writeErr != nil { if runErr != nil { return fmt.Errorf("%v; additionally %w", runErr, writeErr) } diff --git a/internal/emit/provider_core.go b/internal/emit/provider_core.go index e25f7be..8c2a27c 100644 --- a/internal/emit/provider_core.go +++ b/internal/emit/provider_core.go @@ -27,12 +27,12 @@ const DefaultGoVersion = "1.25" // template assembles a value — and the backend and auth booleans exist so // templates branch on presence, never on meaning. type ProviderCore struct { - // AcceptedBodies is what the probe recorded of each entity's accepted + // AcceptedRequestBodies is what the probe recorded of each entity's accepted // creates, keyed by entity. An acceptance fixture is built from these // rather than from values derived again from the document: the document // says what should be accepted, these are what was. Empty for an entity // no run has cleared, which falls back to the derivation. - AcceptedBodies map[string]observe.Bodies + AcceptedRequestBodies map[string]observe.RequestBodies // Module is the provider repo's Go module path, e.g. // "github.com/exampleco/terraform-provider-petstore". diff --git a/internal/emit/render_fixtures.go b/internal/emit/render_fixtures.go index 17dd3c1..0ae71b3 100644 --- a/internal/emit/render_fixtures.go +++ b/internal/emit/render_fixtures.go @@ -81,15 +81,15 @@ func (e *serviceRenderer) resourceFixtures(r *ir.Resource, spec fixtures.Fixture // because its mock is built from the same derived values. accMinimal, accMaximal := spec, spec replayed := false - if rec, ok := e.pc.AcceptedBodies[r.Names.Key]; ok { + if rec, ok := e.pc.AcceptedRequestBodies[r.Names.Key]; ok { required := requiredWireNames(spec) if rec.Minimal != nil { - accMinimal = spec.FromAcceptedBody(rec.Minimal.Request, rec.Minimal.Response, required) + accMinimal = spec.FromAcceptedRequestBody(rec.Minimal.Request, rec.Minimal.Response, required) replayed = true } switch { case rec.Maximal != nil: - accMaximal = spec.FromAcceptedBody(rec.Maximal.Request, rec.Maximal.Response, required) + accMaximal = spec.FromAcceptedRequestBody(rec.Maximal.Request, rec.Maximal.Response, required) replayed = true case rec.Minimal != nil: // No larger create was ever accepted, so the fullest known diff --git a/internal/fixtures/fixtures.go b/internal/fixtures/fixtures.go index 68523d9..25b5d1a 100644 --- a/internal/fixtures/fixtures.go +++ b/internal/fixtures/fixtures.go @@ -638,8 +638,8 @@ func suffixedEntries(values []Entry) []Entry { return out } -// FromAcceptedBody answers the entries a recorded create actually carried, -// with the values it carried them as. +// FromAcceptedRequestBody answers the entries a recorded create actually +// carried, with the values it carried them as. // // This is the difference between a configuration that looks like one the API // would take and one it demonstrably did. A value derived from the document is @@ -649,13 +649,57 @@ func suffixedEntries(values []Entry) []Entry { // reason recorded: terraform compares what it planned against what the // provider answers, so a value the API never echoes reads as the provider // losing it, and no configuration can hold one. -func (s Fixture) FromAcceptedBody(request, response map[string]any, requiredWire map[string]bool) Fixture { +func (s Fixture) FromAcceptedRequestBody(request, response map[string]any, requiredWire map[string]bool) Fixture { out := s out.Entries, out.Omissions = overlayEntries(s.Entries, request, response, requiredWire, nil) out.Omissions = append(out.Omissions, s.Omissions...) + out.Entries = restoredNames(out.Entries) return out } +// restoredNames puts back the invented name of every name-bearing entry a +// declared example displaced. +// +// A replayed body carries the values the API accepted, and for most properties +// that is exactly what a configuration wants. A name is the exception. An API +// that requires one to be unique accepted the document's example once and +// refuses it for good afterwards, so the value that proves the shape is the one +// value the configuration cannot reuse. The invented name carries NamePrefix, +// which is what WithRunSuffix needs to make it unique per run and what the +// audit's cleanup contract matches a live object by. +// +// Only name-bearing entries are restored: every other property keeps the value +// the API took, because nothing about it demands a different one. A field +// whose document declares a format has no invented name to put back — +// scalarFor keeps none — so a URL, an email or a uuid is never rewritten. +func restoredNames(values []Entry) []Entry { + if values == nil { + return nil + } + out := make([]Entry, len(values)) + copy(out, values) + for i := range out { + if out[i].synthesised != "" && nameBearing(out[i].Name) { + out[i].Scalar = out[i].synthesised + } + out[i].Nested = restoredNames(out[i].Nested) + } + return out +} + +// nameBearing reports whether an attribute names its object — the attributes +// whose values must be unique per run and must carry the prefix cleanup +// matches on. +func nameBearing(name string) bool { + lower := strings.ToLower(name) + for _, suffix := range []string{"name", "title", "label"} { + if lower == suffix || strings.HasSuffix(lower, suffix) { + return true + } + } + return false +} + // overlayEntries keeps the entries the body carried, taking their values from // it, and reports the ones it dropped. func overlayEntries(values []Entry, request, response map[string]any, requiredWire map[string]bool, path []string) ([]Entry, []Omission) { diff --git a/internal/fixtures/fixtures_test.go b/internal/fixtures/fixtures_test.go index 99a082a..0dc55de 100644 --- a/internal/fixtures/fixtures_test.go +++ b/internal/fixtures/fixtures_test.go @@ -533,7 +533,7 @@ func TestUnit_Fixturespec_AReplayedBodyCarriesWhatTheAPITook(t *testing.T) { "labels": []any{"first"}, "settings": map[string]any{"retries": int64(3)}, } - got := spec.FromAcceptedBody(request, response, map[string]bool{"name": true}) + got := spec.FromAcceptedRequestBody(request, response, map[string]bool{"name": true}) // The values are the ones the request carried, not the ones derived. if v := valueByName(t, got, "name").Scalar; v != "the-name-it-took" { @@ -565,7 +565,7 @@ func TestUnit_Fixturespec_AReplayDropsWhatTheAPINeverReturns(t *testing.T) { // The API took matchType and answered without it. response := map[string]any{"name": "n", "colour": "#FF0000"} - got := spec.FromAcceptedBody(request, response, map[string]bool{"name": true}) + got := spec.FromAcceptedRequestBody(request, response, map[string]bool{"name": true}) for _, e := range got.Entries { if e.Name == "match_type" { @@ -582,7 +582,7 @@ func TestUnit_Fixturespec_AReplayDropsWhatTheAPINeverReturns(t *testing.T) { t.Errorf("the dropped property was not explained: %#v", got.Omissions) } // A required property stays even unreturned: the create needs it. - requiredUnreturned := spec.FromAcceptedBody( + requiredUnreturned := spec.FromAcceptedRequestBody( map[string]any{"name": "n"}, map[string]any{}, map[string]bool{"name": true}) if len(requiredUnreturned.Entries) != 1 { t.Errorf("a required property was dropped for not being echoed: %#v", requiredUnreturned.Entries) @@ -605,3 +605,45 @@ func TestUnit_Fixturespec_TheRunSuffixOnlyTouchesInventedNames(t *testing.T) { t.Errorf("a document value = %#v, want it left alone", v) } } + +func TestUnit_Fixturespec_AReplayedNameGoesBackToTheInventedOne(t *testing.T) { + spec := Derive(exampleTree()) + // What the audit sent and the API took: the document's own example, which + // an API that requires a unique name accepts once and refuses thereafter. + body := map[string]any{ + "name": "Production metrics stream", + "endpointUrl": "https://api.example.otel-collector", + } + + got := spec.FromAcceptedRequestBody(body, body, map[string]bool{"name": true}) + + if v := valueByName(t, got, "name").Scalar; v != NamePrefix+"name" { + t.Errorf("name = %#v, want the invented name back", v) + } + // The invented name is what the run suffix recognises, so a replayed + // configuration is unique per run rather than a constant that collides. + suffixed := got.WithRunSuffix() + if v := valueByName(t, suffixed, "name").Scalar; v != NamePrefix+"name-"+RunSuffixExpr { + t.Errorf("suffixed name = %#v, want the run suffix appended", v) + } +} + +func TestUnit_Fixturespec_AReplayKeepsEveryValueThatDoesNotNameTheObject(t *testing.T) { + spec := Derive(exampleTree()) + body := map[string]any{ + "name": "Production metrics stream", + "endpointUrl": "https://api.example.otel-collector", + "interval": int64(300), + } + + got := spec.FromAcceptedRequestBody(body, body, map[string]bool{"name": true}) + + // A string the API took that names nothing keeps the value it took: an + // invented name is a string, and this one had to be a URL. + if v := valueByName(t, got, "endpoint_url").Scalar; v != "https://api.example.otel-collector" { + t.Errorf("endpoint_url = %#v, want the value the API took", v) + } + if v := valueByName(t, got, "interval").Scalar; v != int64(300) { + t.Errorf("interval = %#v, want the value the API took", v) + } +} diff --git a/internal/providergen/providergen.go b/internal/providergen/providergen.go index 1c40971..24be8fb 100644 --- a/internal/providergen/providergen.go +++ b/internal/providergen/providergen.go @@ -255,7 +255,7 @@ func generate(opts Options) (*generation, error) { // What a probe run recorded of the API's accepted creates. Absent before // any run, which leaves every acceptance fixture derived from the // document as it was. - pc.AcceptedBodies, err = observe.ReadBodies(filepath.Join(opts.Root, "audit", "bodies")) + pc.AcceptedRequestBodies, err = observe.ReadRequestBodies(filepath.Join(opts.Root, "audit", "request_bodies")) if err != nil { return nil, err } diff --git a/internal/specmodel/load.go b/internal/specmodel/load.go index e8d1c73..35c9972 100644 --- a/internal/specmodel/load.go +++ b/internal/specmodel/load.go @@ -34,10 +34,10 @@ func Load(data []byte) (*Document, error) { } l := &loader{ - doc: &Document{Schemas: map[string]*Schema{}}, - parameters: map[string]Parameter{}, - bodies: map[string]*Schema{}, - responses: map[string]*Schema{}, + doc: &Document{Schemas: map[string]*Schema{}}, + parameters: map[string]Parameter{}, + requestBodies: map[string]*Schema{}, + responses: map[string]*Schema{}, } if err := l.document(top); err != nil { return nil, err @@ -52,11 +52,11 @@ func Load(data []byte) (*Document, error) { // that exist only to be referenced, and the reference fixups the resolution // pass completes once every named schema exists. type loader struct { - doc *Document - parameters map[string]Parameter - bodies map[string]*Schema - responses map[string]*Schema - refs []pendingRef + doc *Document + parameters map[string]Parameter + requestBodies map[string]*Schema + responses map[string]*Schema + refs []pendingRef } func (l *loader) document(top *yaml.Node) error { @@ -128,13 +128,13 @@ func (l *loader) components(node *yaml.Node) error { l.parameters[name] = p } } - if bodies := lookup(node, "requestBodies"); bodies != nil { - for name, bn := range pairs(bodies) { + if requestBodies := lookup(node, "requestBodies"); requestBodies != nil { + for name, bn := range pairs(requestBodies) { s, err := l.contentSchema(deref(bn), "components.requestBodies."+name) if err != nil { return err } - l.bodies[name] = s + l.requestBodies[name] = s } } if responses := lookup(node, "responses"); responses != nil { @@ -238,7 +238,7 @@ func (l *loader) bodySchema(node *yaml.Node, at string) (*Schema, error) { if err != nil { return nil, err } - s, ok := l.bodies[name] + s, ok := l.requestBodies[name] if !ok { return nil, fmt.Errorf("%s: references #/components/requestBodies/%s, which the document does not declare", at, name) }