From 42f827fc69a6b79d61802cefb278ffbb130d79e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:17:12 +0000 Subject: [PATCH] fix: check -p applies exec's version gates (mendixlabs/mxcli#1434) `mxcli check -p` passed scripts that `mxcli exec` then refused for the project's Mendix version (e.g. `create view entity` on a 9.24 project), after running the statements before the refused one. Every version gate lived inline in its handler as a checkFeature call, which neither check nor exec's pre-flight ever reaches. The gates now come from one statement-to-feature table, statementVersionGates (mdl/executor/version_gates.go). The handlers refuse through checkVersionGates(ctx, stmt); Executor.CheckVersionGates reports the same refusals as MDL-VERSION01, run by `check -p` and by exec's pre-flight, so exec refuses before anything is written. A test fails on any new direct checkFeature call outside the table. Still exec-only: gates that depend on stored state (drop/alter of a message definition document that is not there) and the write-time property floor. The issue's own StableId/CaseValues refusal was the latter and is already cured by #1410; the flows now pass both check and exec on 9.24. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W3x8Fyvir8kLyPgJs1BDmF --- .../2026-10-15-check-not-version-aware.json | 1 + .claude/skills/mendix/check-syntax/SKILL.md | 2 +- .../reference/references-resolved.md | 16 ++ cmd/mxcli/check_version_gates_test.go | 148 ++++++++++++ cmd/mxcli/cmd_check.go | 10 + cmd/mxcli/exec_preflight.go | 4 + docs-site/src/tutorial/validation.md | 1 + .../bug-tests/1434-check-version-gates.mdl | 38 +++ mdl/executor/cmd_agenteditor_models.go | 4 +- mdl/executor/cmd_agenteditor_write.go | 12 +- mdl/executor/cmd_alter_workflow.go | 19 +- mdl/executor/cmd_datatransformer.go | 4 +- mdl/executor/cmd_entities.go | 4 +- .../cmd_messagedefinition_documents.go | 9 +- mdl/executor/cmd_microflows_build.go | 23 +- mdl/executor/cmd_odata.go | 9 +- mdl/executor/cmd_pages_create_v3.go | 8 +- mdl/executor/cmd_published_rest.go | 8 +- mdl/executor/cmd_rest_clients.go | 8 +- mdl/executor/cmd_security_write.go | 4 +- mdl/executor/cmd_settings_workflow_groups.go | 5 +- mdl/executor/cmd_workflows_write.go | 14 +- mdl/executor/microflow_document_properties.go | 13 +- mdl/executor/validate_workflow_esp.go | 29 +-- mdl/executor/version_gates.go | 217 ++++++++++++++++++ mdl/executor/version_gates_test.go | 124 ++++++++++ 26 files changed, 591 insertions(+), 143 deletions(-) create mode 100644 .claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json create mode 100644 cmd/mxcli/check_version_gates_test.go create mode 100644 mdl-examples/bug-tests/1434-check-version-gates.mdl create mode 100644 mdl/executor/version_gates.go create mode 100644 mdl/executor/version_gates_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json new file mode 100644 index 0000000000..6b842e8e80 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-15", "symptom": "`mxcli check script.mdl -p app.mpr` printed \"Check passed!\" for a script that `mxcli exec` on the same project then refused for the project's Mendix version, after running the statements before the refused one (on a 9.24 project: `create view entity` -> \"create view entity requires Mendix 10.18.0+\"; the issue's own StableId/CaseValues refusal was the write-time property floor, already cured by #1410)", "cause": "Every version gate exec applies lived inline in its handler as a checkFeature(ctx, area, name, …) call — about two dozen across 17 files, several conditional on the statement's content (send email in the body, a page with parameters, SupportsGraphQL, a timer event sub-process). check -p and exec's pre-flight never run a handler, so neither could see a gate: the project version was consulted only when the handler ran.", "file": "`mdl/executor/version_gates.go` (statementVersionGates: the one statement-to-feature table; checkVersionGates for the handlers; Executor.CheckVersionGates -> MDL-VERSION01 for `cmd/mxcli/cmd_check.go` and `cmd/mxcli/exec_preflight.go`); the handlers in cmd_*.go now call checkVersionGates(ctx, stmt)", "insight": "A refusal only a handler can raise is a check/exec gap by construction (duplicate-resolver-drift, check versus exec): move the decision into a pure function of the statement that the handler and the pre-flight both call, rather than giving check a second copy. TestCheckFeatureIsOnlyCalledFromTheGateTable fails on any new direct checkFeature call, so a future gate cannot bypass the table. Two kinds stay exec-only: gates that depend on stored state (drop/alter of a message definition document that is not there) and the write-time property floor (canon.StripUndeclaredProperties), which needs the encoded BSON and is cured by the writer emitting the version's shape (#1410), not by a check rule. Fixture for any Mendix 9 check: modelsdk/mpr/testdata/v1-project/App.mpr is 9.24.30.", "refs": ["mendixlabs/mxcli#1434", "mendixlabs/mxcli#1410"]} diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 1654f97dd6..2b3ca0d26a 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -62,7 +62,7 @@ pages, microflows and icons — against that project. It reaches inside stored documents where a name can only be answered there: an `ALTER PAGE … SET` is dry-run against the page it edits, so a widget the page does not have, or a property the stored widget does not declare, is reported here rather than -stopping the script partway through `exec`. What else it resolves, and what a pass still does not prove: [reference/references-resolved.md](reference/references-resolved.md). +stopping the script partway through `exec`. What else it resolves (including the project version's gates, MDL-VERSION01), and what a pass still does not prove: [reference/references-resolved.md](reference/references-resolved.md). ```bash mxcli check script.mdl # syntax + model-free rules diff --git a/.claude/skills/mendix/check-syntax/reference/references-resolved.md b/.claude/skills/mendix/check-syntax/reference/references-resolved.md index af8170dca7..e454edd395 100644 --- a/.claude/skills/mendix/check-syntax/reference/references-resolved.md +++ b/.claude/skills/mendix/check-syntax/reference/references-resolved.md @@ -12,6 +12,22 @@ Beyond the page, microflow, entity and icon names a statement names directly, - `Module.Enum.Key` in an expression; - a view entity's FROM / JOIN entities. +## The project's Mendix version + +`check -p` applies the version gates `exec` refuses with — the same +statement-to-feature table, the floors in `sdk/versions/*.yaml` (`show features`) +— as **MDL-VERSION01** (mendixlabs/mxcli#1434): + +``` +statement 5: create view entity requires Mendix 10.18.0+ (project is 9.24.30.51827) [MDL-VERSION01] +``` + +`exec` reports the same line in its pre-flight and writes nothing. Without a +project there is no version, so nothing is reported. Still exec-only: a gate that +depends on what the project stores (a `drop`/`alter message definition` of a +document that is not there), and the write-time property floor (`this Mendix X +project cannot store …`), which needs the encoded document. + ## Not yet resolved A passing check does not prove these; `mxcli docker check` is still the gate: diff --git a/cmd/mxcli/check_version_gates_test.go b/cmd/mxcli/check_version_gates_test.go new file mode 100644 index 0000000000..c8a20d7074 --- /dev/null +++ b/cmd/mxcli/check_version_gates_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// The issue's own script (mendixlabs/mxcli#1434): a module and flows on a +// Mendix 9.24 project. The decision exercises the two properties the reported +// refusal named — Microflow.StableId (10.2+) and SequenceFlow.CaseValues +// (10.15+) — which the flow writers no longer emit below those versions (#1410). +const issue1434Flows = `create module R1434; +create entity R1434.Item ( "Count": integer ); +create microflow R1434.MF_Empty () begin return; end; +create microflow R1434.MF_Decide ($Item: R1434.Item) returns boolean +begin + declare $Result boolean = false; + if $Item/"Count" > 10 then + set $Result = true; + end if; + return $Result; +end; +` + +// The same script plus a statement the project's version cannot store: exec's +// handler refuses `create view entity` below 10.18. +const issue1434Gated = issue1434Flows + `create view entity R1434.ItemView (Name: String(200)) as select cast('a' as String) as Name; +` + +// copyV9Project copies the Mendix 9.24 v1 fixture the modelsdk/mpr tests use. +func copyV9Project(t *testing.T) (dir, mpr string) { + t.Helper() + src := filepath.Join("..", "..", "modelsdk", "mpr", "testdata", "v1-project", "App.mpr") + raw, err := os.ReadFile(src) + if err != nil { + t.Skipf("Mendix 9 fixture not found: %v", err) + } + dir = t.TempDir() + mpr = filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, raw, 0o644); err != nil { + t.Fatal(err) + } + return dir, mpr +} + +// mendixlabs/mxcli#1434: `mxcli check -p` printed "Check passed!" for a script +// that exec, on the same project, refused for the project's Mendix version — +// after the statements before the refused one had run. check -p now applies +// the gates exec's handlers apply (executor.CheckVersionGates), from the same +// statement-to-feature table. +// +// Controls: the gate-free script still passes, and check leaves the project +// as it was. +func TestCheck_RefusesWhatTheProjectVersionCannotStore(t *testing.T) { + dir, mpr := copyV9Project(t) + _ = checkCmd.InheritedFlags() + _ = rootCmd.PersistentFlags().Set("project", mpr) + defer func() { + _ = rootCmd.PersistentFlags().Set("project", "") + rootCmd.PersistentFlags().Lookup("project").Changed = false + }() + if f := checkCmd.Flags().Lookup("format"); f != nil { + _ = f.Value.Set(f.DefValue) + } + before := treeDigest(t, dir) + scripts := t.TempDir() + + var code int + gated := writeScript(t, scripts, "gated.mdl", issue1434Gated) + out := captureStd(t, func() { code = runCheckFile(checkCmd, gated) }) + if code == 0 || strings.Contains(out, "Check passed!") { + t.Errorf("check passed a statement the 9.24 project cannot store (exit %d):\n%s", code, out) + } + for _, want := range []string{"statement 5: create view entity requires Mendix 10.18.0+", "MDL-VERSION01"} { + if !strings.Contains(out, want) { + t.Errorf("check output must name %q:\n%s", want, out) + } + } + + // Control: the flows alone are storable on 9.24 and pass. + flows := writeScript(t, scripts, "flows.mdl", issue1434Flows) + out = captureStd(t, func() { code = runCheckFile(checkCmd, flows) }) + if code != 0 || strings.Contains(out, "MDL-VERSION01") { + t.Errorf("control: a script the 9.24 project can store failed its check (exit %d):\n%s", code, out) + } + + if n := countDiff(before, treeDigest(t, dir)); n > 0 { + t.Errorf("check wrote to the project: %d file(s) changed", n) + } +} + +// The other half of #1434: exec agrees with check on the same project. The +// gated script is refused by the pre-flight before anything is written (it used +// to run statements 1-4 and fail on the fifth); the flows alone — the +// statements the reported StableId / CaseValues refusal came from — pass the +// pre-flight and are written. +func TestExecPreflight_AgreesWithCheckOnVersionGates(t *testing.T) { + _, mpr := copyV9Project(t) + exec := executor.New(io.Discard) + exec.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exec.Execute(&ast.ConnectStmt{Path: mpr}); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = exec.Execute(&ast.DisconnectStmt{}) }) + + build := func(script string) *ast.Program { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + return prog + } + preflight := func(prog *ast.Program) (string, string) { + var out bytes.Buffer + refusal := execPreflight(exec, prog, mpr, "s.mdl", false, false, false, deprecation.Warn, &out, false) + return refusal, out.String() + } + + refusal, out := preflight(build(issue1434Gated)) + if refusal == "" || !strings.Contains(refusal, "Nothing was written") { + t.Errorf("want the pre-flight to refuse the version-gated script, got %q\n%s", refusal, out) + } + if !strings.Contains(out, "create view entity requires Mendix 10.18.0+") { + t.Errorf("pre-flight output must name the gate:\n%s", out) + } + + prog := build(issue1434Flows) + if refusal, out := preflight(prog); refusal != "" { + t.Fatalf("control: the flows were refused by the pre-flight: %s\n%s", refusal, out) + } + if err := exec.ExecuteProgram(prog); err != nil { + t.Errorf("exec refused the flows check passes on Mendix 9.24: %v", err) + } +} diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 095b4ce21f..f3591254ac 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -73,6 +73,11 @@ return in an inserted fragment, ...) is refused by exec under "mdl 1;", and check reports it as an MDL-V1-REBUILD error with exec's message. Under mdl 0 exec rebuilds the whole flow instead, and check reports the MDL-V1-REBUILD warning exec prints. An "alter microflow|nanoflow" exec would refuse is MDL090. + +Given a project it also applies exec's version gates: a statement the project's +Mendix version cannot store (create view entity below 10.18, send email below +11.13, ...) is MDL-VERSION01, with the message exec refuses it with. See +"show features" for the floors. This is the verdict "mxcli diff" reports as "Refused:", computed by the same code. A statement on a flow an earlier statement of the script changes, or whose flow does not build until an earlier statement has run, is not predicted. @@ -324,6 +329,11 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { // already stored that way: re-applying it changes nothing. violations = executor.SettleStoredVoidReturnValues(violations, prog, executor.NewStoredVoidReturns(b)) violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) + // MDL-VERSION01: a statement the project's Mendix version cannot + // store. The gates are the ones exec's handlers refuse with, from + // one table (executor.statementVersionGates), so check -p no + // longer passes a script exec refuses for the version (#1434). + violations = append(violations, exec.CheckVersionGates(prog)...) } } violations = executor.ApplyDeprecationPolicy(violations, depPolicy) diff --git a/cmd/mxcli/exec_preflight.go b/cmd/mxcli/exec_preflight.go index 68dfda7189..5d3eac4327 100644 --- a/cmd/mxcli/exec_preflight.go +++ b/cmd/mxcli/exec_preflight.go @@ -44,6 +44,10 @@ func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath, scri // project for MDL-WORKFLOW10 (ako/mxcli#943). violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) } + // A statement the project's Mendix version cannot store + // (MDL-VERSION01): the gate its handler applies when reached, + // after the statements before it (mendixlabs/mxcli#1434). + violations = append(violations, exec.CheckVersionGates(prog)...) } printPreflightViolations(violations, script, showInfo, w, color) if summary := linter.Summarize(violations); summary.Errors > 0 { diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index 57c7e0ccd7..61bdd407f1 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -44,6 +44,7 @@ This catches everything Level 1 catches, plus: - Page layouts that don't exist in the project - Association endpoints pointing to missing entities - A plain `create` whose name already exists in the project +- A statement the project's Mendix version cannot store (`MDL-VERSION01`, e.g. `create view entity` below 10.18) — the same version gates `exec` refuses with This is the check you should run before executing a script. It's fast (reads the project but doesn't modify it) and catches most mistakes. diff --git a/mdl-examples/bug-tests/1434-check-version-gates.mdl b/mdl-examples/bug-tests/1434-check-version-gates.mdl new file mode 100644 index 0000000000..bc0c9cc816 --- /dev/null +++ b/mdl-examples/bug-tests/1434-check-version-gates.mdl @@ -0,0 +1,38 @@ +mdl 1; +-- mendixlabs/mxcli#1434 — `mxcli check -p` passed a script that `mxcli exec` +-- then refused for the project's Mendix version, after the statements before +-- the refused one had run. check -p now applies exec's version gates (the same +-- statement-to-feature table, executor.statementVersionGates) as MDL-VERSION01, +-- and exec's pre-flight refuses the script before anything is written. +-- +-- Verify (on a copy of a Mendix 9.x project, e.g. modelsdk/mpr/testdata/v1-project): +-- mxcli check 1434-check-version-gates.mdl -p app.mpr +-- -> statement 5: create view entity requires Mendix 10.18.0+ ... [MDL-VERSION01] +-- mxcli exec 1434-check-version-gates.mdl -p app.mpr +-- -> the same line, "Refusing to execute ... Nothing was written." +-- On a 10.18+ project both pass. Without the view entity, both pass on 9.24: +-- the flows are what the reported StableId / CaseValues refusal came from (#1410). + +create module R1434; + +create entity R1434.Item ( + "Count": integer +); + +create microflow R1434.MF_Empty () +begin + return; +end; + +create microflow R1434.MF_Decide ($Item: R1434.Item) returns boolean +begin + declare $Result boolean = false; + if $Item/"Count" > 10 then + set $Result = true; + end if; + return $Result; +end; + +create view entity R1434.ItemView ( + Name: String(200) +) as select cast('a' as String) as Name; diff --git a/mdl/executor/cmd_agenteditor_models.go b/mdl/executor/cmd_agenteditor_models.go index f3ed685907..b1f6e8dfdf 100644 --- a/mdl/executor/cmd_agenteditor_models.go +++ b/mdl/executor/cmd_agenteditor_models.go @@ -140,9 +140,7 @@ func execCreateAgentEditorModel(ctx *ExecContext, s *ast.CreateModelStmt) error // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_model", - "create model", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_agenteditor_write.go b/mdl/executor/cmd_agenteditor_write.go index d26aa64453..b938dd04b0 100644 --- a/mdl/executor/cmd_agenteditor_write.go +++ b/mdl/executor/cmd_agenteditor_write.go @@ -26,9 +26,7 @@ func execCreateConsumedMCPService(ctx *ExecContext, s *ast.CreateConsumedMCPServ // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_consumed_mcp_service", - "create consumed mcp service", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -116,9 +114,7 @@ func execCreateKnowledgeBase(ctx *ExecContext, s *ast.CreateKnowledgeBaseStmt) e // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_knowledge_base", - "create knowledge base", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -223,9 +219,7 @@ func execCreateAgent(ctx *ExecContext, s *ast.CreateAgentStmt) error { // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent", - "create agent", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index e7f932e655..2547287db4 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -23,28 +23,15 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return mdlerrors.NewNotConnectedWrite() } - // Version pre-check: workflows require Mendix 9.12+ - if err := checkFeature(ctx, "workflows", "basic", - "alter workflow", - "upgrade your project to Mendix 9.12+ to use workflows"); err != nil { + // Version pre-check: workflows require Mendix 9.12+, and the added + // activities need their own features (statementVersionGates). + if err := checkVersionGates(ctx, s); err != nil { return err } if vs := completionRuleViolations(alterWorkflowAddedActivities(s), workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } - if workflowUsesAgentTask(alterWorkflowAddedActivities(s)) { - if err := checkFeature(ctx, "workflows", "ai_agent_task", "call agent microflow", - "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"); err != nil { - return err - } - } - if workflowUsesNotificationEvents(alterWorkflowAddedActivities(s)) { - if err := checkFeature(ctx, "workflows", "notification_events", "notification activities and notification boundary events", - "these need Mendix 11.11 or later — use `wait for notification` on older projects"); err != nil { - return err - } - } // Same exec-side guard as CREATE WORKFLOW: ALTER had no reference validation // at all, so an inserted activity could name a microflow that exists nowhere diff --git a/mdl/executor/cmd_datatransformer.go b/mdl/executor/cmd_datatransformer.go index cd20343f5f..ebbde4951b 100644 --- a/mdl/executor/cmd_datatransformer.go +++ b/mdl/executor/cmd_datatransformer.go @@ -108,9 +108,7 @@ func execCreateDataTransformer(ctx *ExecContext, s *ast.CreateDataTransformerStm return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "data_transformer", - "create data transformer", - "upgrade your project to 11.9+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 3c4c3cd0f4..5607b407f5 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -942,9 +942,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { } // Version pre-check - if err := checkFeature(ctx, "domain_model", "view_entities", - "create view entity", - "upgrade your project to 10.18+ or use a regular entity with a microflow data source"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_messagedefinition_documents.go b/mdl/executor/cmd_messagedefinition_documents.go index 78f0e3ba95..f80a3b93f3 100644 --- a/mdl/executor/cmd_messagedefinition_documents.go +++ b/mdl/executor/cmd_messagedefinition_documents.go @@ -51,13 +51,6 @@ func messageCollectionsRemovedError(ctx *ExecContext, statement string) error { "definition, and a mapping names one as Module.Name", statement, version)) } -// requireMessageDefinitionDocuments refuses the per-document form below 11.15. -func requireMessageDefinitionDocuments(ctx *ExecContext, statement string) error { - return checkFeature(ctx, "integration", "message_definition_document", statement, - "below Mendix 11.15 a message definition is an entry of a collection: "+ - "create message definition collection Module.Name { definition Name for Module.Entity { … } };") -} - // projectStoresMessageDocuments reports whether the connected project stores // message definitions as MessageDefinition2 documents. func projectStoresMessageDocuments(ctx *ExecContext) bool { @@ -123,7 +116,7 @@ func execCreateMessageDefinition(ctx *ExecContext, s *ast.CreateMessageDefinitio if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } - if err := requireMessageDefinitionDocuments(ctx, "create message definition"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } existing := findMessageDefinitionDocument(ctx, s.Name.Module, s.Name.Name) diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index 9f73b6bb23..dd1d3e9bca 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -96,13 +96,9 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b } } - // SEND EMAIL writes the 11.13+ shape (StringTemplate subject and bodies); - // an older project stores a different EmailMessage, or no such activity. - if bodyContains(s.Body, func(st ast.MicroflowStatement) bool { _, ok := st.(*ast.SendEmailStmt); return ok }) { - if err := checkFeature(ctx, "microflows", "send_email", "send email", - "use the Email Connector module, or upgrade the project to 11.13+"); err != nil { - return nil, err - } + // SEND EMAIL (11.13+) and a deep link URL (10.6+): statementVersionGates. + if err := checkVersionGates(ctx, s); err != nil { + return nil, err } // Find the module, and the folder, WITHOUT creating either on a dry run: @@ -684,16 +680,9 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui return nil, fmt.Errorf("%s", errMsg) } - // SYNCHRONIZE UNSYNCHRONIZED is the one mode with a floor inside Mendix 9: - // SynchronizationType.Unsynchronized was introduced in 9.4.0, while the - // activity and its other two modes go back further. Gate the mode, not the - // statement, so a 9.0-9.3 project keeps `synchronize all`. - if bodyUsesUnsynchronized(s.Body) { - if err := checkFeature(ctx, "microflows", "synchronize_unsynchronized", - "synchronize unsynchronized", - "use `synchronize all` or `synchronize $Var`, or upgrade the project to 9.4+"); err != nil { - return nil, err - } + // SYNCHRONIZE UNSYNCHRONIZED (9.4+): statementVersionGates. + if err := checkVersionGates(ctx, s); err != nil { + return nil, err } // Build flow graph from body statements diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 3e05487e09..280dfb3c88 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -1488,13 +1488,8 @@ func createODataService(ctx *ExecContext, stmt *ast.CreateODataServiceStmt) erro "the default (Yes), or drop SupportsGraphQL.") } - if stmt.SupportsGraphQL { - if err := checkFeature(ctx, "integration", "odata_graphql", - "SupportsGraphQL on a published OData service", - "Publishing a service as GraphQL as well arrived in Studio Pro 10.14. "+ - "Remove SupportsGraphQL to publish OData only."); err != nil { - return err - } + if err := checkVersionGates(ctx, stmt); err != nil { + return err } module, err := findModule(ctx, stmt.Name.Module) diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index 386cecb182..b67c7b7e0c 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -27,12 +27,8 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { // which locked every 10.x project out of parameterised pages entirely // (mendixlabs/mxcli#1121). The gate stays because 9.0–9.3 genuinely has no // Pages$PageParameter; only the floor and the hint were wrong. - if len(s.Parameters) > 0 { - if err := checkFeature(ctx, "pages", "page_parameters", - "create page with parameters", - "pass data via a non-persistent entity or microflow parameter instead"); err != nil { - return err - } + if err := checkVersionGates(ctx, s); err != nil { + return err } // Find or auto-create module diff --git a/mdl/executor/cmd_published_rest.go b/mdl/executor/cmd_published_rest.go index 2917c0f4cd..160c854eca 100644 --- a/mdl/executor/cmd_published_rest.go +++ b/mdl/executor/cmd_published_rest.go @@ -542,9 +542,7 @@ func execCreatePublishedRestService(ctx *ExecContext, s *ast.CreatePublishedRest return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_service", - "create published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -881,9 +879,7 @@ func execAlterPublishedRestService(ctx *ExecContext, s *ast.AlterPublishedRestSe return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_alter", - "alter published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 503d16db6f..5bc763ad1e 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -358,9 +358,7 @@ func createRestClient(ctx *ExecContext, stmt *ast.CreateRestClientStmt) error { } // Version pre-check: REST clients require 10.1+ - if err := checkFeature(ctx, "integration", "rest_client_basic", - "create rest client", - "upgrade your project to 10.1+"); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } @@ -807,9 +805,7 @@ func createRestClientFromSpec(ctx *ExecContext, stmt *ast.CreateRestClientStmt) return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "rest_client_basic", - "create rest client", - "upgrade your project to 10.1+"); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index 1183b24129..0226dcba12 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -1762,9 +1762,7 @@ func execGrantPublishedRestServiceAccess(ctx *ExecContext, s *ast.GrantPublished return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_grant_revoke", - "grant access on published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_settings_workflow_groups.go b/mdl/executor/cmd_settings_workflow_groups.go index a6eb75d915..de4dad8ee2 100644 --- a/mdl/executor/cmd_settings_workflow_groups.go +++ b/mdl/executor/cmd_settings_workflow_groups.go @@ -40,10 +40,7 @@ func alterSettingsWorkflowGroup(ctx *ExecContext, ps *model.ProjectSettings, stm if ps.Workflows == nil { return mdlerrors.NewNotFound("settings section", "workflows") } - if err := checkFeature(ctx, "workflows", "groups", - "alter settings workflows add group ''", - "Workflow groups (Settings$WorkflowGroup) were introduced in the 11.2 metamodel. "+ - "On an earlier version, model the buckets as your own entity and target user tasks with a microflow."); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } if strings.TrimSpace(stmt.GroupName) == "" { diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index f42ff05ce0..01848e9e19 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -74,19 +74,7 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := ValidateWorkflowEventTypes(s); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } - if len(s.EventHandlers) > 0 { - if err := checkFeature(ctx, "workflows", "event_handlers", "on workflow events", - "remove the `on … workflow event` clauses, or upgrade the project"); err != nil { - return err - } - } - if workflowUsesAgentTask(workflowStatementActivities(s)) { - if err := checkFeature(ctx, "workflows", "ai_agent_task", "call agent microflow", - "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"); err != nil { - return err - } - } - if err := checkEventSubProcessFeatures(ctx, s); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/microflow_document_properties.go b/mdl/executor/microflow_document_properties.go index dde8569d88..4852bd370c 100644 --- a/mdl/executor/microflow_document_properties.go +++ b/mdl/executor/microflow_document_properties.go @@ -22,17 +22,8 @@ import ( // which is the rule these clauses exist to let a script opt out of rather than // to abolish (mendixlabs/mxcli#1120). func applyMicroflowDocumentProperties(ctx *ExecContext, mf *microflows.Microflow, s *ast.CreateMicroflowStmt) error { - // Deep links are Mendix 10.6+. Gate on the clause being STATED, not on the - // resulting value: a rewrite that carries a stored URL forward on an older - // project is preserving what is already there, and refusing that would make - // the guard destroy the very thing it protects. - if s.URL != nil || s.URLSearchParameters != nil { - if err := checkFeature(ctx, "microflows", "deep_link_url", - "a microflow URL (deep link)", - "upgrade your project to 10.6+, or set the URL in Studio Pro"); err != nil { - return err - } - } + // Deep links are Mendix 10.6+: gated with the statement's other version + // gates in buildMicroflowFromStmt (statementVersionGates). if s.URL != nil { if err := checkURLNotTaken(ctx, mf, *s.URL); err != nil { return err diff --git a/mdl/executor/validate_workflow_esp.go b/mdl/executor/validate_workflow_esp.go index e3fae43b73..a783aa8335 100644 --- a/mdl/executor/validate_workflow_esp.go +++ b/mdl/executor/validate_workflow_esp.go @@ -26,6 +26,8 @@ // notification-started event sub-process yes yes yes yes workflows.event_subprocesses (11.8, gen) // notification activity / boundary events no yes yes yes workflows.notification_events // timer-started event sub-process no no no yes workflows.timer_event_subprocesses +// +// The gates on these floors are in statementVersionGates (version_gates.go). package executor import ( @@ -76,33 +78,6 @@ func workflowUsesNotificationEvents(activities []ast.WorkflowActivityNode) bool return found } -// checkEventSubProcessFeatures refuses what the project's Mendix version cannot -// load (see the table above). -func checkEventSubProcessFeatures(ctx *ExecContext, stmt *ast.CreateWorkflowStmt) error { - if len(stmt.EventSubProcesses) > 0 { - if err := checkFeature(ctx, "workflows", "event_subprocesses", "event subprocess", - "event sub-processes need Mendix 11.8 or later"); err != nil { - return err - } - } - for _, esp := range stmt.EventSubProcesses { - if esp.Timer { - if err := checkFeature(ctx, "workflows", "timer_event_subprocesses", "event subprocess … on … timer", - "a timer-started event sub-process needs Mendix 11.13 or later — start it with a notification on older projects"); err != nil { - return err - } - break - } - } - if workflowUsesNotificationEvents(workflowStatementActivities(stmt)) { - if err := checkFeature(ctx, "workflows", "notification_events", "notification activities and notification boundary events", - "these need Mendix 11.11 or later — use `wait for notification` on older projects"); err != nil { - return err - } - } - return nil -} - // ValidateWorkflowEventSubProcesses applies MDL-WF14 and MDL-WF15. func ValidateWorkflowEventSubProcesses(stmt *ast.CreateWorkflowStmt) []linter.Violation { loc := workflowLocation(stmt.Name) diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go new file mode 100644 index 0000000000..428e9d0f91 --- /dev/null +++ b/mdl/executor/version_gates.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// versionGate is one feature a statement needs from the project's Mendix +// version: the sdk/versions registry entry (area, name) that holds its floor, +// how the refusal names the statement, and what to do instead. +type versionGate struct { + area, name string + statement string + hint string +} + +// statementVersionGates lists the version gates stmt has to pass, in the order +// exec applies them. It is the one place a statement is mapped to the features +// it needs: the handlers refuse through checkVersionGates, and `mxcli check -p` +// and exec's pre-flight report through CheckVersionGates, so the three cannot +// disagree about which statement needs which version (mendixlabs/mxcli#1434 — +// check passed a script exec then refused for the project's version). The +// floors themselves live in sdk/versions/*.yaml. +// +// A gate that depends on what the project stores, not only on the statement +// (requireMessageDefinitionDocuments on a drop/alter of a document that is not +// there), stays with its handler: check cannot know it from the script. +func statementVersionGates(stmt ast.Statement) []versionGate { + switch s := stmt.(type) { + case *ast.CreateRestClientStmt: + return []versionGate{{"integration", "rest_client_basic", "create rest client", + "upgrade your project to 10.1+"}} + case *ast.CreateDataTransformerStmt: + return []versionGate{{"integration", "data_transformer", "create data transformer", + "upgrade your project to 11.9+"}} + case *ast.CreatePublishedRestServiceStmt: + return []versionGate{{"integration", "published_rest_service", "create published rest service", + "upgrade your project to 10.0+"}} + case *ast.AlterPublishedRestServiceStmt: + return []versionGate{{"integration", "published_rest_alter", "alter published rest service", + "upgrade your project to 10.0+"}} + case *ast.GrantPublishedRestServiceAccessStmt: + return []versionGate{{"integration", "published_rest_grant_revoke", "grant access on published rest service", + "upgrade your project to 10.0+"}} + case *ast.CreateODataServiceStmt: + if s.SupportsGraphQL { + return []versionGate{{"integration", "odata_graphql", "SupportsGraphQL on a published OData service", + "Publishing a service as GraphQL as well arrived in Studio Pro 10.14. " + + "Remove SupportsGraphQL to publish OData only."}} + } + case *ast.CreateMessageDefinitionStmt: + return []versionGate{messageDefinitionDocumentGate("create message definition")} + case *ast.AlterSettingsStmt: + if (s.AddGroup || s.ModifyGroup || s.UpsertGroup || s.RemoveGroup) && strings.EqualFold(s.Section, "workflows") { + return []versionGate{{"workflows", "groups", "alter settings workflows add group ''", + "Workflow groups (Settings$WorkflowGroup) were introduced in the 11.2 metamodel. " + + "On an earlier version, model the buckets as your own entity and target user tasks with a microflow."}} + } + case *ast.CreateViewEntityStmt: + return []versionGate{{"domain_model", "view_entities", "create view entity", + "upgrade your project to 10.18+ or use a regular entity with a microflow data source"}} + case *ast.CreateModelStmt: + return []versionGate{agentDocumentGate("agent_model", "create model")} + case *ast.CreateConsumedMCPServiceStmt: + return []versionGate{agentDocumentGate("agent_consumed_mcp_service", "create consumed mcp service")} + case *ast.CreateKnowledgeBaseStmt: + return []versionGate{agentDocumentGate("agent_knowledge_base", "create knowledge base")} + case *ast.CreateAgentStmt: + return []versionGate{agentDocumentGate("agent", "create agent")} + case *ast.CreatePageStmtV3: + // Pages$PageParameter is 9.4+ (mendixlabs/mxcli#1121): 9.0–9.3 has none. + if len(s.Parameters) > 0 { + return []versionGate{{"pages", "page_parameters", "create page with parameters", + "pass data via a non-persistent entity or microflow parameter instead"}} + } + case *ast.CreateMicroflowStmt: + var gates []versionGate + // SEND EMAIL writes the 11.13+ shape (StringTemplate subject and + // bodies); an older project stores a different EmailMessage, or no + // such activity. + if bodyContains(s.Body, func(st ast.MicroflowStatement) bool { _, ok := st.(*ast.SendEmailStmt); return ok }) { + gates = append(gates, versionGate{"microflows", "send_email", "send email", + "use the Email Connector module, or upgrade the project to 11.13+"}) + } + // Deep links are Mendix 10.6+. Gated on the clause being STATED, not on + // the resulting value: a rewrite that carries a stored URL forward on + // an older project preserves what is already there. + if s.URL != nil || s.URLSearchParameters != nil { + gates = append(gates, versionGate{"microflows", "deep_link_url", "a microflow URL (deep link)", + "upgrade your project to 10.6+, or set the URL in Studio Pro"}) + } + return gates + case *ast.CreateNanoflowStmt: + // SYNCHRONIZE UNSYNCHRONIZED is the one mode with a floor inside + // Mendix 9: SynchronizationType.Unsynchronized was introduced in 9.4.0, + // while the activity and its other two modes go back further. Gate the + // mode, not the statement, so a 9.0-9.3 project keeps `synchronize all`. + if bodyUsesUnsynchronized(s.Body) { + return []versionGate{{"microflows", "synchronize_unsynchronized", "synchronize unsynchronized", + "use `synchronize all` or `synchronize $Var`, or upgrade the project to 9.4+"}} + } + case *ast.CreateWorkflowStmt: + var gates []versionGate + if len(s.EventHandlers) > 0 { + gates = append(gates, versionGate{"workflows", "event_handlers", "on workflow events", + "remove the `on … workflow event` clauses, or upgrade the project"}) + } + activities := workflowStatementActivities(s) + if workflowUsesAgentTask(activities) { + gates = append(gates, agentTaskGate) + } + if len(s.EventSubProcesses) > 0 { + gates = append(gates, versionGate{"workflows", "event_subprocesses", "event subprocess", + "event sub-processes need Mendix 11.8 or later"}) + } + for _, esp := range s.EventSubProcesses { + if esp.Timer { + gates = append(gates, versionGate{"workflows", "timer_event_subprocesses", "event subprocess … on … timer", + "a timer-started event sub-process needs Mendix 11.13 or later — start it with a notification on older projects"}) + break + } + } + if workflowUsesNotificationEvents(activities) { + gates = append(gates, notificationEventsGate) + } + return gates + case *ast.AlterWorkflowStmt: + // Workflows require Mendix 9.12+. + gates := []versionGate{{"workflows", "basic", "alter workflow", + "upgrade your project to Mendix 9.12+ to use workflows"}} + added := alterWorkflowAddedActivities(s) + if workflowUsesAgentTask(added) { + gates = append(gates, agentTaskGate) + } + if workflowUsesNotificationEvents(added) { + gates = append(gates, notificationEventsGate) + } + return gates + } + return nil +} + +var ( + agentTaskGate = versionGate{"workflows", "ai_agent_task", "call agent microflow", + "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"} + notificationEventsGate = versionGate{"workflows", "notification_events", + "notification activities and notification boundary events", + "these need Mendix 11.11 or later — use `wait for notification` on older projects"} +) + +// agentDocumentGate is the gate of an agent-editor document. Nothing downstream +// catches an older project: the documents are custom blobs, so mxbuild does not +// validate them and the build stays green while Studio Pro cannot open the +// result. +func agentDocumentGate(name, statement string) versionGate { + return versionGate{"agent_documents", name, statement, + "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"} +} + +// messageDefinitionDocumentGate refuses the per-document message definition +// form below 11.15. +func messageDefinitionDocumentGate(statement string) versionGate { + return versionGate{"integration", "message_definition_document", statement, + "below Mendix 11.15 a message definition is an entry of a collection: " + + "create message definition collection Module.Name { definition Name for Module.Entity { … } };"} +} + +// requireMessageDefinitionDocuments refuses the per-document form below 11.15. +func requireMessageDefinitionDocuments(ctx *ExecContext, statement string) error { + return checkGate(ctx, messageDefinitionDocumentGate(statement)) +} + +func checkGate(ctx *ExecContext, g versionGate) error { + return checkFeature(ctx, g.area, g.name, g.statement, g.hint) +} + +// checkVersionGates refuses stmt when the connected project's version lacks a +// feature it needs: the first of statementVersionGates that fails. Nil when not +// connected, as checkFeature is. +func checkVersionGates(ctx *ExecContext, stmt ast.Statement) error { + for _, g := range statementVersionGates(stmt) { + if err := checkGate(ctx, g); err != nil { + return err + } + } + return nil +} + +// CheckVersionGates reports every statement of prog the connected project's +// Mendix version cannot store — the refusals exec's handlers would raise when +// they reach it, after the statements before it. `mxcli check -p` and exec's +// pre-flight run it, so both refuse the script before anything is written. +func (e *Executor) CheckVersionGates(prog *ast.Program) []linter.Violation { + return versionGateViolations(e.newExecContext(context.Background()), prog) +} + +func versionGateViolations(ctx *ExecContext, prog *ast.Program) []linter.Violation { + var out []linter.Violation + for i, stmt := range prog.Statements { + if err := checkVersionGates(ctx, stmt); err != nil { + msg, hint, _ := strings.Cut(err.Error(), "\n hint: ") + out = append(out, linter.Violation{ + RuleID: "MDL-VERSION01", + Severity: linter.SeverityError, + Message: fmt.Sprintf("statement %d: %s", i+1, msg), + Suggestion: hint, + }) + } + } + return out +} diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go new file mode 100644 index 0000000000..f9952e3d32 --- /dev/null +++ b/mdl/executor/version_gates_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1434: every version gate an exec handler applies is +// reported by CheckVersionGates — the pass `mxcli check -p` and exec's +// pre-flight run — on a project below the gate's floor, and none on one at or +// above it. The handlers refuse through checkVersionGates(ctx, stmt), so a +// statement in this table is refused by exec exactly when check reports it. +func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { + url := "item/{Id}" + cases := []struct { + name string + stmt ast.Statement + below [3]int // a version under the gate's floor + want string // the statement the refusal names + }{ + {"rest client", &ast.CreateRestClientStmt{}, [3]int{10, 0, 0}, "create rest client"}, + {"data transformer", &ast.CreateDataTransformerStmt{}, [3]int{11, 8, 0}, "create data transformer"}, + {"published rest", &ast.CreatePublishedRestServiceStmt{}, [3]int{9, 24, 0}, "create published rest service"}, + {"alter published rest", &ast.AlterPublishedRestServiceStmt{}, [3]int{9, 24, 0}, "alter published rest service"}, + {"grant published rest", &ast.GrantPublishedRestServiceAccessStmt{}, [3]int{9, 24, 0}, "grant access on published rest service"}, + {"odata graphql", &ast.CreateODataServiceStmt{SupportsGraphQL: true}, [3]int{10, 13, 0}, "SupportsGraphQL"}, + {"message definition document", &ast.CreateMessageDefinitionStmt{}, [3]int{11, 14, 0}, "create message definition"}, + {"workflow group", &ast.AlterSettingsStmt{Section: "WORKFLOWS", AddGroup: true, GroupName: "G"}, [3]int{11, 1, 0}, "add group"}, + {"view entity", &ast.CreateViewEntityStmt{}, [3]int{10, 17, 0}, "create view entity"}, + {"agent model", &ast.CreateModelStmt{}, [3]int{11, 8, 0}, "create model"}, + {"consumed mcp", &ast.CreateConsumedMCPServiceStmt{}, [3]int{11, 8, 0}, "create consumed mcp service"}, + {"knowledge base", &ast.CreateKnowledgeBaseStmt{}, [3]int{11, 8, 0}, "create knowledge base"}, + {"agent", &ast.CreateAgentStmt{}, [3]int{11, 8, 0}, "create agent"}, + {"page parameters", &ast.CreatePageStmtV3{Parameters: []ast.PageParameter{{Name: "P"}}}, [3]int{9, 3, 0}, "create page with parameters"}, + {"send email", &ast.CreateMicroflowStmt{Body: []ast.MicroflowStatement{ + &ast.IfStmt{ThenBody: []ast.MicroflowStatement{&ast.SendEmailStmt{}}}}}, [3]int{11, 12, 0}, "send email"}, + {"deep link", &ast.CreateMicroflowStmt{URL: &url}, [3]int{10, 5, 0}, "a microflow URL"}, + {"synchronize unsynchronized", &ast.CreateNanoflowStmt{Body: []ast.MicroflowStatement{ + &ast.SynchronizeStmt{SyncType: "Unsynchronized"}}}, [3]int{9, 3, 0}, "synchronize unsynchronized"}, + {"workflow event handlers", &ast.CreateWorkflowStmt{EventHandlers: []ast.WorkflowEventHandlerNode{{AnyEvent: true}}}, [3]int{10, 6, 0}, "on workflow events"}, + {"workflow agent task", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{ + &ast.WorkflowCallMicroflowNode{Agent: true}}}, [3]int{11, 8, 0}, "call agent microflow"}, + {"event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E"}}}, [3]int{11, 7, 0}, "event subprocess"}, + {"timer event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E", Timer: true}}}, [3]int{11, 12, 0}, "on … timer"}, + {"notification", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{&ast.WorkflowNotificationNode{}}}, [3]int{11, 10, 0}, "notification activities"}, + {"alter workflow", &ast.AlterWorkflowStmt{}, [3]int{8, 18, 0}, "alter workflow"}, + {"alter workflow agent task", &ast.AlterWorkflowStmt{Operations: []ast.AlterWorkflowOp{ + &ast.InsertAfterOp{NewActivities: []ast.WorkflowActivityNode{&ast.WorkflowCallMicroflowNode{Agent: true}}}}}, [3]int{11, 8, 0}, "call agent microflow"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{&ast.CreateModuleStmt{Name: "M"}, c.stmt}} + + vs := versionGateViolations(versionCtx(t, c.below[0], c.below[1], c.below[2]), prog) + if len(vs) != 1 { + t.Fatalf("want one MDL-VERSION01 below the floor, got %d: %+v", len(vs), vs) + } + if v := vs[0]; v.RuleID != "MDL-VERSION01" || !strings.HasPrefix(v.Message, "statement 2: ") || + !strings.Contains(v.Message, c.want) || v.Suggestion == "" { + t.Errorf("violation = %+v, want statement 2 naming %q with a hint", v, c.want) + } + // The handler's own refusal is the same text: exec and check agree. + if err := checkVersionGates(versionCtx(t, c.below[0], c.below[1], c.below[2]), c.stmt); err == nil || + !strings.Contains(vs[0].Message, strings.SplitN(err.Error(), "\n", 2)[0]) { + t.Errorf("exec's refusal %v differs from check's %q", err, vs[0].Message) + } + + // Control: a version with every feature passes. + if vs := versionGateViolations(versionCtx(t, 11, 99, 0), prog); len(vs) != 0 { + t.Errorf("control: reported at 11.99: %+v", vs) + } + }) + } +} + +// Statements with no gate, or a gated statement without the gated clause, are +// not reported — `synchronize all` on 9.0, a page without parameters, OData +// without GraphQL. +func TestCheckVersionGates_UngatedFormsPass(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateModuleStmt{Name: "M"}, + &ast.CreateMicroflowStmt{}, + &ast.CreateNanoflowStmt{Body: []ast.MicroflowStatement{&ast.SynchronizeStmt{SyncType: "All"}}}, + &ast.CreatePageStmtV3{}, + &ast.CreateODataServiceStmt{}, + &ast.AlterSettingsStmt{Section: "MODEL"}, + }} + if vs := versionGateViolations(versionCtx(t, 9, 0, 0), prog); len(vs) != 0 { + t.Errorf("ungated forms reported on 9.0: %+v", vs) + } +} + +// One source of truth: a handler that called checkFeature with its own feature +// key would be a gate check -p cannot see. Every gate goes through +// statementVersionGates (or, for a gate that depends on stored state, a helper +// in version_gates.go). +func TestCheckFeatureIsOnlyCalledFromTheGateTable(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + call := regexp.MustCompile(`\bcheckFeature\(`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") || f == "version_gates.go" || f == "cmd_features.go" { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + if call.MatchString(line) && !strings.HasPrefix(strings.TrimSpace(line), "//") { + t.Errorf("%s:%d calls checkFeature directly; add the gate to statementVersionGates so `check -p` applies it too", f, i+1) + } + } + } +}