From 2e4e6a7b093a0f005f1d31145b0e78a813a00cf4 Mon Sep 17 00:00:00 2001 From: ShocOne <62835948+ShocOne@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:44:33 +0100 Subject: [PATCH] feat(audit): per-resource strategy compiler shapes the audit to the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1-derived audit applied one uniform step sequence to every entity and assumed a single maximal configuration, mislearning resources whose valid fields depend on a discriminator. Wave 1 replaces the uniform derivation with a per-resource strategy compiler: Compile reads one classified entity's declared structure — required enums and booleans (gate candidates ranked by discriminating power), oneOf/anyOf/discriminator/allOf and JSON-Schema dependentRequired/dependentSchemas (declared variants and structural co-requirements), and a small vendor-neutral prose extractor over property descriptions (weak hypotheses) — and composes a step program specific to that resource: minimal/maximal skeletons per variant, sorted hypotheses carrying provenance (structural|prose|derived), the ordered steps reusing plan's approved step-kind names, and a complexity-scaled budget. Pure, deterministic, offline; nothing is wired into the CLI or executor (Wave 2 consumes it), and internal/audit/plan is left standing. specmodel gains the standard OpenAPI/JSON-Schema fields the compiler needs — description, pattern, discriminator, dependentRequired, dependentSchemas — as additive typed accessors; no domain vocabulary is coined. Co-Authored-By: Claude Fable 5 --- internal/audit/strategy/gates.go | 52 ++ internal/audit/strategy/internal_test.go | 178 ++++++ internal/audit/strategy/program.go | 171 ++++++ internal/audit/strategy/prose.go | 236 ++++++++ internal/audit/strategy/skeleton.go | 167 ++++++ internal/audit/strategy/strategy.go | 373 +++++++++++++ internal/audit/strategy/strategy_test.go | 663 +++++++++++++++++++++++ internal/audit/strategy/variants.go | 310 +++++++++++ internal/specmodel/load.go | 67 +++ internal/specmodel/load_test.go | 78 +++ internal/specmodel/model.go | 43 ++ 11 files changed, 2338 insertions(+) create mode 100644 internal/audit/strategy/gates.go create mode 100644 internal/audit/strategy/internal_test.go create mode 100644 internal/audit/strategy/program.go create mode 100644 internal/audit/strategy/prose.go create mode 100644 internal/audit/strategy/skeleton.go create mode 100644 internal/audit/strategy/strategy.go create mode 100644 internal/audit/strategy/strategy_test.go create mode 100644 internal/audit/strategy/variants.go diff --git a/internal/audit/strategy/gates.go b/internal/audit/strategy/gates.go new file mode 100644 index 0000000..a225fdb --- /dev/null +++ b/internal/audit/strategy/gates.go @@ -0,0 +1,52 @@ +package strategy + +import ( + "sort" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// detectGates finds the create body's gate candidates: the writable enum and +// boolean fields whose value plausibly selects a variant. Enums need at least +// two members to gate anything — a single-value enum is a constant, not a +// discriminator; a boolean always has its two. +// +// The result is ranked likeliest-first: required enum, then optional enum, +// then boolean, ties broken by field name so the order is deterministic. +func detectGates(createBody *specmodel.Schema) []Gate { + fields := flatFields(createBody) + var gates []Gate + for _, f := range fields { + r := f.schema.Resolved() + switch { + case isEnum(r): + values := stringifyValues(r.Enum) + if len(values) < 2 { + continue + } + kind := GateOptionalEnum + if f.required { + kind = GateRequiredEnum + } + gates = append(gates, Gate{Field: f.name, Kind: kind, Values: values}) + case isBool(r): + gates = append(gates, Gate{Field: f.name, Kind: GateBool, Values: []string{"false", "true"}}) + } + } + sort.Slice(gates, func(i, j int) bool { + if gates[i].Kind.rank() != gates[j].Kind.rank() { + return gates[i].Kind.rank() < gates[j].Kind.rank() + } + return gates[i].Field < gates[j].Field + }) + return gates +} + +// primaryGate is the likeliest discriminator — the first ranked gate — or nil +// when the resource has none. The variant field sets are derived against it. +func primaryGate(gates []Gate) *Gate { + if len(gates) == 0 { + return nil + } + return &gates[0] +} diff --git a/internal/audit/strategy/internal_test.go b/internal/audit/strategy/internal_test.go new file mode 100644 index 0000000..8af41f9 --- /dev/null +++ b/internal/audit/strategy/internal_test.go @@ -0,0 +1,178 @@ +package strategy + +import ( + "reflect" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +func TestStringifyScalar(t *testing.T) { + cases := []struct { + in any + want string + }{ + {"hello", "hello"}, + {true, "true"}, + {false, "false"}, + {7, "7"}, + {int64(9), "9"}, + {1.0, "1"}, + {1.5, "1.5"}, + {map[string]any{"x": 1}, ""}, // non-scalar renders empty + {nil, ""}, + } + for _, c := range cases { + if got := stringifyScalar(c.in); got != c.want { + t.Errorf("stringifyScalar(%v)=%q, want %q", c.in, got, c.want) + } + } +} + +func TestStringifyValuesDedupsAndSorts(t *testing.T) { + got := stringifyValues([]any{"b", "a", "b", 1.0, 1}) + want := []string{"1", "a", "b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("stringifyValues=%v, want %v", got, want) + } +} + +func TestGateKindRank(t *testing.T) { + if GateRequiredEnum.rank() >= GateOptionalEnum.rank() || + GateOptionalEnum.rank() >= GateBool.rank() { + t.Fatal("gate ranks are not required>optional>bool") + } + if GateKind("nonsense").rank() != 3 { + t.Fatalf("unknown gate rank=%d, want 3", GateKind("nonsense").rank()) + } +} + +func TestTokenIndex(t *testing.T) { + if tokenIndex("anything", "") != -1 { + t.Fatal("empty needle should not match") + } + // "type" must not match inside "prototype". + if tokenIndex("the prototype value", "type") != -1 { + t.Fatal("token match ignored word boundaries") + } + if got := tokenIndex("set the type now", "type"); got != 8 { + t.Fatalf("tokenIndex=%d, want 8", got) + } + // A match at the very start, bounded by the string edge. + if got := tokenIndex("type here", "type"); got != 0 { + t.Fatalf("tokenIndex at start=%d, want 0", got) + } +} + +func TestExtractProseNamingSiblingButNoValue(t *testing.T) { + // A conditional phrase naming a sibling with no matchable value becomes a + // requiresField co-requirement, prose provenance. + got := extractProse( + "deep", + "Must be set when advanced is present.", + []string{"deep", "advanced"}, + map[string][]string{}, // advanced has no enum values + ) + if len(got) != 1 || got[0].Kind != HypothesisRequiresField { + t.Fatalf("got %+v, want one requiresField", got) + } + if got[0].Provenance != ProvenanceProse { + t.Fatalf("provenance=%q, want prose", got[0].Provenance) + } + if !contains(got[0].Subjects, "advanced") || !contains(got[0].Subjects, "deep") { + t.Fatalf("subjects=%v, want deep and advanced", got[0].Subjects) + } +} + +func TestExtractProseNamingNothingDiscarded(t *testing.T) { + got := extractProse( + "x", + "Required when something unrelated happens.", + []string{"x", "y"}, + map[string][]string{}, + ) + if len(got) != 0 { + t.Fatalf("got %+v, want nothing (no sibling named)", got) + } +} + +// anyOfSchema exercises the anyOf branch path of gatherBranches: a gate whose +// value selects an anyOf branch's fields. +const anyOfSchema = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Body: + type: object + required: [kind] + properties: + kind: {type: string, enum: [a, b]} + anyOf: + - type: object + properties: + kind: {type: string, enum: [a]} + aOnly: {type: string} + - type: object + properties: + kind: {type: string, enum: [b]} + bOnly: {type: string} +` + +func TestDeriveVariantsFromAnyOf(t *testing.T) { + doc, err := specmodel.Load([]byte(anyOfSchema)) + if err != nil { + t.Fatalf("load: %v", err) + } + body := doc.Schemas["Body"] + gates := detectGates(body) + if len(gates) != 1 || gates[0].Field != "kind" { + t.Fatalf("gates=%+v, want one on kind", gates) + } + variants := deriveVariants(body, gates) + if len(variants) != 3 { + t.Fatalf("variants=%d, want 3", len(variants)) + } + var va *Variant + for i := range variants { + if variants[i].GateValue == "a" { + va = &variants[i] + } + } + if va == nil || va.Provenance != ProvenanceStructural { + t.Fatalf("anyOf variant a not structural: %+v", variants) + } + if !contains(va.Maximal.Fields, "aOnly") || contains(va.Maximal.Fields, "bOnly") { + t.Fatalf("a maximal=%v, want aOnly without bOnly", va.Maximal.Fields) + } +} + +func TestDetectGatesSkipsSingleValueEnum(t *testing.T) { + const spec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Body: + type: object + properties: + only: {type: string, enum: [solo]} + name: {type: string} +` + doc, err := specmodel.Load([]byte(spec)) + if err != nil { + t.Fatalf("load: %v", err) + } + if g := detectGates(doc.Schemas["Body"]); len(g) != 0 { + t.Fatalf("gates=%+v, want none (single-value enum is not a gate)", g) + } +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/internal/audit/strategy/program.go b/internal/audit/strategy/program.go new file mode 100644 index 0000000..214a630 --- /dev/null +++ b/internal/audit/strategy/program.go @@ -0,0 +1,171 @@ +package strategy + +import ( + "fmt" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/audit/plan" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/config" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// The step-kind names are the plan package's closed set, reused verbatim so +// there is one spelling of "createMinimal" in the toolkit, not two. When plan's +// uniform derivation is retired the set moves; until then strategy borrows it. +const ( + stepCreateMinimal = plan.StepCreateMinimal + stepReadWithRetry = plan.StepReadWithRetry + stepReadConsecutive = plan.StepReadConsecutive + stepUpdateField = plan.StepUpdateField + stepDeleteWithConfirmation = plan.StepDeleteWithConfirmation + stepCreateMaximal = plan.StepCreateMaximal + stepOmitRequired = plan.StepOmitRequired + stepUndocumentedEnumValue = plan.StepUndocumentedEnumValue + stepUndeclaredSpecField = plan.StepUndeclaredSpecField + stepCreatePerEnumValue = plan.StepCreatePerEnumValue + stepRead = plan.StepRead + stepCleanupDelete = plan.StepCleanupDelete +) + +// The program caps. Each bounds one rule, so a wide schema widens the program +// sub-linearly and the budget stays honest. +const ( + // budgetBase is the fixed request cost every resource pays: the baseline + // create/read/read cycle, the negatives, and teardown. + budgetBase = 10 + // perObjectCost is how many requests one live object is worth when the + // request ceiling is derived from the object budget. + perObjectCost = 12 + // readOnlyBudget is a read-only entity's budget: a read and a consecutive + // read. + readOnlyBudget = 2 + // maxUpdateFields caps the per-field update checks. + maxUpdateFields = 12 + // maxOmitRequired caps the omit-one-required negatives. + maxOmitRequired = 6 + // maxPerEnumValues caps the value-conditional creates one gate spends. + maxPerEnumValues = 6 +) + +// buildProgram composes the ordered steps for a resource, shaped by its +// variants, gates and hypotheses. The order is fixed and every rule iterates a +// sorted set, so the program is byte-stable. +func buildProgram(createBody *specmodel.Schema, gates []Gate, variants []Variant, hyps []Hypothesis) []Step { + fields := flatFields(createBody) + var prog []Step + + // Create, then read back and read again, per variant. + for _, v := range variants { + prog = append(prog, + gatedStep(stepCreateMinimal, v), + gatedStep(stepReadWithRetry, v), + gatedStep(stepReadConsecutive, v), + ) + } + // The widest valid body, per variant. + for _, v := range variants { + prog = append(prog, gatedStep(stepCreateMaximal, v)) + } + // One update per writable field, capped. + for i, name := range fieldNames(fields) { + if i == maxUpdateFields { + break + } + prog = append(prog, Step{Kind: stepUpdateField, Field: name}) + } + // The negatives: omit each required field, send an undocumented value at + // each enum gate, send one undeclared field. + for i, name := range requiredNames(fields) { + if i == maxOmitRequired { + break + } + prog = append(prog, Step{Kind: stepOmitRequired, Field: name}) + } + for _, g := range gates { + if g.Kind == GateBool { + continue + } + prog = append(prog, Step{Kind: stepUndocumentedEnumValue, Field: g.Field}) + } + prog = append(prog, Step{Kind: stepUndeclaredSpecField}) + + // Value-conditional creates: every gate value, then the value-gated + // prose hypotheses the gate loop did not already cover. + prog = append(prog, perValueSteps(gates, hyps)...) + + // Teardown. + prog = append(prog, + Step{Kind: stepDeleteWithConfirmation}, + Step{Kind: stepCleanupDelete}, + ) + return prog +} + +// perValueSteps builds the createPerEnumValue steps: one per gate value +// (capped per gate), plus one per value-gated prose hypothesis whose gate +// value the gate loop did not already pin. +func perValueSteps(gates []Gate, hyps []Hypothesis) []Step { + var steps []Step + covered := map[string]bool{} + for _, g := range gates { + for i, value := range g.Values { + if i == maxPerEnumValues { + break + } + steps = append(steps, Step{Kind: stepCreatePerEnumValue, Field: g.Field, GateField: g.Field, GateValue: value}) + covered[g.Field+"\x00"+value] = true + } + } + for _, h := range hyps { + if h.GateField == "" || h.GateValue == "" { + continue + } + if h.Kind != HypothesisRequiredWhen && h.Kind != HypothesisValidWhen { + continue + } + key := h.GateField + "\x00" + h.GateValue + if covered[key] { + continue + } + covered[key] = true + subject := "" + if len(h.Subjects) > 0 { + subject = h.Subjects[0] + } + steps = append(steps, Step{Kind: stepCreatePerEnumValue, Field: subject, GateField: h.GateField, GateValue: h.GateValue}) + } + return steps +} + +// gatedStep builds a step carrying a variant's gate, leaving the gate empty on +// the baseline variant. +func gatedStep(kind plan.StepKind, v Variant) Step { + return Step{Kind: kind, GateField: v.GateField, GateValue: v.GateValue} +} + +// deriveBudget sizes the per-resource request budget with complexity: a fixed +// base plus the writable-field count times the variant count, capped by a +// ceiling drawn from the configured live-object budget. The formula string +// records the arithmetic for a plan dump. +// +// requests = base + writableFields × variants (capped at maxObjects × perObjectCost) +func deriveBudget(createBody *specmodel.Schema, variants []Variant, cfg *config.Config) Budget { + writable := len(flatFields(createBody)) + nVariants := len(variants) + requests := budgetBase + writable*nVariants + + maxObjects := cfg.Audit.MaxObjects + if maxObjects < 1 { + maxObjects = 25 + } + ceiling := maxObjects * perObjectCost + + capped := "" + if requests > ceiling { + requests = ceiling + capped = fmt.Sprintf(", capped at maxObjects(%d)×%d", maxObjects, perObjectCost) + } + return Budget{ + Requests: requests, + Formula: fmt.Sprintf("base(%d) + writableFields(%d)×variants(%d)%s", budgetBase, writable, nVariants, capped), + } +} diff --git a/internal/audit/strategy/prose.go b/internal/audit/strategy/prose.go new file mode 100644 index 0000000..7bf7684 --- /dev/null +++ b/internal/audit/strategy/prose.go @@ -0,0 +1,236 @@ +package strategy + +import ( + "sort" + "strings" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// The prose phrase set. It is deliberately small, general English, and +// vendor-neutral: never a phrase from one API's documentation, only the +// conditional-language patterns any REST document uses. The repo's +// pilot-leakage lint guards that nothing here names a pilot vendor. +// +// A phrase yields a hypothesis only when it also NAMES a sibling property (and, +// where the category needs one, a value of that sibling). A phrase that names +// nothing is discarded — prose is the weakest signal, and an unanchored one is +// no signal at all. +type proseCategory int + +const ( + // catRequired: the field is required when the named condition holds. + catRequired proseCategory = iota + // catValid: the field is valid, applies, or is ignored only when the + // named condition holds — a value-gated validity edge either way. + catValid + // catExclusive: the field cannot be combined with the named sibling. + catExclusive +) + +// prosePhrase is one recognised phrase and the edge category it signals. +type prosePhrase struct { + text string + category proseCategory +} + +// prosePhrases is the closed, ordered phrase set. Order is fixed so extraction +// is deterministic; longer, more specific phrases precede the shorter phrases +// they contain so the specific reading wins. +var prosePhrases = []prosePhrase{ + {"required only when", catRequired}, + {"required when", catRequired}, + {"required if", catRequired}, + {"must be set when", catRequired}, + {"applies only when", catValid}, + {"applies only to", catValid}, + {"applies when", catValid}, + {"applies to", catValid}, + {"only applies when", catValid}, + {"only when", catValid}, + {"only if", catValid}, + {"ignored when", catValid}, + {"mutually exclusive with", catExclusive}, + {"mutually exclusive", catExclusive}, + {"cannot be used with", catExclusive}, +} + +// proseHypotheses mines every writable field's description for conditional +// language, emitting prose-provenance hypotheses. Fields are walked in name +// order and phrases in their fixed order, so the output is deterministic. +func proseHypotheses(createBody *specmodel.Schema) []Hypothesis { + fields := flatFields(createBody) + sort.Slice(fields, func(i, j int) bool { return fields[i].name < fields[j].name }) + + siblingEnum := map[string][]string{} + var siblingNames []string + for _, f := range fields { + siblingNames = append(siblingNames, f.name) + if e := f.schema.Resolved().Enum; len(e) > 0 { + siblingEnum[f.name] = stringifyValues(e) + } + } + + var out []Hypothesis + for _, f := range fields { + desc := f.schema.Resolved().Description + if desc == "" { + continue + } + out = append(out, extractProse(f.name, desc, siblingNames, siblingEnum)...) + } + return out +} + +// extractProse runs the phrase set over one field's description. For each +// phrase found, it looks — in the text after the phrase — for a sibling name, +// and for a value of that sibling. What it finds decides the edge; what it +// cannot anchor it discards. +func extractProse(field, desc string, siblingNames []string, siblingEnum map[string][]string) []Hypothesis { + lower := strings.ToLower(desc) + var out []Hypothesis + for _, ph := range prosePhrases { + idx := strings.Index(lower, ph.text) + if idx < 0 { + continue + } + tail := lower[idx+len(ph.text):] + + sibling := earliestSibling(tail, field, siblingNames) + if sibling == "" { + continue // names no sibling: discarded + } + + if ph.category == catExclusive { + out = append(out, mutuallyExclusiveHypothesis(field, sibling)) + continue + } + + value := earliestValue(tail, siblingEnum[sibling]) + if value == "" { + // A conditional phrase that names a sibling but no value is a + // weaker co-requirement: the field relates to the sibling's + // presence. + out = append(out, proseRequiresField(field, sibling)) + continue + } + out = append(out, conditionalHypothesis(ph.category, field, sibling, value)) + } + return out +} + +// conditionalHypothesis builds a value-gated prose edge: requiredWhen for the +// required category, validWhen for the validity category. +func conditionalHypothesis(cat proseCategory, field, sibling, value string) Hypothesis { + kind := HypothesisValidWhen + if cat == catRequired { + kind = HypothesisRequiredWhen + } + return Hypothesis{ + Kind: kind, + Subjects: []string{field}, + GateField: sibling, + GateValue: value, + Provenance: ProvenanceProse, + Check: Check{ + Step: stepCreatePerEnumValue, Field: field, + GateField: sibling, GateValue: value, Expect: "conditional", + }, + } +} + +// proseRequiresField builds a prose co-requirement edge naming a sibling but no +// value. +func proseRequiresField(field, sibling string) Hypothesis { + subjects := []string{field, sibling} + sort.Strings(subjects) + return Hypothesis{ + Kind: HypothesisRequiresField, + Subjects: subjects, + Provenance: ProvenanceProse, + Check: Check{Step: stepCreateMaximal, Field: field, Expect: "conditional"}, + } +} + +// mutuallyExclusiveHypothesis builds a prose exclusion edge between two fields. +func mutuallyExclusiveHypothesis(field, sibling string) Hypothesis { + subjects := []string{field, sibling} + sort.Strings(subjects) + return Hypothesis{ + Kind: HypothesisMutuallyExclusive, + Subjects: subjects, + Provenance: ProvenanceProse, + Check: Check{Step: stepCreateMaximal, Expect: "reject"}, + } +} + +// earliestSibling returns the sibling name whose whole-word occurrence is +// earliest in tail, excluding the field itself; empty when none occurs. Ties +// break by name so the choice is deterministic. +func earliestSibling(tail, field string, siblingNames []string) string { + best, bestAt := "", len(tail)+1 + for _, name := range siblingNames { + if name == field { + continue + } + at := tokenIndex(tail, strings.ToLower(name)) + if at < 0 { + continue + } + if at < bestAt || (at == bestAt && name < best) { + best, bestAt = name, at + } + } + return best +} + +// earliestValue returns the enum value whose whole-word occurrence is earliest +// in tail; empty when none of the values occur. Ties break by value. +func earliestValue(tail string, values []string) string { + best, bestAt := "", len(tail)+1 + for _, v := range values { + at := tokenIndex(tail, strings.ToLower(v)) + if at < 0 { + continue + } + if at < bestAt || (at == bestAt && v < best) { + best, bestAt = v, at + } + } + return best +} + +// tokenIndex finds the earliest whole-token occurrence of needle in haystack, +// bounded by non-alphanumeric characters so "type" does not match inside +// "prototype". Returns -1 when absent or when needle is empty. +func tokenIndex(haystack, needle string) int { + if needle == "" { + return -1 + } + from := 0 + for { + rel := strings.Index(haystack[from:], needle) + if rel < 0 { + return -1 + } + at := from + rel + if boundedToken(haystack, at, len(needle)) { + return at + } + from = at + 1 + } +} + +// boundedToken reports whether the substring at [at, at+n) is bounded by a +// non-alphanumeric character (or the string edge) on both sides. +func boundedToken(s string, at, n int) bool { + before := at == 0 || !isAlphaNum(s[at-1]) + end := at + n + after := end == len(s) || !isAlphaNum(s[end]) + return before && after +} + +// isAlphaNum reports whether a byte is an ASCII letter or digit. +func isAlphaNum(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} diff --git a/internal/audit/strategy/skeleton.go b/internal/audit/strategy/skeleton.go new file mode 100644 index 0000000..9db6a12 --- /dev/null +++ b/internal/audit/strategy/skeleton.go @@ -0,0 +1,167 @@ +package strategy + +import ( + "sort" + "strconv" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// maxDepth bounds the allOf fold, so a self-referencing schema produces a +// finite walk instead of a loop. It mirrors the ceiling the plan package's +// synthesis uses, for the same reason. +const maxDepth = 8 + +// field is one effective property of a create body: its wire name, its +// resolved schema, and whether the flattened body requires it. +type field struct { + name string + schema *specmodel.Schema + required bool +} + +// flatFields returns a schema's effective properties, folding allOf branches +// in order with the first declaration of a name winning — the same collision +// rule the SDK backends apply — and marking each with the flattened required +// set. readOnly properties are excluded: the audit only ever sends writable +// fields, so a read-only property is never a skeleton field, a gate or an +// update target. +func flatFields(s *specmodel.Schema) []field { + var props []specmodel.Property + required := map[string]bool{} + seen := map[string]bool{} + + var walk func(r *specmodel.Schema, depth int) + walk = func(r *specmodel.Schema, depth int) { + if r == nil || depth > maxDepth { + return + } + r = r.Resolved() + for _, p := range r.Properties { + if !seen[p.Name] { + seen[p.Name] = true + props = append(props, p) + } + } + for _, name := range r.Required { + required[name] = true + } + for _, branch := range r.AllOf { + walk(branch, depth+1) + } + } + walk(s, 0) + + out := make([]field, 0, len(props)) + for _, p := range props { + if p.Schema.Resolved().ReadOnly { + continue + } + out = append(out, field{name: p.Name, schema: p.Schema, required: required[p.Name]}) + } + return out +} + +// fieldNames lists a field slice's names, sorted. +func fieldNames(fields []field) []string { + out := make([]string, 0, len(fields)) + for _, f := range fields { + out = append(out, f.name) + } + sort.Strings(out) + return out +} + +// synthHint pulls one field's synthesis material out of its resolved schema. +func synthHint(f field) SynthHint { + r := f.schema.Resolved() + h := SynthHint{ + Field: f.name, + Required: f.required, + Type: r.Type, + Format: r.Format, + Pattern: r.Pattern, + Example: r.Example, + Default: r.Default, + } + if len(r.Enum) > 0 { + h.Enum = stringifyValues(r.Enum) + } + return h +} + +// hintsFor builds the sorted per-field synthesis hints for a set of field +// names, drawn from the given fields. +func hintsFor(names []string, byName map[string]field) []SynthHint { + hints := make([]SynthHint, 0, len(names)) + for _, n := range names { + if f, ok := byName[n]; ok { + hints = append(hints, synthHint(f)) + } + } + sort.Slice(hints, func(i, j int) bool { return hints[i].Field < hints[j].Field }) + return hints +} + +// skeleton assembles a Skeleton from a sorted name set and the field index. +func skeleton(names []string, byName map[string]field) Skeleton { + sorted := append([]string(nil), names...) + sort.Strings(sorted) + return Skeleton{Fields: sorted, Hints: hintsFor(sorted, byName)} +} + +// indexFields maps a field slice by name. +func indexFields(fields []field) map[string]field { + byName := make(map[string]field, len(fields)) + for _, f := range fields { + byName[f.name] = f + } + return byName +} + +// isEnum reports whether a resolved schema declares an enum. +func isEnum(s *specmodel.Schema) bool { + return len(s.Resolved().Enum) > 0 +} + +// isBool reports whether a resolved schema is boolean-typed. +func isBool(s *specmodel.Schema) bool { + return s.Resolved().Type == "boolean" +} + +// stringifyValues renders decoded scalar values as strings, sorted and +// de-duplicated, so a gate's or a hint's value list is byte-stable however the +// document spelled the scalars. +func stringifyValues(vals []any) []string { + seen := map[string]bool{} + out := make([]string, 0, len(vals)) + for _, v := range vals { + s := stringifyScalar(v) + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + sort.Strings(out) + return out +} + +// stringifyScalar renders one decoded scalar canonically: a float that is a +// whole number loses its fraction, so 1 and 1.0 render identically whether +// YAML or JSON produced them. +func stringifyScalar(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + return strconv.FormatBool(t) + case int: + return strconv.FormatInt(int64(t), 10) + case int64: + return strconv.FormatInt(t, 10) + case float64: + return strconv.FormatFloat(t, 'g', -1, 64) + default: + return "" + } +} diff --git a/internal/audit/strategy/strategy.go b/internal/audit/strategy/strategy.go new file mode 100644 index 0000000..0a0dea3 --- /dev/null +++ b/internal/audit/strategy/strategy.go @@ -0,0 +1,373 @@ +// Package strategy is the per-resource strategy compiler: it reads one +// classified entity's declared spec structure and composes the ordered step +// program specific to that resource. Different resources yield different +// strategies — +// a flat resource gets a single baseline, a discriminated one gets a variant +// per gate value, a resource whose prose or dependentRequired declares +// co-requirements gets hypotheses to confirm live. +// +// Compile is pure and deterministic: the same document, classification and +// config always produce the same Strategy, byte for byte under JSON. No +// network, no clock, no randomness, no map-order leaks. It supersedes the +// uniform step derivation in internal/audit/plan — where plan applied one +// fixed sequence to every entity, strategy shapes the sequence to what the +// resource declares — and reuses plan's closed step-kind vocabulary +// (plan.StepKind) rather than minting a second copy of it. +// +// The compiler asserts nothing about the live API. Structural signals +// (oneOf/discriminator/dependentRequired) are the strongest baseline; prose in +// descriptions is the weakest, general, vendor-neutral hint; the live API, +// exercised in a later wave, is the only thing that confirms. Every finding +// leaves here as a hypothesis carrying its provenance, never an assertion. +// +// Deferred naming: the hypothesis-kind values (variant, requiredWhen, +// requiresField, mutuallyExclusive, validWhen) are working identifiers, and the +// final observation-kind names are an owner decision settled in Wave 3. The +// exported type names (Strategy, Variant, Skeleton, Hypothesis, Check, Step) +// are likewise provisional; "probe" is deliberately not used because it is +// retired v1 vocabulary. +package strategy + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/audit/plan" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/config" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// Provenance records how strongly a variant or hypothesis is grounded. The +// set is closed and ordered weakest-last for sorting: structural claims (the +// document's own composition keywords) outrank prose (mined description text), +// and derived is reserved for the triangulating inference of a later wave. +type Provenance string + +const ( + // ProvenanceStructural: read from the document's composition structure — + // oneOf, anyOf, discriminator, dependentRequired, dependentSchemas. + ProvenanceStructural Provenance = "structural" + // ProvenanceProse: mined from a property description by the general + // phrase extractor. The weakest signal; a hint to test, never a claim. + ProvenanceProse Provenance = "prose" + // ProvenanceDerived: a variant seeded from a gate value the document + // gives no distinct field set for — inferred from the gate alone. + ProvenanceDerived Provenance = "derived" +) + +// HypothesisKind names the shape of a candidate conditional edge. These are +// WORKING IDENTIFIERS: the final observation-kind names are an owner decision +// settled in Wave 3, so nothing downstream should treat these spellings as the +// committed vocabulary. +type HypothesisKind string + +const ( + // HypothesisVariant: a gate value selects a distinct valid field set. + HypothesisVariant HypothesisKind = "variant" + // HypothesisRequiredWhen: a field is required only when a sibling gate + // holds a particular value. + HypothesisRequiredWhen HypothesisKind = "requiredWhen" + // HypothesisRequiresField: a field is settable only when a sibling is + // present, regardless of that sibling's value. + HypothesisRequiresField HypothesisKind = "requiresField" + // HypothesisMutuallyExclusive: at most one of a set of fields may be set. + HypothesisMutuallyExclusive HypothesisKind = "mutuallyExclusive" + // HypothesisValidWhen: a field is valid (or prohibited) only when a + // sibling gate holds a particular value. + HypothesisValidWhen HypothesisKind = "validWhen" +) + +// GateKind ranks a gate candidate by discriminating power: a required enum is +// the likeliest discriminator, an optional enum next, a boolean last. +type GateKind string + +const ( + // GateRequiredEnum: a required enum-typed field. + GateRequiredEnum GateKind = "requiredEnum" + // GateOptionalEnum: an optional enum-typed field. + GateOptionalEnum GateKind = "optionalEnum" + // GateBool: a boolean field, required or optional. + GateBool GateKind = "bool" +) + +// rank orders gate kinds; a lower number is a likelier discriminator. +func (k GateKind) rank() int { + switch k { + case GateRequiredEnum: + return 0 + case GateOptionalEnum: + return 1 + case GateBool: + return 2 + default: + return 3 + } +} + +// Gate is one candidate discriminator in the create body: a required-enum, +// optional-enum or boolean field whose value plausibly selects a variant. +type Gate struct { + // Field is the gate's wire property name. + Field string `json:"field"` + // Kind ranks the gate by discriminating power. + Kind GateKind `json:"kind"` + // Values lists the gate's candidate values as strings, sorted: the enum + // members, or "false" and "true" for a boolean. + Values []string `json:"values"` +} + +// SynthHint is the raw material the live executor synthesises a field's value +// from. It carries what the schema declares, never a value: value synthesis +// happens live, so a plan derived twice is identical and a field with no +// example is not guessed at offline. +type SynthHint struct { + // Field is the wire property name. + Field string `json:"field"` + // Required reports whether the field is required in the flattened create + // body. + Required bool `json:"required"` + // Type is the declared JSON type, empty when the schema does not say. + Type string `json:"type,omitempty"` + // Format is the declared string format. + Format string `json:"format,omitempty"` + // Pattern is the declared regular-expression constraint. + Pattern string `json:"pattern,omitempty"` + // Enum lists the declared enum values as strings, sorted. + Enum []string `json:"enum,omitempty"` + // Example is the declared example value, decoded; nil when absent. + Example any `json:"example,omitempty"` + // Default is the declared default value, decoded; nil when absent. + Default any `json:"default,omitempty"` +} + +// Skeleton is a field list plus the per-field synthesis hints for one shape of +// create body. Values are not synthesised here — the executor does that live — +// so a skeleton says which fields to send and what material to build each +// from, not what to send. +type Skeleton struct { + // Fields is the sorted list of writable wire field names to send. + Fields []string `json:"fields"` + // Hints carries one synthesis hint per field, sorted by field. + Hints []SynthHint `json:"hints"` +} + +// Variant is one shape the resource's create body can take: the no-gate +// baseline (empty GateField and GateValue) or a gate value with the field set +// valid for it. Each carries a minimal skeleton (required writable fields for +// the variant) and a maximal skeleton (every writable field plausibly valid +// for it). +type Variant struct { + // GateField and GateValue name the value that selects this variant; both + // empty on the baseline. + GateField string `json:"gateField,omitempty"` + GateValue string `json:"gateValue,omitempty"` + // Provenance records how the variant was seeded: structural for a + // declared oneOf/discriminator branch and for the baseline, derived for a + // gate value with no declared distinct field set. + Provenance Provenance `json:"provenance"` + // Minimal is the smallest valid body for this variant. + Minimal Skeleton `json:"minimal"` + // Maximal is the widest plausibly-valid body for this variant. + Maximal Skeleton `json:"maximal"` +} + +// Check describes the request that would confirm or refute a hypothesis: which +// step kind exercises it, the field it targets, the gate it pins, and what the +// API is expected to do. (Working name — see the package note on deferred +// naming; the retired term "probe" is deliberately avoided.) +type Check struct { + // Step is the step kind that exercises the hypothesis. + Step plan.StepKind `json:"step"` + // Field is the field the check targets, empty when it is about a whole + // variant. + Field string `json:"field,omitempty"` + // GateField and GateValue pin the gate the check holds, where the + // hypothesis has one. + GateField string `json:"gateField,omitempty"` + GateValue string `json:"gateValue,omitempty"` + // Expect is a human-readable statement of the expected outcome: + // "accept", "reject" or "conditional". + Expect string `json:"expect"` +} + +// Hypothesis is one candidate conditional edge for the executor to confirm or +// refute live. Its kind is a working identifier (see HypothesisKind); its +// provenance says how strongly the document grounds it. +type Hypothesis struct { + // Kind names the edge shape (a working identifier). + Kind HypothesisKind `json:"kind"` + // Subjects are the field(s) the edge is about, sorted. + Subjects []string `json:"subjects"` + // GateField and GateValue name the gate value the edge is conditioned on, + // where the kind has one; empty otherwise. + GateField string `json:"gateField,omitempty"` + GateValue string `json:"gateValue,omitempty"` + // Provenance is structural or prose. + Provenance Provenance `json:"provenance"` + // Check is the request that would confirm or refute the edge. + Check Check `json:"check"` +} + +// Step is one ordered step of the resource's step program. It reuses the +// approved step-kind names (plan.StepKind) but is composed per-resource: +// creates and reads repeat per variant, updates walk the writable fields, the +// negative and per-value steps target the gates. +type Step struct { + // Kind is the approved step kind. + Kind plan.StepKind `json:"kind"` + // Field is the wire property under test, for per-field steps. + Field string `json:"field,omitempty"` + // GateField and GateValue pin a variant- or value-scoped step; empty on + // a baseline or entity-level step. + GateField string `json:"gateField,omitempty"` + GateValue string `json:"gateValue,omitempty"` +} + +// Budget is the per-resource request budget. Requests scales with complexity; +// Formula records how it was computed so a plan dump can show the arithmetic. +type Budget struct { + Requests int `json:"requests"` + Formula string `json:"formula"` +} + +// Strategy is the compiled step program for one resource: the gate candidates +// found, the variants (baseline plus one per gate value), the hypotheses to +// confirm live, the ordered step program, and the scaled budget. +type Strategy struct { + // Entity is the classified entity key the strategy is for. + Entity string `json:"entity"` + // Role is "resource" for a full lifecycle, "lookup" for a datasource + // whose only access is the item read, "datasource" for a list-plus-read + // datasource read read-only. + Role string `json:"role"` + // ReadOnly marks a strategy that never writes: its program is reads only. + ReadOnly bool `json:"readOnly"` + // Gates lists the gate candidates, ranked likeliest-first. + Gates []Gate `json:"gates,omitempty"` + // Variants lists the baseline and one variant per gate value. + Variants []Variant `json:"variants,omitempty"` + // Hypotheses lists the candidate conditional edges, sorted. + Hypotheses []Hypothesis `json:"hypotheses,omitempty"` + // Program is the ordered steps. + Program []Step `json:"program"` + // Budget is the per-resource request budget. + Budget Budget `json:"budget"` +} + +// Compile composes the strategy for one classified entity. It is pure: the +// same document, classification and config always produce the same Strategy. +// +// A resource (full create/read/delete lifecycle) yields a writing strategy — +// gates, variants, hypotheses and the full step program. An entity whose only +// access is a read yields a read-only strategy. An entity that is neither is an +// error: the caller decides what to audit, and a strategy for something with no +// exercisable surface would be an empty program masquerading as a plan. +func Compile(doc *specmodel.Document, class specmodel.Classification, cfg *config.Config) (*Strategy, error) { + if doc == nil { + return nil, fmt.Errorf("strategy: the document is nil") + } + if cfg == nil { + return nil, fmt.Errorf("strategy: the config is nil") + } + + if hasKind(class, specmodel.KindResource) { + return compileResource(doc, class, cfg) + } + if class.Read != nil { + return compileReadOnly(class), nil + } + return nil, fmt.Errorf("strategy: entity %q is not auditable: it has neither a create/read/delete lifecycle nor a readable item", class.Key) +} + +// compileReadOnly builds the strategy for an entity the audit only reads: a +// lookup-by-key datasource, or a list-plus-read datasource. Nothing is +// created, so the program is the read and a consecutive read for volatility. +func compileReadOnly(class specmodel.Classification) *Strategy { + role := "datasource" + if class.LookupByKey { + role = "lookup" + } + return &Strategy{ + Entity: class.Key, + Role: role, + ReadOnly: true, + Program: []Step{ + {Kind: plan.StepRead}, + {Kind: plan.StepReadConsecutive}, + }, + Budget: Budget{ + Requests: readOnlyBudget, + Formula: "read-only: 2 reads", + }, + } +} + +// compileResource builds the writing strategy for a full-lifecycle resource. +func compileResource(doc *specmodel.Document, class specmodel.Classification, cfg *config.Config) (*Strategy, error) { + createOp := findOp(doc, class.Create) + if createOp == nil || createOp.RequestBody == nil { + return nil, fmt.Errorf("strategy: resource %q has no create request body to compile against", class.Key) + } + createBody := createOp.RequestBody + + gates := detectGates(createBody) + variants := deriveVariants(createBody, gates) + hyps := deriveHypotheses(createBody, variants) + + s := &Strategy{ + Entity: class.Key, + Role: "resource", + Gates: gates, + Variants: variants, + Hypotheses: hyps, + } + s.Program = buildProgram(createBody, gates, variants, hyps) + s.Budget = deriveBudget(createBody, variants, cfg) + return s, nil +} + +// JSON renders the strategy for printing and for a future `audit plan --print` +// dump: two-space indented, HTML escaping off, trailing newline. Deterministic +// for a given strategy — struct fields encode in declaration order and maps +// encode sorted. +func (s *Strategy) JSON() ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(s); err != nil { + return nil, fmt.Errorf("encoding the strategy: %w", err) + } + return buf.Bytes(), nil +} + +// findOp resolves a classification operation reference to the loaded +// operation, nil when the reference is nil or unresolvable. +func findOp(doc *specmodel.Document, ref *specmodel.Op) *specmodel.Operation { + if ref == nil { + return nil + } + for pi := range doc.Paths { + p := &doc.Paths[pi] + if p.Path != ref.Path { + continue + } + for oi := range p.Operations { + if p.Operations[oi].Method == ref.Method { + return &p.Operations[oi] + } + } + } + return nil +} + +// hasKind reports whether a classification yields the kind. +func hasKind(c specmodel.Classification, k specmodel.Kind) bool { + for _, have := range c.Kinds { + if have == k { + return true + } + } + return false +} diff --git a/internal/audit/strategy/strategy_test.go b/internal/audit/strategy/strategy_test.go new file mode 100644 index 0000000..bd84ed6 --- /dev/null +++ b/internal/audit/strategy/strategy_test.go @@ -0,0 +1,663 @@ +package strategy_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/audit/strategy" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/config" + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// defaultCfg is a config with the live-object budget the ceiling derives from. +func defaultCfg() *config.Config { + return &config.Config{Audit: config.Audit{MaxObjects: 25}} +} + +// compile loads a spec, classifies it, and compiles the named entity. +func compile(t *testing.T, spec, key string, cfg *config.Config) *strategy.Strategy { + t.Helper() + doc, err := specmodel.Load([]byte(spec)) + if err != nil { + t.Fatalf("load: %v", err) + } + cls := specmodel.Classify(doc) + for _, c := range cls.Entities { + if c.Key == key { + s, err := strategy.Compile(doc, c, cfg) + if err != nil { + t.Fatalf("compile %q: %v", key, err) + } + return s + } + } + var got []string + for _, c := range cls.Entities { + got = append(got, c.Key) + } + t.Fatalf("entity %q not classified; classified: %v", key, got) + return nil +} + +// ---- specs ----------------------------------------------------------------- + +// flatSpec: a resource with no enum or boolean field — no gates, one baseline. +const flatSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /widgets: + post: + operationId: createWidget + requestBody: + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + color: {type: string} + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/Widget'}}}}} + /widgets/{id}: + get: + operationId: getWidget + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Widget'}}}}} + delete: + operationId: deleteWidget + responses: {"204": {description: gone}} +components: + schemas: + Widget: + type: object + properties: + id: {type: string, readOnly: true} + name: {type: string} + color: {type: string} +` + +// oneOfSpec: a resource gated by a `kind` enum whose value selects a oneOf +// branch's distinct field set. No discriminator — branches pin the enum. +const oneOfSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /gadgets: + post: + operationId: createGadget + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/GadgetCreate'} + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/Gadget'}}}}} + /gadgets/{id}: + get: + operationId: getGadget + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Gadget'}}}}} + delete: + operationId: deleteGadget + responses: {"204": {description: gone}} +components: + schemas: + Gadget: + type: object + properties: {id: {type: string, readOnly: true}} + GadgetCreate: + type: object + required: [name, kind] + properties: + name: {type: string} + kind: {type: string, enum: [x, y]} + oneOf: + - type: object + properties: + kind: {type: string, enum: [x]} + xField: {type: string} + - type: object + properties: + kind: {type: string, enum: [y]} + yField: {type: string} +` + +// discriminatorSpec: a resource gated by `type`, mapped to named branches by +// an explicit discriminator. +const discriminatorSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /tests: + post: + operationId: createTest + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/TestCreate'} + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/TestObj'}}}}} + /tests/{id}: + get: + operationId: getTest + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/TestObj'}}}}} + delete: + operationId: deleteTest + responses: {"204": {description: gone}} +components: + schemas: + TestObj: + type: object + properties: {id: {type: string, readOnly: true}} + Http: + type: object + properties: {url: {type: string}} + Dns: + type: object + properties: {domain: {type: string}} + TestCreate: + type: object + required: [name, type] + properties: + name: {type: string} + type: {type: string, enum: [http, dns]} + oneOf: + - {$ref: '#/components/schemas/Http'} + - {$ref: '#/components/schemas/Dns'} + discriminator: + propertyName: type + mapping: + http: '#/components/schemas/Http' + dns: '#/components/schemas/Dns' +` + +// dependentSpec: a resource with dependentRequired and dependentSchemas. +const dependentSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /alarms: + post: + operationId: createAlarm + requestBody: + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + a: {type: string} + b: {type: string} + c: {type: string} + d: {type: string} + dependentRequired: + a: [b] + dependentSchemas: + c: + required: [d] + properties: + d: {type: string} + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/Alarm'}}}}} + /alarms/{id}: + get: + operationId: getAlarm + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Alarm'}}}}} + delete: + operationId: deleteAlarm + responses: {"204": {description: gone}} +components: + schemas: + Alarm: + type: object + properties: {id: {type: string, readOnly: true}} +` + +// proseSpec: a resource whose descriptions carry conditional language — a +// value-gated requirement, a validity edge, an exclusion, and one hint that +// names nothing and must be discarded. +const proseSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /rules: + post: + operationId: createRule + requestBody: + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + mode: {type: string, enum: [dynamic, static]} + query: + type: string + description: "Required when mode is dynamic." + region: + type: string + description: "Only applies when mode is static." + primary: + type: string + description: "Cannot be used with secondary." + secondary: {type: string} + notes: + type: string + description: "Free-form operator notes with no special behaviour." + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/Rule'}}}}} + /rules/{id}: + get: + operationId: getRule + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Rule'}}}}} + delete: + operationId: deleteRule + responses: {"204": {description: gone}} +components: + schemas: + Rule: + type: object + properties: {id: {type: string, readOnly: true}} +` + +// boolSpec: a resource gated by a boolean. +const boolSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /flags: + post: + operationId: createFlag + requestBody: + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + enabled: {type: boolean} + responses: {"201": {description: made, content: {application/json: {schema: {$ref: '#/components/schemas/Flag'}}}}} + /flags/{id}: + get: + operationId: getFlag + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Flag'}}}}} + delete: + operationId: deleteFlag + responses: {"204": {description: gone}} +components: + schemas: + Flag: + type: object + properties: {id: {type: string, readOnly: true}} +` + +// lookupSpec: a datasource whose only access is the item read (no list) — +// yields a read-only lookup strategy. +const lookupSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /agents/{name}: + get: + operationId: getAgent + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Agent'}}}}} +components: + schemas: + Agent: + type: object + properties: {name: {type: string}} +` + +// listDatasourceSpec: a list-plus-read datasource (no create/delete) — a +// read-only strategy in the datasource role. +const listDatasourceSpec = `openapi: 3.0.3 +info: {title: T, version: "1"} +paths: + /zones: + get: + operationId: listZones + responses: {"200": {description: ok, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Zone'}}}}}} + /zones/{id}: + get: + operationId: getZone + responses: {"200": {description: ok, content: {application/json: {schema: {$ref: '#/components/schemas/Zone'}}}}} +components: + schemas: + Zone: + type: object + properties: {id: {type: string}} +` + +// ---- helpers --------------------------------------------------------------- + +func findVariant(s *strategy.Strategy, gateField, gateValue string) *strategy.Variant { + for i := range s.Variants { + if s.Variants[i].GateField == gateField && s.Variants[i].GateValue == gateValue { + return &s.Variants[i] + } + } + return nil +} + +func findHypothesis(s *strategy.Strategy, kind strategy.HypothesisKind, subject string) *strategy.Hypothesis { + for i := range s.Hypotheses { + for _, sub := range s.Hypotheses[i].Subjects { + if s.Hypotheses[i].Kind == kind && sub == subject { + return &s.Hypotheses[i] + } + } + } + return nil +} + +func countSteps(s *strategy.Strategy, kind string) int { + n := 0 + for _, st := range s.Program { + if string(st.Kind) == kind { + n++ + } + } + return n +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// ---- tests ----------------------------------------------------------------- + +func TestFlatResourceHasSingleBaseline(t *testing.T) { + s := compile(t, flatSpec, "widget", defaultCfg()) + + if s.Role != "resource" || s.ReadOnly { + t.Fatalf("role=%q readOnly=%v, want resource/false", s.Role, s.ReadOnly) + } + if len(s.Gates) != 0 { + t.Fatalf("gates=%v, want none", s.Gates) + } + if len(s.Variants) != 1 { + t.Fatalf("variants=%d, want 1 baseline", len(s.Variants)) + } + base := s.Variants[0] + if base.GateField != "" || base.GateValue != "" { + t.Fatalf("baseline carries a gate: %+v", base) + } + if !reflect.DeepEqual(base.Minimal.Fields, []string{"name"}) { + t.Fatalf("minimal=%v, want [name]", base.Minimal.Fields) + } + if !reflect.DeepEqual(base.Maximal.Fields, []string{"color", "name"}) { + t.Fatalf("maximal=%v, want [color name]", base.Maximal.Fields) + } + if len(s.Hypotheses) != 0 { + t.Fatalf("hypotheses=%v, want none", s.Hypotheses) + } + // The program covers the full lifecycle and ends with cleanup. + if s.Program[len(s.Program)-1].Kind != "cleanupDelete" { + t.Fatalf("last step=%q, want cleanupDelete", s.Program[len(s.Program)-1].Kind) + } + if countSteps(s, "createMinimal") != 1 { + t.Fatalf("createMinimal count=%d, want 1", countSteps(s, "createMinimal")) + } +} + +func TestOneOfResourceYieldsVariantPerValue(t *testing.T) { + s := compile(t, oneOfSpec, "gadget", defaultCfg()) + + if len(s.Gates) != 1 || s.Gates[0].Field != "kind" || s.Gates[0].Kind != strategy.GateRequiredEnum { + t.Fatalf("gates=%+v, want one required-enum gate on kind", s.Gates) + } + // baseline + x + y. + if len(s.Variants) != 3 { + t.Fatalf("variants=%d, want 3", len(s.Variants)) + } + vx := findVariant(s, "kind", "x") + vy := findVariant(s, "kind", "y") + if vx == nil || vy == nil { + t.Fatalf("missing a gate variant: %+v", s.Variants) + } + if vx.Provenance != strategy.ProvenanceStructural { + t.Fatalf("x provenance=%q, want structural", vx.Provenance) + } + if !contains(vx.Maximal.Fields, "xField") || contains(vx.Maximal.Fields, "yField") { + t.Fatalf("x maximal=%v, want xField without yField", vx.Maximal.Fields) + } + if !contains(vy.Maximal.Fields, "yField") || contains(vy.Maximal.Fields, "xField") { + t.Fatalf("y maximal=%v, want yField without xField", vy.Maximal.Fields) + } + // Each branch's extra field is a structural variant hypothesis. + h := findHypothesis(s, strategy.HypothesisVariant, "xField") + if h == nil || h.GateValue != "x" || h.Provenance != strategy.ProvenanceStructural { + t.Fatalf("missing structural variant hypothesis for xField: %+v", s.Hypotheses) + } + // A per-value create is scheduled for each gate value. + if countSteps(s, "createPerEnumValue") < 2 { + t.Fatalf("createPerEnumValue count=%d, want >=2", countSteps(s, "createPerEnumValue")) + } +} + +func TestDiscriminatorMappingResolvesBranches(t *testing.T) { + s := compile(t, discriminatorSpec, "test", defaultCfg()) + + vhttp := findVariant(s, "type", "http") + vdns := findVariant(s, "type", "dns") + if vhttp == nil || vdns == nil { + t.Fatalf("variants=%+v, want http and dns", s.Variants) + } + if !contains(vhttp.Maximal.Fields, "url") || contains(vhttp.Maximal.Fields, "domain") { + t.Fatalf("http maximal=%v, want url without domain", vhttp.Maximal.Fields) + } + if !contains(vdns.Maximal.Fields, "domain") || contains(vdns.Maximal.Fields, "url") { + t.Fatalf("dns maximal=%v, want domain without url", vdns.Maximal.Fields) + } + if vhttp.Provenance != strategy.ProvenanceStructural { + t.Fatalf("http provenance=%q, want structural", vhttp.Provenance) + } +} + +func TestDependentRequiredYieldsRequiresField(t *testing.T) { + s := compile(t, dependentSpec, "alarm", defaultCfg()) + + // dependentRequired a:[b] -> requiresField {a,b}, structural. + h := findHypothesis(s, strategy.HypothesisRequiresField, "a") + if h == nil { + t.Fatalf("no requiresField hypothesis for a: %+v", s.Hypotheses) + } + if h.Provenance != strategy.ProvenanceStructural { + t.Fatalf("provenance=%q, want structural", h.Provenance) + } + if !contains(h.Subjects, "a") || !contains(h.Subjects, "b") { + t.Fatalf("subjects=%v, want a and b", h.Subjects) + } + // dependentSchemas c -> requires d. + hc := findHypothesis(s, strategy.HypothesisRequiresField, "c") + if hc == nil || !contains(hc.Subjects, "d") { + t.Fatalf("no requiresField hypothesis for c requiring d: %+v", s.Hypotheses) + } +} + +func TestProseRequiredWhenAndDiscard(t *testing.T) { + s := compile(t, proseSpec, "rule", defaultCfg()) + + // "Required when mode is dynamic." -> requiredWhen(query, mode=dynamic), prose. + h := findHypothesis(s, strategy.HypothesisRequiredWhen, "query") + if h == nil { + t.Fatalf("no requiredWhen hypothesis for query: %+v", s.Hypotheses) + } + if h.Provenance != strategy.ProvenanceProse || h.GateField != "mode" || h.GateValue != "dynamic" { + t.Fatalf("query hypothesis=%+v, want prose mode=dynamic", *h) + } + // "Only applies when mode is static." -> validWhen(region, mode=static), prose. + hr := findHypothesis(s, strategy.HypothesisValidWhen, "region") + if hr == nil || hr.GateValue != "static" || hr.Provenance != strategy.ProvenanceProse { + t.Fatalf("region validWhen hypothesis wrong: %+v", s.Hypotheses) + } + // "Cannot be used with secondary." -> mutuallyExclusive{primary,secondary}, prose. + he := findHypothesis(s, strategy.HypothesisMutuallyExclusive, "primary") + if he == nil || !contains(he.Subjects, "secondary") || he.Provenance != strategy.ProvenanceProse { + t.Fatalf("primary mutuallyExclusive hypothesis wrong: %+v", s.Hypotheses) + } + // The notes field names nothing -> discarded, no hypothesis mentions it. + if findHypothesis(s, strategy.HypothesisRequiredWhen, "notes") != nil || + findHypothesis(s, strategy.HypothesisValidWhen, "notes") != nil || + findHypothesis(s, strategy.HypothesisRequiresField, "notes") != nil { + t.Fatalf("a hint naming nothing was not discarded: %+v", s.Hypotheses) + } +} + +func TestBoolGate(t *testing.T) { + s := compile(t, boolSpec, "flag", defaultCfg()) + + if len(s.Gates) != 1 || s.Gates[0].Kind != strategy.GateBool || s.Gates[0].Field != "enabled" { + t.Fatalf("gates=%+v, want one bool gate on enabled", s.Gates) + } + if !reflect.DeepEqual(s.Gates[0].Values, []string{"false", "true"}) { + t.Fatalf("bool values=%v, want [false true]", s.Gates[0].Values) + } + // baseline + false + true, both derived (a bool declares no branch fields). + if len(s.Variants) != 3 { + t.Fatalf("variants=%d, want 3", len(s.Variants)) + } + vt := findVariant(s, "enabled", "true") + if vt == nil || vt.Provenance != strategy.ProvenanceDerived { + t.Fatalf("enabled=true variant wrong: %+v", s.Variants) + } + // A bool gate gets no undocumented-value step. + if countSteps(s, "undocumentedEnumValue") != 0 { + t.Fatalf("undocumentedEnumValue count=%d, want 0 for a bool gate", countSteps(s, "undocumentedEnumValue")) + } +} + +func TestLookupDatasourceIsReadOnly(t *testing.T) { + s := compile(t, lookupSpec, "agent", defaultCfg()) + + if !s.ReadOnly || s.Role != "lookup" { + t.Fatalf("role=%q readOnly=%v, want lookup/true", s.Role, s.ReadOnly) + } + if len(s.Gates) != 0 || len(s.Variants) != 0 { + t.Fatalf("read-only strategy carries gates/variants: %+v", s) + } + kinds := []string{} + for _, st := range s.Program { + kinds = append(kinds, string(st.Kind)) + } + if !reflect.DeepEqual(kinds, []string{"read", "readConsecutive"}) { + t.Fatalf("program=%v, want [read readConsecutive]", kinds) + } +} + +func TestListDatasourceIsReadOnly(t *testing.T) { + s := compile(t, listDatasourceSpec, "zone", defaultCfg()) + if !s.ReadOnly || s.Role != "datasource" { + t.Fatalf("role=%q readOnly=%v, want datasource/true", s.Role, s.ReadOnly) + } +} + +func TestBudgetScalesWithComplexity(t *testing.T) { + flat := compile(t, flatSpec, "widget", defaultCfg()) + disc := compile(t, discriminatorSpec, "test", defaultCfg()) + + if disc.Budget.Requests <= flat.Budget.Requests { + t.Fatalf("discriminated budget %d not greater than flat %d", disc.Budget.Requests, flat.Budget.Requests) + } + if flat.Budget.Formula == "" || disc.Budget.Formula == "" { + t.Fatalf("budget formula missing: flat=%q disc=%q", flat.Budget.Formula, disc.Budget.Formula) + } + // The read-only budget is small and fixed. + ro := compile(t, lookupSpec, "agent", defaultCfg()) + if ro.Budget.Requests != 2 { + t.Fatalf("read-only budget=%d, want 2", ro.Budget.Requests) + } +} + +func TestBudgetCeilingCaps(t *testing.T) { + // A tiny object budget forces the ceiling to bind. + cfg := &config.Config{Audit: config.Audit{MaxObjects: 1}} + s := compile(t, discriminatorSpec, "test", cfg) + if s.Budget.Requests != 12 { // maxObjects(1) × perObjectCost(12) + t.Fatalf("capped budget=%d, want 12", s.Budget.Requests) + } + if !strings.Contains(s.Budget.Formula, "capped") { + t.Fatalf("formula %q should record the cap", s.Budget.Formula) + } +} + +func TestBudgetDefaultsMaxObjects(t *testing.T) { + // MaxObjects unset (0) falls back to 25, so the ceiling does not bind. + cfg := &config.Config{} + s := compile(t, flatSpec, "widget", cfg) + if s.Budget.Requests == 0 { + t.Fatalf("budget should be non-zero with defaulted maxObjects") + } +} + +func TestDeterministic(t *testing.T) { + doc, err := specmodel.Load([]byte(discriminatorSpec)) + if err != nil { + t.Fatalf("load: %v", err) + } + cls := specmodel.Classify(doc) + var c specmodel.Classification + for _, e := range cls.Entities { + if e.Key == "test" { + c = e + } + } + + s1, err := strategy.Compile(doc, c, defaultCfg()) + if err != nil { + t.Fatalf("compile 1: %v", err) + } + s2, err := strategy.Compile(doc, c, defaultCfg()) + if err != nil { + t.Fatalf("compile 2: %v", err) + } + if !reflect.DeepEqual(s1, s2) { + t.Fatalf("Compile is not deterministic under DeepEqual") + } + j1, err := s1.JSON() + if err != nil { + t.Fatalf("json 1: %v", err) + } + j2, err := s2.JSON() + if err != nil { + t.Fatalf("json 2: %v", err) + } + if string(j1) != string(j2) { + t.Fatalf("JSON is not deterministic:\n%s\n---\n%s", j1, j2) + } + if len(j1) == 0 || j1[len(j1)-1] != '\n' { + t.Fatalf("JSON should end with a newline") + } +} + +func TestCompileErrors(t *testing.T) { + doc, _ := specmodel.Load([]byte(flatSpec)) + cls := specmodel.Classify(doc) + var widget specmodel.Classification + for _, e := range cls.Entities { + if e.Key == "widget" { + widget = e + } + } + + if _, err := strategy.Compile(nil, widget, defaultCfg()); err == nil { + t.Fatal("nil doc should error") + } + if _, err := strategy.Compile(doc, widget, nil); err == nil { + t.Fatal("nil cfg should error") + } + // An entity that is neither a resource nor readable. + action := specmodel.Classification{ + Key: "invoke", + Kinds: []specmodel.Kind{specmodel.KindAction}, + Create: &specmodel.Op{Method: "POST", Path: "/invoke"}, + } + if _, err := strategy.Compile(doc, action, defaultCfg()); err == nil { + t.Fatal("non-auditable entity should error") + } + // A resource whose create operation cannot be resolved to a body. + bad := specmodel.Classification{ + Key: "phantom", + Kinds: []specmodel.Kind{specmodel.KindResource}, + Create: &specmodel.Op{Method: "POST", Path: "/nowhere"}, + } + if _, err := strategy.Compile(doc, bad, defaultCfg()); err == nil { + t.Fatal("resource with no create body should error") + } +} diff --git a/internal/audit/strategy/variants.go b/internal/audit/strategy/variants.go new file mode 100644 index 0000000..11221da --- /dev/null +++ b/internal/audit/strategy/variants.go @@ -0,0 +1,310 @@ +package strategy + +import ( + "sort" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen-1/internal/specmodel" +) + +// maxVariantValues caps the gate values that become variants, so a wide enum +// yields a bounded program. The cap takes the sorted-first values, keeping the +// selection deterministic. +const maxVariantValues = 8 + +// deriveVariants composes the baseline variant and one variant per value of +// the primary gate. Each variant's field set is the common (base) writable +// fields plus, where a declared branch selects on the gate value, that +// branch's writable fields. A variant whose value maps to no declared branch +// still exists — its field set is the baseline, its provenance derived — so +// the executor still creates one object per value to observe value-conditional +// behaviour. +func deriveVariants(createBody *specmodel.Schema, gates []Gate) []Variant { + base := flatFields(createBody) + baseIndex := indexFields(base) + + baseline := Variant{ + Provenance: ProvenanceStructural, + Minimal: skeleton(requiredNames(base), baseIndex), + Maximal: skeleton(fieldNames(base), baseIndex), + } + variants := []Variant{baseline} + + gate := primaryGate(gates) + if gate == nil { + return variants + } + + branches := gatherBranches(createBody) + disc := createBody.Resolved().Discriminator + + values := gate.Values + if len(values) > maxVariantValues { + values = values[:maxVariantValues] + } + for _, value := range values { + branch := matchBranch(gate.Field, value, branches, disc) + variants = append(variants, buildVariant(gate.Field, value, base, branch)) + } + return variants +} + +// buildVariant assembles one gate-value variant from the base fields and the +// matched branch (nil when the value maps to no declared branch). +func buildVariant(gateField, value string, base []field, branch *specmodel.Schema) Variant { + combined := append([]field(nil), base...) + provenance := ProvenanceDerived + if branch != nil { + provenance = ProvenanceStructural + for _, f := range flatFields(branch) { + combined = appendUniqueField(combined, f) + } + } + index := indexFields(combined) + + minimal := requiredNames(combined) + minimal = appendUnique(minimal, gateField) // the variant pins the gate + maximal := fieldNames(combined) + + return Variant{ + GateField: gateField, + GateValue: value, + Provenance: provenance, + Minimal: skeleton(minimal, index), + Maximal: skeleton(maximal, index), + } +} + +// gatherBranches collects a schema's oneOf and anyOf branches, folding through +// allOf so a branch declared beside a composition is still found. Branches are +// returned unresolved, so a $ref branch keeps its target name for discriminator +// matching. +func gatherBranches(s *specmodel.Schema) []*specmodel.Schema { + var out []*specmodel.Schema + var walk func(r *specmodel.Schema, depth int) + walk = func(r *specmodel.Schema, depth int) { + if r == nil || depth > maxDepth { + return + } + res := r.Resolved() + out = append(out, res.OneOf...) + out = append(out, res.AnyOf...) + for _, b := range res.AllOf { + walk(b, depth+1) + } + } + walk(s, 0) + return out +} + +// matchBranch finds the composition branch a gate value selects: by explicit +// discriminator mapping first (the branch whose reference name the mapping +// names), then by the branch that constrains the gate field to the value +// through its own enum. Nil when no branch matches. +func matchBranch(gateField, value string, branches []*specmodel.Schema, disc *specmodel.Discriminator) *specmodel.Schema { + if disc != nil && disc.PropertyName == gateField { + if target, ok := disc.Mapping[value]; ok { + for _, b := range branches { + if b.Ref == target || b.Resolved().Name == target { + return b + } + } + } + } + for _, b := range branches { + if branchAdmitsValue(b, gateField, value) { + return b + } + } + return nil +} + +// branchAdmitsValue reports whether a branch constrains the gate field to the +// value: the branch declares the gate property with an enum whose members +// include the value. +func branchAdmitsValue(branch *specmodel.Schema, gateField, value string) bool { + for _, f := range flatFields(branch) { + if f.name != gateField { + continue + } + for _, v := range stringifyValues(f.schema.Resolved().Enum) { + if v == value { + return true + } + } + } + return false +} + +// deriveHypotheses composes every candidate conditional edge: the structural +// ones (variants, dependentRequired, dependentSchemas) and the prose ones +// (mined from descriptions), sorted and de-duplicated. +func deriveHypotheses(createBody *specmodel.Schema, variants []Variant) []Hypothesis { + var hyps []Hypothesis + hyps = append(hyps, variantHypotheses(variants)...) + hyps = append(hyps, dependentHypotheses(createBody)...) + hyps = append(hyps, proseHypotheses(createBody)...) + + sortHypotheses(hyps) + return dedupHypotheses(hyps) +} + +// variantHypotheses emits one variant edge per structural (branch-backed) +// variant: the fields the branch adds beyond the baseline are valid only under +// that gate value. +func variantHypotheses(variants []Variant) []Hypothesis { + if len(variants) == 0 { + return nil + } + baseline := map[string]bool{} + for _, f := range variants[0].Maximal.Fields { + baseline[f] = true + } + var out []Hypothesis + for _, v := range variants[1:] { + if v.Provenance != ProvenanceStructural { + continue + } + var only []string + for _, f := range v.Maximal.Fields { + if !baseline[f] && f != v.GateField { + only = append(only, f) + } + } + if len(only) == 0 { + continue + } + sort.Strings(only) + out = append(out, Hypothesis{ + Kind: HypothesisVariant, + Subjects: only, + GateField: v.GateField, + GateValue: v.GateValue, + Provenance: ProvenanceStructural, + Check: Check{ + Step: stepCreateMaximal, GateField: v.GateField, GateValue: v.GateValue, + Expect: "conditional", + }, + }) + } + return out +} + +// dependentHypotheses emits the co-requirement edges JSON-Schema declares: +// dependentRequired (present X ⇒ required Y…) and dependentSchemas whose +// subschema adds a required set. Both become requiresField hypotheses with +// structural provenance. +func dependentHypotheses(createBody *specmodel.Schema) []Hypothesis { + r := createBody.Resolved() + var out []Hypothesis + for _, dr := range r.DependentRequired { + subjects := appendUnique(append([]string(nil), dr.Requires...), dr.Property) + sort.Strings(subjects) + out = append(out, requiresFieldHypothesis(subjects, dr.Property)) + } + for _, ds := range r.DependentSchemas { + req := ds.Schema.Resolved().Required + if len(req) == 0 { + continue + } + subjects := appendUnique(append([]string(nil), req...), ds.Property) + sort.Strings(subjects) + out = append(out, requiresFieldHypothesis(subjects, ds.Property)) + } + return out +} + +// requiresFieldHypothesis builds a structural requiresField edge whose check is +// a maximal create — send the whole set and see whether the API enforces the +// co-requirement. +func requiresFieldHypothesis(subjects []string, trigger string) Hypothesis { + return Hypothesis{ + Kind: HypothesisRequiresField, + Subjects: subjects, + Provenance: ProvenanceStructural, + Check: Check{Step: stepCreateMaximal, Field: trigger, Expect: "conditional"}, + } +} + +// requiredNames lists the required fields of a field slice, sorted. +func requiredNames(fields []field) []string { + var out []string + for _, f := range fields { + if f.required { + out = append(out, f.name) + } + } + sort.Strings(out) + return out +} + +// appendUnique appends s to names when absent. +func appendUnique(names []string, s string) []string { + for _, n := range names { + if n == s { + return names + } + } + return append(names, s) +} + +// appendUniqueField appends f to fields when its name is absent. +func appendUniqueField(fields []field, f field) []field { + for _, existing := range fields { + if existing.name == f.name { + return fields + } + } + return append(fields, f) +} + +// sortHypotheses orders hypotheses so the compiled strategy is byte-stable: by +// kind, then subjects, then gate, then provenance. +func sortHypotheses(hyps []Hypothesis) { + sort.SliceStable(hyps, func(i, j int) bool { + a, b := hyps[i], hyps[j] + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if ja, jb := joinSubjects(a.Subjects), joinSubjects(b.Subjects); ja != jb { + return ja < jb + } + if a.GateField != b.GateField { + return a.GateField < b.GateField + } + if a.GateValue != b.GateValue { + return a.GateValue < b.GateValue + } + return a.Provenance < b.Provenance + }) +} + +// dedupHypotheses drops hypotheses identical in kind, subjects and gate, +// keeping the first — which, after the structural-before-prose append order and +// the stable sort, is the better-grounded one. +func dedupHypotheses(hyps []Hypothesis) []Hypothesis { + seen := map[string]bool{} + out := hyps[:0] + for _, h := range hyps { + key := string(h.Kind) + "\x00" + joinSubjects(h.Subjects) + "\x00" + h.GateField + "\x00" + h.GateValue + if seen[key] { + continue + } + seen[key] = true + out = append(out, h) + } + return out +} + +// joinSubjects renders a subject list for sorting and keying. +func joinSubjects(subjects []string) string { + s := append([]string(nil), subjects...) + sort.Strings(s) + out := "" + for i, v := range s { + if i > 0 { + out += "," + } + out += v + } + return out +} diff --git a/internal/specmodel/load.go b/internal/specmodel/load.go index e6119ca..e4c52bb 100644 --- a/internal/specmodel/load.go +++ b/internal/specmodel/load.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "sort" + "strings" "gopkg.in/yaml.v3" ) @@ -407,6 +408,12 @@ func (l *loader) schema(node *yaml.Node, at string) (*Schema, error) { if f := lookup(node, "format"); f != nil { s.Format = f.Value } + if pat := lookup(node, "pattern"); pat != nil { + s.Pattern = pat.Value + } + if desc := lookup(node, "description"); desc != nil { + s.Description = desc.Value + } if ro := deref(lookup(node, "readOnly")); ro != nil { if err := ro.Decode(&s.ReadOnly); err != nil { return nil, fmt.Errorf("%s.readOnly: must be true or false, got %q", at, ro.Value) @@ -481,9 +488,69 @@ func (l *loader) schema(node *yaml.Node, at string) (*Schema, error) { } } + if disc := deref(lookup(node, "discriminator")); disc != nil { + d, err := discriminator(disc, at+".discriminator") + if err != nil { + return nil, err + } + s.Discriminator = d + } + if dr := deref(lookup(node, "dependentRequired")); dr != nil { + for name, listNode := range pairs(dr) { + var reqs []string + for _, rn := range deref(listNode).Content { + reqs = append(reqs, deref(rn).Value) + } + s.DependentRequired = append(s.DependentRequired, DependentRequired{Property: name, Requires: reqs}) + } + sort.Slice(s.DependentRequired, func(i, j int) bool { + return s.DependentRequired[i].Property < s.DependentRequired[j].Property + }) + } + if ds := deref(lookup(node, "dependentSchemas")); ds != nil { + for name, sn := range pairs(ds) { + child, err := l.schema(sn, at+".dependentSchemas."+name) + if err != nil { + return nil, err + } + s.DependentSchemas = append(s.DependentSchemas, DependentSchema{Property: name, Schema: child}) + } + sort.Slice(s.DependentSchemas, func(i, j int) bool { + return s.DependentSchemas[i].Property < s.DependentSchemas[j].Property + }) + } + return s, nil } +// discriminator reads an OpenAPI discriminator object: a required propertyName +// and an optional value→schema mapping, whose reference values are reduced to +// the target component name so a consumer never re-parses a pointer. +func discriminator(node *yaml.Node, at string) (*Discriminator, error) { + pn := lookup(node, "propertyName") + if pn == nil || pn.Value == "" { + return nil, fmt.Errorf("%s: a discriminator needs a non-empty propertyName", at) + } + d := &Discriminator{PropertyName: pn.Value} + if m := deref(lookup(node, "mapping")); m != nil { + d.Mapping = map[string]string{} + for value, refNode := range pairs(m) { + d.Mapping[value] = discriminatorTarget(deref(refNode).Value) + } + } + return d, nil +} + +// discriminatorTarget reduces a discriminator mapping value to a schema name: +// a "#/components/schemas/Name" reference becomes "Name", and a bare name is +// taken as written. +func discriminatorTarget(ref string) string { + if i := strings.LastIndex(ref, "/"); i >= 0 { + return ref[i+1:] + } + return ref +} + // schemaType reads a type declaration, collapsing a 3.1 type array to its // first non-"null" entry: nullability is not a distinction any consumer of // this model acts on, and the underlying type is. diff --git a/internal/specmodel/load_test.go b/internal/specmodel/load_test.go index 2fb627d..c63a022 100644 --- a/internal/specmodel/load_test.go +++ b/internal/specmodel/load_test.go @@ -637,3 +637,81 @@ func TestUnit_Specmodel_LoadRefusesUndecodableDefault(t *testing.T) { t.Fatalf("Load = %v, want a default decode error", err) } } + +// TestLoadStructuralFields covers the schema fields the audit strategy compiler +// reads: description, pattern, discriminator (with a mapping reduced to a +// component name and a bare-name mapping), dependentRequired and +// dependentSchemas, each sorted by triggering property. +func TestLoadStructuralFields(t *testing.T) { + doc, err := Load([]byte(`openapi: 3.0.3 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Http: {type: object, properties: {url: {type: string}}} + Body: + type: object + required: [type] + properties: + type: {type: string, enum: [http, dns]} + code: + type: string + pattern: "^[a-z]+$" + description: "A short code." + oneOf: + - {$ref: '#/components/schemas/Http'} + discriminator: + propertyName: type + mapping: + http: '#/components/schemas/Http' + dns: Dns + dependentRequired: + z: [b] + a: [c] + dependentSchemas: + p: + required: [q] +`)) + if err != nil { + t.Fatalf("Load: %v", err) + } + body := doc.Schemas["Body"] + + code, _ := body.Property("code") + if code.Pattern != "^[a-z]+$" || code.Description != "A short code." { + t.Fatalf("pattern=%q description=%q", code.Pattern, code.Description) + } + if body.Discriminator == nil || body.Discriminator.PropertyName != "type" { + t.Fatalf("discriminator not parsed: %+v", body.Discriminator) + } + if body.Discriminator.Mapping["http"] != "Http" { + t.Fatalf("mapping http=%q, want Http (reference reduced to name)", body.Discriminator.Mapping["http"]) + } + if body.Discriminator.Mapping["dns"] != "Dns" { + t.Fatalf("mapping dns=%q, want Dns (bare name)", body.Discriminator.Mapping["dns"]) + } + if len(body.DependentRequired) != 2 || body.DependentRequired[0].Property != "a" || body.DependentRequired[1].Property != "z" { + t.Fatalf("dependentRequired not sorted by property: %+v", body.DependentRequired) + } + if len(body.DependentSchemas) != 1 || body.DependentSchemas[0].Property != "p" { + t.Fatalf("dependentSchemas: %+v", body.DependentSchemas) + } + if len(body.DependentSchemas[0].Schema.Resolved().Required) != 1 { + t.Fatalf("dependentSchemas subschema required not parsed") + } +} + +func TestLoadDiscriminatorRejectsEmptyPropertyName(t *testing.T) { + _, err := Load([]byte(`openapi: 3.0.3 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Body: + type: object + discriminator: {mapping: {a: A}} +`)) + if err == nil || !strings.Contains(err.Error(), "propertyName") { + t.Fatalf("Load = %v, want a propertyName error", err) + } +} diff --git a/internal/specmodel/model.go b/internal/specmodel/model.go index 4e4dafb..ffb4806 100644 --- a/internal/specmodel/model.go +++ b/internal/specmodel/model.go @@ -106,6 +106,11 @@ type Schema struct { Type string // Format is the declared format. Format string + // Pattern is the declared regular-expression constraint on a string. + Pattern string + // Description is the declared human description. The audit's strategy + // compiler mines it for weak conditional hints; nothing else reads it. + Description string // ReadOnly is the declared readOnly flag. ReadOnly bool // Enum lists the declared enum values as decoded scalars. @@ -126,6 +131,18 @@ type Schema struct { AllOf []*Schema OneOf []*Schema AnyOf []*Schema + // Discriminator is the OpenAPI discriminator object when the schema + // declares one: the property whose value selects a oneOf/anyOf branch, + // plus any explicit value→branch mapping. Nil when absent. + Discriminator *Discriminator + // DependentRequired holds JSON-Schema dependentRequired entries, sorted + // by the triggering property: when the property is present, the listed + // siblings become required. + DependentRequired []DependentRequired + // DependentSchemas holds JSON-Schema dependentSchemas entries, sorted by + // the triggering property: when the property is present, the subschema + // additionally applies. + DependentSchemas []DependentSchema // Extensions holds the schema's x-tfpfgen-* keys. Extensions Extensions @@ -134,6 +151,32 @@ type Schema struct { resolved *Schema } +// Discriminator is the OpenAPI discriminator object: the property whose value +// selects which composition branch applies, and an optional explicit mapping +// from a value to the named component schema it selects. +type Discriminator struct { + // PropertyName is the wire name of the discriminating property. + PropertyName string + // Mapping maps a discriminator value to the referenced schema's name. + // Nil or empty when the document declares no explicit mapping, in which + // case branches are matched by the value their own property enum admits. + Mapping map[string]string +} + +// DependentRequired is one JSON-Schema dependentRequired entry: when Property +// is present, every name in Requires is required too. +type DependentRequired struct { + Property string + Requires []string +} + +// DependentSchema is one JSON-Schema dependentSchemas entry: when Property is +// present, Schema additionally constrains the object. +type DependentSchema struct { + Property string + Schema *Schema +} + // Property is one named property, in document order. type Property struct { Name string