From 00295a3d51bc37516604bff83d2605b42fcead89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:46:42 +0000 Subject: [PATCH 1/3] endtoend: add an opt-in core context to TestReplay TestReplay's contexts could only mutate the config, so the coreanalyzer experiment could reach a case only through its own exec.json. Contexts now also name the experiments every case in them runs with, and a new "core" context runs the corpus through the analysis core. The two paths still disagree, so the context is opt-in: it runs only when SQLC_TEST_CORE is set. That gate is an environment variable rather than a test flag because the documented workflow runs the whole module, and a flag defined in one test binary fails every package that does not define it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161e7oMkzNW9DZMUPtyQibH --- internal/endtoend/endtoend_test.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/endtoend/endtoend_test.go b/internal/endtoend/endtoend_test.go index dd37a0ad54..5c261dc7aa 100644 --- a/internal/endtoend/endtoend_test.go +++ b/internal/endtoend/endtoend_test.go @@ -120,6 +120,10 @@ func BenchmarkExamples(b *testing.B) { type textContext struct { Mutate func(*testing.T, string) func(*config.Config) Enabled func() bool + // Experiments names the experiments every case in this context runs + // with. A case's own SQLCEXPERIMENT is appended to these, so a case can + // still turn one back off with the "no" prefix. + Experiments func() []string } func TestReplay(t *testing.T) { @@ -235,6 +239,16 @@ func TestReplay(t *testing.T) { return postgresURI != "" || mysqlURI != "" }, }, + "core": { + Mutate: func(t *testing.T, path string) func(*config.Config) { return func(c *config.Config) {} }, + Experiments: func() []string { return []string{"coreanalyzer"} }, + Enabled: func() bool { + // Running the whole corpus through the analysis core is opt-in + // while the two paths still disagree. The core needs no + // database, so nothing else gates this. + return os.Getenv("SQLC_TEST_CORE") != "" + }, + }, } for name, testctx := range contexts { @@ -276,9 +290,14 @@ func TestReplay(t *testing.T) { } } + experiments := args.Env["SQLCEXPERIMENT"] + if testctx.Experiments != nil { + experiments = strings.Join(append(testctx.Experiments(), experiments), ",") + } + opts := cmd.Options{ Env: cmd.Env{ - Experiment: opts.ExperimentFromString(args.Env["SQLCEXPERIMENT"]), + Experiment: opts.ExperimentFromString(experiments), }, Stderr: &stderr, MutateConfig: testctx.Mutate(t, path), From e56435b0015416d3ce33cee523438096f9470daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:48:06 +0000 Subject: [PATCH 2/3] docs: describe TestReplay contexts and the core opt-in Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161e7oMkzNW9DZMUPtyQibH --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4b4aca32df..43d86b49fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,25 @@ stdout against `stdout.txt`. A case that is expected to fail commits its `stderr.txt`. Regenerate a golden by running the command in its directory and writing the output back over the committed file. +`TestReplay` runs the whole corpus once per *context*. `base` runs each case as +committed and `managed-db` reruns it against a live database, so a context can +change the config a case is generated with and the experiments it is generated +under. A case restricts itself to some of them with `"contexts": [...]` in its +`exec.json`, and commits per-context expected errors as `stderr/.txt`. +There is only one set of committed golden files, so every context is expected +to generate identical code. + +The `core` context generates every case through the analysis core +(`SQLCEXPERIMENT=coreanalyzer`). The two paths still disagree, so it is opt-in +and needs no database: + +```bash +SQLC_TEST_CORE=1 go test ./internal/endtoend -run 'TestReplay/core' +``` + +Go aborts a test binary on panic, so a case that panics the core analyzer ends +the run early. Run a subset to get past one (`-run 'TestReplay/core/^select'`). + ### Example Tests - **Location:** `/examples/` directory From c554d7611d72bfd736c1d4afb3b8e5cb852fdfbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:03:08 +0000 Subject: [PATCH 3/3] core/analyzer: bind FROM items against the scope being built A set-returning function in FROM takes its arguments from the items before it, so bindRangeFunction types the call while the scope is still being assembled. The scope under construction was never installed on the analyzer, so a.scope was nil and resolving one of those arguments dereferenced it: FROM transactions, jsonb_each(jsonb_extract_path(transactions.data, '...')) AS x buildScope and relationScope now install the scope they are filling for as long as they fill it, and put back the one they replaced on the way out. Resolving against a nil scope also reports the column as unresolved rather than crashing, so no other half-built statement can panic on the way to the error it was going to report anyway. Both queries now analyze to an ordinary error instead of taking down the process, which is what the rest of the corpus needed: TestReplay's core context runs to completion in one process rather than aborting partway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0161e7oMkzNW9DZMUPtyQibH --- internal/core/analyzer/dml.go | 1 + internal/core/analyzer/scope.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/core/analyzer/dml.go b/internal/core/analyzer/dml.go index 2954a4e43c..f1cb397c1c 100644 --- a/internal/core/analyzer/dml.go +++ b/internal/core/analyzer/dml.go @@ -83,6 +83,7 @@ func (a *analyzer) analyzeDelete(s *ast.DeleteStmt) error { // report a multi-table DELETE that way — a single FROM node. func (a *analyzer) relationScope(relations, extra *ast.List, from ast.Node) (*scope, error) { sc := &scope{} + defer a.binding(sc)() for _, item := range listItems(relations) { if err := a.appendFromItem(sc, item); err != nil { return nil, err diff --git a/internal/core/analyzer/scope.go b/internal/core/analyzer/scope.go index b87d3cfc6a..020d3d1f12 100644 --- a/internal/core/analyzer/scope.go +++ b/internal/core/analyzer/scope.go @@ -22,6 +22,7 @@ type scopeRel struct { func (a *analyzer) buildScope(from *ast.List) (*scope, error) { items := listItems(from) sc := &scope{rels: make([]scopeRel, 0, len(items))} + defer a.binding(sc)() for _, item := range items { if err := a.appendFromItem(sc, item); err != nil { return nil, err @@ -30,6 +31,17 @@ func (a *analyzer) buildScope(from *ast.List) (*scope, error) { return sc, nil } +// binding makes the scope under construction the one the analyzer resolves +// against, and returns the func that puts back the scope it replaced. A FROM +// item can refer to the ones before it — a set-returning function takes its +// arguments from them — so binding an item has to see what is bound so far +// rather than no scope at all. +func (a *analyzer) binding(sc *scope) func() { + prev := a.scope + a.scope = sc + return func() { a.scope = prev } +} + func (a *analyzer) appendFromItem(sc *scope, item ast.Node) error { switch v := item.(type) { case *ast.RangeVar: @@ -202,6 +214,12 @@ func (a *analyzer) resolveColumn(relation, column string) (scopeRel, core.ClassC // relation. It reports an error when more than one relation in scope offers // that name. func (s *scope) resolveColumn(relation, column string) (rel scopeRel, col core.ClassColumn, ok bool, err error) { + // A statement whose scope is not built yet offers no columns. Report the + // column as unresolved and let the caller say so, rather than crashing on + // the way to the same answer. + if s == nil { + return rel, col, false, nil + } found := 0 for _, r := range s.rels { if relation != "" && r.alias != relation {