From 6b0e079dfbfaaab41d120264231969bb946c1357 Mon Sep 17 00:00:00 2001 From: Dave Sanderson Date: Fri, 31 Jul 2026 10:22:32 -0600 Subject: [PATCH] workflow: allow compile-time variable references for max_visits StepSpec.MaxVisits was a primitive int decoded directly by gohcl, which rejected variable traversals such as max_visits = var.max_refine even when the variable had a known default. The sibling parallel_max attribute already avoided this by reading from sp.Remain and evaluating through decodeIntAttr + FoldExpr. Remove the primitive MaxVisits field from StepSpec so max_visits stays in Remain, add a decodeMaxVisits helper that uses the same FoldExpr path, and wire it into the three step compile paths (adapter, iterating, subworkflow). This preserves the existing >= 0 validation and produces the same clear single diagnostics for runtime-only references, unknown variables, and fractional values that parallel_max already emits. Add regression tests covering var.* and local.* references, missing defaults, fractional values, negative values, runtime-only references, literal fallback, and JSON round-trip of StepNode.MaxVisits. Update the generated language spec to reflect the schema change and keep max_visits documented as an additional step attribute. Closes: max_visits_rejects_variable_reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/LANGUAGE-SPEC.md | 27 ++- workflow/compile_steps_adapter.go | 11 +- workflow/compile_steps_iteration.go | 40 +++- workflow/compile_steps_max_visits_test.go | 236 ++++++++++++++++++++++ workflow/compile_steps_subworkflow.go | 7 +- workflow/schema.go | 6 +- 6 files changed, 292 insertions(+), 35 deletions(-) create mode 100644 workflow/compile_steps_max_visits_test.go diff --git a/docs/LANGUAGE-SPEC.md b/docs/LANGUAGE-SPEC.md index efa9d4e4..e1ca0c67 100644 --- a/docs/LANGUAGE-SPEC.md +++ b/docs/LANGUAGE-SPEC.md @@ -131,7 +131,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `output "name" { ... }` -- **Source:** [`workflow/schema.go:322`](../workflow/schema.go#L322) +- **Source:** [`workflow/schema.go:320`](../workflow/schema.go#L320) - **Labels:** `name` - **Attributes:** @@ -159,7 +159,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `subworkflow "name" { ... }` -- **Source:** [`workflow/schema.go:332`](../workflow/schema.go#L332) +- **Source:** [`workflow/schema.go:330`](../workflow/schema.go#L330) - **Labels:** `name` - **Attributes:** @@ -180,17 +180,16 @@ The following block types are defined. Tables are auto-generated from [`workflow |---|---|---|---| | `on_crash` | string | no | _(no description)_ | | `on_failure` | string | no | OnFailure controls iteration failure behaviour: "continue" (default for sequential for_each/count steps), "abort" (stop on first failure; default for parallel steps), or "ignore" (treat all as success). | -| `max_visits` | number | no | MaxVisits limits how many times this step may be evaluated in a single run. 0 (default) means unlimited. Negative values are rejected at compile time. | | `config` | map(string) | no | Config is the legacy map attribute; retained for parse-time detection so the compiler can emit a helpful "use input { } block" diagnostic. | | `timeout` | string | no | _(no description)_ | | `allow_tools` | list(string) | no | _(no description)_ | -- **Additional attributes:** Captures: target (required — adapter traversal e.g. adapter.copilot.main, or subworkflow.); for_each, count, parallel, while (optional iteration controls); environment (optional, bare traversal e.g. shell.ci). +- **Additional attributes:** Captures: target (required — adapter traversal e.g. adapter.copilot.main, or subworkflow.); for_each, count, parallel, while (optional iteration controls); environment (optional, bare traversal e.g. shell.ci); max_visits (optional integer, accepts a literal or a compile-time-resolvable variable/local reference). - **Nested blocks:** [`input`](#input---), [`secret_input`](#secret_input---), [`outcome`](#outcome-name---) ### `state "name" { ... }` -- **Source:** [`workflow/schema.go:426`](../workflow/schema.go#L426) +- **Source:** [`workflow/schema.go:424`](../workflow/schema.go#L424) - **Labels:** `name` - **Attributes:** @@ -203,7 +202,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `wait "name" { ... }` -- **Source:** [`workflow/schema.go:409`](../workflow/schema.go#L409) +- **Source:** [`workflow/schema.go:407`](../workflow/schema.go#L407) - **Labels:** `name` - **Attributes:** @@ -216,7 +215,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `approval "name" { ... }` -- **Source:** [`workflow/schema.go:418`](../workflow/schema.go#L418) +- **Source:** [`workflow/schema.go:416`](../workflow/schema.go#L416) - **Labels:** `name` - **Attributes:** @@ -229,13 +228,13 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `switch "name" { ... }` -- **Source:** [`workflow/schema.go:437`](../workflow/schema.go#L437) +- **Source:** [`workflow/schema.go:435`](../workflow/schema.go#L435) - **Labels:** `name` - **Nested blocks:** [`match`](#match---), [`default`](#default---) ### `permissions { ... }` -- **Source:** [`workflow/schema.go:476`](../workflow/schema.go#L476) +- **Source:** [`workflow/schema.go:474`](../workflow/schema.go#L474) - **Attributes:** | Attribute | Type | Required | Description | @@ -245,7 +244,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `policy { ... }` -- **Source:** [`workflow/schema.go:457`](../workflow/schema.go#L457) +- **Source:** [`workflow/schema.go:455`](../workflow/schema.go#L455) - **Attributes:** | Attribute | Type | Required | Description | @@ -273,7 +272,7 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `outcome "name" { ... }` -- **Source:** [`workflow/schema.go:387`](../workflow/schema.go#L387) +- **Source:** [`workflow/schema.go:385`](../workflow/schema.go#L385) - **Labels:** `name` - **Attributes:** @@ -286,17 +285,17 @@ The following block types are defined. Tables are auto-generated from [`workflow ### `match { ... }` -- **Source:** [`workflow/schema.go:446`](../workflow/schema.go#L446) +- **Source:** [`workflow/schema.go:444`](../workflow/schema.go#L444) - **Additional attributes:** captures: condition (required), next (required), output (optional) ### `default { ... }` -- **Source:** [`workflow/schema.go:452`](../workflow/schema.go#L452) +- **Source:** [`workflow/schema.go:450`](../workflow/schema.go#L450) - **Additional attributes:** captures: next (required), output (optional) ### `write { ... }` -- **Source:** [`workflow/schema.go:395`](../workflow/schema.go#L395) +- **Source:** [`workflow/schema.go:393`](../workflow/schema.go#L393) - **Attributes:** | Attribute | Type | Required | Description | diff --git a/workflow/compile_steps_adapter.go b/workflow/compile_steps_adapter.go index 6ab978d5..a130f41d 100644 --- a/workflow/compile_steps_adapter.go +++ b/workflow/compile_steps_adapter.go @@ -61,9 +61,8 @@ func compileAdapterStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[strin timeout, d := decodeStepTimeout(sp) diags = append(diags, d...) - if sp.MaxVisits < 0 { - diags = append(diags, &hcl.Diagnostic{Severity: hcl.DiagError, Summary: fmt.Sprintf("step %q: max_visits must be >= 0", sp.Name)}) - } + maxVisits, d := decodeMaxVisits(sp.Name, sp.Remain, g) + diags = append(diags, d...) envKey, d := resolveStepEnvironmentOverride(sp.Name, sp.Remain, g) diags = append(diags, d...) @@ -84,7 +83,7 @@ func compileAdapterStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[strin outputSchema := resolveOutputSchema(adapterType, schemas) - node := newAdapterStepNode(sp, spec, adapterRef, effectiveOnCrash, envKey, timeout, inputMap, inputExprs, secretInputMap, secretInputExprs, outputSchema) + node := newAdapterStepNode(sp, spec, adapterRef, effectiveOnCrash, envKey, timeout, inputMap, inputExprs, secretInputMap, secretInputExprs, outputSchema, maxVisits) diags = append(diags, maybeCopilotAliasWarnings(sp.Name, adapterType, node.AllowTools)...) diags = append(diags, compileOutcomeBlock(sp, node, g, opts, schemas[adapterRef].OutputSchema)...) @@ -150,14 +149,14 @@ func maybeCopilotAliasWarnings(stepName, adapterName string, tools []string) hcl func newAdapterStepNode(sp *StepSpec, spec *Spec, adapterRef string, effectiveOnCrash string, envKey string, timeout time.Duration, inputMap map[string]string, inputExprs map[string]hcl.Expression, secretInputMap map[string]string, secretInputExprs map[string]hcl.Expression, - outputSchema map[string]ConfigField) *StepNode { + outputSchema map[string]ConfigField, maxVisits int) *StepNode { return &StepNode{ Name: sp.Name, TargetKind: StepTargetAdapter, AdapterRef: adapterRef, OnCrash: effectiveOnCrash, OnFailure: sp.OnFailure, - MaxVisits: sp.MaxVisits, + MaxVisits: maxVisits, Input: inputMap, InputExprs: inputExprs, SecretInputs: secretInputMap, diff --git a/workflow/compile_steps_iteration.go b/workflow/compile_steps_iteration.go index 7ada9ddf..a615231f 100644 --- a/workflow/compile_steps_iteration.go +++ b/workflow/compile_steps_iteration.go @@ -47,9 +47,8 @@ func compileIteratingStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[str timeout, d := decodeStepTimeout(sp) diags = append(diags, d...) - if sp.MaxVisits < 0 { - diags = append(diags, &hcl.Diagnostic{Severity: hcl.DiagError, Summary: fmt.Sprintf("step %q: max_visits must be >= 0", sp.Name)}) - } + maxVisits, d := decodeMaxVisits(sp.Name, sp.Remain, g) + diags = append(diags, d...) ie, d := decodeRemainIter(sp, g) diags = append(diags, d...) @@ -71,7 +70,7 @@ func compileIteratingStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[str if ie.While == nil { diags = append(diags, validateWhileRefs(sp.Name, swInputExprs)...) } - node = newSubworkflowIterStepNode(sp, spec, subworkflowRef, effectiveOnCrash, envKey, timeout, swInputExprs) + node = newSubworkflowIterStepNode(sp, spec, subworkflowRef, effectiveOnCrash, envKey, timeout, swInputExprs, maxVisits) } else { inputMap, inputExprs, d := decodeStepInput(g, sp, schemas, opts, adapterType) diags = append(diags, d...) @@ -88,7 +87,7 @@ func compileIteratingStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[str } } // each.* references are valid inside iterating steps; no error emitted. - node = newAdapterStepNode(sp, spec, adapterRef, effectiveOnCrash, envKey, timeout, inputMap, inputExprs, secretInputMap, secretInputExprs, outputSchema) + node = newAdapterStepNode(sp, spec, adapterRef, effectiveOnCrash, envKey, timeout, inputMap, inputExprs, secretInputMap, secretInputExprs, outputSchema, maxVisits) diags = append(diags, maybeCopilotAliasWarnings(sp.Name, adapterType, node.AllowTools)...) // parallel_safe capability gate: when the step uses parallel = [...] the // adapter must declare "parallel_safe". When the adapter is absent from the @@ -126,14 +125,14 @@ func compileIteratingStep(g *FSMGraph, sp *StepSpec, spec *Spec, schemas map[str } // newSubworkflowIterStepNode constructs a StepNode for an iterating subworkflow step. -func newSubworkflowIterStepNode(sp *StepSpec, _ *Spec, subworkflowRef, effectiveOnCrash, envKey string, timeout time.Duration, inputExprs map[string]hcl.Expression) *StepNode { +func newSubworkflowIterStepNode(sp *StepSpec, _ *Spec, subworkflowRef, effectiveOnCrash, envKey string, timeout time.Duration, inputExprs map[string]hcl.Expression, maxVisits int) *StepNode { return &StepNode{ Name: sp.Name, TargetKind: StepTargetSubworkflow, SubworkflowRef: subworkflowRef, OnCrash: effectiveOnCrash, OnFailure: sp.OnFailure, - MaxVisits: sp.MaxVisits, + MaxVisits: maxVisits, Timeout: timeout, InputExprs: inputExprs, Outcomes: map[string]*CompiledOutcome{}, @@ -240,6 +239,33 @@ func decodeParallelMax(stepName string, attr *hcl.Attribute, g *FSMGraph) (int, return val, nil } +// decodeMaxVisits decodes and validates the max_visits attribute from a step body. +// It accepts literal integers and compile-time-resolvable references (var.* with a +// known default, local.*). Missing attribute returns 0 (unlimited). Runtime-only +// references and unknown values are rejected with clear diagnostics. +func decodeMaxVisits(stepName string, body hcl.Body, g *FSMGraph) (int, hcl.Diagnostics) { + if body == nil { + return 0, nil + } + attr, diags := readStepBodyAttr("max_visits", body) + if attr == nil { + return 0, diags + } + var val int + d := decodeIntAttr(attr, g, &val) + if d.HasErrors() { + return 0, append(diags, d...) + } + if val < 0 { + return 0, append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Subject: attr.Expr.StartRange().Ptr(), + Summary: fmt.Sprintf("step %q: max_visits must be >= 0", stepName), + }) + } + return val, diags +} + // validateWhileExprType performs a compile-time static type check on the while // expression. If the expression can be folded at compile time (i.e. it references // only known constants or compile-time-resolved variables) and the result is not diff --git a/workflow/compile_steps_max_visits_test.go b/workflow/compile_steps_max_visits_test.go new file mode 100644 index 00000000..260227a2 --- /dev/null +++ b/workflow/compile_steps_max_visits_test.go @@ -0,0 +1,236 @@ +package workflow + +import ( + "encoding/json" + "strings" + "testing" +) + +// maxVisitsWorkflowSrc returns a minimal workflow source that declares a +// variable and a step using max_visits = var.max_refine. The returned src +// intentionally omits any iteration modifiers so the compiled StepNode has no +// hcl.Expression fields, making JSON round-trips trivial. +func maxVisitsWorkflowSrc(maxVisitsExpr string, varDefault *string, localExpr string) string { + varBlock := "" + if varDefault != nil { + varBlock = ` +variable "max_refine" { + type = number + default = ` + *varDefault + ` +} +` + } else { + varBlock = ` +variable "max_refine" { + type = number +} +` + } + localBlock := "" + if localExpr != "" { + localBlock = ` +local "max_refine" { + value = ` + localExpr + ` +} +` + } + return ` +workflow { + name = "max_visits_test" + version = "0.1" + initial_state = "execute" + target_state = "done" +} +` + varBlock + localBlock + ` +adapter "noop" "default" {} + +step "execute" { + target = adapter.noop.default + max_visits = ` + maxVisitsExpr + ` + outcome "done" { next = step.done } +} + +state "done" { + terminal = true + success = true +} +` +} + +func mustCompileMaxVisits(t *testing.T, src string) *FSMGraph { + t.Helper() + spec, diags := Parse("test.hcl", []byte(src)) + if diags.HasErrors() { + t.Fatalf("parse error: %v", diags.Error()) + } + g, diags := Compile(spec, nil) + if diags.HasErrors() { + t.Fatalf("compile error: %v", diags.Error()) + } + return g +} + +// TestMaxVisits_VarRef_Compiles verifies that max_visits = var.max_refine +// compiles when the variable has a known numeric default and the resolved +// value survives a JSON round-trip. +func TestMaxVisits_VarRef_Compiles(t *testing.T) { + defaultVal := "4" + src := maxVisitsWorkflowSrc("var.max_refine", &defaultVal, "") + g := mustCompileMaxVisits(t, src) + + step := g.Steps["execute"] + if step.MaxVisits != 4 { + t.Errorf("MaxVisits = %d, want 4", step.MaxVisits) + } + + raw, err := json.Marshal(step) + if err != nil { + t.Fatalf("marshal step: %v", err) + } + var rt StepNode + if err := json.Unmarshal(raw, &rt); err != nil { + t.Fatalf("unmarshal step: %v", err) + } + if rt.MaxVisits != 4 { + t.Errorf("round-trip MaxVisits = %d, want 4", rt.MaxVisits) + } +} + +// TestMaxVisits_LocalRef_Compiles verifies that max_visits = local.max_refine +// compiles when the local resolves to a known integer. +func TestMaxVisits_LocalRef_Compiles(t *testing.T) { + defaultVal := "5" + src := maxVisitsWorkflowSrc("local.max_refine", &defaultVal, "5") + g := mustCompileMaxVisits(t, src) + + if g.Steps["execute"].MaxVisits != 5 { + t.Errorf("MaxVisits = %d, want 5", g.Steps["execute"].MaxVisits) + } +} + +// TestMaxVisits_MissingDefault_Fails verifies that a variable with no default +// used as max_visits produces a single clear compile-time diagnostic. +func TestMaxVisits_MissingDefault_Fails(t *testing.T) { + src := maxVisitsWorkflowSrc("var.max_refine", nil, "") + spec, diags := Parse("test.hcl", []byte(src)) + if diags.HasErrors() { + t.Fatalf("parse error: %v", diags.Error()) + } + _, diags = Compile(spec, nil) + if !diags.HasErrors() { + t.Fatal("expected compile error for variable with no default") + } + msg := diags.Error() + if strings.Contains(msg, "Variables not allowed") { + t.Errorf("unexpected 'Variables not allowed' diagnostic: %s", msg) + } + if strings.Contains(msg, "Unsuitable value type") { + t.Errorf("unexpected 'Unsuitable value type' diagnostic: %s", msg) + } + if !strings.Contains(msg, "compile-time") && !strings.Contains(msg, "known default") { + t.Errorf("expected compile-time diagnostic; got: %s", msg) + } + if strings.Count(msg, "Error:") > 1 { + t.Errorf("expected a single error diagnostic; got: %s", msg) + } +} + +// TestMaxVisits_FractionalVariable_Fails verifies that a variable whose default +// is a fractional number used as max_visits is rejected with a whole-number +// diagnostic. +func TestMaxVisits_FractionalVariable_Fails(t *testing.T) { + defaultVal := "3.5" + src := maxVisitsWorkflowSrc("var.max_refine", &defaultVal, "") + spec, diags := Parse("test.hcl", []byte(src)) + if diags.HasErrors() { + t.Fatalf("parse error: %v", diags.Error()) + } + _, diags = Compile(spec, nil) + if !diags.HasErrors() { + t.Fatal("expected compile error for fractional max_visits") + } + msg := diags.Error() + if !strings.Contains(msg, "whole number") && !strings.Contains(msg, " fractional") { + t.Errorf("expected whole-number diagnostic; got: %s", msg) + } +} + +// TestMaxVisits_NegativeVariable_Fails verifies that a variable whose default +// is negative used as max_visits is rejected with the existing >= 0 diagnostic. +func TestMaxVisits_NegativeVariable_Fails(t *testing.T) { + defaultVal := "-1" + src := maxVisitsWorkflowSrc("var.max_refine", &defaultVal, "") + spec, diags := Parse("test.hcl", []byte(src)) + if diags.HasErrors() { + t.Fatalf("parse error: %v", diags.Error()) + } + _, diags = Compile(spec, nil) + if !diags.HasErrors() { + t.Fatal("expected compile error for negative max_visits") + } + msg := diags.Error() + if !strings.Contains(msg, "max_visits must be >= 0") { + t.Errorf("expected 'max_visits must be >= 0'; got: %s", msg) + } +} + +// TestMaxVisits_RuntimeReference_Fails verifies that runtime-only references +// such as steps.* are rejected with a single clear compile-time diagnostic. +func TestMaxVisits_RuntimeReference_Fails(t *testing.T) { + src := ` +workflow { + name = "max_visits_test" + version = "0.1" + initial_state = "first" + target_state = "done" +} +adapter "noop" "default" {} +step "first" { + target = adapter.noop.default + outcome "done" { next = step.second } +} +step "second" { + target = adapter.noop.default + max_visits = steps.first.done + outcome "done" { next = step.done } +} +state "done" { + terminal = true + success = true +} +` + spec, diags := Parse("test.hcl", []byte(src)) + if diags.HasErrors() { + t.Fatalf("parse error: %v", diags.Error()) + } + _, diags = Compile(spec, nil) + if !diags.HasErrors() { + t.Fatal("expected compile error for runtime-only max_visits reference") + } + msg := diags.Error() + if strings.Contains(msg, "Variables not allowed") { + t.Errorf("unexpected 'Variables not allowed' diagnostic: %s", msg) + } + if !strings.Contains(msg, "compile-time") && !strings.Contains(msg, "runtime-only") { + t.Errorf("expected compile-time/runtime-only diagnostic; got: %s", msg) + } + if strings.Count(msg, "Error:") > 1 { + t.Errorf("expected a single error diagnostic; got: %s", msg) + } +} + +// TestMaxVisits_LiteralStillWorks is a regression guard confirming that a plain +// literal integer still compiles and resolves correctly. +func TestMaxVisits_LiteralStillWorks(t *testing.T) { + src := maxVisitsWorkflowSrc("3", nil, "") + // Remove the required variable declaration since it is unused with a literal. + src = strings.Replace(src, ` +variable "max_refine" { + type = number +} +`, "", 1) + g := mustCompileMaxVisits(t, src) + if g.Steps["execute"].MaxVisits != 3 { + t.Errorf("MaxVisits = %d, want 3", g.Steps["execute"].MaxVisits) + } +} diff --git a/workflow/compile_steps_subworkflow.go b/workflow/compile_steps_subworkflow.go index 724908b5..911aa4a9 100644 --- a/workflow/compile_steps_subworkflow.go +++ b/workflow/compile_steps_subworkflow.go @@ -49,9 +49,8 @@ func compileSubworkflowStep(g *FSMGraph, sp *StepSpec, _ *Spec, subworkflowRef s timeout, d := decodeStepTimeout(sp) diags = append(diags, d...) - if sp.MaxVisits < 0 { - diags = append(diags, &hcl.Diagnostic{Severity: hcl.DiagError, Summary: fmt.Sprintf("step %q: max_visits must be >= 0", sp.Name)}) - } + maxVisits, d := decodeMaxVisits(sp.Name, sp.Remain, g) + diags = append(diags, d...) effectiveOnCrash := sp.OnCrash if effectiveOnCrash != "" && !isValidOnCrash(effectiveOnCrash) { @@ -69,7 +68,7 @@ func compileSubworkflowStep(g *FSMGraph, sp *StepSpec, _ *Spec, subworkflowRef s TargetKind: StepTargetSubworkflow, SubworkflowRef: subworkflowRef, OnCrash: effectiveOnCrash, - MaxVisits: sp.MaxVisits, + MaxVisits: maxVisits, Timeout: timeout, InputExprs: inputExprs, Outcomes: map[string]*CompiledOutcome{}, diff --git a/workflow/schema.go b/workflow/schema.go index 5fd96ce6..adc17927 100644 --- a/workflow/schema.go +++ b/workflow/schema.go @@ -248,9 +248,6 @@ type StepSpec struct { // sequential for_each/count steps), "abort" (stop on first failure; default // for parallel steps), or "ignore" (treat all as success). OnFailure string `hcl:"on_failure,optional"` - // MaxVisits limits how many times this step may be evaluated in a single run. - // 0 (default) means unlimited. Negative values are rejected at compile time. - MaxVisits int `hcl:"max_visits,optional"` // Config is the legacy map attribute; retained for parse-time detection so the // compiler can emit a helpful "use input { } block" diagnostic. Config map[string]string `hcl:"config,optional"` @@ -264,7 +261,8 @@ type StepSpec struct { // quoted-string form causes a compile error with a migration hint. Outcomes []OutcomeSpec `hcl:"outcome,block"` // Captures: target (required — adapter traversal e.g. adapter.copilot.main, or subworkflow.); - // for_each, count, parallel, while (optional iteration controls); environment (optional, bare traversal e.g. shell.ci). + // for_each, count, parallel, while (optional iteration controls); environment (optional, bare traversal e.g. shell.ci); + // max_visits (optional integer, accepts a literal or a compile-time-resolvable variable/local reference). Remain hcl.Body `hcl:",remain"` // LegacyConfigRange, when set by Parse, points at the source range for a // legacy config = { ... } attribute so compile diagnostics can include