Skip to content

core,platform: Emit bare Terraform traversals instead of interpolation-only references - #159

Merged
samir-gandhi merged 6 commits into
mainfrom
polaris/fix-tflint-deprecated-interpolation
Oct 16, 2026
Merged

samir-gandhi merged 6 commits into
mainfrom
polaris/fix-tflint-deprecated-interpolation

Conversation

@samir-gandhi

@samir-gandhi samir-gandhi commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Exported HCL wrapped interpolation-only "${var.X}" and "${type.label.field}" strings inside hand-built jsonencode({...}) RawHCLValue blobs, tripping tflint's terraform_deprecated_interpolation rule on every exported module. This PR replaces the interpolation-only form with bare Terraform traversals (var.X, type.label.field) at the blob producers, so tflint reports zero findings on exported HCL, and adds a three-layer tflint guard so the rule cannot regress silently.

What changed and why (not just which files): the exporter emitted interpolation-only strings in two kinds of places — (1) the DaVinci connector-instance property producers (internal/platform/pingone/resource_connection.go, simple + complex property paths) and (2) the embedded-reference resolver (internal/core/embedded_references.go, variable / fallback / resolved-resource substitution sites). Because custom transforms return opaque core.RawHCLValue blobs that the HCL formatter writes verbatim, the fix had to land in the producers, not the formatters. Substitution uses a JSON-safe sentinel (@@TFREF@@<token>@@) that is materialized to bare tokens in a final sweep — required because ResolveEmbeddedReferences re-parses the same blob JSON across multiple rule applications, and a bare token would break that re-parse. The regression-compare tool was also taught that an interpolation-only quoted reference and its bare form are equivalent (the change is intentionally output-visible), and a tflint config + sample module + make tflint target + CI step guard fresh exports.

Evaluated Terraform output is identical — only the rendered text changes (quotes and ${} dropped). Re-export is recommended; no HCL rework beyond re-export.

Fixes #158

PR quality checklist

  1. Validating tests exist (named): TestFormatter_Format_RawHCLValue_BareVarRefs (HCL end-to-end: bare present, interpolation-only absent, hclwrite.ParseConfig validity, re-indentation), TestFormat_RawHCLValue_BareVarBlob_WrapsAsTemplate (tfjson wrap form), TestResolveEmbeddedReferences_MultiRule_SingleBlob (sentinel re-parse across rules), TestResolveEmbeddedReferences_NoSentinelLeak_Materialized, TestResolveEmbeddedReferences_FirstMatchSemantics_Preserved, raw_hcl_refs_test.go (100% coverage on the new helpers), the two resource_connection_test.go table runners with per-run negatives, and deinterpolation_test.go (compare-tool equivalence, incl. negative cases: partial interpolations still break). Existing tests were updated, not deleted (all 14 platform ${var. assertions and 11+ core assertions converted).
  2. Impacted tests passing: full go test ./internal/... -count=1 green (14 packages); make test (15 packages) green; make tflint exit 0; make vet, gofmt, go run ./tools/validate-definitions clean.
  3. Failures explained: TestResolveEmbeddedReferences_DisambiguatedName_StableAcrossEnvironments is a pre-existing ~20-30% flake (production nondeterminism: wildcard-map iteration order decides which same-titled fallback node claims the un-suffixed variable name). Verified failing at base 42765c5 in both directions; untouched by this branch. Re-run to confirm; follow-up issue flagged. make lint reports findings, all pre-existing at base — none on code this branch changes. make build's go mod tidy prunes 2 stale pingone-go-client v0.12.0 go.sum lines (pre-existing; reverted in this branch's commits).
  4. Accurate description: see Summary — the change swaps the rendered form of embedded references inside jsonencode({...}) blobs; variable names, counts, sensitivity flags, fallback allocation, and the dependency graph are untouched (metadata tests pass unmodified); tfjson formatter code has zero changes (its "${" + blob + "}" wrap keeps yielding valid .tf.json, existing tfjson ${var.X} assertions pass unmodified); rich-text UnwrapMode == "rich_text" re-embeddings and any UUID substituted mid-string keep ${...}.
  5. No silent breaking changes: the rendered config text changes on upgrade, which is why the changelog entry (below) tells customers to re-export. Evaluated Terraform output is identical, no scripts/HCL rework beyond re-export. This is an enhancement, not a breaking change.
  6. UX justification: Core (CI/CD pipelines): cleaner lint, same evaluated output, regression-compare equivalence keeps automated pipelines green. Side (config-to-code conversion): same benefit, plus the tflint CI guard. Good for both.
  7. Naming: "Ping CLI" (two words); "DaVinci" (capital D, lowercase a, capital V).
  8. Release versions: no version bump required (no API/contract change in code).
  9. Schema → API docs: no schema change; YAML definitions untouched (validator passes 8/8).
  10. Changelog: .changelog/pr-159.txt — release-note:enhancement describing the output-format change, re-export recommendation, identical evaluated output, and unchanged .tf.json wrapping. (Renamed to match this PR number.)

