Find and prove a single runtime divergence between OpenTofu and pulumi-hcl with a failing tfcompat test. Use when the user asks to "find another bug/mismatch", find a tfcompat mismatch, or hunt for behavior that differs from Terraform/OpenTofu.
npx skills add https://github.com/pulumi/pulumi-hcl --skill find-tfcompat-bug
Hunt one genuine runtime divergence between OpenTofu (tofu apply) and pulumi-hcl, and
prove it with a failing tfcompat test. That is the whole job of this skill: find a
real divergence and leave behind a test that fails on master for the right reason. You
stop at the proven failure — you do not fix it. To turn a proven failure into a
shipped fix, hand off to the fix-tfcompat-bug skill.
The divergence can be anywhere the two runtimes can disagree — an expression/function
result (the L1 seam), or resource, module, provider, or lifecycle behavior (the L2
seam). Functions are the easiest place to start, not the only place to look.
Direction matters — only one direction is a bug. A bug is an input that **works in
OpenTofu but does not work (or works differently) in pulumi-hcl**: OpenTofu produces a
value and pulumi-hcl errors or returns something else. That is the case a user migrating
real Terraform/OpenTofu configuration into pulumi-hcl actually hits. The reverse — an
input pulumi-hcl accepts that OpenTofu rejects (pulumi-hcl is more lenient) — is not a
bug to chase here: no OpenTofu config depends on it, and tightening pulumi-hcl to also
reject it removes capability without helping any migration. If the only divergence you can
find is pulumi-hcl being more permissive than OpenTofu, discard it and keep hunting.
Each invocation produces exactly one proven failing test. Don't bundle several.
against OpenTofu source, the cty stdlib source, the docs, or an empirical tofu
probe — never from memory. A backwards memory of token semantics has already burned
us once.
tests/tfcompat/ files. Don't editruntime source under pkg/ (that's the fixing skill's job), and don't touch staging the
user left.
The Go module cache root is !go env GOPATH/pkg/mod.
go env GOPATH/pkg/mod/github.com/pulumi/opentofu@*/lang/funcs/*.gogo env GOPATH/pkg/mod/github.com/zclconf/go-cty@*/cty/function/stdlib/pkg/hcl/ (expression eval, resource / module / provider handling)pkg/hcl/eval/functions.go(func Functions(baseDir string) at the top binds every name to its impl)
pkg/hcl/eval/functions_test.goCase type: tests/testutil/tfcompat/harness.gotofu on your PATH (!which tofu)Look for any input the two runtimes can evaluate differently. Two seams, easiest first:
pkg/hcl/eval/functions.go with ahand-rolled Impl rather than a delegated stdlib.*Func. Hand-rolled impls are
where function divergences hide. Diff against the OpenTofu impl/docs, and where cheap,
probe both sides:
# OpenTofu side — write a tiny main.tf and apply
echo 'output "x" { value = <expr> }' > /tmp/probe/main.tf
(cd /tmp/probe && tofu apply -auto-approve)
providers, count/for_each, pre/postconditions, or the preview / destroy / replace
lifecycle. These exercise a provider (e.g. providers.SimpleProvider) — compare a
small program end-to-end through apply, and where relevant preview and destroy.
Good hunting grounds: encoding/escaping funcs, numeric edge cases, empty-collection
errors, date/time token tables, string normalization, unknowns in preview,
precondition/postcondition error messages, replace/destroy lifecycle, module/provider
passthrough. Look for: wrong error on edge input, precision loss, off-by-one token
mapping, charset handling, escaping rules, a field that diverges only in state, an
operation that succeeds on one path and errors on the other.
Keep the direction in mind (see the intro): the case you want is one where **OpenTofu
succeeds and pulumi-hcl errors or returns a different value**. When an `operation that
succeeds on one path and errors on the other` turns up, check which side errors — if
pulumi-hcl is the one that succeeds and OpenTofu errors, that is pulumi-hcl being more
lenient, which is not the bug you are looking for. Discard it and keep hunting.
Observability caveat: pulumi serializes stack-output numbers as float64. Integer
precision past ~16 significant digits is lost on BOTH paths, so it cannot be exposed via
output comparison. Don't build a case whose only difference is unobservable in outputs —
reach for AssertState/ExpectErr (below), or prove it with a unit test, or pick a
different bug.
Name the case l1_<name> for an expression/function bug or l2_<name> for a
resource/module/lifecycle bug, and create:
tests/tfcompat/<l1|l2>_<name>_test.gotests/tfcompat/testdata/cases/<l1|l2>_<name>/main.tfExisting cases (pick a fresh name):
!ls tests/tfcompat/testdata/cases
The test (copy the Apache header from any sibling _test.go):
// L1 — pure expression, no providers
func TestL1<Name>(t *testing.T) {
t.Parallel()
tfcompat.RunCase(t, "l1_<name>", tfcompat.Case{})
}
// L2 — register the provider(s) the program uses
func TestL2<Name>(t *testing.T) {
t.Parallel()
tfcompat.RunCase(t, "l2_<name>", tfcompat.Case{
Providers: []tfcompat.Provider{
{Name: "simple", Factory: providers.SimpleProvider},
},
})
}
RunCase loads every file under testdata/cases/<name>/ and runs it through both
tofu apply and pulumi up, asserting identical stack outputs. For an L1 case the
fixture is pure locals + output (one output per behavior). **Put the fixture on disk
under testdata/cases/<name>/ and pass an empty tfcompat.Case{} — this is the default,
including multi-file cases** (e.g. a templatefile case ships its main.tf *and* the
template it reads; RunCase loads every file in the directory). The Case struct gives
you more knobs when outputs alone can't show the divergence:
Providers / Config — register TF providers / set input variables. Config is theonly channel for values not known until runtime (ports, temp paths): declare a variable
in the fixture and set it here — never generate program text in Go.
AssertState — assert on resource fields not reachable via outputs (e.g. Protect).Stages — per-stage behavior matched positionally against the disk fixture: Mode(StageApply / StagePreview / StageDestroy), ExpectErr (require BOTH runtimes to
fail with a matching error substring — the way to prove an error-behavior divergence),
and AssertOutput. A Stage carries behavior only; program files always come from the
case directory. A flat case dir with N Stages entries runs the same program N times
(e.g. preview then apply); a program that must *change* between stages uses numbered
subdirs testdata/cases/<name>/0/, 1/, ... with one Stages entry per subdir (or
none).
Confirm the bug is real on master:
PATH="$PWD/bin:$PATH" go test ./tests/tfcompat/ -run 'Test(L1|L2)<Name>' -count=1 -v
It must FAIL, and the failure must show the genuine OpenTofu-vs-pulumi difference (a real
output diff or a matched ExpectErr), not a harness/provider/fixture error. If it passes,
the bug isn't real (or isn't observable) — go back to Step 1.
You now have a proven failing tfcompat test, left uncommitted in the working tree.
Report:
To fix the divergence and ship it, continue with the
fix-tfcompat-bug skill. Do not fix it here.
Take pulumi/find-tfcompat-bug from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.