Verification beyond unit tests

  • make tflint (sample module mirroring exporter output): passes; reintroducing an interpolation-only string fails it (exit 2) in both var.X and type.label.field shapes.
  • Regression workflow: the hcl matrix entries now run tflint against the fresh PR export (output-pr/regression-module), so a deprecated-interpolation finding fails the job; tfjson entries are correctly excluded.
  • tflint against the committed regression snapshot: 92 terraform_deprecated_interpolation findings pre-fix, 0 findings on the same content in the post-fix form.
  • Regression-compare tool: snapshot (old form) vs de-interpolated copy → "No differences detected", exit 0; renaming a variable → breaking value_mismatch, exit 1 (the equivalence does not mask real regressions).
  • Live environment export + tflint (issue AC 1 direct check): pending operator run via tests/regression/environment/export.sh with 1Password credentials — the committed snapshot verification above covers the same mechanism.

Known limitations / accepted residual risks

  • A string value in API data that is exactly "@@TFREF@@<token>@@" would be materialized as a traversal — practically unreachable (sanitizers strip @ from generated tokens; no producer emits @@TFREF@@ into user-visible strings) and documented in MaterializeBareRefs's doc comment.
  • ReplaceMaskedSecrets (internal/core/transforms.go:201,211) still emits the old form — it has no production callers (no YAML definition uses masked_secret) and was intentionally left untouched.
  • The tflint CI step reads .tflint.hcl from the PR head, so a PR could disable the target rule in its own config; the config diff is visible in review (candidate hardening follow-up: base-branch config sourcing or a CI canary).

Files

  • internal/core/raw_hcl_refs.go (new) — sentinel constant, BareRefPlaceholder, BareRefSentinelCore, MaterializeBareRefs + recursive attribute walker
  • internal/core/embedded_references.go — three substitution sites → bare tokens via sentinel; end-of-resolution materialization sweep
  • internal/core/raw_hcl_refs_test.go, internal/core/embedded_references_test.go — new helper tests + 3 new resolver tests; assertions updated to bare form
  • internal/platform/pingone/resource_connection.go — three producer sites → bare form (direct write / marshal-safe sentinel + post-marshal materialization)
  • internal/platform/pingone/resource_connection_test.go — 14 assertions updated + per-runner negatives
  • internal/platform/pingone/resource_flow_test.go — 4 plain-path assertions updated (rich-text untouched)
  • internal/formatters/hcl/formatter_test.go — new bare-var integration test
  • internal/formatters/tfjson/formatter_test.go — new bare-var wrap test
  • internal/compare/content.go, internal/compare/json.go, internal/compare/deinterpolation_test.go — de-interpolation equivalence for the regression comparators
  • .tflint.hcl, tests/testdata/tflint-sample/ (5 files), Makefile, .github/workflows/regression.yaml, .gitignore — tflint guard (sample module, make target, CI step)
  • .changelog/pr-159.txt — release note
  • contributing/ARCHITECTURE.md, contributing/DEVELOPER_HANDBOOK.md — step 8 replacement-form docs (bare full-string substitution, two-phase sentinel, rich-text retains ${...})

🤖 Generated with Claude Code

samir-gandhi and others added 6 commits September 23, 2026 00:23
Replace interpolation-only "${var.X}" / "${type.label.field}" substitution
inside jsonencode(...) RawHCLValue blobs with bare Terraform traversals
(var.X, type.label.field) so tflint's terraform_deprecated_interpolation
rule no longer fires on exported HCL (issue #158).

Bare tokens are not valid JSON, and processRawHCLValue re-parses the same
blob JSON on every rule application, so plain-path substitution now swaps
the quoted UUID for a quoted JSON-safe sentinel ("@@TFREF@@<token>@@") via
new shared helpers in raw_hcl_refs.go (BareRefPlaceholder /
MaterializeBareRefs). ResolveEmbeddedReferences materializes all sentinels
to bare tokens in one sweep after every rule has run. The rich_text
UnwrapMode path keeps its "${...}" form — its reference is re-embedded
mid-string inside the Slate wrapper, never interpolation-only.

Existing assertions are updated to the bare form (updated, not deleted);
new tests cover the multi-rule single-blob re-parse case, sentinel-leak
absence, and first-match substitution semantics. Docs updated to describe
the new replacement form.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…oducers

Switch the three DaVinci connector-instance property producers from
interpolation-only "${var.X}" strings to bare Terraform traversals
(var.X) inside jsonencode({...}) blobs, so tflint's
terraform_deprecated_interpolation rule no longer fires on exported
HCL (issue #158).

- formatPropertyValue (simple path) writes the bare token directly
  into the hand-built blob.
- The complex-property secret and scalar branches store the
  marshal-safe sentinel core (core.BareRefSentinelCore) in the Go
  map; core.MaterializeBareRefs swaps each quoted placeholder for the
  bare token after json.MarshalIndent, before the jsonencode(...) wrap.
  Sorted-key determinism is unaffected.
- core: export bareRefSentinelCore as BareRefSentinelCore (same body)
  for the platform producer; BareRefPlaceholder's doc now states the
  marshal-safety contract; MaterializeBareRefs' doc acknowledges the
  accepted exact-quoted-form residual risk (task 1 review).

Extracted-variable metadata (names, count, types, sensitivity) is
unchanged; tfjson formatter code is untouched (bare-var blobs wrap
into valid "${jsonencode(...)}" template expressions, covered by a
new test). Tests updated to the bare form with added negative
assertions (no "${var." and no @@TFREF@@ leak); new HCL formatter
integration test validates bare rendering and hclwrite re-parse of
nested jsonencode blobs. ReplaceMaskedSecrets untouched (no
production callers).

Changelog: .changelog/pr-158.txt (release-note:enhancement).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Teach the regression-compare comparators that a quoted interpolation-only
reference ("${var.x}", "${type.label.field}") and its bare-traversal form
(var.x, type.label.field) are equivalent when the entire compared string is
one traversal-shaped interpolation. The HCL path normalizes attribute values
and jsonencode blob text via normalizeValue; the .tf.json path normalizes
parsed values in compareJSONResource, including the old formatter's
double-wrapped "${"${var.x}"}" form. Object-literal values (graph_data-style
bodies) are de-interpolated per line so reports stay clean; those mismatches
were already classified acceptable.

Issue #158 intentionally changes the exported rendering form from quoted
interpolation-only refs to bare traversals inside jsonencode blobs (the two
forms evaluate identically in Terraform), so without this equivalence the
regression workflow would fail the PR on its own intentional output change.
The rule is deliberately narrow: partial interpolations
("prefix-${var.x}", rich-text Slate wrappers) and non-traversal expressions
(function calls, indexes, jsonencode calls) are never normalized, so genuine
value changes still compare as breaking.

Verified against the committed regression snapshot: the tool reports no
differences between the old-form snapshot and a de-interpolated copy, and
still reports breaking (exit 1) when a variable name genuinely changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Add a three-layer guard so tflint's terraform_deprecated_interpolation
rule cannot regress silently on exported HCL (issue #158):

- .tflint.hcl at the repo root: terraform ruleset enabled with the
  deprecated-interpolation rule explicitly on. Four other default rules
  are disabled with documented reasons — each fires on valid exporter
  output (naming_convention: 285 findings on the regression snapshot;
  unused_declarations: 7; standard_module_structure: 1) or does not
  terminate in practical time on a real flow export
  (map_duplicate_keys). None of them touches the target rule.

- tests/testdata/tflint-sample/: a hand-written sample module mirroring
  the post-fix exporter output — connector instance jsonencode blobs
  with bare var.X values (simple and post-marshal materialized nested
  forms), and a flow node graph_data blob with a bare
  type.label.field resource reference.

- Makefile: new tflint target running tflint against the sample module
  with the repo-root config; fails non-zero on findings.

- regression.yaml: tflint step in the regression-test job for hcl
  matrix entries only (the rule does not apply to .tf.json), running
  against the fresh PR export. Adds a PR-head checkout into a
  pr-source subdirectory because the job previously never checked out
  the repo and the config lives there.

- .gitignore: negate *.hcl for .tflint.hcl so the config is checked in.

Verified: make tflint passes (exit 0); reintroducing an interpolation-
only "${var.X}" or "${type.label.field}" string into a scratch copy of
the sample module makes tflint report the finding and exit 2; the rule
fires 92 findings on the pre-fix regression snapshot and 0 on a copy
de-interpolated the way the fixed exporter renders.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The regression workflow's export steps pass --module-dir
regression-module, so the child module lands at
./output-pr/regression-module (internal/module/generator.go joins
OutputDir with ModuleDirName). The tflint step pointed at
./output-pr/ping-export-module — the export command's default name,
which the workflow overrides — so tflint failed with a nonexistent
--chdir (exit 1) on every hcl matrix entry and never linted the
export.

Fix the path and name it in .tflint.hcl's header comment so the
config and the workflow cannot drift apart again.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Regression Test Results

Matrix Entry Status Breaking Acceptable

✅ No regressions detected

All export configurations produced compatible output.


Generated by regression workflow • View run

@github-actions

Copy link
Copy Markdown

Regression Test Results

Matrix Entry Status Breaking Acceptable
default-hcl ✅ PASS 0 0
default-tfjson ✅ PASS 0 11
hcl-include-all ✅ PASS 0 0
hcl-skip-deps ✅ PASS 0 0
tfjson-skip-deps ✅ PASS 0 0

✅ No regressions detected

All export configurations produced compatible output.


Generated by regression workflow • View run

@samir-gandhi
samir-gandhi merged commit af64022 into main Oct 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HCL output: interpolation-only "${var.X}" strings inside jsonencode trigger tflint terraform_deprecated_interpolation

1 participant