diff --git a/.claude/commands/mendix/lint.md b/.claude/commands/mendix/lint.md index ce4e601023..cd957e3ad6 100644 --- a/.claude/commands/mendix/lint.md +++ b/.claude/commands/mendix/lint.md @@ -44,12 +44,13 @@ mxcli lint -p app.mpr --baseline lint-baseline.json | MPR005 | quality | ImageSource - IMAGE widgets with no source configured | | MPR006 | quality | EmptyContainer - Empty layout containers | | MPR007 | security | PageNavigationSecurity - Navigation pages need allowed roles (CE0557); every user role must be able to open its home page (at Production security, else CE2729 per widget) | -| MPR012 | correctness | LegacyImageWidget - staticimage/dynamicimage are unsupported by the React client (CE0582; info on the classic client) | +| MPR012 | correctness | ReactUnsupportedWidget - static/dynamic image, drop-down, legacy data grid and reference selector are unsupported by the React client (CE0582; info on the classic client) | | MPR013 | correctness | SnippetCallAppearance - Class/Style/DynamicClasses/design properties on a snippet call never reach the page (it renders no element) | | SEC001 | security | NoEntityAccessRules - Persistent entities need access rules | | SEC002 | security | WeakPasswordPolicy - Password minimum length should be 8+ | | SEC003 | security | DemoUsersActive - Demo users should be off at Production security | | SEC010 | security | PageEntityNotReadable - A page data widget lists an entity the user roles that open the page cannot read; at Production it renders empty, silently (info when only row-scoped) | +| SEC011 | security | XPathAssociationNotReadable - A page data source's XPath crosses an association a role that opens the page may not read; at Production the page fails to open ("No access rights for …") | | CONV011 | performance | NoCommitInLoop - Commits inside loops (commit actions, or create/change with commit) cause N+1 issues | | CONV012 | quality | ExclusiveSplitCaption - Exclusive splits need meaningful captions | | CONV013 | quality | ErrorHandlingOnCalls - External calls (REST/WS/Java) need custom error handling | diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-fixtures-depend-on-the-hosts-identity.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-fixtures-depend-on-the-hosts-identity.json new file mode 100644 index 0000000000..e6870bf17c --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-fixtures-depend-on-the-hosts-identity.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-01", "symptom": "Every `TestGitMerge_*` fixture and both real-mx `TestGitMergeIntegration_*` tests fail on a host named `vm` with `Committer identity unknown … unable to auto-detect email address (got 'root@vm.(none)')`, before the command under test runs; they pass on CI runners and developer machines", "cause": "The fixtures drop the global git config (GIT_CONFIG_GLOBAL=/dev/null) for hermeticity, but `gitInE` and the command's own in-process `git merge`/`git commit` in the integration fixture ran without the fixed identity `gitIn` sets, so git fell back to guessing one from the hostname — which works wherever the hostname has a domain", "file": "`cmd/mxcli/gitdoctor_test.go` (`gitInE` sets GIT_AUTHOR_*/GIT_COMMITTER_*), `cmd/mxcli/gitmerge_integration_test.go` (`mergeIntegrationRepo` sets them in the process environment)", "insight": "**Removing the global config is half of hermetic**: git still guesses an identity from the hostname, so a fixture that never sets one passes exactly where the hostname has a domain — every runner image and laptop — and fails in a bare container. `git merge` needs the identity even when it stops on a conflict. Pattern: docs-wiki/bug-patterns/the-platform-ci-does-not-run.md", "refs": ["mendixlabs/mxcli#1674"]} diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-plan-drops-what-the-engine-merged.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-plan-drops-what-the-engine-merged.json new file mode 100644 index 0000000000..c0199fd2cc --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-plan-drops-what-the-engine-merged.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-01", "symptom": "`mxcli git merge --plan ` commits a valid two-parent merge that has silently lost one side's change to a unit the plan never names: ours adds `MyFirstModule.OursOnly`, theirs adds `MyFirstModule.TheirsOnly`, both edit `FeedbackModule.Feedback`'s documentation; the engine exits 2, the template lists only FeedbackModule's domain model, and after `--plan` with take theirs `TheirsOnly` is gone — the hash gate passes and nothing in the output mentions the unit", "cause": "applyPlan resolved from git's two sides — ours for every model file git conflicted, theirs for the planned units — and never ran the engine. Once the template listed only the engine's ManualResolutionRequired units, a unit the engine merges by itself (MergeContents/AutoResolved: an entity added on each side of one domain model) was taken from ours wholesale", "file": "`cmd/mxcli/cmd_git_merge.go` (`applyPlan` re-runs the engine over `checkoutRevisions` worktrees, checks the plan against `engineDecisions`, copies the result back with `copyEngineResult`, takes each planned unit with `git checkout -- `, `clearDecidedMarkers`, then refuses anything `conflictMarkers` still lists); `modelsdk/mpr/merge_conflicts.go` (`Reader.UnitConflicts`, `Writer.ClearContentsConflicts`); tests `TestGitMerge_PlanKeepsWhatTheEngineMerged`, `TestGitMerge_PlanMustMatchTheEnginesDecisions` in `cmd/mxcli/gitmerge_test.go`, `TestGitMergeIntegration_PlanKeepsWhatTheEngineMerged` (real mx) in `cmd/mxcli/gitmerge_integration_test.go`", "insight": "**\"AutoResolved\" means merged in the engine's result, not merged in yours**: a step that rebuilds from the sides instead of the result has to re-decide every unit, and listing fewer units to it hides the ones it gets wrong. Change the review list and the resolution source together. **The engine's exit-2 result is usable**: MINE holds every auto-resolved unit merged and marks only the undecided ones, in `Unit.ContentsConflicts` (a JSON list where every other row holds '' — not NULL); `mx check` reports CE0137 while a marker is left, which makes it the headless oracle for 'did the clear read as resolved'. Take a planned unit from its side's commit, not from MINE: MINE's copy of an undecided unit is not byte-identical to ours'. Measured with mx 11.13 on the PedApp fixture and an `mx create-project` scaffold. Pattern: docs-wiki/bug-patterns/rewrite-drops-unauthored-state.md", "refs": ["mendixlabs/mxcli#1674"]} diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-refusal-reason-read-from-stdout.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-refusal-reason-read-from-stdout.json new file mode 100644 index 0000000000..e8af79698e --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-11-01-git-merge-refusal-reason-read-from-stdout.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-11-01", "symptom": "`mxcli git merge side -p App.mpr` on a working tree with an uncommitted change the merge would overwrite fails with `git merge side: Updating 5d6b73c..2c3fd47`, and without a git identity with `git merge side: ` and nothing after it; the same with `--plan`", "cause": "When git merge stopped before any conflict existed (no MERGE_HEAD), the error was built from `firstLineOf(mergeOut)` — git's stdout, which holds at most the fast-forward 'Updating a..b' line — while git's reason is on stderr, which gitOutIn already returns as the error", "file": "`cmd/mxcli/cmd_git_merge.go` (`mergeRun` and `applyPlan` report `mergeErr`); test `TestGitMerge_RefusedMergeSaysWhy` in `cmd/mxcli/gitmerge_test.go`", "insight": "**A wrapper that splits stdout from stderr has to pick the stream per outcome**: git writes progress to stdout and refusals to stderr, so the line that is right on success is the wrong one on failure. Found only because a fixture ran on a host where git could not guess an identity — the empty message was the first clue. Pattern: docs-wiki/bug-patterns/misleading-diagnostics.md", "refs": ["mendixlabs/mxcli#1674"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-11-01-mcp-drop-ignores-ped-delete-document.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-11-01-mcp-drop-ignores-ped-delete-document.json index 6f0537c8dc..8bfa6aab94 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend/2026-11-01-mcp-drop-ignores-ped-delete-document.json +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-11-01-mcp-drop-ignores-ped-delete-document.json @@ -1 +1 @@ -{"area": "mdl/backend/mcp", "date": "2026-11-01", "symptom": "Over `--mcp` on Studio Pro 11.16, every DROP of a standalone document (constant, enumeration, microflow, nanoflow, page, workflow, business-event service) demanded `--mcp-concord`, though 11.16 ships `ped_delete_document`. Separately, a PED tool whose handler throws answers `Error executing tool handler: …` with `isError:false`, which any caller trusting the flag reads as success", "cause": "`concordDeleteDocument` was the only DROP path, written when PED had no delete tool. `Client.CallTool` passed `isError` through verbatim, and only the create/update callers (`pedOpError`'s `SUCCESS` prefix) noticed failures reported in the text", "file": "`mdl/backend/mcp/delete.go` (new `deleteDocument`: native when `HasTool(\"ped_delete_document\")`, Concord fallback), `client.go` (`HasTool`, `handlerFailed` sets `IsError`), the seven `Delete*` call sites, `capabilities.yaml`/`.go` report text", "insight": "**Probe tool presence, never the version**: `serverInfo.version` was 1.0.0 on 11.12.0 and 11.16.0 reports itself, but the delete tool is the fact that matters. **On 11.16.0 the native delete fails for every caller outside Studio Pro's own agent session** (`Session 'mcp-server' not found`, measured from a devcontainer through four clientInfo names), so the success reply has never been seen. The code therefore settles an unrecognised reply by `ped_find_document` instead of guessing the text, and keeps Concord as a fallback. **Revert check**: stubbing `handlerFailed` fails `TestCallTool_HandlerErrorIsAnError`, and forcing the Concord path fails three `TestDeleteDocument_*`. Live runs are in mx-test-projects/runs/native-delete/"} +{"area": "mdl/backend/mcp", "date": "2026-11-01", "symptom": "Over `--mcp` on Studio Pro 11.16, every DROP of a standalone document (constant, enumeration, microflow, nanoflow, page, workflow, business-event service) demanded `--mcp-concord`, though 11.16 ships `ped_delete_document`. Separately, a PED tool whose handler throws answers `Error executing tool handler: …` with `isError:false`, which any caller trusting the flag reads as success", "cause": "`concordDeleteDocument` was the only DROP path, written when PED had no delete tool. `Client.CallTool` passed `isError` through verbatim, and only the create/update callers (`pedOpError`'s `SUCCESS` prefix) noticed failures reported in the text", "file": "`mdl/backend/mcp/delete.go` (new `deleteDocument`: native when `HasTool(\"ped_delete_document\")`, else an error naming 11.16+; Concord support removed in the same change), `client.go` (`HasTool`, `handlerFailed` sets `IsError`), the seven `Delete*` call sites, `capabilities.yaml`/`.go` report text", "insight": "**Probe tool presence, never the version**: `serverInfo.version` was 1.0.0 on 11.12.0 and 11.16.0 reports itself, but the delete tool is the fact that matters. **On 11.16.0 the native delete fails for every caller outside Studio Pro's own agent session** (`Session 'mcp-server' not found`, measured from a devcontainer through four clientInfo names), so the success reply has never been seen. The code therefore settles an unrecognised reply by `ped_find_document` instead of guessing the text. **Revert check**: stubbing `handlerFailed` fails `TestCallTool_HandlerErrorIsAnError`, and removing the `HasTool` gate fails `TestDeleteDocument_NoNativeToolNeeds1116`. Live runs are in mx-test-projects/runs/native-delete/"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-11-01-custom-content-column-header-shows-name.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-01-custom-content-column-header-shows-name.json new file mode 100644 index 0000000000..85bc8554b7 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-11-01-custom-content-column-header-shows-name.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-11-01", "symptom": "A Data grid 2 custom-content column written with `Caption: ''` (or no caption) through the generic `pluggablewidget 'com.mendix.widget.web.datagrid.Datagrid'` path showed its MDL column name (`colTriage`) as the header to end users; mx check, check and lint all passed.", "cause": "applyColumnHeaderFallback filled an empty header on an attribute-less column with the column's NAME, a name Mendix does not store for a column. It was written on the belief that an empty header is CE0463; that CE0463 was a null TextTemplate, and the empty Forms$ClientTemplate (ChipCoV8 #11) builds clean. The keyword `datagrid` path already wrote the empty header.", "file": "mdl/executor/widget_engine.go", "insight": "Read the widget's own bundle before reasoning about a render: Datagrid.mjs renders `header.trim() || ' '`, so no runtime fallback exists and the text had to come from the writer. Grepping the stored unit for the name settled which path wrote it. A CE0463 'fix' that substitutes a value should be re-measured once the null-vs-empty shape is known; this one outlived the shape fix by three months. Separately: create or replace does not clear a column caption to '' on either path (Unchanged page), so an app already holding the name needs the page dropped and recreated.", "issue": "ako/mxcli#535"} diff --git a/.claude/skills/mendix/assess-quality/SKILL.md b/.claude/skills/mendix/assess-quality/SKILL.md index 6a879c8caa..d2deb96d89 100644 --- a/.claude/skills/mendix/assess-quality/SKILL.md +++ b/.claude/skills/mendix/assess-quality/SKILL.md @@ -12,14 +12,14 @@ description: "Audit a Mendix project against best practices for naming, security |---|---| | 29-36 | When to Use This Skill | | 37-96 | Assessment Workflow | -| 97-354 | Quality Guidelines Reference | -| 355-365 | Assessment Report Template | -| 366-378 | Executive Summary | -| 379-389 | project Metrics | -| 390-402 | Automated Findings | -| 403-422 | Manual Review Findings | -| 423-438 | Recommendations | -| 439-449 | Sources | +| 97-355 | Quality Guidelines Reference | +| 356-366 | Assessment Report Template | +| 367-379 | Executive Summary | +| 380-390 | project Metrics | +| 391-403 | Automated Findings | +| 404-423 | Manual Review Findings | +| 424-439 | Recommendations | +| 440-450 | Sources | # Assess Mendix Project Quality @@ -152,7 +152,7 @@ After reviewing automated results, assess the following areas manually. The guid ### C. Security -**Enforced by rules:** SEC001-SEC010, CONV006, CONV007, CONV008, MPR007 +**Enforced by rules:** SEC001-SEC011, CONV006, CONV007, CONV008, MPR007 #### C.1 Entity Access @@ -160,6 +160,7 @@ After reviewing automated results, assess the following areas manually. The guid |-----------|------|----------| | All persistent entities must have access rules | SEC001 | Critical | | Every role that opens a page can read the entities its grids, galleries and combo boxes list (else it renders empty at Production) | SEC010 | High | +| Every association a page data source's XPath crosses is readable by the roles that open the page, on the entity the step starts from (else the page fails to open at Production) | SEC011 | High | | Do not grant Create or Delete rights on entity access rules | CONV006 | High | | All entity access rules should have XPath constraints | CONV007 | High | | Set default access rights to None | Manual check | High | @@ -441,7 +442,7 @@ Overall Score: [X]/100 (from `mxcli report`) This skill incorporates guidelines from: - **Conventions.pdf** — Squad Apps internal best practices (14 categories, 80+ guidelines) - **CONV001-CONV017 lint rules** — Automated checks derived from Conventions.pdf -- **MPR001-MPR007, SEC001-SEC010** — Built-in linter rules +- **MPR001-MPR007, SEC001-SEC011** — Built-in linter rules - **ARCH, DESIGN, QUAL series** — Starlark architecture/quality rules - **Mendix Performance Best Practices** — Official Mendix documentation on performance optimization - **Mendix Security Best Practices** — Official Mendix documentation on security configuration diff --git a/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md index 47fcff31d5..d96bdd6ff9 100644 --- a/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md +++ b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md @@ -14,9 +14,9 @@ description: "Change the model through mxcli while Studio Pro has the project op | 36-42 | How it works (hybrid: local reads, live writes) | | 43-61 | Connect | | 62-91 | What you can change via MCP — check first | -| 92-104 | Two MCP servers — use the built-in one by default | -| 105-114 | Caveats | -| 115-122 | Verify your change | +| 92-99 | The MCP server | +| 100-112 | Caveats | +| 113-120 | Verify your change | # Live-Editing an Open Studio Pro Project with mxcli (MCP) @@ -89,26 +89,24 @@ New modules and their dependents resolve within the same run, so document in a folder at create time (`create microflow X.MF folder 'A/B'`), since MOVE can't re-parent over MCP. -## Two MCP servers — use the built-in one by default +## The MCP server -The machine may run two MCP servers: - -- **Studio Pro built-in (port 7782 by default)** — model authoring. **Use this by - default.** From **11.13** Studio Pro **auto-selects a free port** when 7782 is - taken, so multiple instances can run side by side. The active port is shown in - Studio Pro's **status bar** (and set under Preferences > AI > MCP Server) — read - it there rather than assuming 7782, and pass it to `--mcp-dial`. -- **Concord (port 7783)** — a temporary gap-filler with operational/refactor tools - (`delete_document`, `save_all`, `run_app`, `check_model`). **Only** reach for - Concord when the built-in server lacks the capability you need. +Studio Pro's built-in MCP server (port 7782 by default) is the only one mxcli +uses. From **11.13** Studio Pro **auto-selects a free port** when 7782 is taken, so +multiple instances can run side by side. The active port is shown in Studio Pro's +**status bar** (and set under Preferences > AI > MCP Server) — read it there rather +than assuming 7782, and pass it to `--mcp-dial`. ## Caveats -- **Writes are unsaved.** They land in Studio Pro's in-memory model (shown as - unsaved). Save in Studio Pro to persist to disk. -- **No DROP of standalone documents** (enumeration / microflow / page) through the - built-in server — it has no delete tool. Remove them in Studio Pro (or via - Concord's `delete_document`). +- **Writes are saved as they land.** PED writes each change to disk immediately + (measured on 11.12.0 and 11.16.0), so there is no save step. +- **DROP of standalone documents needs Studio Pro 11.16+** (`ped_delete_document`). + On 11.16.0 that tool fails with `Session 'mcp-server' not found` for callers + outside Studio Pro's own agent session; delete the document in Studio Pro then. +- **`--mcp-check`** runs Studio Pro's project-wide error check (11.15+), and + **`--mcp-run`** starts the app through the `mendix_app` handler and prints its URL + (11.16+). - **`-p` must match the open project.** A mismatched `-p` silently reads the wrong model. diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index e75335f11b..bdb44f8d84 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -15,9 +15,9 @@ description: "Configure Mendix security in MDL — module and user roles, access | 44-120 | The System-module ceiling — decide the role model around this FIRST | | 121-560 | Syntax Reference | | 561-575 | Starlark Lint Rule APIs | -| 576-628 | Common Workflow: Setting Up Module Security | -| 629-638 | Common Mistakes | -| 639-648 | Validation | +| 576-637 | Common Workflow: Setting Up Module Security | +| 638-647 | Common Mistakes | +| 648-657 | Validation | # Security Management Skill @@ -626,6 +626,15 @@ Audit members are the one case where naming a member cannot change its rights: member access to write for it and a rule that carries one fails CE0066. Let the rule's default cover it, or change the default. +**A page data source's XPath needs the association member too.** At Production the +runtime checks member access on every association step of a data-source XPath, on the +entity the step starts from: `database from M.Account where [M.Order_Account/M.Order/IsActive += true()]` with `M.Order_Account` at None in Account's rule (owner Both) opens to an error +dialog, `No access rights for M$Account/M.Order_Account`, while mxbuild reports nothing. +A step from an entity the association is not a member of (the TO side of an `OWNER +Default` association) is not checked, and neither is an XPath inside an access rule +(measured, 11.15). `mxcli lint` reports it as SEC011. + ## Common Mistakes 1. **Creating module roles before the module exists** — `create module` must come first diff --git a/.claude/skills/mendix/teamserver-git/SKILL.md b/.claude/skills/mendix/teamserver-git/SKILL.md index e064d43702..60b9e47c11 100644 --- a/.claude/skills/mendix/teamserver-git/SKILL.md +++ b/.claude/skills/mendix/teamserver-git/SKILL.md @@ -14,9 +14,9 @@ description: "Commit, branch and push a Mendix project in git the way Studio Pro | 42-55 | Branches: create them on the server | | 56-101 | Commit from the agent | | 102-116 | Push the branch and the notes together, and soon | -| 117-129 | Never rewrite or merge model history with git | -| 130-140 | Reading what Studio Pro recorded | -| 141-149 | Checklist | +| 117-138 | Never rewrite or merge model history with git | +| 139-149 | Reading what Studio Pro recorded | +| 150-158 | Checklist | # Working with Git on a Mendix Team Server Project @@ -116,11 +116,20 @@ git push origin refs/notes/mx_metadata ## Never rewrite or merge model history with git -- **No `git merge`, `rebase` or `cherry-pick` of model changes.** The `.mpr` is - a SQLite file; git cannot merge it, and a textual merge of `mprcontents/` - leaves an index that does not match the files. Studio Pro has a model-aware - merge: **Version Control › Merge Changes Here** (*Merge feature branch* or - *Cherry Pick*). Studio Pro's history on Team Server is linear for that reason. +- **No plain `git merge`, `rebase` or `cherry-pick` of model changes.** The + `.mpr` is a SQLite file; git cannot merge it (every two-sided model change + is a binary conflict), and a textual merge of `mprcontents/` leaves an + index that does not match the files. Model-aware merging exists in two + forms: **`mxcli git merge `** from the command line (it hands the + conflict to `mx merge` over the three revisions' project files — Studio + Pro's own engine, Linux devcontainers included — and commits the resolved + model as a normal merge commit with the note), and **Version + Control › Merge Changes Here** in Studio Pro (*Merge feature branch* or + *Cherry Pick*) — which is also where the conflicts mx cannot auto-resolve + go. A conflict `mxcli git merge` cannot resolve — and an engine failure — + aborts the merge cleanly; nothing is left half-done. + Studio Pro's history on Team Server is linear for a reason: do not "fix" + a conflicted model merge by hand-editing files. - **No `--amend` or force-push of a pushed commit.** Before pushing, an amend is fine: the `notes.rewriteRef` Studio Pro puts in `.git/config` carries the note. Re-run `mxcli git note --write` anyway; it is cheap. diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d1d7dff3..f19d2dfeca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **DROP over `--mcp` uses Studio Pro 11.16's `ped_delete_document`** — DROP of a constant, enumeration, microflow, nanoflow, page, workflow or business-event service no longer needs `--mcp-concord` when Studio Pro advertises the native delete tool; Concord stays as the fallback for older servers and for a native call that fails. +- **SEC011: a page data source whose XPath crosses an association the page's roles cannot read** (ako/mxcli#945, #1337, mendixlabs/mxcli#1471) — at Production security such a page opens to an error dialog (`No access rights for Shop$Account/Shop.Order_Account`) while mxbuild, `check` and `lint` were all clean. The rule checks each association step from the entity it starts on, per user role that can open the page, and reports it as an error with the member to grant. Measured on 11.15: an association is checked only where it is a member (its FROM entity, and its TO entity when the owner is Both), and an XPath inside an entity access rule is not checked at all, so neither is reported. +- **MPR012 covers every widget measured as CE0582 under the React client** (ako/mxcli#564) — besides the static and dynamic image it now reports the classic drop-down, the legacy data grid and the reference selector a project already holds, each with its replacement (`combobox`, Data grid 2). The set is measured on 11.16 with mxbuild, controls included: the navigation tree, menu bar, file manager, scroll container, image uploader and the common input widgets build clean and stay silent. The rule is renamed `ReactUnsupportedWidget`; its ID is unchanged. +- **DROP over `--mcp` uses Studio Pro 11.16's `ped_delete_document`** (ako/mxcli#1851) — DROP of a constant, enumeration, microflow, nanoflow, page, workflow or business-event service calls the native delete tool. A server without it (Studio Pro before 11.16) gets an error naming the version it needs. - **`mxcli lint --baseline ` reports only new findings** (ako/mxcli#1328, mendixlabs/mxcli#1455) — record a baseline with `--update-baseline`, commit it, and later runs report only the findings it does not have, with an exit code that follows them alone and a `N new, M already known, K fixed` summary on stderr. Findings are matched on rule and document, not message, so rewording a rule does not make its findings new; a scoped run (`-m`, `-d`, `-r`) reports new findings but cannot update the baseline. - **Lint permission rows name their access rule** (ako/mxcli#1437, mendixlabs/mxcli#1539) — `permissions()` and `permissions_for()` rows carry `access_rule_id` and `access_rule_index`, so a rule can count an entity's access rules per setting or list one rule's module roles exactly, instead of guessing by grouping on XPath, default and rights. The catalog schema moves to version 31, so a cached catalog is rebuilt once. - **Lint rules can read the data guideline rules asked for** (ako/mxcli#1453, ako/mxcli#1301) — a microflow's `excluded` and `apply_entity_access`, an enumeration's `values` with their captions (so a rule can resolve `getCaption(Module.Enum.Value)`), `constant_values()` for the run-configuration values, and `project_settings()` (default language, OQL version, Java version, hash algorithm, time zone, rounding, decimal scale, optimistic locking, foreign keys, web client). The catalog schema moves to version 30, so a cached catalog is rebuilt once. @@ -17,10 +19,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Concord support is removed; `--mcp-check` and `--mcp-run` use Studio Pro's own MCP server** — the optional second MCP server (Concord) and the `--mcp-concord`, `--mcp-concord-dial` and `--mcp-save` flags are gone. PED writes each change to disk as it lands, so there is nothing to save. `--mcp-check` runs a project-wide `ped_check_errors` (Studio Pro 11.15+), and `--mcp-run` starts the app through the `mendix_app` handler and prints its URL (11.16+). - **The first command on a project says it is building the unit index** (ako/mxcli#1806) — after an upgrade to the `unit-index-2.idx` format, or on a fresh clone, one command opens every unit file to build the index, which on Windows is a visible stall. It now prints one line on stderr (`Building the unit index for this project's N units (once; …)`); `-q` / `MXCLI_QUIET` keep it silent. ### Fixed +- **A custom-content data grid column with an empty caption no longer shows its MDL name as the header** (ako/mxcli#535) — through the generic `pluggablewidget 'com.mendix.widget.web.datagrid.Datagrid'` form, `column colTriage (Caption: '') { … }` (or no caption) wrote `colTriage` as the header text, which end users saw; Mendix stores no name for a column. It now writes an empty header, as the keyword `datagrid` form already did; it builds clean (11.15.0) and Data grid 2 renders it blank. A page that already holds the name keeps it until the page is dropped and created again: `create or replace` does not yet clear a caption to `''`. - **`--mcp http://localhost:/mcp` reaches Studio Pro on a Windows machine with Docker Desktop** (mendixlabs/mxcli#1684, ako/mxcli#1833) — without `--mcp-dial`, mxcli dialled `host.docker.internal` whenever that name resolved, taking it to mean "inside a container". Docker Desktop for Windows puts the name in the hosts file, pointing at the LAN address, and Studio Pro listens on loopback only, so the connection timed out (`dial tcp :7782: i/o timeout`) on the very machine Studio Pro runs on. mxcli now dials the URL's own host first and tries `host.docker.internal` only when that fails, so the same command still works from a devcontainer. `--mcp-dial` is unchanged: when given, it is the only address tried. - **`--mcp-trace` and `--mcp-verbose` list the PED calls** (mendixlabs/mxcli#1684, ako/mxcli#1833) — they printed each MDL statement but never a tool call, on `exec`, `-c`, the REPL and `mxcli mcp capabilities` alike. A run that sent every write to Studio Pro read as one that sent none, which is how a report concluded that `exec --mcp` wrote the `.mpr` instead. The backend the CLI builds for `--mcp` now gets the tracer too, so each statement is followed by its `→ ped_…` calls. - **A PED tool whose handler throws is reported as a failure** — Studio Pro answers `Error executing tool handler: …` with `isError:false` (measured on 11.16.0), which a caller trusting the flag read as success; the MCP client now classifies that text as an error. @@ -42,6 +46,7 @@ Headline: **Write what the project's own Mendix version stores, and commit it th - **Lint rule CONV021 (CreateChangeThenCommit)** — warns when a microflow's Create or Change object activity without commit is directly followed by a Commit of the same object, and suggests the one activity that does both: Commit set on the create/change (Yes, or Yes without events for a commit without events; Refresh in client kept). It stays quiet when merging could change behaviour: another flow into the commit, an error-handler flow between them, or custom error handling on either. - **`mxcli git commit -m … -p app.mpr` — commit the way Studio Pro does** (mendixlabs/mxcli#1669) — Studio Pro's *Commit* is more than `git commit`: it keeps the ContentsHash index in step, stages exactly the files a Mendix repository ships, and attaches the mx_metadata note. The new command is that sequence in one line: mismatched hashes are repaired from the files on disk first (`MISSING` and `ORPHAN` entries have no automatic repair and refuse the commit), everything is staged and the files Studio Pro never commits (`project-settings.user.json`, `*.mpr.lock`, `*.mpr.bak`, and the project's own `.mendix-cache/`, `deployment/`, `theme-cache/`, `.mxcli/` beside the `.mpr` — a folder of the same name further down, such as `resources/deployment/`, is committed) are unstaged again, `git commit` runs with the caller's message and identity, and the mx_metadata note is computed from the new commit and attached, so Studio Pro can version-check the revision and show what changed. `--push` pushes the branch and the notes ref together (`git push` alone loses the note the next time Studio Pro fetches); `--dry-run` reports what it would commit without committing — and without repairing anything, hash drift included, because a dry run writes nothing: it stages into a copy of the index, so what the user had staged is left as it was. The commit is refused while Studio Pro has the project open (`.mpr.lock`) — honouring the same `MXCLI_ALLOW_STUDIO_PRO_OPEN=1` override as every write — and during an unresolved merge, which `git add -A` would otherwise commit with its conflict markers. Docs: "Working Outside Studio Pro" › *One commit, all the steps*. +- **`mxcli git merge ` — model-aware merging from the command line** (mendixlabs/mxcli#1669) — `git merge` cannot merge Mendix models: the `.mpr` is SQLite and every `.mxunit` is BSON, so two sides changing the model always meet a binary `CONFLICT (content)` (measured in the git lab: both files, every time). Studio Pro resolves that with a model-aware merge, and the same engine ships as a command — `mx merge BASE MINE THEIRS` over the three revisions' project files, which runs on Linux devcontainers too. `mxcli git merge ` runs `git merge` first (a merge git resolves itself is annotated like `mxcli git commit`; a merge stopped on text conflicts resumes once those are resolved and staged), and on a model-file conflict checks the base, ours (a clean checkout of HEAD — never the working tree mid-merge, where the engine chokes) and theirs revisions out into temporary worktrees and hands the three projects' `.mpr` files to the engine, mx resolved for the project's Mendix version (PATH, the mxcli cache, the Studio Pro installation): everything the engine auto-resolves is copied back from MINE, hash-checked, staged and committed as the normal two-parent merge commit with the mx_metadata note — and the engine owns the `.mpr`, so a unit one side added while the other changed existing ones is kept, not dropped, and a deletion it resolved removes the unit file with it; the engine's own `-v` conflict list (one JSON array per run, `ManualResolutionRequired` vs `AutoResolved`) decides which units the review material and plan template cover, with git's conflicted-file list as the fallback; a conflict the engine reports as `ManualResolutionRequired` (exit 2) is a review step, never a silent choice: the merge aborts leaving the working tree exactly as before, and both sides' changes are printed unit by unit at property level (from `mx diff`), with a plan template (`.merge-plan.json`, never staged by `mxcli git commit`) beside the project — the human re-runs with `--plan ` to resolve **per unit** over the engine's own result (the engine runs again and the plan decides only the units it left open — everything it merged stays merged; each planned unit takes `ours` or `theirs` whole, its conflict marker is cleared and the `.mpr` index repaired from the files; a plan naming more or fewer units than the engine left open is refused; modified-on-both units only, added/deleted/moved goes to Studio Pro), or with `--ours`/`--theirs` to take one side of every conflicted unit — hash-gated: when the sides' document sets differ (one side added or deleted a unit) the commit is refused and the merge aborted, because a whole side would orphan the other side's documents — or resolves in Studio Pro (*Version Control › Merge Changes Here*), which remains the tool for mixed resolutions; any other engine exit (its 129, an mx that will not start) is reported as the engine failing and the merge is aborted cleanly, never steered into a decision. Non-model conflicts (text sources) are left in place like plain git: resolve them, `git add` them, re-run the command. Refused while Studio Pro has the project open. Docs: "Working Outside Studio Pro" › *Merging branches*. - **`mxcli git doctor [-p app.mpr] [--fix]` — audit a repository for everything a plain-git history misses, and repair it** (mendixlabs/mxcli#1669) — a repository committed only with git drifts from what Studio Pro expects in four ways, each invisible on its own: the local git config Studio Pro writes is absent (`core.autocrlf=False` keeps a checkout from rewriting line endings inside `.mxunit` files on Windows; `mendix.commits-since-gc` and `mendix.lineEndingResetDone` are its own bookkeeping), the remote is SSH which Studio Pro cannot use (HTTPS only), commits have no note under `refs/notes/mx_metadata` (history shows `(unknown)`, the version check fails), and a malformed note Studio Pro cannot read at all. `doctor` reports all four in one read-only pass and exits 1 while an issue remains, so it can gate CI. `--fix` sets the config keys, rewrites SSH remote URLs (`git@host:org/app.git`, `ssh://…`) to HTTPS — judged as configured, so a `url..insteadOf` rule in the user's git config (https → `git@` is a common one) is reported rather than "repaired" on every run — and backfills notes: computed from the model with `-p` (the same engine `git note` uses), or a template note with a `--version` ModelerVersion where the model at a commit cannot be read (MPR v1 history, a revision without the `.mpr`); with neither, the newest valid note in history supplies the version. Notes Studio Pro wrote are never touched, with or without `--fix`; `--fix` does not push. A clone whose notes ref lacks the remote's notes — `git clone` never fetches `refs/notes/*`, so every fresh clone and CI checkout — is reported as exactly that, with the fetch command, rather than as a history of unannotated commits; `--fix` fetches the notes first, as a fast-forward only. Docs: "Working Outside Studio Pro" › *Auditing a repository*. - **Workflows say what Studio Pro lets them say** — a user task takes a `task name '…'` clause (what the task inbox shows, until now always the caption), and the workflow's display and description and a task's name and description take template parameters, `display 'Approval for {1}' with ({1} = $WorkflowContext/Title)`, in CREATE and ALTER. A timer boundary event takes `caption '…'`, and a non-interrupting timer can recur: `repeat every 1 day at most 3 times`. `alter workflow … set (Annotation: '…')` sets the workflow's note, and `… on ` an activity's. A `describe` → `create or modify` that would still reset a setting MDL cannot state is refused, naming the setting, instead of reporting "Modified workflow". - **MDL-WF20: a user task whose page cannot complete one of its outcomes** — Mendix adds no buttons to a task page, so a page that only shows the task passes `mx check` and the build, and every instance then waits at the task forever. `check` now warns per task, listing the outcomes no `complete task` button, or button whose microflow reaches `set task outcome`, can complete. diff --git a/cmd/mxcli/cmd_git_commit.go b/cmd/mxcli/cmd_git_commit.go index 3942c276ce..27e575613f 100644 --- a/cmd/mxcli/cmd_git_commit.go +++ b/cmd/mxcli/cmd_git_commit.go @@ -152,16 +152,22 @@ func commitRun(mprPath, message string, push, dryRun bool, out io.Writer) error fmt.Fprintln(out, "nothing to commit, working tree clean") return nil } - fmt.Fprintf(out, "Staged %d file(s):\n", len(staged)-len(junk)) - kept := staged - if len(kept) > 10 { - kept = kept[:10] + kept := make([]string, 0, len(staged)-len(junk)) + for _, f := range staged { + if !isJunkPath(f, projDir) { + kept = append(kept, f) + } } - for _, f := range kept { + fmt.Fprintf(out, "Staged %d file(s):\n", len(kept)) + shown := kept + if len(shown) > 10 { + shown = shown[:10] + } + for _, f := range shown { fmt.Fprintf(out, " %s\n", f) } - if len(staged)-len(junk) > 10 { - fmt.Fprintf(out, " … and %d more\n", len(staged)-len(junk)-10) + if len(kept) > 10 { + fmt.Fprintf(out, " … and %d more\n", len(kept)-10) } if dryRun { @@ -184,16 +190,7 @@ func commitRun(mprPath, message string, push, dryRun bool, out io.Writer) error return fmt.Errorf("committed %s, but attaching the note failed (%v) — run 'mxcli git note -p %s --write' to attach it", shortCommit(head), err, mprPath) } - if push { - if branch == "" { - return errors.New("detached HEAD; cannot push — check out a branch and push manually") - } - if _, err := gitOutIn(root, nil, "push", "origin", branch, mxMetadataRef); err != nil { - return fmt.Errorf("push %s and %s: %w", branch, mxMetadataRef, err) - } - fmt.Fprintf(out, "Pushed %s and %s to origin.\n", branch, mxMetadataRef) - } - return nil + return pushBranchAndNotes(out, root, branch, push) } // indexCopy copies the repository's index to a temporary file, for a @@ -264,6 +261,10 @@ func isJunkPath(rel, projDir string) bool { return true case strings.HasSuffix(strings.ToLower(base), ".mpr.lock"), strings.HasSuffix(strings.ToLower(base), ".mpr.bak"): return true + // `mxcli git merge` writes the plan template beside the .mpr for the + // human to edit; it is working material, never project content. + case strings.HasSuffix(strings.ToLower(base), ".merge-plan.json"): + return true } inProject := f if projDir != "." && projDir != "" { diff --git a/cmd/mxcli/cmd_git_doctor.go b/cmd/mxcli/cmd_git_doctor.go index 06fb78f185..2413ff8700 100644 --- a/cmd/mxcli/cmd_git_doctor.go +++ b/cmd/mxcli/cmd_git_doctor.go @@ -23,8 +23,9 @@ var gitDoctorCmd = &cobra.Command{ Checked: 1. Local git config: core.autocrlf=False (a checkout must not rewrite line - endings inside .mxunit files), mendix.commits-since-gc and - mendix.lineEndingResetDone (Studio Pro's own bookkeeping). --fix sets them. + endings inside .mxunit files; --fix rewrites any other value), and the + mendix.* keys (Studio Pro's own bookkeeping) backfilled when missing — + a value Studio Pro wrote stays. 2. The remote URL, as configured. Studio Pro can only use HTTPS; an SSH remote (git@host:org/app.git or ssh://…) is rewritten to HTTPS by --fix. Other non-HTTPS URLs are reported without being changed. A diff --git a/cmd/mxcli/cmd_git_merge.go b/cmd/mxcli/cmd_git_merge.go new file mode 100644 index 0000000000..6718583559 --- /dev/null +++ b/cmd/mxcli/cmd_git_merge.go @@ -0,0 +1,1008 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" + "github.com/mendixlabs/mxcli/modelsdk/mpr" + "github.com/spf13/cobra" +) + +// cmd_git_merge.go: `mxcli git merge ` — merge a branch the way Studio +// Pro does. `git merge` runs first; when it stops on the model files (the +// .mpr is SQLite and every .mxunit is BSON, so any two-sided model change is +// a binary conflict), the three revisions are handed to `mx merge` — the same +// model-aware engine Studio Pro's own merge uses — as three .mpr paths over +// clean checkouts (base, ours at HEAD, theirs), never the working tree +// mid-merge, with the project's Mendix version's mx. The engine writes its +// result into MINE, which is copied back into the working tree. What the +// engine resolves is hash-checked, staged and committed as a normal +// two-parent merge commit with the mx_metadata note; what it reports as +// ManualResolutionRequired (exit 2) aborts the merge with the conflict list +// and a pointer to Studio Pro, leaving nothing behind; any other non-zero +// exit is the engine failing, reported as such, also with a clean abort. + +var gitMergeCmd = &cobra.Command{ + Use: "merge ", + Short: "Merge a branch with model-aware conflict resolution (mx merge)", + Long: `Merge into the current branch, resolving model conflicts the way +Studio Pro does. + +'git merge ' runs first. A merge git resolves itself (disjoint files, a +fast-forward) is committed and annotated like 'mxcli git commit' does. When +git stops on the model files — the .mpr and the .mxunit units are binary, so +two sides changing the model always conflict — the base, ours (a clean +checkout of HEAD) and theirs revisions are checked out into temporary +worktrees and the three projects' .mpr files go to +'mx merge BASE MINE THEIRS' (Studio Pro's own model-aware merge, resolved +for the project's Mendix version). MINE is never the working tree mid-merge: +the engine chokes on the conflicted state. Its result is copied back: + + - everything the engine auto-resolves is copied back, hash-checked, + staged and committed as the merge commit, with the mx_metadata note + attached (this keeps both sides — the usual best outcome; the engine + owns the .mpr, so a unit one side added while the other changed + existing ones is kept, not dropped); + - a conflict the engine reports as ManualResolutionRequired (exit 2) is + never merged silently: the merge is aborted, and both sides' changes are + printed for review — at property level, from 'mx diff' (e.g. + "New type > Length: 100 → 150" on ours, "→ 200" on theirs). Then decide: + --plan resolve per unit from the plan template written beside + the .mpr (.merge-plan.json): set "take" to + "ours"/"theirs" per unit. The engine runs again and the + plan decides only the units it left open — everything + it merged stays merged, so the plan must name exactly + those units (a stale or hand-widened plan is refused). + Each planned unit is taken whole from its side, its + conflict marker cleared, and the .mpr index repaired. + Modified-on-both units only — added/deleted/moved + conflicts go to Studio Pro. + --ours / --theirs take one side of every conflicted unit + (deterministic; mx is not consulted again). The .mpr is + taken from that side too and the result is hash-checked: + when the sides' document sets differ (one added or + deleted a unit), the commit is refused — a whole side + would leave the other side's units orphaned on disk or + listed with no file — and the merge is aborted. Resolve + those in Studio Pro instead. + Studio Pro (Version Control › Merge Changes Here) for mixed + within-unit resolutions — still the tool when a decision + must mix both sides inside one unit; + - any other engine exit is mx failing, not a decision: it is reported and + the merge is aborted cleanly; + - a conflict in non-model files (text sources) is left in place for manual + resolution, like plain git: resolve them, 'git add' them, and re-run + this command — it finishes the merge (model conflicts included) and + commits. + +--ours/--theirs take one side of every conflicted unit — never a side of the +.mpr without the hash check, because the .mpr lists every document: a whole +side would drop the other side's added units and resurrect its deleted ones. + +The merge is refused while Studio Pro has the project open (.mpr.lock). +MPR v2 projects only. The mx binary is resolved for the project's Mendix +version (PATH, the mxcli cache, or the Studio Pro installation).`, + Example: ` mxcli git merge feature/orders -p app.mpr + mxcli git merge origin/main -p app.mpr --push + mxcli git merge feature/orders -p app.mpr --theirs # after reviewing the report + mxcli git merge feature/orders -p app.mpr --plan app.mpr.merge-plan.json`, + Args: cobra.ExactArgs(1), + RunE: runGitMerge, + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + gitMergeCmd.Flags().StringP("project", "p", "", "path to the Mendix project (.mpr)") + _ = gitMergeCmd.MarkFlagRequired("project") + gitMergeCmd.Flags().StringP("message", "m", "", "merge commit message (default: git's)") + gitMergeCmd.Flags().Bool("ours", false, "on a model conflict mx cannot resolve, take our side of every conflicted model file") + gitMergeCmd.Flags().Bool("theirs", false, "on a model conflict mx cannot resolve, take their side of every conflicted model file") + gitMergeCmd.Flags().String("plan", "", "resolve per unit from a plan file written by a previous run (.merge-plan.json)") + gitMergeCmd.Flags().Bool("push", false, "push the branch and the notes ref to their remote afterwards") + gitCmd.AddCommand(gitMergeCmd) +} + +func runGitMerge(cmd *cobra.Command, args []string) error { + ref := args[0] + message, _ := cmd.Flags().GetString("message") + ours, _ := cmd.Flags().GetBool("ours") + theirs, _ := cmd.Flags().GetBool("theirs") + plan, _ := cmd.Flags().GetString("plan") + push, _ := cmd.Flags().GetBool("push") + project, _ := cmd.Flags().GetString("project") + strategy := "" + switch { + case ours && theirs: + return errors.New("--ours and --theirs cannot be combined") + case ours && plan != "", theirs && plan != "": + return errors.New("--plan cannot be combined with --ours/--theirs: the plan is the finer decision") + case ours: + strategy = "ours" + case theirs: + strategy = "theirs" + } + return mergeRun(project, ref, message, strategy, plan, push, mxRunners{merge: realMxMerge, diff: realMxDiff}, cmd.OutOrStdout()) +} + +// mxRunners are the mx invocations, injected for tests. +// +// merge runs the engine as `mx merge -v BASE MINE THEIRS` over the three +// revisions' project files — clean checkouts of base, ours (HEAD) and +// theirs — and writes its result into MINE. The -v conflict list (one JSON +// array on one stdout line) is what the review step reports from. Exit 0 is +// a resolved merge, 2 is a decision the engine could not make, anything else +// is the engine failing. +type mxMergeRunner func(baseMpr, mineMpr, theirsMpr string, shas [3]string) (int, string, error) +type mxDiffRunner func(base, mine, outJSON string) error + +type mxRunners struct { + merge mxMergeRunner + diff mxDiffRunner +} + +// mergeRun is the command without its wiring. runners are injected for tests. +// +// Conflict handling is a three-step flow a human stays in charge of: +// +// 1. `git merge` runs; a merge it resolves itself is annotated and done. +// 2. A model conflict goes to `mx merge` over the three revisions' .mpr +// files (clean worktrees; the result is copied back from MINE) first — +// what the engine auto-resolves keeps both sides, which is almost +// always the best outcome — and is committed with the note. +// 3. What the engine reports as ManualResolutionRequired (exit 2) is never +// merged silently: with no strategy the merge is aborted and both sides' +// changes are printed for review; `--plan` then resolves the units the +// engine left open, over the engine's own result, and +// `--ours`/`--theirs` take one side of every conflicted unit, hash-gated +// so a side that would drop or orphan documents is refused. Any other +// engine exit is a failure, not a decision: reported, aborted cleanly. +func mergeRun(mprPath, ref, message, strategy, plan string, push bool, runners mxRunners, out io.Writer) error { + switch strategy { + case "", "ours", "theirs": + default: + return fmt.Errorf("unknown strategy %q (use --ours or --theirs)", strategy) + } + abs, err := filepath.Abs(mprPath) + if err != nil { + return err + } + if !mpr.AllowWritesWhileStudioProOpen && os.Getenv(mpr.AllowStudioProOpenEnv) != "1" { + if lock, mod := mpr.StudioProLockFile(abs); lock != "" { + return &mpr.StudioProOpenError{MprPath: abs, LockPath: lock, LockTime: mod} + } + } + repo, err := openGitNoteRepo(mprPath) + if err != nil { + return err + } + defer repo.Close() + root := repo.Root + + branch := strings.TrimSpace(gitOutOr(root, "symbolic-ref", "--quiet", "--short", "HEAD")) + if branch == "" { + return errors.New("detached HEAD: check out a branch before merging") + } + + if plan != "" { + return applyPlan(root, repo, abs, mprPath, ref, plan, message, branch, push, runners, out) + } + + // A merge already in progress is resumed, not re-run: a previous run may + // have stopped on text conflicts, which the human resolved and staged + // since. Re-running git merge would only refuse ("not concluded"). + if _, mhErr := gitOut(root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr != nil { + mergeOut, mergeErr := gitOutIn(root, nil, "merge", "--no-edit", ref) + if mergeErr == nil { + if strings.Contains(mergeOut, "Already up to date") { + fmt.Fprintln(out, "Already up to date.") + return nil + } + head := strings.TrimSpace(gitOutOr(root, "rev-parse", "HEAD")) + fmt.Fprintf(out, "Merged %s without model conflicts (%s).\n", ref, shortCommit(head)) + return finishMerge(out, repo, head, branch, push) + } + // A ref that does not resolve fails before any conflict exists, and + // so does a merge git refuses to start (local changes it would + // overwrite, no identity): git's reason is on stderr, which is the + // error — stdout holds at most an "Updating a..b" line. + if _, mhErr := gitOut(root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr != nil { + return fmt.Errorf("git merge %s: %v", ref, mergeErr) + } + } + + conflicted := nulFields(gitOutOr(root, "diff", "--name-only", "--diff-filter=U", "-z")) + var model, other []string + for _, p := range conflicted { + if isModelPath(repo, p) { + model = append(model, p) + } else { + other = append(other, p) + } + } + if len(other) > 0 { + fmt.Fprintln(out, "Non-model conflicts, resolve by hand, then 'git add' them and re-run this command to resolve the model and commit:") + for _, p := range other { + fmt.Fprintf(out, " %s\n", p) + } + return errors.New("merge has non-model conflicts left for manual resolution") + } + + baseSha := strings.TrimSpace(gitOutOr(root, "merge-base", "HEAD", "MERGE_HEAD")) + oursSha := strings.TrimSpace(gitOutOr(root, "rev-parse", "HEAD")) + theirsSha := strings.TrimSpace(gitOutOr(root, "rev-parse", "MERGE_HEAD")) + + // The human has decided: take one side of every conflicted model file. mx + // is not consulted — `git checkout --ours/--theirs` reads the conflicted + // stages directly. The .mpr is taken from the side too, and the result is + // hash-checked before committing: a whole side includes the .mpr's list of + // documents, so when the sides' document sets differ (one side added or + // deleted a unit) committing would orphan the other side's units or list + // units with no file. That refusal aborts the merge — it never commits a + // half-project. + if strategy != "" { + side := "--" + strategy + for _, p := range model { + if _, err := gitOutIn(root, nil, "checkout", side, "--", p); err != nil { + return abortMerge(root, fmt.Errorf("take the %s version of %s: %w", strategy, p, err)) + } + } + if err := repairOrRefuse(root, abs, out); err != nil { + return err + } + return commitTheMerge(root, repo, model, message, branch, push, out) + } + + // The engine first: what it auto-resolves keeps both sides, which is + // almost always better than taking one side wholesale. The three + // revisions are checked out as clean worktrees; MINE is the one at HEAD, + // never the working tree mid-merge — the engine chokes on the conflicted + // state (measured: it exits 129 when theirs deleted a document). Its + // result lands in MINE and is copied back. + revs, err := checkoutRevisions(root, ref, baseSha, oursSha, theirsSha) + if err != nil { + return err + } + tmp, baseDir, oursDir, theirsDir := revs.tmp, revs.baseDir, revs.oursDir, revs.theirsDir + cleanWorktrees := revs.cleanup + + version := projectMendixVersion(mprPath) + fmt.Fprintf(out, "Model files conflict (%s); merging the model with mx %s…\n", + strings.Join(shortNames(model), ", "), version) + code, mxOut, runErr := revs.runEngine(runners, repo, out) + if runErr != nil { + cleanWorktrees() + return abortMerge(root, fmt.Errorf("mx merge failed to run: %w", runErr)) + } + if code != 0 && code != 2 { + // A crash, an unreadable base, anything but a merge decision: report + // the engine as failing and leave nothing behind. Only exit 2 asks + // for a decision. + cleanWorktrees() + return abortMerge(root, fmt.Errorf("mx merge failed (exit %d); the merge is aborted — if it persists, resolve in Studio Pro (Version Control › Merge Changes Here)", code)) + } + if code == 2 { + // The review step: what each side changed, at property level (mx + // diff), plus a per-unit plan template. Then leave nothing behind — + // the human decides. The unit list follows the engine's own conflict + // list — git's can differ (a stale one-side index makes the engine + // content-merge a unit git sees as changed). Without a parsable + // list, the units the engine's result marks; without those, git's + // conflicted units: err toward showing work rather than hiding it. + // --plan reads the same set back, so the template it is given + // matches what it checks the plan against. + oursMpr := filepath.Join(oursDir, filepath.FromSlash(repo.MPRPath)) + manual := engineDecisions(mxOut, oursMpr) + if len(manual) == 0 { + manual = conflictedUnitIDs(model) + } + if runners.diff != nil { + oursJSON := filepath.Join(tmp, "ours-diff.json") + theirsJSON := filepath.Join(tmp, "theirs-diff.json") + d1 := runners.diff(filepath.Join(baseDir, filepath.FromSlash(repo.MPRPath)), oursMpr, oursJSON) + d2 := runners.diff(filepath.Join(baseDir, filepath.FromSlash(repo.MPRPath)), filepath.Join(theirsDir, filepath.FromSlash(repo.MPRPath)), theirsJSON) + if d1 == nil && d2 == nil { + fmt.Fprintln(out, propertyReport(oursJSON, theirsJSON, manual)) + } + } + if err := writePlanTemplate(mprPath, baseSha, oursSha, theirsSha, manual); err != nil { + fmt.Fprintf(out, " (could not write the plan template: %v)\n", err) + } else { + fmt.Fprintf(out, "Plan template: %s — set \"take\" per unit, re-run with --plan.\n", filepath.Base(mprPath)+".merge-plan.json") + } + cleanWorktrees() + if _, abortErr := gitOutIn(root, nil, "merge", "--abort"); abortErr != nil { + return fmt.Errorf("the model merge has conflicts and aborting it failed (%v); resolve in Studio Pro", abortErr) + } + fmt.Fprintln(out, "Merge aborted; the working tree is back to the pre-merge state.") + return errors.New("the model merge needs a decision: re-run with --plan to resolve per unit, or resolve in Studio Pro (Version Control › Merge Changes Here)") + } + + // Exit 0: the engine resolved the model into MINE, the clean worktree at + // HEAD. Copy its result into the working tree — units the result dropped + // go with it — gate the .mpr, and only then stage and commit: the gate + // repairs hashes from the files, so staging must come after it, or the + // commit would carry the pre-repair .mpr. + if err := copyEngineResult(root, repo, oursDir); err != nil { + cleanWorktrees() + return abortMerge(root, fmt.Errorf("copy the engine's merge result: %w", err)) + } + if err := repairOrRefuse(root, abs, out); err != nil { + cleanWorktrees() + return err + } + // Stage the whole model surface, not just the conflicted paths: a unit + // the engine's result dropped was never conflicted, and its deletion + // must be staged too. + if _, err := gitOutIn(root, nil, "add", "-A", "--", + filepath.FromSlash(repo.MPRPath), filepath.FromSlash(repo.Contents)); err != nil { + cleanWorktrees() + return abortMerge(root, fmt.Errorf("stage the resolved model files: %w", err)) + } + if err := commitTheMerge(root, repo, nil, message, branch, push, out); err != nil { + cleanWorktrees() + return err + } + cleanWorktrees() + return nil +} + +// mergeRevisions are clean worktrees of a merge's three revisions — the +// engine's BASE, MINE (ours, at HEAD) and THEIRS — under one temp folder. +type mergeRevisions struct { + root, tmp string + baseDir, oursDir, theirsDir string + shas [3]string +} + +// checkoutRevisions checks the merge's three revisions out as detached +// worktrees; cleanup removes them again. +func checkoutRevisions(root, ref, baseSha, oursSha, theirsSha string) (*mergeRevisions, error) { + tmp, err := os.MkdirTemp("", "mxcli-gitmerge-") + if err != nil { + return nil, err + } + m := &mergeRevisions{ + root: root, tmp: tmp, + baseDir: filepath.Join(tmp, "base"), + oursDir: filepath.Join(tmp, "ours"), + theirsDir: filepath.Join(tmp, "theirs"), + shas: [3]string{baseSha, oursSha, theirsSha}, + } + if _, err := gitOutIn(root, nil, "worktree", "add", "--detach", m.baseDir, baseSha); err != nil { + os.RemoveAll(tmp) + return nil, fmt.Errorf("check out the merge base: %w", err) + } + if _, err := gitOutIn(root, nil, "worktree", "add", "--detach", m.oursDir, oursSha); err != nil { + _, _ = gitOutIn(root, nil, "worktree", "remove", "--force", m.baseDir) + os.RemoveAll(tmp) + return nil, fmt.Errorf("check out HEAD: %w", err) + } + if _, err := gitOutIn(root, nil, "worktree", "add", "--detach", m.theirsDir, theirsSha); err != nil { + _, _ = gitOutIn(root, nil, "worktree", "remove", "--force", m.baseDir) + _, _ = gitOutIn(root, nil, "worktree", "remove", "--force", m.oursDir) + os.RemoveAll(tmp) + return nil, fmt.Errorf("check out %s: %w", ref, err) + } + return m, nil +} + +func (m *mergeRevisions) cleanup() { + _, _ = gitOutIn(m.root, nil, "worktree", "remove", "--force", m.baseDir) + _, _ = gitOutIn(m.root, nil, "worktree", "remove", "--force", m.oursDir) + _, _ = gitOutIn(m.root, nil, "worktree", "remove", "--force", m.theirsDir) + _ = os.RemoveAll(m.tmp) +} + +// runEngine runs the engine in file form — the three revisions' project +// files, MINE the clean worktree at HEAD — and echoes what it printed. Its +// result lands in MINE. +func (m *mergeRevisions) runEngine(runners mxRunners, repo *gitNoteRepo, out io.Writer) (int, string, error) { + mprIn := func(dir string) string { return filepath.Join(dir, filepath.FromSlash(repo.MPRPath)) } + code, mxOut, err := runners.merge(mprIn(m.baseDir), mprIn(m.oursDir), mprIn(m.theirsDir), m.shas) + if s := strings.TrimSpace(mxOut); s != "" { + fmt.Fprintln(out, s) + } + return code, mxOut, err +} + +// engineDecisions is the set of units the engine left for a human after an +// exit 2: the ManualResolutionRequired entries of its -v list or, when it +// printed no list, the units its result (mineMpr) marks as conflicted. +func engineDecisions(mxOut, mineMpr string) []string { + if ids := engineManualIDs(parseEngineConflicts(mxOut)); len(ids) > 0 { + return ids + } + marks, err := conflictMarkers(mineMpr) + if err != nil { + return nil + } + var ids []string + for _, c := range marks { + ids = append(ids, c.UnitID) + } + return ids +} + +// conflictMarkers lists the units a project's index marks as conflicted — +// what the engine writes for a decision it did not make, and what Studio Pro +// reports as CE0137 until it is gone. +func conflictMarkers(mprPath string) ([]mpr.UnitConflict, error) { + r, err := mpr.Open(mprPath) + if err != nil { + return nil, err + } + defer r.Close() + return r.UnitConflicts() +} + +// copyEngineResult copies the engine's merged model files — the .mpr and +// every .mxunit under mprcontents — from the MINE worktree into the working +// tree, removing working-tree units the result no longer has (a deletion the +// engine resolved). Non-model files are untouched, so a human's staged text +// resolutions survive as they are. +func copyEngineResult(root string, repo *gitNoteRepo, mineDir string) error { + srcContents := filepath.Join(mineDir, filepath.FromSlash(repo.Contents)) + dstContents := filepath.Join(root, filepath.FromSlash(repo.Contents)) + if err := copyFile( + filepath.Join(mineDir, filepath.FromSlash(repo.MPRPath)), + filepath.Join(root, filepath.FromSlash(repo.MPRPath)), + ); err != nil { + return err + } + present := map[string]bool{} + err := filepath.WalkDir(srcContents, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".mxunit") { + return nil + } + rel, err := filepath.Rel(srcContents, p) + if err != nil { + return err + } + present[filepath.ToSlash(rel)] = true + return copyFile(p, filepath.Join(dstContents, rel)) + }) + if err != nil { + return err + } + return filepath.WalkDir(dstContents, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(d.Name(), ".mxunit") { + return nil + } + rel, err := filepath.Rel(dstContents, p) + if err != nil { + return err + } + if !present[filepath.ToSlash(rel)] { + return os.Remove(p) + } + return nil + }) +} + +func copyFile(src, dst string) error { + raw, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, raw, 0o644) +} + +// abortMerge backs a failed resolution out entirely — working tree, index and +// MERGE_HEAD return to the pre-merge state — and returns the original error. +// Every refusal on this command ends here: a half-done merge makes Studio Pro +// re-run it (and choke) every time the project opens. +func abortMerge(root string, err error) error { + if _, abortErr := gitOutIn(root, nil, "merge", "--abort"); abortErr != nil { + return fmt.Errorf("%w (aborting the merge failed too: %v — resolve it in Studio Pro)", err, abortErr) + } + return err +} + +// repairOrRefuse is the hash gate every side- or plan-based resolution ends +// with: ContentsHash values are repaired from the files on disk (the files +// are the truth once units have been taken from both sides), and a MISSING or +// ORPHAN entry — a unit the .mpr lists with no file behind it, or a file no +// .mpr entry lists — refuses the commit: committing it would drop or orphan +// documents. The refusal aborts the merge. +func repairOrRefuse(root, mprPath string, out io.Writer) error { + err := fixHashes(mprPath, true, out) + if err == nil { + return nil + } + if !errors.Is(err, errHashIssuesRemain) { + return abortMerge(root, err) + } + return abortMerge(root, errors.New("refusing to commit: the ContentsHash index has MISSING or ORPHAN entries (see above) — the sides' document sets differ, and committing would drop or orphan units; resolve in Studio Pro (Version Control › Merge Changes Here)")) +} + +// commitTheMerge commits the resolved merge (MERGE_HEAD is present), writes +// the note and pushes. stagedPaths, when given, are staged first. +func commitTheMerge(root string, repo *gitNoteRepo, stagedPaths []string, message, branch string, push bool, out io.Writer) error { + if stagedPaths != nil { + args := []string{"add", "--"} + args = append(args, stagedPaths...) + if _, err := gitOutIn(root, nil, args...); err != nil { + return fmt.Errorf("stage the resolved model files: %w", err) + } + } + commitArgs := []string{"commit", "--no-edit"} + if message != "" { + commitArgs = []string{"commit", "-m", message} + } + if _, err := gitOutIn(root, nil, commitArgs...); err != nil { + return fmt.Errorf("commit the merge: %w", err) + } + + head := strings.TrimSpace(gitOutOr(root, "rev-parse", "HEAD")) + fmt.Fprintf(out, "Model merge committed as %s.\n", shortCommit(head)) + return finishMerge(out, repo, head, branch, push) +} + +// finishMerge attaches the mx_metadata note to the finished merge and prints +// (or, with push, runs) the branch-and-notes push. +func finishMerge(out io.Writer, repo *gitNoteRepo, head, branch string, push bool) error { + if err := annotateCommits(out, repo, []string{head}, branch, true, false); err != nil { + return fmt.Errorf("committed %s, but attaching the note failed (%v) — run 'mxcli git note -p … --write' to attach it", shortCommit(head), err) + } + return pushBranchAndNotes(out, repo.Root, branch, push) +} + +func pushBranchAndNotes(out io.Writer, root, branch string, push bool) error { + if push { + if branch == "" { + return errors.New("detached HEAD; cannot push — check out a branch and push manually") + } + if _, err := gitOutIn(root, nil, "push", "origin", branch, mxMetadataRef); err != nil { + return fmt.Errorf("push %s and %s: %w", branch, mxMetadataRef, err) + } + fmt.Fprintf(out, "Pushed %s and %s to origin.\n", branch, mxMetadataRef) + return nil + } + if branch != "" { + fmt.Fprintf(out, "Push with: git push origin %s %s\n", branch, mxMetadataRef) + } + return nil +} + +// isModelPath reports whether a repo-relative path is part of the model: +// the .mpr itself or an .mxunit under mprcontents. +func isModelPath(repo *gitNoteRepo, rel string) bool { + f := filepath.ToSlash(rel) + if f == repo.MPRPath { + return true + } + return strings.HasPrefix(f, repo.Contents+"/") && strings.HasSuffix(f, ".mxunit") +} + +// projectMendixVersion reads the project's _ProductVersion for the mx +// resolution; "" on any error (ResolveMxForVersion then falls back to PATH). +func projectMendixVersion(mprPath string) string { + r, err := mpr.Open(mprPath) + if err != nil { + return "" + } + defer r.Close() + v, err := r.GetMendixVersion() + if err != nil { + return "" + } + return v +} + +// realMxMerge shells out to `mx merge -v BASE MINE THEIRS` over the three +// revisions' project files, mx resolved for the project's Mendix version; +// -v makes the engine print its per-unit conflict list. +func realMxMerge(baseMpr, mineMpr, theirsMpr string, shas [3]string) (int, string, error) { + version := projectMendixVersion(mineMpr) + mxPath, err := docker.ResolveMxForVersion("", version) + if err != nil { + return 1, "", fmt.Errorf("no mx for Mendix %s: %w (model conflicts need it)", version, err) + } + cmd := exec.Command(mxPath, "merge", "-v", baseMpr, mineMpr, theirsMpr) + docker.PrepareMxCommand(cmd) + b, err := cmd.CombinedOutput() + code := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + code = ee.ExitCode() + } else { + return 1, string(b), err + } + } + return code, string(b), nil +} + +func shortNames(paths []string) []string { + out := make([]string, 0, len(paths)) + for _, p := range paths { + out = append(out, filepath.Base(p)) + } + return out +} + +// conflictedUnitIDs extracts the unit ids of conflicted .mxunit paths. +func conflictedUnitIDs(model []string) []string { + var ids []string + for _, p := range model { + if strings.HasSuffix(p, ".mxunit") { + ids = append(ids, strings.TrimSuffix(filepath.Base(p), ".mxunit")) + } + } + return ids +} + +// engineUnitConflict is one entry of the engine's per-unit conflict list, +// printed only with `-v` as one JSON array on one stdout line. Only +// ManualResolutionDetails "ManualResolutionRequired" needs a decision; +// "AutoResolved" entries are already merged. +type engineUnitConflict struct { + UnitID string `json:"UnitId"` + UnitName *string `json:"UnitName"` + ModuleName string `json:"ModuleName"` + ContainmentName string `json:"ContainmentName"` + Type string `json:"Type"` + TreeConflict string `json:"TreeConflict"` + ConflictResolutionDetails string `json:"ConflictResolutionDetails"` +} + +// parseEngineConflicts finds the engine's conflict list in its output: the +// last line that parses as a JSON array of objects. nil when the engine +// printed none — the caller falls back to git's conflicted-file list. +func parseEngineConflicts(mxOut string) []engineUnitConflict { + var found []engineUnitConflict + for _, line := range strings.Split(mxOut, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "[") { + continue + } + var cs []engineUnitConflict + if err := json.Unmarshal([]byte(line), &cs); err == nil && len(cs) > 0 { + found = cs + } + } + return found +} + +// engineManualIDs keeps the entries that need a human decision; entries +// without a unit id say nothing decidable and are skipped. +func engineManualIDs(cs []engineUnitConflict) []string { + var ids []string + for _, c := range cs { + if c.ConflictResolutionDetails == "ManualResolutionRequired" && c.UnitID != "" { + ids = append(ids, c.UnitID) + } + } + return ids +} + +// --- property-level conflict report and the per-unit plan ------------------- + +type mxDiffChange struct { + Change string `json:"change"` + ChangeDescription string `json:"changeDescription"` + Property string `json:"property"` + BaseValue string `json:"baseValue"` + MineValue string `json:"mineValue"` + ChangeDetails []mxDiffChange `json:"changeDetails"` +} + +type mxDiffUnit struct { + ID string `json:"id"` + Type string `json:"type"` + Change string `json:"change"` + ChangeDetails []mxDiffChange `json:"changeDetails"` +} + +type mxDiffFile struct { + UnitDifferences []mxDiffUnit `json:"unitDifferences"` +} + +// propertyReport renders the two mx diff files as one property-level report +// over the conflicted units: what each side changed. +func propertyReport(oursJSON, theirsJSON string, unitIDs []string) string { + want := map[string]bool{} + for _, id := range unitIDs { + want[id] = true + } + lines := map[string][]string{} + for _, f := range []struct{ side, path string }{{"Yours", oursJSON}, {"Theirs", theirsJSON}} { + raw, err := os.ReadFile(f.path) + if err != nil { + lines[f.side] = append(lines[f.side], fmt.Sprintf(" (diff unavailable: %v)", err)) + continue + } + var d mxDiffFile + if err := json.Unmarshal(bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")), &d); err != nil { + lines[f.side] = append(lines[f.side], fmt.Sprintf(" (diff unreadable: %v)", err)) + continue + } + var side []string + for _, u := range d.UnitDifferences { + if !want[u.ID] { + continue + } + side = append(side, flattenChanges(u.ChangeDetails, " ")...) + } + if len(side) == 0 { + side = append(side, " (no change details)") + } + lines[f.side] = side + } + var b strings.Builder + b.WriteString("\nProperty-level changes on the conflicted units:\n") + for _, side := range []string{"Yours", "Theirs"} { + fmt.Fprintf(&b, "%s:\n", side) + for _, l := range lines[side] { + b.WriteString(l) + b.WriteString("\n") + } + } + return b.String() +} + +// flattenChanges walks mx diff's change tree and yields one line per leaf: +// the description, and for property leaves the value movement. +func flattenChanges(changes []mxDiffChange, indent string) []string { + var out []string + for _, c := range changes { + if c.Property != "" { + out = append(out, fmt.Sprintf("%s%s: %s → %s", indent, c.Property, c.BaseValue, c.MineValue)) + continue + } + desc := c.ChangeDescription + if desc == "" { + desc = c.Change + } + out = append(out, fmt.Sprintf("%s%s", indent, desc)) + out = append(out, flattenChanges(c.ChangeDetails, indent+" ")...) + } + return out +} + +// mergePlanTemplate is the file a ManualResolutionRequired run writes beside +// the .mpr; the human sets "take" per unit and re-runs with --plan. +type mergePlanTemplate struct { + Base string `json:"base"` + Ours string `json:"ours"` + Theirs string `json:"theirs"` + Units []planUnit `json:"units"` +} + +type planUnit struct { + ID string `json:"id"` + Take string `json:"take"` +} + +func writePlanTemplate(mprPath, baseSha, oursSha, theirsSha string, unitIDs []string) error { + tmpl := mergePlanTemplate{Base: baseSha, Ours: oursSha, Theirs: theirsSha} + for _, id := range unitIDs { + tmpl.Units = append(tmpl.Units, planUnit{ID: id, Take: "ours"}) + } + b, err := json.MarshalIndent(tmpl, "", " ") + if err != nil { + return err + } + return os.WriteFile(mprPath+".merge-plan.json", b, 0o644) +} + +// applyPlan re-runs the merge unless one is already in progress (it must +// conflict the same way), checks the plan against those revisions, runs the +// engine again and resolves over its result: everything the engine merged +// stays merged, and each unit it left open takes the plan's side — whole, +// from that side's commit — with its conflict marker cleared. The .mpr index +// is repaired from the files and the merge committed with the note. Every +// refusal after the merge started aborts it: a half-done merge makes Studio +// Pro re-run it (and choke) on every open. +// +// The plan must name exactly the units the engine left open. Resolving from +// git's two sides instead — ours for every conflicted file, theirs for the +// planned units — took a unit the engine had merged (an entity each side +// added to one domain model) from ours, dropping theirs' change to it +// without the plan ever naming it. +func applyPlan(root string, repo *gitNoteRepo, mprAbs, mprPath, ref, planPath, message, branch string, push bool, runners mxRunners, out io.Writer) error { + raw, err := os.ReadFile(planPath) + if err != nil { + return fmt.Errorf("read plan: %w", err) + } + var plan struct { + Base string `json:"base"` + Ours string `json:"ours"` + Theirs string `json:"theirs"` + Units []planUnit `json:"units"` + } + if err := json.Unmarshal(raw, &plan); err != nil { + return fmt.Errorf("plan %s is not valid JSON: %w", planPath, err) + } + + // An unfinished merge (text conflicts resolved and staged, say) is + // resumed; otherwise the merge runs and must conflict the same way. + if _, mhErr := gitOut(root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr != nil { + _, mergeErr := gitOutIn(root, nil, "merge", "--no-edit", ref) + if mergeErr == nil { + return errors.New("the merge no longer conflicts — the plan is unnecessary; nothing was done") + } + if _, mhErr := gitOut(root, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr != nil { + return fmt.Errorf("git merge %s: %v", ref, mergeErr) + } + } + baseSha := strings.TrimSpace(gitOutOr(root, "merge-base", "HEAD", "MERGE_HEAD")) + oursSha := strings.TrimSpace(gitOutOr(root, "rev-parse", "HEAD")) + theirsSha := strings.TrimSpace(gitOutOr(root, "rev-parse", "MERGE_HEAD")) + if plan.Base != baseSha || plan.Ours != oursSha || plan.Theirs != theirsSha { + return abortMerge(root, errors.New("stale plan: the merge revisions moved since it was written; delete it and re-run the merge to regenerate")) + } + + // Every planned unit must be modified on both sides; anything else + // (added on one side, deleted, moved) only Studio Pro can decide. + baseSnap, err := repo.snapshot(baseSha) + if err != nil { + return abortMerge(root, err) + } + oursSnap, err := repo.snapshot(oursSha) + if err != nil { + return abortMerge(root, err) + } + theirsSnap, err := repo.snapshot(theirsSha) + if err != nil { + return abortMerge(root, err) + } + oursStatus := changeStatuses(computeModelChanges(baseSnap, oursSnap, nil)) + theirsStatus := changeStatuses(computeModelChanges(baseSnap, theirsSnap, nil)) + take := map[string]string{} + for _, u := range plan.Units { + if u.Take != "ours" && u.Take != "theirs" { + return abortMerge(root, fmt.Errorf("plan: unit %s has take %q (ours|theirs)", shortCommit(u.ID), u.Take)) + } + if oursStatus[u.ID] != "Modified" || theirsStatus[u.ID] != "Modified" { + return abortMerge(root, fmt.Errorf("plan: unit %s is %s on ours and %s on theirs — only modified-on-both conflicts can be planned; resolve it in Studio Pro", shortCommit(u.ID), statusOrNone(oursStatus[u.ID]), statusOrNone(theirsStatus[u.ID]))) + } + take[u.ID] = u.Take + } + + // The engine again, over clean worktrees as in the merge itself: the plan + // decides only what it leaves open. + revs, err := checkoutRevisions(root, ref, baseSha, oursSha, theirsSha) + if err != nil { + return abortMerge(root, err) + } + defer revs.cleanup() + fmt.Fprintf(out, "Merging the model with mx %s; the plan decides what the engine leaves open…\n", projectMendixVersion(mprPath)) + code, mxOut, runErr := revs.runEngine(runners, repo, out) + switch { + case runErr != nil: + return abortMerge(root, fmt.Errorf("mx merge failed to run: %w", runErr)) + case code == 0: + return abortMerge(root, errors.New("the engine now merges the model without a decision — the plan is unnecessary; re-run without --plan")) + case code != 2: + return abortMerge(root, fmt.Errorf("mx merge failed (exit %d); the merge is aborted — if it persists, resolve in Studio Pro (Version Control › Merge Changes Here)", code)) + } + open := engineDecisions(mxOut, filepath.Join(revs.oursDir, filepath.FromSlash(repo.MPRPath))) + if len(open) == 0 { + return abortMerge(root, errors.New("the engine stopped on a decision but named no unit; resolve in Studio Pro (Version Control › Merge Changes Here)")) + } + isOpen := map[string]bool{} + for _, id := range open { + isOpen[id] = true + if _, planned := take[id]; !planned { + return abortMerge(root, fmt.Errorf("plan: unit %s is left for a decision by the engine and the plan does not decide it — delete the plan and re-run without --plan to regenerate it", shortCommit(id))) + } + } + for _, u := range plan.Units { + if !isOpen[u.ID] { + return abortMerge(root, fmt.Errorf("plan: unit %s is merged without a decision by the engine — remove it from the plan: taking a side there drops the other side's change to it", shortCommit(u.ID))) + } + } + + // The engine's result first, then each planned unit whole from its + // side's commit, its marker cleared. The hashes are rewritten from the + // files — files are the truth — and the gate refuses (and aborts) a + // result whose index and files disagree on which units exist. + if err := copyEngineResult(root, repo, revs.oursDir); err != nil { + return abortMerge(root, fmt.Errorf("copy the engine's merge result: %w", err)) + } + var decided []string + for _, u := range plan.Units { + sha := oursSha + if u.Take == "theirs" { + sha = theirsSha + } + p := path.Join(repo.Contents, u.ID[:2], u.ID[2:4], u.ID+".mxunit") + if _, err := gitOutIn(root, nil, "checkout", sha, "--", p); err != nil { + return abortMerge(root, fmt.Errorf("take the %s version of %s: %w", u.Take, p, err)) + } + decided = append(decided, u.ID) + fmt.Fprintf(out, " %s: %s\n", shortCommit(u.ID), u.Take) + } + if err := clearDecidedMarkers(mprAbs, decided); err != nil { + return abortMerge(root, err) + } + if err := repairOrRefuse(root, mprPath, out); err != nil { + return err + } + // Nothing may be committed still marked: Studio Pro would report the + // project as mid-conflict (CE0137) on every open. + marks, err := conflictMarkers(mprAbs) + if err != nil { + return abortMerge(root, fmt.Errorf("read the merge conflict markers: %w", err)) + } + if len(marks) > 0 { + var ids []string + for _, c := range marks { + ids = append(ids, shortCommit(c.UnitID)) + } + return abortMerge(root, fmt.Errorf("refusing to commit: the index still marks unit(s) %s as conflicted; resolve in Studio Pro (Version Control › Merge Changes Here)", strings.Join(ids, ", "))) + } + + if _, err := gitOutIn(root, nil, "add", "-A", "--", + filepath.FromSlash(repo.MPRPath), filepath.FromSlash(repo.Contents)); err != nil { + return abortMerge(root, fmt.Errorf("stage the resolved model files: %w", err)) + } + return commitTheMerge(root, repo, nil, message, branch, push, out) +} + +// clearDecidedMarkers records the planned units as decided in the index: their +// files now hold the chosen side, so the engine's marker on them goes. +func clearDecidedMarkers(mprPath string, ids []string) error { + w, err := mpr.NewWriter(mprPath) + if err != nil { + return fmt.Errorf("open project: %w", err) + } + defer w.Close() + if _, err := w.ClearContentsConflicts(ids); err != nil { + return fmt.Errorf("clear the decided units' conflict markers: %w", err) + } + return nil +} + +func changeStatuses(changes []mxModelChange) map[string]string { + out := map[string]string{} + for _, c := range changes { + out[c.UnitID] = c.Status + } + return out +} + +func statusOrNone(s string) string { + if s == "" { + return "unchanged" + } + return s +} + +// realMxDiff shells out to `mx diff BASE MINE OUTPUT` (resolved like the merge). +func realMxDiff(base, mine, outJSON string) error { + version := projectMendixVersion(mine) + mxPath, err := docker.ResolveMxForVersion("", version) + if err != nil { + return fmt.Errorf("no mx for Mendix %s: %w", version, err) + } + cmd := exec.Command(mxPath, "diff", base, mine, outJSON) + docker.PrepareMxCommand(cmd) + if b, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("mx diff: %v: %s", err, strings.TrimSpace(string(b))) + } + return nil +} diff --git a/cmd/mxcli/cmd_lint.go b/cmd/mxcli/cmd_lint.go index 66a2ab5d1d..625f4e028e 100644 --- a/cmd/mxcli/cmd_lint.go +++ b/cmd/mxcli/cmd_lint.go @@ -30,10 +30,12 @@ Built-in rules check for: - Empty containers (MPR006) - layout containers with no children - Navigation page security (MPR007) - pages in navigation need allowed roles, and every user role must be able to open its home page - Gallery selection listener (MPR009) - DataView 'DataSource: selection X' needs gallery 'ItemSelectionMode: toggle' (Studio Pro CE3637) + - React-unsupported widgets (MPR012) - static/dynamic image, drop-down, legacy data grid, reference selector are CE0582 under the React client - Entity access rules (SEC001) - persistent entities need access rules - Password policy (SEC002) - password minimum length should be 8+ - Demo users (SEC003) - demo users should be off at Production security - Unreadable page data (SEC010) - a data widget's entity must be readable by the roles that open its page (at Production it renders empty) + - XPath through an unreadable association (SEC011) - a page data source's XPath may cross only associations the roles opening the page can read (at Production the page fails) - Un-describable branch structure (MDL-FLOW01) - decision branches that do not nest, so DESCRIBE cannot render them faithfully Bundled Starlark rules (in .claude/lint-rules/): @@ -475,7 +477,7 @@ func builtinLintRules() []linter.Rule { rules.NewDomainModelSizeRule(), rules.NewValidationFeedbackRule(), rules.NewImageSourceRule(), - rules.NewLegacyImageWidgetRule(), + rules.NewReactUnsupportedWidgetRule(), rules.NewSnippetCallAppearanceRule(), rules.NewEmptyContainerRule(), rules.NewGallerySelectionListenerRule(), @@ -485,10 +487,11 @@ func builtinLintRules() []linter.Rule { rules.NewNoEntityAccessRulesRule(), rules.NewWeakPasswordPolicyRule(), rules.NewDemoUsersActiveRule(), - rules.NewPageEntityReadAccessRule(), // SEC010 - full catalog (widgets, permissions) - rules.NewOverlappingActivitiesRule(), // MPR008 - requires BSON inspection - rules.NewLoopChildContainmentRule(), // MPR011 - requires BSON inspection - rules.NewNoCommitInLoopRule(), // CONV011-CONV014 - require BSON inspection + rules.NewPageEntityReadAccessRule(), // SEC010 - full catalog (widgets, permissions) + rules.NewXPathAssociationAccessRule(), // SEC011 - full catalog (xpath_expressions, permissions) + rules.NewOverlappingActivitiesRule(), // MPR008 - requires BSON inspection + rules.NewLoopChildContainmentRule(), // MPR011 - requires BSON inspection + rules.NewNoCommitInLoopRule(), // CONV011-CONV014 - require BSON inspection rules.NewExclusiveSplitCaptionRule(), rules.NewErrorHandlingOnCallsRule(), rules.NewNoContinueErrorHandlingRule(), diff --git a/cmd/mxcli/engine.go b/cmd/mxcli/engine.go index 74a17c9059..131bdae30c 100644 --- a/cmd/mxcli/engine.go +++ b/cmd/mxcli/engine.go @@ -54,22 +54,15 @@ func newBackendFactory() func() backend.FullBackend { // writes route to Studio Pro, reads come from -p. if globalMCPURL != "" { url, dial := globalMCPURL, globalMCPDial - concordURL, concordDial := globalMCPConcord, globalMCPConcordDial - saveOnExit, checkOnExit, runOnExit := globalMCPSave, globalMCPCheck, globalMCPRun + checkOnExit, runOnExit := globalMCPCheck, globalMCPRun // --mcp-trace / --mcp-verbose: the executor prints each statement, the // backend's client each PED call under it. Without the backend's half // the trace showed statements only, which reads as "nothing was sent to // Studio Pro" (mendixlabs/mxcli#1684). tracer := mcpTracer() return func() backend.FullBackend { - b := mcpbackend.New(url, dial).WithTracer(tracer) - if concordURL != "" || saveOnExit || checkOnExit || runOnExit { - b = b.WithConcord(mcpbackend.ConcordConfig{ - URL: concordURL, Dial: concordDial, - SaveOnExit: saveOnExit, CheckOnExit: checkOnExit, RunOnExit: runOnExit, - }) - } - return b + return mcpbackend.New(url, dial).WithTracer(tracer). + WithExitActions(mcpbackend.ExitActions{Check: checkOnExit, Run: runOnExit}) } } warnIfEngineRequested() diff --git a/cmd/mxcli/gitdoctor.go b/cmd/mxcli/gitdoctor.go index 0d106e9f6f..8e8a2df1b8 100644 --- a/cmd/mxcli/gitdoctor.go +++ b/cmd/mxcli/gitdoctor.go @@ -31,11 +31,23 @@ import ( var errDoctorIssuesRemain = errors.New("issues remain (re-run with --fix to repair)") -// mxConfigKeys is the local git config Studio Pro writes and expects. -var mxConfigKeys = [][2]string{ - {"core.autocrlf", "False"}, - {"mendix.commits-since-gc", "0"}, - {"mendix.lineEndingResetDone", "True"}, +// mxConfigKey is one entry of the local git config Studio Pro writes. +// fallback is what a missing key is backfilled with. presentValuePasses marks +// the keys whose value is Studio Pro's own bookkeeping — measured on a real +// checkout (the mx-git-lab lab, S0), commits-since-gc counts up between gc +// runs and lineEndingResetDone comes back in any case, so only a MISSING key +// is an issue there. core.autocrlf is judged: true (or input) makes a +// checkout rewrite line endings inside .mxunit files on Windows. +type mxConfigKey struct { + key string + fallback string + presentValuePasses bool +} + +var mxConfigKeys = []mxConfigKey{ + {"core.autocrlf", "False", false}, + {"mendix.commits-since-gc", "0", true}, + {"mendix.lineEndingResetDone", "True", true}, } var ( @@ -52,25 +64,32 @@ func doctorRun(root, mprPath, remoteOverride, version string, fix bool, out io.W found, fixed := 0, 0 fmt.Fprintln(out, "Git local config") - for _, kv := range mxConfigKeys { - key, want := kv[0], kv[1] + for _, ck := range mxConfigKeys { + key, want := ck.key, ck.fallback current := gitConfigLocal(root, key) - if strings.EqualFold(current, want) { - fmt.Fprintf(out, " [x] %s = %s\n", key, current) - continue - } - found++ - if current == "" { + switch { + case current == "": + found++ fmt.Fprintf(out, " [ ] %s — missing (expected %s)\n", key, want) - } else { + if fix { + if _, err := gitOut(root, "config", "--local", key, want); err != nil { + return fmt.Errorf("set %s: %w", key, err) + } + fixed++ + fmt.Fprintf(out, " set to %s\n", want) + } + case ck.presentValuePasses || strings.EqualFold(current, "false"): + fmt.Fprintf(out, " [x] %s = %s\n", key, current) + default: + found++ fmt.Fprintf(out, " [ ] %s = %s (expected %s)\n", key, current, want) - } - if fix { - if _, err := gitOut(root, "config", "--local", key, want); err != nil { - return fmt.Errorf("set %s: %w", key, err) + if fix { + if _, err := gitOut(root, "config", "--local", key, want); err != nil { + return fmt.Errorf("set %s: %w", key, err) + } + fixed++ + fmt.Fprintf(out, " set to %s\n", want) } - fixed++ - fmt.Fprintf(out, " set to %s\n", want) } } diff --git a/cmd/mxcli/gitdoctor_test.go b/cmd/mxcli/gitdoctor_test.go index 5457a1b31b..43ca6a6630 100644 --- a/cmd/mxcli/gitdoctor_test.go +++ b/cmd/mxcli/gitdoctor_test.go @@ -95,6 +95,44 @@ func TestGitDoctor_FixWritesConfigAndPasses(t *testing.T) { } } +// Measured on a real Studio Pro checkout (the mx-git-lab lab, S0): Studio Pro +// writes core.autocrlf=false in lowercase, counts commits-since-gc up between +// garbage collections (2 after two commits) and writes lineendingresetdone in +// lowercase. A healthy Studio Pro checkout must pass untouched. +func TestGitDoctor_StudioProValuesPass(t *testing.T) { + dir := t.TempDir() + doctorEnv(t, dir) + gitIn(t, dir, "init", "-q", "-b", "main") + gitIn(t, dir, "config", "--local", "core.autocrlf", "false") + gitIn(t, dir, "config", "--local", "mendix.commits-since-gc", "2") + gitIn(t, dir, "config", "--local", "mendix.lineendingresetdone", "true") + + var out bytes.Buffer + if err := doctorRun(dir, "", "", "", false, &out); err != nil { + t.Fatalf("a healthy Studio Pro checkout must pass: %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "mendix.commits-since-gc — ") { + t.Errorf("the gc counter must not be judged:\n%s", out.String()) + } +} + +// core.autocrlf=true is the one config value that damages a Mendix checkout: +// a checkout rewrites line endings inside .mxunit files on Windows. +func TestGitDoctor_AutoCRLFTrueIsFixed(t *testing.T) { + dir := t.TempDir() + doctorEnv(t, dir) + gitIn(t, dir, "init", "-q", "-b", "main") + gitIn(t, dir, "config", "--local", "core.autocrlf", "true") + + var out bytes.Buffer + if err := doctorRun(dir, "", "", "", true, &out); err != nil { + t.Fatalf("--fix must repair autocrlf=true: %v\n%s", err, out.String()) + } + if got := configLocal(t, dir, "core.autocrlf"); !strings.EqualFold(got, "false") { + t.Errorf("core.autocrlf = %q, want false", got) + } +} + // --- doctor: remote URL scheme ---------------------------------------------- func TestGitDoctor_SSHRemoteRewrittenByFix(t *testing.T) { @@ -153,7 +191,7 @@ func TestGitDoctor_InsteadOfRewriteIsNotAnSSHRemote(t *testing.T) { t.Setenv("GIT_SSH_COMMAND", "false") // the rewritten URL is SSH: never reach the network gitIn(t, dir, "init", "-q", "-b", "main") for _, kv := range mxConfigKeys { - gitIn(t, dir, "config", "--local", kv[0], kv[1]) + gitIn(t, dir, "config", "--local", kv.key, kv.fallback) } gitIn(t, dir, "config", "--local", "url.git@git.example.com:.insteadOf", "https://git.example.com/") gitIn(t, dir, "remote", "add", "origin", "https://git.example.com/org/app.git") @@ -197,10 +235,20 @@ func TestGitDoctor_ConfiguredSSHRemoteFoundBehindRewrite(t *testing.T) { // doctorNoteRepo makes a two-commit repository holding an MPR v2 project: // the base commit, then a page retitle. Returns dir and the .mpr path. +// +// The fixture's git is hermetic: no system or global config leaks in from the +// machine this runs on (runner images, developer setups and CI images differ), +// bytes are committed exactly as written, and no repository above the temp +// dir is discovered. Without this, git-merge fixtures see a different merge +// on each machine and fail with the merge's own evidence thrown away. func doctorNoteRepo(t *testing.T) (string, string) { t.Helper() dir := t.TempDir() + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(dir)) gitIn(t, dir, "init", "-q", "-b", "feature") + gitIn(t, dir, "config", "core.autocrlf", "false") units := baseUnits(t) writeProject(t, dir, "11.13.0", units) gitIn(t, dir, "add", "-A") @@ -284,7 +332,7 @@ func TestGitDoctor_UnfetchedNotesAreNotMissing(t *testing.T) { clone := filepath.Join(t.TempDir(), "clone") gitIn(t, filepath.Dir(clone), "clone", "-q", "-b", "feature", remote, clone) for _, kv := range mxConfigKeys { - gitIn(t, clone, "config", "--local", kv[0], kv[1]) + gitIn(t, clone, "config", "--local", kv.key, kv.fallback) } mprPath := filepath.Join(clone, "App.mpr") head := strings.TrimSpace(gitOutOr(clone, "rev-parse", "HEAD")) @@ -557,6 +605,12 @@ func TestGitCommit_NothingToCommit(t *testing.T) { func gitInE(t *testing.T, dir string, args ...string) (string, error) { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + // The same fixed identity gitIn commits with: `git merge` needs one even + // when it stops on a conflict, and a host whose name gives git nothing to + // guess from (a bare "vm", no domain) refused the fixture's merge. + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") b, err := cmd.CombinedOutput() return string(b), err } @@ -747,6 +801,32 @@ func TestGitCommit_DryRunLeavesHashDriftUntouched(t *testing.T) { } } +// The staged-files summary must list what the commit will hold, not the junk +// that was just unstaged (measured on the mx-git-lab lab: the list opened with +// 10 .mxcli/ paths while the commit held 2 model files). +func TestGitCommit_StagedListOmitsJunk(t *testing.T) { + dir, mprPath := doctorNoteRepo(t) + doctorEnv(t, dir) + units := baseUnits(t) + units[tPage] = tUnit{tFolder, unitDoc(t, "Forms$Page", "Home", bson.E{Key: "Title", Value: "List"})} + writeProject(t, dir, "11.13.0", units) + if err := os.WriteFile(filepath.Join(dir, "project-settings.user.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + if err := commitRun(mprPath, "clean list", false, false, &out); err != nil { + t.Fatalf("commitRun: %v\n%s", err, out.String()) + } + section := out.String()[strings.Index(out.String(), "Staged"):] + if strings.Contains(section, "project-settings.user.json") { + t.Errorf("the staged list names junk that was left out:\n%s", section) + } + if !strings.Contains(section, "App.mpr") { + t.Errorf("the staged list must name the model files:\n%s", section) + } +} + func TestGitCommit_DryRunWithPushRefused(t *testing.T) { dir, mprPath := doctorNoteRepo(t) doctorEnv(t, dir) diff --git a/cmd/mxcli/gitmerge_integration_test.go b/cmd/mxcli/gitmerge_integration_test.go new file mode 100644 index 0000000000..fb63e840c7 --- /dev/null +++ b/cmd/mxcli/gitmerge_integration_test.go @@ -0,0 +1,330 @@ +//go:build integration + +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" +) + +// The engine path against a REAL mx: `mx merge` over three .mpr paths with +// MINE as a clean worktree, the result copied back — the part the fake-runner +// unit tests cannot see. One scaffold, both engine verdicts: +// +// TestGitMergeIntegration_EngineAutoResolvesAddAdd exit 0: two sides each +// add a page; both kept. +// TestGitMergeIntegration_PropertyConflictNeedsDecisionAborts +// exit 2: both sides +// change the same page's +// title; clean abort. +// +// Requires a resolvable mx for the scaffolded project's version (provided by +// the CI integration job's `mxcli setup mxbuild`); skips otherwise. +// +// MXCLI_BIN=/path/to/mxcli go test -tags integration ./cmd/mxcli/ -run TestGitMergeIntegration -v +func TestGitMergeIntegration_EngineAutoResolvesAddAdd(t *testing.T) { + dir, mpr, bin := mergeIntegrationRepo(t) + + applyPage(t, bin, dir, mpr, "PageMain", "Main page") + commitAll(t, dir, "main adds PageMain") + gitIn(t, dir, "switch", "-q", "-c", "side", "HEAD~1") + applyPage(t, bin, dir, mpr, "PageSide", "Side page") + commitAll(t, dir, "side adds PageSide") + gitIn(t, dir, "switch", "-q", "main") + + var out bytes.Buffer + runners := mxRunners{merge: realMxMerge, diff: realMxDiff} + if err := mergeRun(mpr, "side", "", "", "", false, runners, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the merge:\n%s", st) + } + var out2 bytes.Buffer + if err := fixHashes(mpr, false, &out2); err != nil { + t.Errorf("the merged .mpr index is inconsistent: %v\n%s", err, out2.String()) + } + for _, name := range []string{"PageMain", "PageSide"} { + if unitFilesContaining(t, dir, name) == 0 { + t.Errorf("no unit file carries %s: the engine's result lost a side", name) + } + } +} + +func TestGitMergeIntegration_PropertyConflictNeedsDecisionAborts(t *testing.T) { + dir, mpr, bin := mergeIntegrationRepo(t) + + // The shared page exists in the base, so both sides MODIFY the same unit: + // side branches from the commit that already has the page. + applyPage(t, bin, dir, mpr, "Shared", "Base title") + commitAll(t, dir, "base adds Shared") + gitIn(t, dir, "switch", "-q", "-c", "side") + applyPage(t, bin, dir, mpr, "Shared", "Side title") + commitAll(t, dir, "side retitles Shared") + gitIn(t, dir, "switch", "-q", "main") + applyPage(t, bin, dir, mpr, "Shared", "Main title") + commitAll(t, dir, "main retitles Shared") + + before := gitOutT(t, dir, "rev-parse", "HEAD") + var out bytes.Buffer + runners := mxRunners{merge: realMxMerge, diff: realMxDiff} + err := mergeRun(mpr, "side", "", "", "", false, runners, &out) + if err == nil || !strings.Contains(err.Error(), "Studio Pro") { + t.Fatalf("a property conflict must end in the Studio Pro referral, got %v\n%s", err, out.String()) + } + if got := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")); got != strings.TrimSpace(before) { + t.Errorf("HEAD moved despite the abort: %s", got) + } + if _, mhErr := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr == nil { + t.Error("MERGE_HEAD still present: the merge was not aborted") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } +} + +// --plan over the engine's own result: each side adds an entity to +// MyFirstModule's domain model — a unit the engine merges by itself — and +// both retitle the same page, which it leaves for a decision. The plan the +// first run writes names only the page; applying it must keep both entities +// and commit an index with no conflict marker left (`mx check` reports +// CE0137 while one is). Resolving from git's two sides instead took the +// domain model from ours and lost theirs' entity, with the plan never +// naming that unit. +func TestGitMergeIntegration_PlanKeepsWhatTheEngineMerged(t *testing.T) { + dir, mpr, bin := mergeIntegrationRepo(t) + + applyPage(t, bin, dir, mpr, "Shared", "Base title") + commitAll(t, dir, "base adds Shared") + gitIn(t, dir, "switch", "-q", "-c", "side") + applyMDL(t, bin, dir, mpr, "TheirsOnly", "mdl 1;\ncreate non-persistent entity MyFirstModule.TheirsOnly (Code: String(50));\n") + applyPage(t, bin, dir, mpr, "Shared", "Theirs retitle") + commitAll(t, dir, "side adds TheirsOnly, retitles Shared") + gitIn(t, dir, "switch", "-q", "main") + applyMDL(t, bin, dir, mpr, "OursOnly", "mdl 1;\ncreate non-persistent entity MyFirstModule.OursOnly (Name: String(50));\n") + applyPage(t, bin, dir, mpr, "Shared", "Ours retitle") + commitAll(t, dir, "main adds OursOnly, retitles Shared") + // Control: the title is distinctive — only the page carries it (the + // scaffold's own units say "Main title", for one). + if n := unitFilesContaining(t, dir, "Ours retitle"); n != 1 { + t.Fatalf("%d unit files carry ours' title before the merge, want 1 (the page)", n) + } + + runners := mxRunners{merge: realMxMerge, diff: realMxDiff} + var first bytes.Buffer + if err := mergeRun(mpr, "side", "", "", "", false, runners, &first); err == nil { + t.Fatalf("the page conflict must stop for a decision\n%s", first.String()) + } + raw, err := os.ReadFile(mpr + ".merge-plan.json") + if err != nil { + t.Fatalf("no plan template: %v\n%s", err, first.String()) + } + _ = os.Remove(mpr + ".merge-plan.json") // working material, never committed + var plan mergePlanTemplate + if err := json.Unmarshal(raw, &plan); err != nil { + t.Fatal(err) + } + if len(plan.Units) != 1 { + t.Fatalf("the template must name only the unit the engine left open (the page), got %+v\n%s", plan.Units, first.String()) + } + plan.Units[0].Take = "theirs" + edited, _ := json.Marshal(plan) + planPath := filepath.Join(t.TempDir(), "plan.json") + if err := os.WriteFile(planPath, edited, 0o644); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + if err := mergeRun(mpr, "side", "", "", planPath, false, runners, &out); err != nil { + t.Fatalf("mergeRun --plan: %v\n%s", err, out.String()) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the merge:\n%s", st) + } + for _, name := range []string{"OursOnly", "TheirsOnly", "Theirs retitle"} { + if unitFilesContaining(t, dir, name) == 0 { + t.Errorf("no unit file carries %q: the plan's merge lost it", name) + } + } + if unitFilesContaining(t, dir, "Ours retitle") != 0 { + t.Error("the page still carries ours' title: the plan said theirs") + } + if marks, err := conflictMarkers(mpr); err != nil || len(marks) != 0 { + t.Errorf("merge conflict markers committed: %+v (%v)", marks, err) + } + mxPath, err := docker.ResolveMxForVersion("", projectMendixVersion(mpr)) + if err != nil { + t.Fatal(err) + } + check := exec.Command(mxPath, "check", mpr) + docker.PrepareMxCommand(check) + // The scaffold's own errors make the exit code meaningless here; what + // matters is that the check loaded the project and found no conflict. + checkOut, _ := check.CombinedOutput() + if !bytes.Contains(checkOut, []byte("Loading the mpr file")) { + t.Fatalf("mx check never loaded the project, so its silence on CE0137 proves nothing:\n%s", checkOut) + } + if bytes.Contains(checkOut, []byte("CE0137")) { + t.Errorf("mx check still reports the merge conflict (CE0137):\n%s", checkOut) + } +} + +// mergeIntegrationRepo scaffolds a real Mendix project (`mx create-project`) +// in a hermetic git repository and returns its dir, .mpr path and a built +// mxcli binary for applying MDL. The project and the binary live in SHORT +// temp dirs: the test names make t.TempDir() paths long enough for +// create-project's template extraction to hit PathTooLongException. +func mergeIntegrationRepo(t *testing.T) (string, string, string) { + t.Helper() + dir, err := os.MkdirTemp("", "mxmrg") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(dir)) + // The command's own git merge and commit run in this process: with the + // global config gone, they need an identity a hostname cannot supply. + t.Setenv("GIT_AUTHOR_NAME", "t") + t.Setenv("GIT_AUTHOR_EMAIL", "t@example.com") + t.Setenv("GIT_COMMITTER_NAME", "t") + t.Setenv("GIT_COMMITTER_EMAIL", "t@example.com") + + bin := os.Getenv("MXCLI_BIN") + if bin == "" { + binDir, err := os.MkdirTemp("", "mxbin") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(binDir) }) + bin = filepath.Join(binDir, "mxcli.exe") + build := exec.Command("go", "build", "-o", bin, ".") + build.Dir = "." // the package dir: cmd/mxcli + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + } + + scaffoldProject(t, dir) + mpr := filepath.Join(dir, "App.mpr") + if _, err := docker.ResolveMxForVersion("", projectMendixVersion(mpr)); err != nil { + t.Skipf("no mx for the scaffolded project: %v", err) + } + // mxcli's own unit index is working material, not project content: it is + // rewritten on every exec and would conflict on both sides. + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".mxcli/\n"), 0o644); err != nil { + t.Fatal(err) + } + + gitIn(t, dir, "init", "-q", "-b", "main") + gitIn(t, dir, "config", "core.autocrlf", "false") + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "scaffold") + return dir, mpr, bin +} + +// scaffoldProject runs `mx create-project` into dir like the docker suite +// does; its failure skips the test (no mx, no fixture). +func scaffoldProject(t *testing.T, dir string) { + t.Helper() + mxPath, err := docker.ResolveMxForVersion("", "") + if err != nil { + t.Skipf("no mx at all: %v", err) + } + create := exec.Command(mxPath, "create-project") + create.Dir = dir + docker.PrepareMxCommand(create) + if out, err := create.CombinedOutput(); err != nil { + t.Skipf("mx create-project: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "App.mpr")); err != nil { + t.Skipf("mx create-project did not produce App.mpr: %v", err) + } +} + +// applyPage writes and applies one page change through the real mxcli exec +// path (in-process model write, --no-check skips the semantic preflight). +// The script lives outside the repository: it is test scaffolding, not +// project content, and committing it would conflict on both sides. +func applyPage(t *testing.T, bin, dir, mpr, name, title string) { + t.Helper() + applyMDL(t, bin, dir, mpr, name, fmt.Sprintf("mdl 1;\ncreate or modify page MyFirstModule.%s (title: '%s', layout: Atlas_Core.Atlas_Default) {\n dynamictext t1 (content: '%s')\n};\n", name, title, title)) +} + +// applyMDL applies one MDL script the same way; what names the change in a +// failure message. +func applyMDL(t *testing.T, bin, dir, mpr, what, mdl string) { + t.Helper() + f, err := os.CreateTemp("", "mxpage") + if err != nil { + t.Fatal(err) + } + script := f.Name() + t.Cleanup(func() { os.Remove(script) }) + if _, err := f.WriteString(mdl); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bin, "exec", "-p", mpr, "--no-check", script) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("apply %s: %v\n%s", what, err, stderr.String()) + } +} + +func commitAll(t *testing.T, dir, msg string) { + t.Helper() + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", msg) +} + +// unitFilesContaining counts the .mxunit files whose bytes carry needle +// (page names are ASCII in a unit's BSON). +func unitFilesContaining(t *testing.T, dir, needle string) int { + t.Helper() + n := 0 + err := filepath.WalkDir(filepath.Join(dir, "mprcontents"), func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + if bytes.Contains(raw, []byte(needle)) { + n++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return n +} diff --git a/cmd/mxcli/gitmerge_test.go b/cmd/mxcli/gitmerge_test.go new file mode 100644 index 0000000000..ba2a71261f --- /dev/null +++ b/cmd/mxcli/gitmerge_test.go @@ -0,0 +1,1004 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/mpr" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func bsonKeyValue(key, value string) bson.E { + return bson.E{Key: key, Value: value} +} + +// `mxcli git merge ` — merge a branch with model-aware resolution: git +// merge first, and on a model-file conflict `mx merge` (the same engine +// Studio Pro uses). The mx run is injected as runMx so the orchestration is +// testable without a Mendix installation; the real runner shells out to mx. + +// modelConflictRepo makes a repository whose merge of "side" into "feature" +// conflicts on both model files: each side changed the same DomainModel unit. +func modelConflictRepo(t *testing.T) (string, string) { + t.Helper() + dir, mprPath := doctorNoteRepo(t) + units := baseUnits(t) + units[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey)} + writeProject(t, dir, "11.13.0", units) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "main side") + gitIn(t, dir, "switch", "-q", "-c", "side", "HEAD~1") + side := baseUnits(t) + side[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", sideKey)} + writeProject(t, dir, "11.13.0", side) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "side change") + gitIn(t, dir, "switch", "-q", "feature") + mergeOut, err := gitInE(t, dir, "merge", "side") + if err != nil && !strings.Contains(err.Error(), "exit status") { + t.Fatalf("fixture: expected a conflicted merge, got %v\n%s", err, mergeOut) + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); !strings.Contains(uu, ".mxunit") { + t.Fatalf("fixture: expected unresolved model files, got %q\nmerge said:\n%s", uu, mergeOut) + } + return dir, mprPath +} + +// twoUnitConflictRepo is a modelConflictRepo where BOTH the domain model and +// the microflow unit were changed on each side — two conflicted units, so a +// plan can take one unit from ours and the other from theirs. +func twoUnitConflictRepo(t *testing.T) (string, string) { + t.Helper() + dir, mprPath := doctorNoteRepo(t) + units := baseUnits(t) + units[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey)} + units[tMf] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Save", mainSideKey)} + writeProject(t, dir, "11.13.0", units) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "main side") + gitIn(t, dir, "switch", "-q", "-c", "side", "HEAD~1") + side := baseUnits(t) + side[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", sideKey)} + side[tMf] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Save", sideKey)} + writeProject(t, dir, "11.13.0", side) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "side change") + gitIn(t, dir, "switch", "-q", "feature") + mergeOut, err := gitInE(t, dir, "merge", "side") + if err != nil && !strings.Contains(err.Error(), "exit status") { + t.Fatalf("fixture: expected a conflicted merge, got %v\n%s", err, mergeOut) + } + return dir, mprPath +} + +// addConflictRepo is a repository where each side ADDED a different unit: only +// the .mpr conflicts, and the sides' document sets differ — the class a whole +// side must refuse, never commit. +func addConflictRepo(t *testing.T) (string, string) { + t.Helper() + dir, mprPath := doctorNoteRepo(t) + ours := baseUnits(t) + ours[addOursID] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Ours")} + writeProject(t, dir, "11.13.0", ours) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "ours adds a microflow") + gitIn(t, dir, "switch", "-q", "-c", "side", "HEAD~1") + theirs := baseUnits(t) + theirs[addTheirsID] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Theirs")} + writeProject(t, dir, "11.13.0", theirs) + gitIn(t, dir, "add", "-A") + gitIn(t, dir, "commit", "-q", "-m", "theirs adds a microflow") + gitIn(t, dir, "switch", "-q", "feature") + mergeOut, err := gitInE(t, dir, "merge", "side") + if err != nil && !strings.Contains(err.Error(), "exit status") { + t.Fatalf("fixture: expected a conflicted merge, got %v\n%s", err, mergeOut) + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); !strings.Contains(uu, ".mpr") { + t.Fatalf("fixture: expected the .mpr conflict, got %q\nmerge said:\n%s", uu, mergeOut) + } + return dir, mprPath +} + +// unitRel is the mprcontents path of a unit id inside a work tree. +func unitRel(id string) string { + return "mprcontents/" + id[:2] + "/" + id[2:4] + "/" + id + ".mxunit" +} + +var ( + mainSideKey = bsonKeyValue("DocA", "main") + sideKey = bsonKeyValue("DocB", "side") +) + +// The units each side adds in addConflictRepo. +const ( + addOursID = "11111111-0000-0000-0000-000000000009" + addTheirsID = "11111111-0000-0000-0000-00000000000a" +) + +func okRunner(out string) mxMergeRunner { + return func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 0, out, nil + } +} + +func TestGitMerge_CleanMergeTextFileWritesNote(t *testing.T) { + dir, mprPath := doctorNoteRepo(t) + doctorEnv(t, dir) + gitIn(t, dir, "branch", "docs", "HEAD~1") + gitIn(t, dir, "switch", "-q", "docs") + if err := os.WriteFile(filepath.Join(dir, "NOTES.txt"), []byte("docs\n"), 0o644); err != nil { + t.Fatal(err) + } + gitIn(t, dir, "add", "NOTES.txt") + gitIn(t, dir, "commit", "-q", "-m", "docs side") + gitIn(t, dir, "switch", "-q", "feature") + + noMx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + t.Fatal("mx must not run on a merge git resolves itself") + return 0, "", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "docs", "", "", "", false, noMx, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + var n mxNote + if err := json.Unmarshal([]byte(noteOn(t, dir, "HEAD")), &n); err != nil { + t.Fatalf("merge commit has no valid note: %v", err) + } + if !n.HasModelerVersion || n.BranchName != "feature" { + t.Errorf("note header: %+v", n) + } +} + +// fakeEngineResult simulates the engine having written its merge result into +// the MINE project folder: it overwrites dstDir's project with srcDir's +// (the .mpr and all of mprcontents, extras in dst removed). +func fakeEngineResult(t *testing.T, srcDir, dstDir string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(srcDir, "App.mpr")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dstDir, "App.mpr"), raw, 0o644); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(dstDir, "mprcontents")); err != nil { + t.Fatal(err) + } + srcContents := filepath.Join(srcDir, "mprcontents") + _ = filepath.WalkDir(srcContents, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcContents, p) + if err != nil { + return err + } + dst := filepath.Join(dstDir, "mprcontents", rel) + if d.IsDir() { + return os.MkdirAll(dst, 0o755) + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(dst, raw, 0o644) + }) +} + +func TestGitMerge_ModelConflictAutoResolvedByMx(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + oursUnits := baseUnits(t) + oursUnits[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey)} + + var gotBase, gotMine, gotTheirs string + var gotShas [3]string + var baseHasMpr, theirsHasMpr bool + runner := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + gotBase, gotMine, gotTheirs, gotShas = base, mine, theirs, shas + // The worktrees are removed when the merge finishes; check inside. + _, baseHasMprErr := os.Stat(base) + _, theirsHasMprErr := os.Stat(theirs) + baseHasMpr, theirsHasMpr = baseHasMprErr == nil, theirsHasMprErr == nil + // The engine writes its result into MINE. + writeProject(t, filepath.Dir(mine), "11.13.0", oursUnits) + return 0, "Merge successful.", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", "", false, runner, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + // File form: the three args are the revisions' .mpr paths, and MINE is a + // clean worktree at HEAD — never the working tree mid-merge. + if filepath.Base(gotMine) != "App.mpr" { + t.Errorf("runner's mine = %q, want the project's .mpr path", gotMine) + } + if filepath.Dir(gotMine) == dir { + t.Errorf("runner's mine = the working tree %q; MINE must be a clean worktree", dir) + } + if !baseHasMpr { + t.Errorf("runner's base %q must hold the project's .mpr", gotBase) + } + if !theirsHasMpr { + t.Errorf("runner's theirs %q must hold the project's .mpr", gotTheirs) + } + if gotShas[0] == "" || gotShas[1] == gotShas[0] || gotShas[2] == gotShas[1] { + t.Errorf("shas must be base/ours/theirs distinct: %v", gotShas) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U") + if strings.TrimSpace(uu) != "" { + t.Errorf("conflicts still unresolved:\n%s", uu) + } + if !strings.Contains(noteOn(t, dir, "HEAD"), `"HasModelerVersion":true`) { + t.Errorf("merge commit has no note:\n%s", noteOn(t, dir, "HEAD")) + } + // The hash gate repairs the .mpr; staging must come after it, or the + // commit would carry the pre-repair bytes and the tree would stay dirty. + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the merge:\n%s", st) + } +} + +// The engine owns the .mpr, so its result carries both sides' document sets: +// a unit each side added must all be on disk and in the index after the merge. +func TestGitMerge_KeepsUnitsBothSidesAdded(t *testing.T) { + dir, mprPath := addConflictRepo(t) + doctorEnv(t, dir) + merged := baseUnits(t) + merged[addOursID] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Ours")} + merged[addTheirsID] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Theirs")} + + runner := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + writeProject(t, filepath.Dir(mine), "11.13.0", merged) + return 0, "Merge successful.", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", "", false, runner, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + for _, id := range []string{addOursID, addTheirsID} { + if _, err := os.Stat(filepath.Join(dir, unitRel(id))); err != nil { + t.Errorf("unit %s must exist after the merge: %v", id, err) + } + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if err := fixHashes(mprPath, false, &out); err != nil { + t.Errorf("hashes drift after the merge: %v\n%s", err, out.String()) + } + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the merge:\n%s", st) + } +} + +// A deletion the engine resolved travels with its result: a unit file the +// result no longer has is removed from the working tree, not left behind as +// an orphan the hash gate would refuse. +func TestGitMerge_CopyBackRemovesUnitsTheResultDrops(t *testing.T) { + dir, mprPath := addConflictRepo(t) + doctorEnv(t, dir) + + runner := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + // result = theirs: theirs' project is the merged outcome, without + // ours' added unit. + fakeEngineResult(t, filepath.Dir(theirs), filepath.Dir(mine)) + return 0, "Merge successful.", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", "", false, runner, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + if _, err := os.Stat(filepath.Join(dir, unitRel(addOursID))); !os.IsNotExist(err) { + t.Errorf("ours' unit must be gone with the engine's result: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, unitRel(addTheirsID))); err != nil { + t.Errorf("theirs' unit must be on disk: %v", err) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if err := fixHashes(mprPath, false, &out); err != nil { + t.Errorf("hashes drift after the merge: %v\n%s", err, out.String()) + } + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the merge:\n%s", st) + } +} + +func TestGitMerge_ModelConflictUnresolvedAborts(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + fails := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 2, `[{"ConflictResolutionDetails":"ManualResolutionRequired"}]`, nil + }} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", "", false, fails, &out) + if err == nil || !strings.Contains(err.Error(), "Studio Pro") { + t.Fatalf("want a Studio Pro referral, got %v", err) + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved despite the abort: %q", got) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present: the merge was not aborted") + } + uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U") + if strings.TrimSpace(uu) != "" { + t.Errorf("conflicted files remain:\n%s", uu) + } +} + +func TestGitMerge_NonModelConflictLeftAlone(t *testing.T) { + dir, mprPath := doctorNoteRepo(t) + doctorEnv(t, dir) + gitIn(t, dir, "checkout", "-q", "-b", "side", "HEAD~1") + if err := os.WriteFile(filepath.Join(dir, "NOTES.txt"), []byte("side\n"), 0o644); err != nil { + t.Fatal(err) + } + gitIn(t, dir, "add", "NOTES.txt") + gitIn(t, dir, "commit", "-q", "-m", "side notes") + gitIn(t, dir, "checkout", "-q", "feature") + if err := os.WriteFile(filepath.Join(dir, "NOTES.txt"), []byte("main\n"), 0o644); err != nil { + t.Fatal(err) + } + gitIn(t, dir, "add", "NOTES.txt") + gitIn(t, dir, "commit", "-q", "-m", "main notes") + _, err := gitInE(t, dir, "merge", "side") + if err != nil && !strings.Contains(err.Error(), "exit status") { + t.Fatalf("fixture: expected a conflict, got %v", err) + } + + calls := 0 + counting := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + calls++ + return 0, "", nil + }} + var out bytes.Buffer + merr := mergeRun(mprPath, "side", "", "", "", false, counting, &out) + if merr == nil { + t.Fatal("a non-model conflict must be reported, not silently passed") + } + if calls != 0 { + t.Errorf("mx must not run for non-model conflicts (ran %d times)", calls) + } + uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U") + if !strings.Contains(uu, "NOTES.txt") { + t.Errorf("the conflicted text file must be left for manual resolution:\n%s", uu) + } + gitInE(t, dir, "merge", "--abort") // clean up the fixture +} + +func TestGitMerge_AlreadyUpToDate(t *testing.T) { + dir, mprPath := doctorNoteRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + noMx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + t.Fatal("mx must not run") + return 0, "", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "HEAD~1", "", "", "", false, noMx, &out); err != nil { + t.Fatalf("mergeRun: %v", err) + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved: %q", got) + } + if !strings.Contains(out.String(), "up to date") { + t.Errorf("must say so:\n%s", out.String()) + } +} + +// The conflict report is the review material: on ManualResolutionRequired the +// merge is aborted and both sides' changes are printed, so a human can decide +// before anything lands. mx is not even needed for the report. +func TestGitMerge_ConflictReportShowsBothSidesAndAborts(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + noMx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 2, `[{"ConflictResolutionDetails":"ManualResolutionRequired"}]`, nil + }} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", "", false, noMx, &out) + if err == nil || !strings.Contains(err.Error(), "--plan") { + t.Fatalf("the report must point at the resolution flags, got %v", err) + } + report := out.String() + for _, want := range []string{"Plan template", "merge-plan.json", "Merge aborted"} { + if !strings.Contains(report, want) { + t.Errorf("report must contain %q:\n%s", want, report) + } + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved: %q", got) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } +} + +// --ours / --theirs take the decided side for every conflicted model file, +// commit the merge and write the note — deterministically, without mx. +func TestGitMerge_SideTakingResolves(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + + noMx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + t.Fatal("--ours must not consult mx") + return 0, "", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "ours", "", false, noMx, &out); err != nil { + t.Fatalf("mergeRun --ours: %v\n%s", err, out.String()) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if !strings.Contains(noteOn(t, dir, "HEAD"), `"HasModelerVersion":true`) { + t.Errorf("merge commit has no note") + } + page := filepath.Join(dir, "mprcontents", tDM[:2], tDM[2:4], tDM+".mxunit") + raw, err := os.ReadFile(page) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("DocA")) || bytes.Contains(raw, []byte("DocB")) { + t.Error("--ours must leave our side of the unit on disk") + } + + // --theirs flips to the other side, deterministically. + dir2, mprPath2 := modelConflictRepo(t) + theirs := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + t.Fatal("--theirs must not consult mx") + return 0, "", nil + }} + var out2 bytes.Buffer + if err := mergeRun(mprPath2, "side", "", "theirs", "", false, theirs, &out2); err != nil { + t.Fatalf("mergeRun --theirs: %v\n%s", err, out2.String()) + } + page2 := filepath.Join(dir2, "mprcontents", tDM[:2], tDM[2:4], tDM+".mxunit") + raw2, err := os.ReadFile(page2) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw2, []byte("DocB")) || bytes.Contains(raw2, []byte("DocA")) { + t.Error("--theirs must leave their side of the unit on disk") + } +} + +// engineMarker is what `mx merge` writes into ContentsConflicts for a unit it +// left for a decision (measured, mx 11.13); every other row holds an empty +// string. +const engineMarker = `{"Conflicts":[{"ID":"00000000-0000-0000-0000-000000000000","HeadModification":"Change","MineModification":"Change"}]}` + +// engineDecides simulates `mx merge` stopping on a decision (exit 2): it +// writes result into MINE as the merged project, marks the manual units in +// MINE's index the way the engine does, and prints the -v list naming manual +// units ManualResolutionRequired and auto units AutoResolved. +func engineDecides(t *testing.T, result map[string]tUnit, manual, auto []string) mxMergeRunner { + return func(base, mine, theirs string, shas [3]string) (int, string, error) { + writeProject(t, filepath.Dir(mine), "11.13.0", result) + db, err := sql.Open("sqlite", mine) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var list []string + for _, id := range manual { + if _, err := db.Exec(`UPDATE Unit SET ContentsConflicts = ? WHERE UnitID = ?`, engineMarker, mpr.IDToBsonBinary(id).Data); err != nil { + t.Fatal(err) + } + list = append(list, `{"UnitId":"`+id+`","Type":"MergeContents","TreeConflict":"None","ConflictResolutionDetails":"ManualResolutionRequired"}`) + } + for _, id := range auto { + list = append(list, `{"UnitId":"`+id+`","Type":"MergeContents","TreeConflict":"None","ConflictResolutionDetails":"AutoResolved"}`) + } + return 2, "Merging MPRs.\n[" + strings.Join(list, ",") + "]\nConflicts found during merging.", nil + } +} + +// writePlanFor writes a plan for the merge in progress in dir — outside the +// repository, so it never shows in the working tree — taking each unit from +// the given side. +func writePlanFor(t *testing.T, dir string, take map[string]string) string { + t.Helper() + plan := mergePlanTemplate{ + Base: strings.TrimSpace(gitOutT(t, dir, "merge-base", "HEAD", "side")), + Ours: strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")), + Theirs: strings.TrimSpace(gitOutT(t, dir, "rev-parse", "side")), + } + for id, side := range take { + plan.Units = append(plan.Units, planUnit{ID: id, Take: side}) + } + raw, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(t.TempDir(), "merge-plan.json") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func assertUnitHas(t *testing.T, dir, id, key string, want bool) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, unitRel(id))) + if err != nil { + t.Fatal(err) + } + if got := bytes.Contains(raw, []byte(key)); got != want { + t.Errorf("%s contains %q = %v, want %v", id, key, got, want) + } +} + +// assertPlanMergeCommitted checks what every successful plan must leave: one +// two-parent merge commit, a clean working tree, an index whose hashes match +// the files, and no merge conflict marker left in it. +func assertPlanMergeCommitted(t *testing.T, dir, mprPath string) { + t.Helper() + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } + if st := gitOutT(t, dir, "status", "--porcelain"); strings.TrimSpace(st) != "" { + t.Errorf("working tree not clean after the plan merge:\n%s", st) + } + var out bytes.Buffer + if err := fixHashes(mprPath, false, &out); err != nil { + t.Errorf("hashes drift after the plan merge: %v\n%s", err, out.String()) + } + r, err := mpr.Open(mprPath) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if marks, err := r.UnitConflicts(); err != nil || len(marks) != 0 { + t.Errorf("merge conflict markers left in the committed index: %+v (%v)", marks, err) + } +} + +// A plan resolves per unit: the engine left both units for a decision, and +// the plan takes tDM from ours and tMf from theirs — one merge commit, hashes +// repaired from the files, both markers cleared. +func TestGitMerge_PlanMixesPerUnit(t *testing.T) { + dir, mprPath := twoUnitConflictRepo(t) + doctorEnv(t, dir) + // The engine's result for undecided units: ours' content, marked. + result := baseUnits(t) + result[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey)} + result[tMf] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Save", mainSideKey)} + planPath := writePlanFor(t, dir, map[string]string{tDM: "ours", tMf: "theirs"}) + + mx := mxRunners{merge: engineDecides(t, result, []string{tDM, tMf}, nil)} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", planPath, false, mx, &out); err != nil { + t.Fatalf("mergeRun --plan: %v\n%s", err, out.String()) + } + assertUnitHas(t, dir, tDM, "DocA", true) + assertUnitHas(t, dir, tDM, "DocB", false) + assertUnitHas(t, dir, tMf, "DocB", true) + assertUnitHas(t, dir, tMf, "DocA", false) + assertPlanMergeCommitted(t, dir, mprPath) +} + +// The plan decides only what the engine could not. Here the engine merged +// tDM on its own (both sides' keys in its result) and left tMf for a +// decision; a plan deciding tMf must keep the engine's tDM. Taking tDM from +// ours instead drops theirs' change to it without the plan ever naming it — +// measured with real mx: an entity each side added to the same domain model +// is AutoResolved, while both sides editing one entity's documentation is not, +// and the plan's commit lost theirs' entity. +func TestGitMerge_PlanKeepsWhatTheEngineMerged(t *testing.T) { + dir, mprPath := twoUnitConflictRepo(t) + doctorEnv(t, dir) + result := baseUnits(t) + result[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey, sideKey)} + result[tMf] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Save", mainSideKey)} + planPath := writePlanFor(t, dir, map[string]string{tMf: "theirs"}) + + mx := mxRunners{merge: engineDecides(t, result, []string{tMf}, []string{tDM})} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", planPath, false, mx, &out); err != nil { + t.Fatalf("mergeRun --plan: %v\n%s", err, out.String()) + } + assertUnitHas(t, dir, tDM, "DocA", true) + assertUnitHas(t, dir, tDM, "DocB", true) // theirs' change, merged by the engine + assertUnitHas(t, dir, tMf, "DocB", true) + assertUnitHas(t, dir, tMf, "DocA", false) + assertPlanMergeCommitted(t, dir, mprPath) +} + +// A plan must decide exactly the units the engine left for a decision: one it +// leaves out would be committed with the engine's marker still in the index +// (CE0137 in Studio Pro), and one the engine merged on its own would undo +// that merge. Both are refused, and the refusal aborts the merge. +func TestGitMerge_PlanMustMatchTheEnginesDecisions(t *testing.T) { + for _, tc := range []struct { + name string + manual, auto []string + take map[string]string + want string + }{ + {"leaves out an undecided unit", []string{tDM, tMf}, nil, + map[string]string{tMf: "theirs"}, "does not decide"}, + {"decides a unit the engine merged", []string{tMf}, []string{tDM}, + map[string]string{tDM: "ours", tMf: "theirs"}, "merged without a decision"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir, mprPath := twoUnitConflictRepo(t) + doctorEnv(t, dir) + result := baseUnits(t) + result[tDM] = tUnit{tMod, unitDoc(t, "DomainModels$DomainModel", "", mainSideKey)} + result[tMf] = tUnit{tMod, unitDoc(t, "Microflows$Microflow", "ACT_Save", mainSideKey)} + planPath := writePlanFor(t, dir, tc.take) + before := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")) + + mx := mxRunners{merge: engineDecides(t, result, tc.manual, tc.auto)} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", planPath, false, mx, &out) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want a refusal mentioning %q, got %v\n%s", tc.want, err, out.String()) + } + if got := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")); got != before { + t.Errorf("HEAD moved despite the refusal: %s", got) + } + if _, mhErr := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr == nil { + t.Error("MERGE_HEAD still present: the refusal must abort the merge") + } + }) + } +} + +// A plan whose revisions no longer match the merge state is stale and refused. +func TestGitMerge_PlanStaleRefused(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + plan := `{"base":"0000000000000000000000000000000000000000","ours":"0000000000000000000000000000000000000001","theirs":"0000000000000000000000000000000000000002","units":[]}` + planPath := filepath.Join(dir, "merge-plan.json") + if err := os.WriteFile(planPath, []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + noMx := mxRunners{} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", planPath, false, noMx, &out) + if err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("want a stale-plan refusal, got %v", err) + } + gitInE(t, dir, "merge", "--abort") // clean up the fixture +} + +// On ManualResolutionRequired the report is property-level (from mx diff) and +// a plan template is written beside the .mpr. +func TestGitMerge_ManualWritesPlanAndPropertyReport(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + fixture := `{"unitDifferences":[{"id":"` + tDM + `","type":"DomainModels$DomainModel","change":"Modified","changeDetails":[{"change":"Modified","changeDescription":"Attribute 'MyFirstModule.CreatedByMxcli.Nickname'","changeDetails":[{"property":"New type > Length","baseValue":"100","mineValue":"150"}]}]}]}` + mx := mxRunners{ + merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 2, `[{"ConflictResolutionDetails":"ManualResolutionRequired"}]`, nil + }, + diff: func(base, mine, outJSON string) error { + return os.WriteFile(outJSON, []byte(fixture), 0o644) + }, + } + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", "", false, mx, &out) + if err == nil { + t.Fatal("a manual conflict must end with the decision error") + } + report := out.String() + for _, want := range []string{"Length", "100", "150", "Nickname"} { + if !strings.Contains(report, want) { + t.Errorf("report must contain %q:\n%s", want, report) + } + } + planRaw, err := os.ReadFile(mprPath + ".merge-plan.json") + if err != nil { + t.Fatalf("plan template not written: %v", err) + } + var plan struct { + Units []struct { + Id string `json:"id"` + Take string `json:"take"` + } `json:"units"` + } + if err := json.Unmarshal(planRaw, &plan); err != nil { + t.Fatal(err) + } + if len(plan.Units) != 1 || plan.Units[0].Id != tDM || plan.Units[0].Take != "ours" { + t.Errorf("plan template: %+v", plan) + } + gitInE(t, dir, "merge", "--abort") // clean up the fixture +} + +// A whole side of a project includes the .mpr's list of documents. When the +// sides' document sets differ (one side added a unit), taking --theirs would +// leave ours' unit on disk with no .mpr entry: the commit is refused and the +// merge aborted, never silently half-committed. +func TestGitMerge_SideTakingRefusesWhenDocumentSetsDiffer(t *testing.T) { + dir, mprPath := addConflictRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + noMx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + t.Fatal("--theirs must not consult mx") + return 0, "", nil + }} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "theirs", "", false, noMx, &out) + if err == nil || !strings.Contains(err.Error(), "refusing to commit") { + t.Fatalf("--theirs must refuse a side that drops ours' unit, got %v\n%s", err, out.String()) + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved: %q", got) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present: the refusal must abort the merge") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } +} + +// Exit 129 is the engine failing, not a merge decision: report it, abort +// cleanly, and never write a plan template. +func TestGitMerge_EngineCrashAborts(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + crash := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 129, "Exception in MergeLib", nil + }} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", "", false, crash, &out) + if err == nil || !strings.Contains(err.Error(), "exit 129") { + t.Fatalf("want the engine-failure report, got %v\n%s", err, out.String()) + } + if strings.Contains(out.String(), "Plan template") { + t.Errorf("a crash must not produce a plan:\n%s", out.String()) + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved: %q", got) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present: the merge was not aborted") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } + if _, err := os.Stat(mprPath + ".merge-plan.json"); !os.IsNotExist(err) { + t.Errorf("a crash must not write a plan template: %v", err) + } +} + +// mx not being found or not starting is the engine failing to run: the merge +// aborts with the reason, and nothing is left behind. +func TestGitMerge_EngineStartFailureAborts(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + before := commitSubject(t, dir) + + missing := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 0, "", errors.New("no mx for Mendix 11.13.0: not on PATH or in the cache (model conflicts need it)") + }} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", "", false, missing, &out) + if err == nil || !strings.Contains(err.Error(), "failed to run") { + t.Fatalf("want the mx failure, got %v\n%s", err, out.String()) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present: the merge was not aborted") + } + if got := commitSubject(t, dir); got != before { + t.Errorf("HEAD moved: %q", got) + } +} + +// A plan whose take value is not ours/theirs is refused, and the refusal +// aborts the merge: nothing may be left half-done. +func TestGitMerge_PlanRefusalAborts(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + base := strings.TrimSpace(gitOutT(t, dir, "merge-base", "HEAD", "side")) + ours := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")) + theirs := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "side")) + plan := fmt.Sprintf(`{"base":%q,"ours":%q,"theirs":%q,"units":[{"id":%q,"take":"sideways"}]}`, + base, ours, theirs, tDM) + planPath := filepath.Join(dir, "merge-plan.json") + if err := os.WriteFile(planPath, []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + noMx := mxRunners{} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", planPath, false, noMx, &out) + if err == nil || !strings.Contains(err.Error(), "ours|theirs") { + t.Fatalf("want the invalid-take refusal, got %v\n%s", err, out.String()) + } + if _, err := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); err == nil { + t.Error("MERGE_HEAD still present: the refusal must abort the merge") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } +} + +// Re-entry: with the merge already in progress (a previous run stopped and +// the human resolved what they handle by hand), re-running resolves the model +// and finishes — it does not re-run git merge and die on MERGE_HEAD. +func TestGitMerge_ResumesAMergeAlreadyInProgress(t *testing.T) { + dir, mprPath := modelConflictRepo(t) + doctorEnv(t, dir) + + calls := 0 + runner := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + calls++ + return 0, "Merge successful.", nil + }} + var out bytes.Buffer + if err := mergeRun(mprPath, "side", "", "", "", false, runner, &out); err != nil { + t.Fatalf("mergeRun: %v\n%s", err, out.String()) + } + if calls != 1 { + t.Errorf("engine runs = %d, want 1", calls) + } + parents := strings.Fields(gitOutT(t, dir, "rev-list", "--parents", "-n", "1", "HEAD")) + if len(parents) != 3 { + t.Fatalf("want a two-parent merge commit, got %v", parents) + } +} + +// A git merge that refuses to start (here: local changes it would overwrite) +// writes its reason to stderr, not stdout; the error must carry it, or the +// user is left with "git merge side: " and nothing after it. +func TestGitMerge_RefusedMergeSaysWhy(t *testing.T) { + dir, mprPath := doctorNoteRepo(t) + doctorEnv(t, dir) + gitIn(t, dir, "switch", "-q", "-c", "side") + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("side\n"), 0o644); err != nil { + t.Fatal(err) + } + gitIn(t, dir, "add", "notes.txt") + gitIn(t, dir, "commit", "-q", "-m", "side notes") + gitIn(t, dir, "switch", "-q", "feature") + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("uncommitted\n"), 0o644); err != nil { + t.Fatal(err) + } + + for _, plan := range []string{"", filepath.Join(t.TempDir(), "plan.json")} { + if plan != "" { + if err := os.WriteFile(plan, []byte(`{"units":[]}`), 0o644); err != nil { + t.Fatal(err) + } + } + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", plan, false, mxRunners{}, &out) + if err == nil || !strings.Contains(err.Error(), "would be overwritten") { + t.Errorf("plan=%q: want git's reason in the error, got %v", plan, err) + } + } +} + +// The plan template is working material: `mxcli git commit` never stages it. +func TestJunkPath_CoversMergePlanTemplate(t *testing.T) { + if !isJunkPath("App.mpr.merge-plan.json", ".") { + t.Error("App.mpr.merge-plan.json must be junk for staging") + } + if !isJunkPath("app/Project.mpr.merge-plan.json", "app") { + t.Error("a project-folder plan template must be junk too") + } +} + +// The engine prints its per-unit conflict list only with -v, as one JSON +// array on one stdout line; the parser must find it in the engine's noise +// and keep only what needs a human decision. +func TestParseEngineConflicts_FindsArrayInEngineNoise(t *testing.T) { + mxOut := "Merging...\n" + `[{"UnitId":"0131b342-0000-0000-0000-000000000001","UnitName":null,"ModuleName":"FeedbackModule","ContainmentName":"DomainModel","Type":"MergeContents","TreeConflict":"None","ConflictResolutionDetails":"ManualResolutionRequired"},{"UnitId":"6a912436-0000-0000-0000-000000000002","UnitName":"ACT_Theirs","ModuleName":"MyFirstModule","ContainmentName":"Documents","Type":"AddFromHead","TreeConflict":"None","ConflictResolutionDetails":"AutoResolved"}]` + "\nDone.\n" + cs := parseEngineConflicts(mxOut) + if len(cs) != 2 || cs[0].UnitID != "0131b342-0000-0000-0000-000000000001" || cs[1].Type != "AddFromHead" { + t.Fatalf("parsed: %+v", cs) + } +} + +func TestParseEngineConflicts_NoneYieldsNil(t *testing.T) { + if cs := parseEngineConflicts("Merge successful.\n"); cs != nil { + t.Errorf("want nil, got %+v", cs) + } +} + +// Only ManualResolutionRequired entries need a decision; entries without a +// unit id say nothing decidable and are skipped. +func TestEngineManualIDs_FiltersAutoResolvedAndEmpty(t *testing.T) { + cs := parseEngineConflicts(`[{"UnitId":"aaa","ConflictResolutionDetails":"ManualResolutionRequired"},{"UnitId":"","ConflictResolutionDetails":"ManualResolutionRequired"},{"UnitId":"bbb","ConflictResolutionDetails":"AutoResolved"}]`) + ids := engineManualIDs(cs) + if len(ids) != 1 || ids[0] != "aaa" { + t.Errorf("manual ids: %v", ids) + } +} + +// A plan unit that is not modified on both sides is refused outright — the +// gate that also makes the engine's list and git's index unable to disagree +// dangerously: a unit git resolved on its own (one-side or no change, even +// behind a stale ContentsHash index) never passes "modified on both", so it +// can never be silently skipped to ours by the theirs loop. +func TestGitMerge_PlanRefusesUnitsNotModifiedOnBoth(t *testing.T) { + dir, mprPath := modelConflictRepo(t) // git conflicts tDM; tMf untouched by both + doctorEnv(t, dir) + base := strings.TrimSpace(gitOutT(t, dir, "merge-base", "HEAD", "side")) + ours := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "HEAD")) + theirs := strings.TrimSpace(gitOutT(t, dir, "rev-parse", "side")) + plan := fmt.Sprintf(`{"base":%q,"ours":%q,"theirs":%q,"units":[{"id":%q,"take":"theirs"}]}`, + base, ours, theirs, tMf) + planPath := filepath.Join(dir, "merge-plan.json") + if err := os.WriteFile(planPath, []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + noMx := mxRunners{} + var out bytes.Buffer + err := mergeRun(mprPath, "side", "", "", planPath, false, noMx, &out) + if err == nil || !strings.Contains(err.Error(), "only modified-on-both conflicts can be planned") { + t.Fatalf("want the modified-on-both refusal, got %v\n%s", err, out.String()) + } + if _, mhErr := gitOut(dir, "rev-parse", "--verify", "--quiet", "MERGE_HEAD"); mhErr == nil { + t.Error("MERGE_HEAD still present: the refusal must abort the merge") + } + if uu := gitOutT(t, dir, "diff", "--name-only", "--diff-filter=U"); strings.TrimSpace(uu) != "" { + t.Errorf("working tree not restored:\n%s", uu) + } +} + +// The review material follows the engine's conflict list: a unit the engine +// auto-resolved (git still lists it — one side's stale ContentsHash index +// content-merges cleanly) never lands in the plan template, and one only the +// engine flags as manual does. The engine's list here names tMf manual and +// tDM auto — the opposite of git's conflicted-unit list — so the test can +// tell which one the plan was built from. +func TestGitMerge_PlanTemplateFollowsEngineConflictList(t *testing.T) { + dir, mprPath := modelConflictRepo(t) // git's conflicted unit: tDM + doctorEnv(t, dir) + auto := `[{"UnitId":"` + tDM + `","Type":"MergeContents","ConflictResolutionDetails":"AutoResolved"},{"UnitId":"` + tMf + `","Type":"MergeContents","ConflictResolutionDetails":"ManualResolutionRequired"}]` + mx := mxRunners{merge: func(base, mine, theirs string, shas [3]string) (int, string, error) { + return 2, auto, nil + }} + var out bytes.Buffer + _ = mergeRun(mprPath, "side", "", "", "", false, mx, &out) + raw, err := os.ReadFile(mprPath + ".merge-plan.json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), tMf) || strings.Contains(string(raw), tDM) { + t.Errorf("plan must hold only the engine's manual unit:\n%s", raw) + } + gitInE(t, dir, "merge", "--abort") +} diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index 926bd612a3..2af995617f 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -178,9 +178,6 @@ beta language; --mdl 0 starts them in the alpha language, and in the REPL an globalJSONFlag, _ = cmd.Flags().GetBool("json") globalMCPURL, _ = cmd.Flags().GetString("mcp") globalMCPDial, _ = cmd.Flags().GetString("mcp-dial") - globalMCPConcord, _ = cmd.Flags().GetString("mcp-concord") - globalMCPConcordDial, _ = cmd.Flags().GetString("mcp-concord-dial") - globalMCPSave, _ = cmd.Flags().GetBool("mcp-save") globalMCPCheck, _ = cmd.Flags().GetBool("mcp-check") globalMCPRun, _ = cmd.Flags().GetBool("mcp-run") globalMCPVerbose, _ = cmd.Flags().GetBool("mcp-verbose") @@ -248,15 +245,12 @@ var globalJSONFlag bool // routes model writes to a live Studio Pro while reads come from the local // .mpr given by -p. See docs/11-proposals/PROPOSAL_mcp_backend.md. var ( - globalMCPURL string - globalMCPDial string - globalMCPConcord string - globalMCPConcordDial string - globalMCPSave bool - globalMCPCheck bool - globalMCPRun bool - globalMCPVerbose bool - globalMCPTrace bool + globalMCPURL string + globalMCPDial string + globalMCPCheck bool + globalMCPRun bool + globalMCPVerbose bool + globalMCPTrace bool ) // resolveFormat returns the effective output format for a command. @@ -356,11 +350,8 @@ func init() { rootCmd.PersistentFlags().Bool("json", false, "Output in JSON format") rootCmd.PersistentFlags().String("mcp", "", "Route model writes to a live Studio Pro MCP server (e.g. http://localhost:7782/mcp); reads come from -p") rootCmd.PersistentFlags().String("mcp-dial", "", "Override the TCP address dialed for --mcp (e.g. host.docker.internal:7782 from a devcontainer)") - rootCmd.PersistentFlags().String("mcp-concord", "", "Optional second MCP server (Concord) for capabilities the built-in server lacks: delete, save, validate, run (e.g. http://localhost:7783/mcp)") - rootCmd.PersistentFlags().String("mcp-concord-dial", "", "Override the TCP address dialed for --mcp-concord") - rootCmd.PersistentFlags().Bool("mcp-save", false, "After the command, save all changes in Studio Pro via Concord (requires --mcp-concord; the built-in server has no save tool)") - rootCmd.PersistentFlags().Bool("mcp-check", false, "After the command, run Studio Pro's model consistency check via Concord and print the report (requires --mcp-concord)") - rootCmd.PersistentFlags().Bool("mcp-run", false, "After the command, start the app in Studio Pro via Concord (run_app) and print its URL (requires --mcp-concord)") + rootCmd.PersistentFlags().Bool("mcp-check", false, "After the command, run Studio Pro's project-wide error check (ped_check_errors, Studio Pro 11.15+) and print the report") + rootCmd.PersistentFlags().Bool("mcp-run", false, "After the command, start the app in Studio Pro (mendix_app handler, Studio Pro 11.16+) and print its URL") rootCmd.PersistentFlags().Bool("mcp-verbose", false, "Print each PED tool call the MCP backend makes (requires --mcp)") rootCmd.PersistentFlags().Bool("mcp-trace", false, "Print each MDL command with the PED tool calls it makes (implies --mcp-verbose; requires --mcp)") rootCmd.PersistentFlags().String("engine", "", "Deprecated and ignored: there is one model engine. Kept so scripts pinning the old one keep running.") diff --git a/cmd/mxcli/mcp.go b/cmd/mxcli/mcp.go index 14171ec99a..c4c9bff39f 100644 --- a/cmd/mxcli/mcp.go +++ b/cmd/mxcli/mcp.go @@ -38,9 +38,6 @@ Example: os.Exit(1) } b := mcpbackend.New(globalMCPURL, globalMCPDial).WithTracer(mcpTracer()) - if globalMCPConcord != "" { - b = b.WithConcord(mcpbackend.ConcordConfig{URL: globalMCPConcord, Dial: globalMCPConcordDial}) - } if err := b.Connect(projectPath); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/docs-site/src/language/widget-types.md b/docs-site/src/language/widget-types.md index 7921989c23..bb5efac424 100644 --- a/docs-site/src/language/widget-types.md +++ b/docs-site/src/language/widget-types.md @@ -352,7 +352,11 @@ The classic drop-down (`DROPDOWN`, stored as `Forms$DropDown`) is not supported the React client: on a project whose Web UI setting is `OptimizedClient: Yes` (`show settings`), mxbuild reports CE0582 and `check --references` reports **MDL-WIDGET40**. Use `COMBOBOX` over the same attribute. Under `No` and -`MigrationMode` the drop-down builds, and nothing is reported. +`MigrationMode` the drop-down builds, and nothing is reported. A drop-down the +project already holds is reported by `mxcli lint` as **MPR012**, together with +the other built-in widgets that are CE0582 under the React client: the static and +dynamic image, the legacy data grid and the reference selector (measured on +11.16; the template grid and the reference set selectors are not yet measured). ### TEXTBOX diff --git a/docs-site/src/tools/builtin-rules.md b/docs-site/src/tools/builtin-rules.md index ba8990a4e8..1caa05338b 100644 --- a/docs-site/src/tools/builtin-rules.md +++ b/docs-site/src/tools/builtin-rules.md @@ -30,6 +30,7 @@ The **lint rules** below run with `mxcli lint`. There is also a separate group o | **SEC002** | Password policy -- Checks for secure password configuration | | **SEC003** | Demo users -- Warns about demo users in production security level | | **SEC010** | Unreadable page data -- A data grid, gallery, list view or combo box lists an entity that a user role allowed on the page cannot read (including its generalizations and specializations). At Production security it renders empty for that role and nothing else reports it. Info when the role reads only through an XPath-constrained rule; System entities use access measured from a deployed model, so a combo box over System.User gets its own message (it lists only the current user) | +| **SEC011** | XPath through an unreadable association -- A page data source's XPath crosses an association that a user role allowed on the page may not read on the entity the step starts from (an association is a member of its FROM entity's rules, and of its TO entity's when the owner is Both). At Production security the page fails to open with "No access rights for Module$Entity/Module.Association", while mxbuild reports nothing. Error. Entity access-rule XPaths are not checked: the runtime does not apply member access there (measured, 11.15) | ## Convention Rules diff --git a/docs-site/src/tools/linting.md b/docs-site/src/tools/linting.md index 4183f83e41..eb2c49c4ac 100644 --- a/docs-site/src/tools/linting.md +++ b/docs-site/src/tools/linting.md @@ -17,7 +17,7 @@ The linting system provides: | Category | Prefix | Focus | |----------|--------|-------| | MDL | MDL001-MDL007 | Naming conventions, empty microflows, domain model size | -| Security | SEC001-SEC010 | Access rules, password policy, demo users, PII exposure | +| Security | SEC001-SEC011 | Access rules, password policy, demo users, PII exposure | | Convention | CONV001-CONV021 | Best practice conventions, error handling | | Quality | QUAL001-QUAL004 | Complexity, documentation, long microflows | | Architecture | ARCH001-ARCH003 | Cross-module data, entity business keys | diff --git a/docs-site/src/tools/mcp-connect.md b/docs-site/src/tools/mcp-connect.md index 8c43aa6d40..933269cf15 100644 --- a/docs-site/src/tools/mcp-connect.md +++ b/docs-site/src/tools/mcp-connect.md @@ -33,11 +33,8 @@ of routing through MCP, see |---|---| | `--mcp ` | Enable the MCP backend. The URL is what the **server** must see; its host drives the `Host` header and must stay `localhost` — e.g. `http://localhost/mcp`. | | `--mcp-dial ` | The TCP address mxcli actually connects to, independent of the URL host. From a devcontainer this is typically `host.docker.internal:`. | -| `--mcp-concord ` | Optional second MCP server ("Concord") for operations PED lacks: **delete**, **save**, **validate**, **run** (default port `7783`). | -| `--mcp-concord-dial ` | Dial override for Concord. | -| `--mcp-save` | After the command, save all changes in Studio Pro (requires `--mcp-concord`). | -| `--mcp-check` | After the command, run Studio Pro's consistency check and print the report (requires `--mcp-concord`). | -| `--mcp-run` | After the command, start the app in Studio Pro and print its URL (requires `--mcp-concord`). | +| `--mcp-check` | After the command, run Studio Pro's project-wide error check (`ped_check_errors`) and print the report. Needs Studio Pro 11.15+. | +| `--mcp-run` | After the command, start the app in Studio Pro (the `mendix_app` handler) and print its URL. Needs Studio Pro 11.16+. | | `--mcp-verbose` | Print each PED tool call the backend makes (to stderr). | | `--mcp-trace` | Print each MDL command with the PED tool calls it makes (implies `--mcp-verbose`). | @@ -125,24 +122,26 @@ see what actually goes over the wire — for debugging, or just to understand th This makes the statement-to-tool-call mapping (and surprises like a read-back reconstructing a dirty module) visible at runtime. -## Adding Concord (optional) +## Delete, check and run -PED cannot delete documents, save, validate, or run the app. A companion server, -**Concord**, fills those gaps (default port `7783`, usually reachable directly from -the container — no bridge needed): +Studio Pro's own MCP server covers what used to need a second server: + +- **Saving** needs nothing: PED writes each change to disk as it lands. +- **`DROP`** of a standalone document (enumeration, constant, microflow, nanoflow, + page, workflow, business-event service) calls `ped_delete_document`, which + Studio Pro 11.16 added. An older Studio Pro gets an error naming the version. +- **`--mcp-check`** runs `ped_check_errors` over the whole project (11.15+). +- **`--mcp-run`** starts the app through the `mendix_app` handler (11.16+), waits + up to two minutes and prints its URL. A project with model errors is not run; + the refusal is printed instead. The runtime log is `read_file /app-logs/app.log` + on the same server. ```bash mxcli exec changes.mdl -p app.mpr \ --mcp http://localhost/mcp --mcp-dial host.docker.internal:7784 \ - --mcp-concord http://localhost:7783/mcp \ - --mcp-save --mcp-check + --mcp-check --mcp-run ``` -Here mxcli applies the script through PED, then asks Concord to **save** the -project and run a **consistency check**, printing the report. `DROP` of standalone -documents (enumerations, microflows, pages, …) also requires `--mcp-concord`, -because the delete tool lives on Concord. - ## Same machine as Studio Pro If mxcli runs on the host itself, point `--mcp-dial` at the loopback bridge @@ -172,8 +171,8 @@ connected version supports. See also |---|---| | `connection refused` on dial | The bridge isn't running, or the port is wrong. Re-run `socat` on the host and confirm PED's port with `lsof` (it changes per session). | | Request rejected / 403-style error | The `Host` header guard. Keep the **URL host `localhost`** (`http://localhost/mcp`) and redirect the connection with `--mcp-dial` — never put the container/host address in the URL itself. | -| Reads don't reflect a change you just made | Expected for some edits until the project is saved — reads of an *unchanged* doc come from the local `.mpr`. A module you just wrote is reconstructed live; other doc types may need a save (`--mcp-save`) to appear locally. | -| `DROP` / save / check / run fails or is rejected | Those need Concord — add `--mcp-concord` (and `--mcp-save` / `--mcp-check` / `--mcp-run`). | +| Reads don't reflect a change you just made | Expected for some edits until the project is saved — reads of an *unchanged* doc come from the local `.mpr`. A module you just wrote is reconstructed live. | +| `DROP` / `--mcp-check` / `--mcp-run` reports it needs a newer Studio Pro | `DROP` and `--mcp-run` need Studio Pro 11.16+, `--mcp-check` 11.15+. On 11.16.0, `ped_delete_document` fails with `Session 'mcp-server' not found` for callers outside Studio Pro's own agent session; delete the document in Studio Pro. | | Intermittent timeouts | The transport can be flaky across a bridge; a timed-out write may still have applied. Re-run `mxcli mcp capabilities` to re-establish, and prefer idempotent scripts. | ## See also diff --git a/docs-site/src/tools/outside-studio-pro.md b/docs-site/src/tools/outside-studio-pro.md index e63779ea77..b9772f570c 100644 --- a/docs-site/src/tools/outside-studio-pro.md +++ b/docs-site/src/tools/outside-studio-pro.md @@ -233,6 +233,64 @@ ContentsHash drift, which it does not repair — a dry run writes nothing), and reports what it would commit without committing. It stages into a copy of the index, so whatever you had staged is left exactly as it was. +## Merging branches: `mxcli git merge` + +`git merge` cannot merge Mendix models: the `.mpr` is SQLite and every +`.mxunit` is BSON, so two sides changing the model always meet a binary +`CONFLICT (content)` — there is nothing for git's textual merge to work on. +Studio Pro's *Merge Changes Here* resolves it with a model-aware merge, and +that engine ships as a command: `mx merge BASE MINE THEIRS` over the three +revisions' project files (it runs on Linux devcontainers too). + +`mxcli git merge ` puts the two together: + +```bash +mxcli git merge feature/orders -p app.mpr +mxcli git merge origin/main -p app.mpr --push +``` + +`git merge ` runs first; a merge git resolves itself (disjoint files, +a fast-forward) is annotated with the mx_metadata note like `mxcli git +commit` does. On a model-file conflict the base, ours (a clean checkout of +HEAD) and theirs revisions are checked out into temporary worktrees and the +three projects' .mpr files go to `mx merge` (resolved for the project's +Mendix version; MINE is never the working tree mid-merge — the engine +chokes on the conflicted state); the engine writes its result into MINE and +it is copied back, so the engine owns the `.mpr`: + +- what the engine auto-resolves is copied back, hash-checked, staged and + committed as the normal two-parent merge commit, with the note attached. + The engine's `.mpr` carries both sides' document sets, so a unit one side + added while the other changed existing ones is kept, not dropped; +- a conflict the engine reports as `ManualResolutionRequired` (exit 2) is + never merged silently: the merge is aborted, and **both sides' changes are + printed for review** — at property level, from `mx diff` ("New type > + Length: 100 → 150" on ours, "→ 200" on theirs). Then decide: + `--plan ` resolves **per unit** from the plan template written beside + the .mpr (`.merge-plan.json`, which `mxcli git commit` never stages): + set `"take"` to `ours`/`theirs` per unit. The engine runs again and the plan + decides **only the units it left open** — everything the engine merged + stays merged, so the template names just those units, and a plan that names + more or fewer (stale, or edited by hand) is refused. Each planned unit is + taken whole from its side, its conflict marker cleared (Studio Pro reports + a marker left in the index as CE0137), and the `.mpr` index repaired from + the files (modified-on-both units only; added/deleted/moved conflicts go to + Studio Pro). `--ours` or `--theirs` takes one side of every + conflicted unit, hash-gated: when the sides' document sets differ (one side + added or deleted a unit), committing a whole side would orphan the other + side's documents, so the commit is refused and the merge aborted. Any other + engine exit is mx failing, not a decision: it is reported and the merge is + aborted cleanly. Resolutions that must mix both sides *inside* one unit + stay in Studio Pro (*Version Control › Merge Changes Here*); +- a conflict in non-model files (text sources) is left in place for manual + resolution, like plain git: resolve them, `git add` them, and re-run the + command — it finishes the merge (model conflicts included) and commits. + +Merging is still never done by plain `git merge` on a Mendix repository: use +`mxcli git merge` or Studio Pro, never a textual merge of `mprcontents/`. +Like every mxcli write, the command is refused while Studio Pro has the +project open. + ## Auditing a repository: `mxcli git doctor` A repository that has known only plain git misses several things Studio Pro @@ -247,7 +305,7 @@ mxcli git doctor --fix --version 10.21.0 | Check | `--fix` | |-------|---------| -| local git config: `core.autocrlf=False` (a checkout must not rewrite line endings inside `.mxunit` files), `mendix.commits-since-gc`, `mendix.lineEndingResetDone` | sets them | +| local git config: `core.autocrlf=False` (a checkout must not rewrite line endings inside `.mxunit` files; any other value is rewritten), `mendix.commits-since-gc`, `mendix.lineEndingResetDone` — the `mendix.*` values are Studio Pro's own bookkeeping and are only backfilled when missing | sets/backs them | | the remote URL as configured: Studio Pro can only use HTTPS | rewrites SSH (`git@host:org/app.git`, `ssh://…`) to HTTPS; other non-HTTPS URLs are reported unchanged. A `url..insteadOf` rule in your git config that makes git use another URL is reported, and left alone | | the remote's `refs/notes/mx_metadata` | reported only — push the notes ref together with the branch | | the local `refs/notes/mx_metadata` holds the remote's notes: `git clone` and a plain `git fetch` never fetch them, so a fresh clone or a CI checkout has none | fetches them, as a fast-forward only; refused when the local ref has notes the remote does not | diff --git a/docs-wiki/architecture/mcp-backend.md b/docs-wiki/architecture/mcp-backend.md index a817e51746..234458a216 100644 --- a/docs-wiki/architecture/mcp-backend.md +++ b/docs-wiki/architecture/mcp-backend.md @@ -6,7 +6,8 @@ sources: - docs/03-development/PED_MCP_CAPABILITIES.md - mdl/backend/mcp/backend.go - mdl/backend/mcp/read_router.go - - mdl/backend/mcp/concord.go + - mdl/backend/mcp/delete.go + - mdl/backend/mcp/app.go --- > **Do not duplicate**: the tool matrix, transport setup, and capability gaps @@ -20,8 +21,9 @@ A second implementation of the executor's backend interface (alongside the MPR backend) that does **not** write BSON to disk. Instead it routes model mutations to a **live, running Studio Pro** through that IDE's embedded MCP server — codenamed **PED** — while still reading structure from the local `.mpr` on disk. It is a -*hybrid* backend: disk for reads, the live IDE for writes. A second optional MCP -server, **Concord**, fills a few gaps PED lacks (notably document deletion). +*hybrid* backend: disk for reads, the live IDE for writes. Deleting a document and +running the app go through the same server: `ped_delete_document` and the +`mendix_app` handler, both new in Studio Pro 11.16. ## How it fits diff --git a/docs/01-project/MDL_FEATURE_MATRIX.md b/docs/01-project/MDL_FEATURE_MATRIX.md index b7d4b9b59e..f8c7c8bb68 100644 --- a/docs/01-project/MDL_FEATURE_MATRIX.md +++ b/docs/01-project/MDL_FEATURE_MATRIX.md @@ -17,42 +17,41 @@ sits above both axes — how the whole Maia/mxcli spectrum compares.) ## Agent × tooling at a glance (Maia vs mxcli) -One-slide comparison of how a Mendix app can be edited by an AI agent across five +One-slide comparison of how a Mendix app can be edited by an AI agent across four setups: **Maia** (Studio Pro's built-in agent — reads/writes the model through Studio Pro's *internal* model APIs) → **Maia + MCP** (those same APIs now exposed -via the PED MCP server) → **Maia + MCP + Concord** (plus the Concord extension's -tools) → **mxcli** (external CLI, file-based on the on-disk `.mpr`, project closed) → -**mxcli + MCP + Concord** (external CLI driving the **live** model while Studio Pro -is open). +via the PED MCP server) → **mxcli** (external CLI, file-based on the on-disk `.mpr`, +project closed) → **mxcli + MCP** (external CLI driving the **live** model while +Studio Pro is open). -The first three columns share Maia's **native reach** — MCP/Concord don't change +The first two columns share Maia's **native reach** — MCP doesn't change *what Maia can do*; they expose that reach to **external** agents (the bottom *drivable externally* row). PED is exactly that exposure of Studio Pro's model APIs, -and mxcli (cols 4–5) is the external agent consuming it. The capability rows show +and mxcli (cols 3–4) is the external agent consuming it. The capability rows show each setup's *own* reach; a Maia–vs–mxcli-live gap on a row is either a PED-subset limit or just where each agent's implementation has gotten to. Legend: ✓ full · ◑ partial / subset · ✗ none · — n/a. -| Capability | Maia | Maia + MCP | Maia + MCP + Concord | mxcli (file) | mxcli + MCP + Concord | -|------------|:----:|:----------:|:--------------------:|:------------:|:---------------------:| -| **Authoring** | | | | | | -| Domain model — entities, view entities, enums, associations, constraints | ✓ | ✓ | ✓ | ✓ | ◑¹ | -| Pages (incl. pluggable widgets) | ◑² | ◑² | ◑² | ✓ | ◑² | -| Microflows | ◑³ | ◑³ | ◑³ | ✓ | ◑³ | -| Workflows | ✓ | ✓ | ✓ | ✓ | ✓ | -| Theming / styling | ✓ | ✓ | ✓ | ◑⁴ | ◑⁴ | -| Integrations — REST/OData/SOAP, business events, mappings, mobile | ✗ | ✗ | ✗ | ◑⁵ | ✗ | -| Security — roles & access | ✗ | ✗ | ✗ | ✓ | ✗ | -| Java / JS / CSS source | ✓⁶ | ✓⁶ | ✓⁶ | ✓ | ✓ | -| **Search & analysis** | | | | | | -| Impact / lint / catalog SQL / full-text | ✗ | ✗ | ✗ | ✓ | ✓ | -| Mendix KB search / NL→OQL | ✓ | ✓ | ✓ | ✗ | ✗ | -| **Reach & workflow** | | | | | | -| Validation before writing | ◑⁷ | ◑⁷ | ◑⁷ | ✓ | ✓ | -| Batch / scriptable / CI | ✗ | ✗ | ✗ | ✓ | ✓ | -| Live edits in the open IDE | ✓ | ✓ | ✓ | ✗⁸ | ✓ | -| Drivable by an external agent (headless) | ✗ | ◑⁹ | ✓ | ✓ | ✓ | +| Capability | Maia | Maia + MCP | mxcli (file) | mxcli + MCP | +|------------|:----:|:----------:|:------------:|:-----------:| +| **Authoring** | | | | | +| Domain model — entities, view entities, enums, associations, constraints | ✓ | ✓ | ✓ | ◑¹ | +| Pages (incl. pluggable widgets) | ◑² | ◑² | ✓ | ◑² | +| Microflows | ◑³ | ◑³ | ✓ | ◑³ | +| Workflows | ✓ | ✓ | ✓ | ✓ | +| Theming / styling | ✓ | ✓ | ◑⁴ | ◑⁴ | +| Integrations — REST/OData/SOAP, business events, mappings, mobile | ✗ | ✗ | ◑⁵ | ✗ | +| Security — roles & access | ✗ | ✗ | ✓ | ✗ | +| Java / JS / CSS source | ✓⁶ | ✓⁶ | ✓ | ✓ | +| **Search & analysis** | | | | | +| Impact / lint / catalog SQL / full-text | ✗ | ✗ | ✓ | ✓ | +| Mendix KB search / NL→OQL | ✓ | ✓ | ✗ | ✗ | +| **Reach & workflow** | | | | | +| Validation before writing | ◑⁷ | ◑⁷ | ✓ | ✓ | +| Batch / scriptable / CI | ✗ | ✗ | ✓ | ✓ | +| Live edits in the open IDE | ✓ | ✓ | ✗⁸ | ✓ | +| Drivable by an external agent (headless) | ✗ | ◑⁹ | ✓ | ✓ | ¹ mxcli over MCP can't author indexes or entity validation rules (file mode can); Maia does constraints, not indexes.   ² Pluggable widgets: Maia not yet; mxcli @@ -65,8 +64,9 @@ reads.   ⁶ Maia via the `write_file` virtual FS; mxcli edits source on di directly (any mode).   ⁷ Maia: live, reactive validation in the IDE; mxcli: static pre-flight (`mxcli check`) *before* writing.   ⁸ File mode edits the on-disk `.mpr`; reopen Studio Pro to see changes.   ⁹ MCP exposes Maia's -read + authoring surface (the PED subset) to external agents; Concord additionally -exposes delete + save / build-run. Maia performs all of this natively in-IDE — its +read + authoring surface (the PED subset) to external agents; Studio Pro 11.16 +adds delete (`ped_delete_document`) and run (the `mendix_app` handler), though on +11.16.0 delete fails for callers outside Studio Pro's own agent session. Maia performs all of this natively in-IDE — its column is about *external* reach. **Takeaway:** Maia has broad **native** authoring (domain model, pages, microflows, @@ -74,7 +74,7 @@ workflows, theming, source), and **MCP is what exposes that surface to external agents** — the architectural point. mxcli is that external agent: **mxcli (file)** is the only setup with *full* authoring (indexes, security, integrations) plus analysis + pre-flight validation + batch/CI, at the cost of the project being closed; -**mxcli + MCP + Concord** keeps the analysis / validation / scripting while editing +**mxcli + MCP** keeps the analysis / validation / scripting while editing the **live** model (authoring capped to the PED subset, which differs slightly from Maia's — pluggable widgets yes, theming/source via disk). Maia owns the conversational + Mendix-KB experience and in-IDE liveness. (Maia capabilities per the @@ -110,19 +110,19 @@ live distinction is **MPR vs MCP**. | **Modules** | Y | Y | Y | P | CREATE only (flushes to disk); no module DROP | | **Entities** | Y | Y | Y | Y | CREATE/DROP; ALTER add/drop/rename/docs — MODIFY *type* rejected (PED can't migrate) | | **Associations** | Y | Y | Y | P | Within-module CREATE/DROP; no custom delete behavior; cross-module / external N | -| **Enumerations** | Y | Y | Y | P | CREATE; DROP via `ped_delete_document` (Studio Pro 11.16+) or Concord; ALTER (modify values) N | +| **Enumerations** | Y | Y | Y | P | CREATE; DROP via `ped_delete_document` (Studio Pro 11.16+); ALTER (modify values) N | | **Microflows** | Y | Y | Y | P | Broad CREATE (most actions + control flow); rejects show-page, cast, retrieve sort/range, inheritance/rule splits | -| **Nanoflows** | Y | Y | Y | N | **Not creatable** — PED's `ped_create_document` rejects `Microflows$Nanoflow` (create whitelist covers microflows, not nanoflows); CREATE/ALTER rejected with a clear error. DROP of an existing nanoflow works via Concord. | +| **Nanoflows** | Y | Y | Y | N | **Not creatable** — PED's `ped_create_document` rejects `Microflows$Nanoflow` (create whitelist covers microflows, not nanoflows); CREATE/ALTER rejected with a clear error. DROP of an existing nanoflow uses `ped_delete_document` (Studio Pro 11.16+). | | **Pages** | Y | Y | Y | P | CREATE + ALTER via `pg_*`; widget/data-source coverage grows one type at a time | | **Snippets** | Y | Y | Y | P | ALTER via the page mutator; CREATE not wired | | **Layouts** | Y | Y | Y | N | Not wired (reads delegate to the local `.mpr`) | | **View Entities** | Y | Y | Y | P | CREATE; CREATE OR REPLACE fails (no delete-document); DROP orphans the source doc | | **Workflows** | Y | Y | Y | Y | **Complete** — CREATE / CREATE OR REPLACE / DROP / ALTER, all activity types & ops, any nesting depth | | **Java Actions** | Y | Y | Y | N | **Not authorable** — PED refuses to create `JavaActions$JavaAction` (backed by a `.java` source the IDE manages); CREATE/ALTER rejected with a clear error. *Calling* a Java action from a microflow IS supported. | -| **Constants** | Y | Y | Y | P | CREATE / CREATE OR MODIFY (value + exposed-to-client) / DROP (via Concord); type limited to String/Integer/Decimal/Boolean/DateTime; in-place type change rejected; documentation not carried | +| **Constants** | Y | Y | Y | P | CREATE / CREATE OR MODIFY (value + exposed-to-client) / DROP (`ped_delete_document`, Studio Pro 11.16+); type limited to String/Integer/Decimal/Boolean/DateTime; in-place type change rejected; documentation not carried | | **OData Clients / Services** | Y | Y | Y | N | Not wired | | **External Entities** | Y | Y | Y | N | Not wired | -| **Business Events** | Y | Y | Y | P | SHOW/DESCRIBE read the local `.mpr`; DROP via `ped_delete_document` (Studio Pro 11.16+) or Concord. **CREATE/ALTER blocked** — PED won't create `BusinessEvents$BusinessEventService` (off the create whitelist). The supporting domain model (published-event entities `extends BusinessEvents.PublishedBusinessEvent`, constants) IS creatable. | +| **Business Events** | Y | Y | Y | P | SHOW/DESCRIBE read the local `.mpr`; DROP via `ped_delete_document` (Studio Pro 11.16+). **CREATE/ALTER blocked** — PED won't create `BusinessEvents$BusinessEventService` (off the create whitelist). The supporting domain model (published-event entities `extends BusinessEvents.PublishedBusinessEvent`, constants) IS creatable. | | **Navigation** | Y | Y | Y | N | Not wired | | **Project Settings** | Y | Y | Y | N | Not wired | @@ -130,7 +130,7 @@ live distinction is **MPR vs MCP**. | Feature | Mendix | MDL | MPR | MCP | MCP notes | |---------|:------:|:---:|:---:|:---:|-----------| -| **Module / user roles, demo users, project security, entity / microflow / nanoflow / page access** | Y | Y | Y | N | **Not authorable via MCP** — PED's document tools reject every `Security$*` type and Concord exposes security *reads* only; neither server has a write path. | +| **Module / user roles, demo users, project security, entity / microflow / nanoflow / page access** | Y | Y | Y | N | **Not authorable via MCP** — PED's document tools reject every `Security$*` type; there is no write path. | ### Project organization diff --git a/docs/03-development/MCP_SAVE_VERIFICATION.md b/docs/03-development/MCP_SAVE_VERIFICATION.md deleted file mode 100644 index 27c9929e3f..0000000000 --- a/docs/03-development/MCP_SAVE_VERIFICATION.md +++ /dev/null @@ -1,89 +0,0 @@ -# Verifying `--mcp-save` from Concord (in-Studio-Pro test) - -`--mcp-save` flushes PED-authored, in-memory writes to disk via Concord's -`save_all` (PED has no save tool). `save_all` synthesizes a macOS Cmd-S -(osascript → System Events), so its reliability depends on the environment — see -the `save_all` caveats in [`PED_MCP_CAPABILITIES.md`](PED_MCP_CAPABILITIES.md). - -Testing it from a **devcontainer** is inconclusive: with two Studio Pro instances -sharing one bundle id, the synthetic Cmd-S targets the wrong window and silently -no-ops (`{"status":"save_command_sent"}` with no disk change). The authoritative -test is from **Claude Code running in the Concord terminal inside Studio Pro on -macOS**, with a single active instance — there the keystroke has an unambiguous -target. - -## The test prompt - -Paste this into Claude Code in the Concord terminal (Studio Pro, macOS): - -> **Test whether mxcli's `--mcp-save` actually persists a change to disk.** -> -> You're in the Concord terminal inside Studio Pro on macOS. Two local MCP -> servers: **PED** (Studio Pro's built-in authoring) on `localhost:7782`, and -> **Concord** on `localhost:7783`. mxcli is built from `main`. The MCP backend -> reads from the local `.mpr` and writes via PED; `--mcp-save` flushes via -> Concord's `save_all`. Everything is local — **no socat/port-forwarding**. -> -> Goal: confirm a PED-authored entity, flushed with `--mcp-save`, lands on disk. -> -> 1. **Find the open project.** It's the project Studio Pro has open. Set: -> ```bash -> MPR="" # e.g. .../mx-test-projects/test7-app/test7.mpr -> DIR=$(dirname "$MPR") -> ``` -> Pick an **existing user module** in it (e.g. `MyFirstModule` in test7) — call it `MOD`. -> -> 2. **Record disk state before:** -> ```bash -> stat -f '%m %N' "$DIR/mprcontents" # note this mtime -> ``` -> -> 3. **Create an entity via PED and save via Concord:** -> ```bash -> mxcli -p "$MPR" \ -> --mcp http://localhost/mcp --mcp-dial localhost:7782 \ -> --mcp-concord http://localhost/mcp --mcp-concord-dial localhost:7783 \ -> --mcp-save --mcp-check \ -> -c "create entity $MOD.SaveProbeMac (Code: string)" -> ``` -> If it can't connect to PED, retry with bare `--mcp http://localhost:7782/mcp` -> (and same for `--mcp-concord` on 7783). Note any `warning: --mcp-save failed …` line. -> -> 4. **Verify it persisted** (these are the same files Studio Pro saves to): -> ```bash -> stat -f '%m %N' "$DIR/mprcontents" # mtime should have advanced -> grep -rl SaveProbeMac "$DIR/mprcontents/" # should find the entity -> ``` -> -> 5. **Report:** -> - Did mxcli print `Created entity`? Any `--mcp-save failed` warning? What did `--mcp-check` print? -> - Did the `mprcontents` mtime change, and is `SaveProbeMac` on disk? -> - **Conclusion: did `--mcp-save` persist the change?** -> -> Important conditions (the whole point of this test): -> - Run with **only ONE Studio Pro instance** open — `save_all` sends a synthetic -> Cmd-S, which is ambiguous if two instances share the bundle id. -> - Make sure **Studio Pro is the frontmost/active app** when the command runs. -> - If it didn't persist, do a **manual Cmd-S** in Studio Pro and re-check — that -> isolates "the automation didn't land" from "the project can't save." - -## Result (2026-06-08, Studio Pro 11.11 Beta) - -**Confirmed broken.** Run from Claude Code in the Concord terminal with a single -active Studio Pro instance (frontmost), `save_all` did **not** persist the change -**and hung Studio Pro** while Concord tried to drive the save. This rules out the -two-instance ambiguity — it is a genuine **Concord/Studio Pro `save_all` bug** -(the synthetic Cmd-S hangs the IDE), to be reported upstream. Use manual Cmd-S in -Studio Pro meanwhile. `--mcp-save` is wired correctly and will work unchanged once -`save_all` is fixed; re-run this procedure to re-confirm after a Concord update. - -## Interpreting the result - -- **mtime advances + `SaveProbeMac` on disk** → `--mcp-save` works; the - devcontainer failure was purely the two-instance ambiguity. Update the - `save_all` caveat in `PED_MCP_CAPABILITIES.md` accordingly. -- **No disk change, but manual Cmd-S does save** → Concord's `save_all` keystroke - isn't reaching Studio Pro even single-instance; report upstream to Concord (its - own error text invites this). -- **Even manual Cmd-S doesn't save** → a Studio Pro / project issue unrelated to - mxcli. diff --git a/docs/03-development/PED_MCP_CAPABILITIES.md b/docs/03-development/PED_MCP_CAPABILITIES.md index b2e24eae81..0af8c554db 100644 --- a/docs/03-development/PED_MCP_CAPABILITIES.md +++ b/docs/03-development/PED_MCP_CAPABILITIES.md @@ -564,7 +564,7 @@ behaviour change, not an error. | **Changed** | Workflows constructor (`ped_get_schema`) | `Workflows$Workflow`'s constructor renamed `caption` to **`title`**; `eventSubProcesses` gained a default `[]`; the `EventSubProcess` constructor no longer lists `persistentId` (reads still return it, so the boundary-event lookup by `persistentId` is unaffected). **Neither release's constructor applies the title or `workflowName` it is given** — both store the document name — so DISPLAY was lost over MCP before 11.15 too. **Fixed:** the probe picks the key, and `workflowCreateLeafOps` sets `/title` and `/workflowName/text` after the create, which both releases accept. | | **Removed** | `read_skill` | Skills are files under `/skills` (`glob`/`read_file`). Unused by mxcli. | | **Removed** | `install_marketplace_module`, `search_mendix_knowledge_base` | Now **handlers**, reached through `query_handlers` / `execute_handler`. Unused by mxcli. | -| **New** | `query_handlers`, `execute_handler` | A handler registry: `install_marketplace_module`, `search_mendix_knowledge_base`, `mendix_app` (run / stop / status of the runtime), `vc_commit`, `vc_push`, the translation tools (`batch_translate`, `check_translation_status`, `find_text_usages`, `list_text_containers`, `reset_translations`) and marketplace search. `mendix_app` and `vc_commit` overlap what Concord supplies today (`--mcp-run`; and a commit is a save point) — not wired yet. | +| **New** | `query_handlers`, `execute_handler` | A handler registry: `install_marketplace_module`, `search_mendix_knowledge_base`, `mendix_app` (run / stop / status of the runtime), `vc_commit`, `vc_push`, the translation tools (`batch_translate`, `check_translation_status`, `find_text_usages`, `list_text_containers`, `reset_translations`) and marketplace search. `mendix_app` backs `--mcp-run` (see Delete, check and run); `vc_commit` is not wired. | | **Changed** | `read_file`, `write_file` | `startLine/endLine` → `offset/limit`; `newContent`+`span` → `oldString`/`newString`. Unused by mxcli (see the tool matrix). | | **Changed** | `glob` | New `/app-logs` file domain. | @@ -673,15 +673,15 @@ The right-hand column carries the latest measured status, not the 11.11 one. |-----|-----------------------------|-------------------------------| | **No delete-document tool** | `DROP` of any *standalone document* (enum, microflow, page) is impossible. Only entities/associations delete, via a `ped_update_document` remove op on the domain-model array. Test docs created via MCP cannot be cleaned up — they persist until the user closes Studio Pro without saving. | Watch for a `ped_delete_document` / equivalent. | | **No list-modules tool** ⟶ **CLOSED in 11.12** | PED cannot enumerate modules. The backend must read modules/structure from the local mounted `.mpr` (hybrid model); `-p` must be the same project Studio Pro has open. | **11.12 adds `list_modules`** (`[{moduleName, writable, fromMarketplace}]`) → pure-MCP module enumeration is now possible; the local-`.mpr` dependency for *module listing* can be lifted on 11.12+. Other reads (structure, IDs) still use the hybrid model. | -| **No save/flush tool** | `ped_update_document` edits stay in Studio Pro's in-memory model; the on-disk `.mpr` is stale until the user saves. Drives the dirty-set read router. **Refined on 11.12 (observed live, test8):** *created* documents (`ped_create_document`, `pg_patch_page` page-create) flush to `mprcontents/` immediately — no Concord `save_all` needed for them — but *update* ops still stay in memory (an `alter entity add attribute` was visible to `ped_check_errors` yet absent on disk → CE1613 on an `mx check` of a mid-session disk copy). (`ped_create_module` already flushed immediately on 11.11.) | Watch for a save tool / autosave; recheck the create-flush behavior each version. | +| **No save/flush tool** | `ped_update_document` edits stay in Studio Pro's in-memory model; the on-disk `.mpr` is stale until the user saves. Drives the dirty-set read router. **Refined on 11.12 (observed live, test8):** *created* documents (`ped_create_document`, `pg_patch_page` page-create) flush to `mprcontents/` immediately — but *update* ops still stay in memory (an `alter entity add attribute` was visible to `ped_check_errors` yet absent on disk → CE1613 on an `mx check` of a mid-session disk copy). (`ped_create_module` already flushed immediately on 11.11.) | Watch for a save tool / autosave; recheck the create-flush behavior each version. | | **Reads omit `$ID`** | Reads expose `$QualifiedName` only. Association refs need entity GUIDs, recovered from the local reader (= live `$ID` for saved entities). Reconstructed reads use synthetic IDs mapped to names. | Recheck whether reads expose `$ID`. | | **Array reads omit primitive types** | A `/entities/N/attributes` read gives attribute names (`$QualifiedName`) but not their primitive type or documentation — those need a per-*leaf* read (`/entities/N/attributes/M/type` → a `DomainModels$*AttributeType` constructor; `/…/documentation` → string). Reconstruction recovers them with a single **batched** leaf read (`enrichReconstructedEntities`) so a dirty/session module reports real types (correct DESCRIBE + a reliable ALTER diff); it falls back to placeholder `String` only if the read fails. | **11.14 — partially CLOSED.** An attributes-array read now carries each attribute's `type.$Type`, `value.$Type` and `documentation` inline (`{"$Type":"DomainModels$Attribute","$QualifiedName":"…Title","type":{"$Type":"DomainModels$StringAttributeType"},"value":{"$Type":"DomainModels$StoredValue"},"name":"Title","documentation":""}`). But the type's **parameters are still dropped**: the same attribute's leaf read returns `{"$Type":"DomainModels$StringAttributeType","length":200}` — `length` appears **only** in the leaf read. So `enrichReconstructedEntities` is still required for `length` / enumeration refs, but the type *kind* is now free. Not yet exploited; measured on one String attribute, so widen the sample before relying on it. | | **No in-place attribute-type change** | `set /entities/N/attributes/M/type` is rejected (`"only allowed to set primitive or reference properties directly"` — the type is a nested element), and a whole-attribute replace is rejected too (`"only allowed to update elements …"`). The only route to a new type is remove+add, which drops the attribute's `$ID` → **drops the column data**. So `ALTER ENTITY … MODIFY ATTRIBUTE ` is rejected over MCP; do it against a local `.mpr` (Studio Pro does an in-place migration PED can't). | **11.14 — the structural refusal is GONE.** 11.14's `set` explicitly *"can set or replace element-valued properties"*. Probed with a deliberately invalid value so it could not land: `set /entities/0/attributes/0/type` = `{"$Type":"DomainModels$NoSuchAttributeType"}` is now rejected as a **value validation** error (`"Type … is not a valid concrete subtype of DomainModels$AttributeType."`), not with 11.13's structural *"only allowed to set primitive or reference properties directly"*. That means the operation now reaches validation — i.e. a **valid** subtype would plausibly be accepted. **Not confirmed:** proving it needs a successful mutation against a throwaway project, and the data-safety question behind the gap (does an in-place type change migrate the column, or drop it?) is unanswered and is the *real* gate on `ALTER ENTITY … MODIFY ATTRIBUTE`. `capabilities.yaml` therefore still reports it unauthorable. | | **Two write protocols** | Pages **must** use `pg_*`; everything else uses `ped_*`. The system prompt forbids PED for pages. | Stable, but reconfirm. | -| **`ped_create_document` doc-type whitelist** | The create tool accepts only certain document types; some model documents are rejected even though they have a `$constructor` schema. Confirmed off the whitelist: **`Microflows$Nanoflow`** (`"… Did you mean: Microflows$Microflow?"`), **`Projects$Folder`** (empty folders), and **`BusinessEvents$BusinessEventService`** (a `$element`, not a `$constructor` — its CREATE/ALTER are rejected in `businessevent.go`, but SHOW/DESCRIBE read the `.mpr`, DROP goes via Concord, and the published-event entities/constants it relies on are creatable). So nanoflows aren't creatable over MCP (CREATE/ALTER rejected — `nanoflow.go`; DROP works via Concord), and an *empty* folder can't be created. **But `ped_create_document` accepts a `folderPath` per document, which auto-creates the whole folder path** — so a document is placed in a (possibly nested) folder at create time (`folder.go`/`resolveDocContainer`). What you can't do: re-parent an *existing* document — `/folderPath` is not a settable property (so `MOVE`/`DROP FOLDER`/`MOVE FOLDER` are rejected), and pages can't be foldered (`pg_write_page` takes no folderPath). | Re-probe the create whitelist each version. | +| **`ped_create_document` doc-type whitelist** | The create tool accepts only certain document types; some model documents are rejected even though they have a `$constructor` schema. Confirmed off the whitelist: **`Microflows$Nanoflow`** (`"… Did you mean: Microflows$Microflow?"`), **`Projects$Folder`** (empty folders), and **`BusinessEvents$BusinessEventService`** (a `$element`, not a `$constructor` — its CREATE/ALTER are rejected in `businessevent.go`, but SHOW/DESCRIBE read the `.mpr`, DROP uses `ped_delete_document` (11.16+), and the published-event entities/constants it relies on are creatable). So nanoflows aren't creatable over MCP (CREATE/ALTER rejected — `nanoflow.go`; DROP uses `ped_delete_document`, 11.16+), and an *empty* folder can't be created. **But `ped_create_document` accepts a `folderPath` per document, which auto-creates the whole folder path** — so a document is placed in a (possibly nested) folder at create time (`folder.go`/`resolveDocContainer`). What you can't do: re-parent an *existing* document — `/folderPath` is not a settable property (so `MOVE`/`DROP FOLDER`/`MOVE FOLDER` are rejected), and pages can't be foldered (`pg_write_page` takes no folderPath). | Re-probe the create whitelist each version. | | **No Java action document creation** | `ped_create_document` rejects `JavaActions$JavaAction` outright (`"Document type 'JavaActions$JavaAction' cannot be created."`) — a Java action is backed by a `.java` source file Studio Pro generates/manages, so the model document can't be created standalone. `CREATE/ALTER JAVA ACTION` are rejected with an actionable error (`mdl/backend/mcp/javaaction.go`); *calling* a Java action from a microflow is unaffected. | Watch for a Java-action create tool. | | **No mapping document ops** | PED's `ped_read/find/update_document` reject `ImportMappings$ImportMapping` and `ExportMappings$ExportMapping` ("Unknown document type" / "not supported"), while `ped_get_schema` returns full `element` **and** `constructor` schemas and `ped_list_folder` lists them — the security-document shape exactly. The constructor is `{ $Type, name }` only, so even a whitelisted create would yield an empty shell with no way to populate it. mxcli's MCP backend matches: all ten mapping methods are `unsupportedBackend` stubs (`unsupported_gen.go`). `JsonStructures$JsonStructure` — the mapping's *source* — **is** fully supported, and `ped_check_errors` **does** accept a mapping by name, so an on-disk-authored mapping can still be error-checked by Studio Pro. Re-probed 11.13.0, 2026-08-24. | Watch for the mapping types appearing in `ped_read_document`. | -| **No security document ops** | PED's `ped_find/read/update/create_document` reject every security type (`Security$ModuleSecurity`, `Security$ProjectSecurity`, …) as "Unknown document type" — only `ped_get_schema` knows them as nested *elements*. Concord exposes only security *reads* (`audit_security`, `read_entity_access_rules`, `read_microflow_security`, `read_security_info`). So the security **documents** cannot be authored via MCP (module/user roles, demo users, project security level) — neither server has a write path. **Entity access rules are the exception and are authorable**: a `DomainModels$AccessRule` lives on the domain model, not the security document, so `GRANT` works (ADD-only — PED refuses to remove a rule or a member access, so `REVOKE` and replacing a rule in place are rejected). Security **reads** are served from the local `.mpr` by the backend's reader, so `SHOW MODULE ROLES` / `USER ROLES` / `PROJECT SECURITY` work over `--mcp` and `GRANT` can validate the role it references — routing that read to PED instead is what made every `GRANT --mcp` abort before its first tool call (#900). Determine support with `ped_read_document`, NOT `ped_find_document`: `find` also reports the (supported) nameless `DomainModels$DomainModel` as "Unknown", so it is not a reliable probe. | **11.14 — still sealed, and now actively mis-advertised.** The tool descriptions name `Settings$ProjectSettings`, `Security$ProjectSecurity` and `Security$ModuleSecurity` as supported and `ped_get_schema` returns ten fully documented security element types, but every document-API call is refused (`Unknown document type` / `Document type … is not supported.`), against a `Navigation$NavigationDocument` control that reaches the operation layer in the same probe. No security type declares a **method**, so the new `call` op opens nothing. **One thing did move:** replacing an existing `DomainModels$AccessRule` in place no longer needs member removal — 11.14's `set` replaces a non-null element, and the probe clears shape validation and fails only at reference resolution. So re-GRANT looks reachable while **REVOKE is still unmeasured** (bounds and writability checks both mask removability). See the 11.14 delta for the probes and the control. | +| **No security document ops** | PED's `ped_find/read/update/create_document` reject every security type (`Security$ModuleSecurity`, `Security$ProjectSecurity`, …) as "Unknown document type" — only `ped_get_schema` knows them as nested *elements*. So the security **documents** cannot be authored via MCP (module/user roles, demo users, project security level) — there is no write path. **Entity access rules are the exception and are authorable**: a `DomainModels$AccessRule` lives on the domain model, not the security document, so `GRANT` works (ADD-only — PED refuses to remove a rule or a member access, so `REVOKE` and replacing a rule in place are rejected). Security **reads** are served from the local `.mpr` by the backend's reader, so `SHOW MODULE ROLES` / `USER ROLES` / `PROJECT SECURITY` work over `--mcp` and `GRANT` can validate the role it references — routing that read to PED instead is what made every `GRANT --mcp` abort before its first tool call (#900). Determine support with `ped_read_document`, NOT `ped_find_document`: `find` also reports the (supported) nameless `DomainModels$DomainModel` as "Unknown", so it is not a reliable probe. | **11.14 — still sealed, and now actively mis-advertised.** The tool descriptions name `Settings$ProjectSettings`, `Security$ProjectSecurity` and `Security$ModuleSecurity` as supported and `ped_get_schema` returns ten fully documented security element types, but every document-API call is refused (`Unknown document type` / `Document type … is not supported.`), against a `Navigation$NavigationDocument` control that reaches the operation layer in the same probe. No security type declares a **method**, so the new `call` op opens nothing. **One thing did move:** replacing an existing `DomainModels$AccessRule` in place no longer needs member removal — 11.14's `set` replaces a non-null element, and the probe clears shape validation and fails only at reference resolution. So re-GRANT looks reachable while **REVOKE is still unmeasured** (bounds and writability checks both mask removability). See the 11.14 delta for the probes and the control. | ## Transport (per environment, not per version) @@ -731,8 +731,7 @@ documentation field** (dropped). CREATE OR MODIFY sets the `defaultValue` and `exposedToClient` leaves in place; a *type* change is rejected because the model's `type` is a nested `DataTypes$*Type` element PED can't set directly (same constraint as an attribute's type — `UpdateConstant` reads the live type and refuses a -mismatch). DROP goes through Concord's `delete_document` (no PED delete tool), like -enumerations. +mismatch). DROP uses `ped_delete_document` (Studio Pro 11.16+), like enumerations. **CREATE MICROFLOW** maps a broad set of actions (variable/object/list changes, create/commit/delete/rollback, aggregate, list operations, retrieve, call @@ -1040,159 +1039,25 @@ the duplicate source-document create. See `mdl/backend/mcp/` and the return a clear "not supported by the MCP backend" error via the generated `unsupportedBackend` base. -## Concord (optional second client — gap-filler) - -> **Concord is Windows-only — it does not run on macOS** (confirmed 2026-08-11 on -> the 11.13 Mac host). On macOS every Concord-backed capability is simply absent, -> so `DROP` of a standalone document (enumeration, microflow, page) has **no path -> at all**: PED has no delete tool and Concord is the only gap-filler. `check_model` -> is likewise unavailable; `ped_check_errors` remains for per-document validation. -> This also means MCP-authored test documents cannot be cleaned up on macOS — -> remove them in Studio Pro, or close without saving. - -Some deployments run a second MCP server, **Concord** (a Studio Pro extension; -`concord-mcp`), alongside the built-in PED server. Concord is **not** an authoring -server — it has none of the `ped_*`/`pg_*` create tools — but it provides -operational/refactor capabilities PED lacks. The backend uses **PED for authoring -by default** and reaches for Concord **only** for these gaps. Configure it with -`--mcp-concord` / `--mcp-concord-dial` (a second `Client`, dialed independently); -it stays `nil` when not configured, and every Concord-backed op errors clearly if -it's missing. - -Wired so far: -- **`delete_document`** — real `DROP` of standalone documents (enumeration, - microflow, page), which PED cannot delete at all. `DROP ENUMERATION/MICROFLOW/ - PAGE` resolves the document's module + name and calls - `delete_document {module_name, document_name}`. Unlike `save_all` this is - **model-based, not keystroke automation**, so it is robust. (Entities and - associations still delete via PED's array-element removal — no Concord needed.) -- **`check_model`** (`--mcp-check`) — domain-model consistency check after writes - (PED has no validation for the live model). Parses `{success, healthy, summary - {errorCount, warningCount, …}, errors[], warnings[]}` and prints a report to - stderr on Disconnect. Model-based (robust). Note: `healthy: true` means zero - *errors*, not zero warnings — the report shows both. -- **`save_all`** (`--mcp-save`) — PED has no save tool, so writes live only in - Studio Pro's in-memory model until the user saves. `--mcp-save` flushes via - Concord's `save_all` on Disconnect. **Unreliable — keystroke automation.** - Concord's `save_all` synthesizes a macOS Cmd+S (osascript → System Events), with - two failure modes observed against 11.11 Beta: - 1. **Permission.** Needs macOS **Accessibility** on the *responsible* process. - If Studio Pro is launched from a shell that exec's the binary directly, the - responsible process is the **terminal**, not Studio Pro — relaunch via - `open -n -a "" --args …` (launchd → app is its own responsible process) - and grant Studio Pro Accessibility. Otherwise it fails `osascript is not - allowed to send keystrokes (1002)`. - 2. **The keystroke does not save, and hangs Studio Pro — confirmed broken in - 11.11 Beta (2026-06-08).** First observed from a devcontainer - (`{"status":"save_command_sent"}`, no disk change). Then re-tested the - authoritative way — from Claude Code in the Concord terminal, **single active - Studio Pro instance, app frontmost** — and it still did **not** persist *and* - **hung Studio Pro** while Concord tried to drive the save. So it is **not** - the two-instance ambiguity; it is a genuine **Concord/Studio Pro `save_all` - bug** (the synthetic Cmd-S hangs the IDE). **Do not rely on `--mcp-save`; - save manually (Cmd-S) in Studio Pro, and report `save_all` upstream to the - Concord/Studio Pro team.** The backend wiring is correct and will work - unchanged once `save_all` is fixed; a silently-no-op `save_command_sent` it - cannot detect. Model-based gap-closers (`delete_document`, `check_model`) are - unaffected — they do not use keystroke automation. - -- **`get_app_status`** — the API call works and returns well-formed - `{data:{running, runningUrl, projectName, …}}` (exposed as `GetAppStatus()`, - printed by `--mcp-run`). **But `running`/`runningUrl` are effectively a - port/process probe**, not the current session's console-managed runtime: it - reported `running | :8080` while the Studio Pro runtime console was empty — - because an **orphaned runtime from a previous run** was still bound to `:8080` - (restarting Studio Pro doesn't kill the separate runtime process; `:8080` was - confirmed listening). So trust the API shape, but treat `running: running` as - "something is bound to the runtime port," which may be stale. -- **`run_app` / `stop_app`** (`--mcp-run` starts the app) — ⚠️ **same - UI-automation failure as `save_all`.** They are "click the Run/Stop button" - automations: `stop_app` returned `{"status":"command_sent"}` but the app stayed - running across repeated `get_app_status` polls (2026-06-08, 11.11 Beta). So like - `save_all` they report success without taking effect; `run_app` almost certainly - behaves the same. Wired correctly (will work once Concord's UI automation is - fixed), but **don't rely on `--mcp-run`/`stop_app` — start/stop the app manually - in Studio Pro.** Report upstream. - -**Pattern (important):** Concord's **model-editing** tools work (`delete_document`, -`check_model`); its **UI-automation** tools that synthesize button clicks / -keystrokes do **not** in this environment (`save_all`, `stop_app`, `run_app` all -return a `*_command_sent` status with no actual effect, and `save_all` can hang -Studio Pro). `get_app_status` is read-only and returns valid data but reflects raw -port state (can be a stale runtime). Net: only `delete_document` and -`check_model` are dependable today. - -### Full tool inventory (44 tools, captured 2026-06-15) - -Concord identity: `concord-mcp` (proto `2025-03-26`), port 7783 (directly -container-reachable; no socat). **Two behaviors to know:** - -- **`tools/list` is context-curated.** After `concord__set_task_context`, a - subsequent `tools/list` returns only a *curated subset* relevant to the declared - task. The full 44 below come from a fresh `tools/list` with no task context set. - Always re-probe without a task context to see everything. -- **No Maia / agent-invoke tool.** Like PED, Concord exposes *tools an agent uses*, - not a "run the Maia agent" entrypoint. The `concord__*` tools are agent **self- - management** helpers (calibration, friction journal, tool discovery), not Maia. - -Wired/tested status: ✅ dependable · ⚠️ UI-automation, unreliable (see above) · -○ present, not yet wired/vetted. `R` = readOnlyHint. - -| Category | Tool | R/W | Status / note | -|----------|------|-----|---------------| -| Domain model | `rename_entity` | W | ○ in-place, identity-preserving rename (PED only does `set /name`) | -| Domain model | `rename_attribute` | W | ○ identity-preserving | -| Domain model | `rename_association` | W | ○ identity-preserving | -| Domain model | `rename_module` | W | ○ identity-preserving | -| Domain model | `rename_document` | W | ○ identity-preserving | -| Domain model | `set_documentation` | W | ○ set element documentation | -| Domain model | `arrange_domain_model` | W | ○ auto-layout entities/associations | -| Domain model | `delete_model_element` | W | ○ delete entity/attribute/association (PED already removes these via array op) | -| Constants/enums | `rename_enumeration_value` | W | ○ identity-preserving | -| Microflows | `modify_microflow_activity` | W | ○ mutate an activity by 1-based position | -| Microflows | `insert_before_activity` | W | ○ sequence an activity | -| Microflows | `set_microflow_url` | W | ○ | -| Pages | `delete_document` | W | ✅ real `DROP` of standalone docs (wired) | -| Pages | `exclude_document` | W | ○ | -| Pages | `generate_overview_pages` | W | ○ CRUD list + new-edit pages | -| Navigation | `manage_navigation` | W | ○ menu structure, home page, role gating (Concord convenience; mxcli now authors web-profile nav directly via generic `ped_update_document` on `Navigation$NavigationDocument` — see below) | -| Project settings | `read_configurations` | R | ○ | -| Project settings | `set_configuration` | W | ○ | -| Project settings | `read_runtime_settings` | R | ○ | -| Project settings | `set_runtime_settings` | W | ○ | -| Project settings | `get_active_run_configuration` | R | ○ which run config will run | -| Validation/diag | `check_model` | R | ✅ domain-model consistency check (wired, `--mcp-check`) | -| Validation/diag | `check_project_errors` | R | ○ full-project "Check All Errors" — reported stubbed in Concord | -| Validation/diag | `analyze_project_patterns` | R | ○ architectural pattern/anti-pattern scan | -| Validation/diag | `get_last_error` | R | ○ | -| Validation/diag | `get_studio_pro_logs` | R | ○ | -| Security (read) | `audit_security` | R | ○ full security audit (intended) | -| Security (read) | `read_entity_access_rules` | R | ○ | -| Security (read) | `read_microflow_security` | R | ○ | -| Security (read) | `read_security_info` | R | ○ project security overview | -| App runtime | `get_app_status` | R | ✅ valid data, but raw port probe (can be a stale runtime) | -| App runtime | `run_app` | W | ⚠️ UI automation — `command_sent`, no effect | -| App runtime | `stop_app` | W | ⚠️ UI automation — `command_sent`, app stays running | -| App runtime | `save_all` | W | ⚠️ UI automation — does not persist and **hangs Studio Pro**; save manually | -| Project | `refresh_project` | W | ○ force re-scan of project dir for external file changes | -| Concord meta | `concord__session_context` | R | ○ session-start briefing (call once) | -| Concord meta | `concord__set_task_context` | W | ○ declares task → curates subsequent `tools/list` | -| Concord meta | `concord__find_tool` | R | ○ free-text "which tool for this task" | -| Concord meta | `concord__verify` | R | ○ verify a high-level workflow (e.g. `entity_created`) actually applied | -| Concord meta | `concord__diagnose_project` | R | ○ parallel project-state probes (SP running? .mpr locked?) | -| Concord meta | `concord__calibration_summary` | R | ○ C4 calibration journal summary | -| Concord meta | `concord__record_calibration` | W | ○ append (claimed, actual) calibration entry | -| Concord meta | `concord__silence_friction` | W | ○ suppress friction-wire emission for a tool | -| Concord meta | `concord__tried_approaches` | R | ○ prior friction occurrences for a pattern/tool | - -**Highest-value unwired gap-closers** (would fill PED limits the matrix currently -marks unsupported, pending probe + identity-preservation vetting per -[ADR-0002](../13-decisions/0002-backend-abstraction.md) and the "don't fake -identity ops" rule): the `rename_*` family (true renames vs PED's `set /name`). -(Navigation is no longer in this list — mxcli authors web-profile navigation -directly over PED; see the navigation note below.) `check_project_errors` and -`refresh_project` are also candidates. `delete_model_element` is low priority (PED -already removes entities/associations). +## Delete, check and run + +mxcli once paired PED with a second MCP server, Concord (a Studio Pro extension), +for delete, save, validate and run. That support is removed: Studio Pro's own +server covers each of them, and Concord's save and run were keystroke and button +automations that reported success without taking effect. The Concord measurements +are in this file's git history. + +| Operation | Tool | Studio Pro | Notes | +|---|---|---|---| +| Save | — | all | Nothing to do. PED writes each change to disk as it lands (measured on 11.12.0 and 11.16.0; Save All then writes 0 files). | +| `DROP` of a standalone document | `ped_delete_document` | 11.16+ | `deleteDocument` (`delete.go`), gated on the tool's presence. An older server gets an error naming 11.16+. **On 11.16.0 the tool fails for every caller outside Studio Pro's own agent session** (`Error executing tool handler: Session 'mcp-server' not found`, `isError:false`), so its success reply is unmeasured and an unrecognised reply is settled with `ped_find_document`. | +| `--mcp-check` | `ped_check_errors`, no filters | 11.15+ | `CheckProject` (`check_errors.go`): the whole project's listing, every page. Up to 11.14 the tool requires `documents`, so there is no project-wide form. | +| `--mcp-run` | `execute_handler` `mendix_app` `{action: run, timeoutSeconds: 120}` | 11.16+ | `RunApp` (`app.go`). Presence is asked once with `query_handlers {operation: getDetails, names: [mendix_app]}`; 11.12 has neither tool. `status` answers `{"running":false,"launching":false,"appUrl":"http://localhost:8080/"}` from outside Studio Pro (measured 2026-11-01). The `run` reply is unmeasured: one that is not a status is settled by asking `status`. A project with model errors is refused, not run. Runtime log: `read_file /app-logs/app.log`. | + +**Playwright** is reachable the same way: `playwright_status` answered "Playwright +is available; use the browser_* handlers to check the UI." on 11.16.0, and the +`mcp_studio-pro-playwright_browser_*` handlers (navigate, snapshot, click, type, +wait_for, …) drive the running app. mxcli does not call them yet. ## Onboarding a new Studio Pro version diff --git a/docs/11-proposals/PROPOSAL_playwright_check_via_mcp.md b/docs/11-proposals/PROPOSAL_playwright_check_via_mcp.md new file mode 100644 index 0000000000..b7ee8adb28 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_playwright_check_via_mcp.md @@ -0,0 +1,325 @@ +--- +title: Playwright Check Through Studio Pro's MCP Browser +status: draft +date: 2026-11-01 +related: + - PROPOSAL_mcp_backend + - PROPOSAL_playwright_session_reuse + - proposal-playwright-cli +--- + +# Proposal: Playwright Check Through Studio Pro's MCP Browser + +**Status:** Draft +**Date:** 2026-11-01 + +## Problem Statement + +`mxcli playwright check` gives a one-line text verdict per page of a running app. +It covers rendering, rows, error banners, console errors, failed requests, the +sign-in form, access denied and wrong-page redirects. To get there it launches a +**local** headless Chromium through Node and `playwright` (`cmd/mxcli/docker/pagecheck.go`). +That only works when three things are true of the machine mxcli runs on: + +1. Node, Playwright and a Chromium are installed there. +2. The app is reachable from there. For `run --local` that is localhost. For an + app that **Studio Pro** runs on the host, a devcontainer has to forward the + port and the check has to be pointed at it with `--base-url`. +3. The app it checks is the one the model edits went into. + +Under `--mcp`, all three fail together. mxcli writes the model into a live Studio +Pro, and from 11.16 `--mcp-run` starts the app there too, through the +`mendix_app` handler. The app then runs **on Studio Pro's machine**, and the +local check either cannot reach it or has no browser to reach it with. So the +loop "edit through MCP → run in Studio Pro → check the page" breaks at the last +step. The agent falls back to driving the `browser_*` handlers by hand. That is +the hand-rolled navigate / snapshot / read sequence `playwright check` was built +to replace, and here every accessibility snapshot lands in the agent's context. + +Studio Pro 11.16 ships its own Playwright behind `execute_handler`. On +2026-11-01, `playwright_status` on 11.16.0 answered *"Playwright is available; +use the browser_\* handlers to check the UI."* Its browser runs next to the app, +so neither the reachability problem nor the install problem comes up. + +**Who benefits:** agents and contributors working with `--mcp` from a +devcontainer or another machine, which is the setup the MCP backend exists for. + +## Decision 1: A Mode of `playwright check`, Not a Flag on `--mcp-run` + +Recommendation: **`mxcli playwright check --mcp `**. It is the existing +command with the existing assertions and verdict format, and the browser is +Studio Pro's. It is **not** a `--mcp-check-page` added to `--mcp-run`. + +- **Building and checking are separate steps with separate failure modes.** + `--mcp-run` answers "did the app start" with exit 0 or an error. A check + answers "is this page right" with exit 0 for pass, 1 for fail and 2 for could + not check. One exit code cannot carry both answers, and agents read the exit + code. +- **A check is repeated far more often than a run.** The usual loop is: run + once, then check, fix through MCP, check again, and so on. Tied to `--mcp-run`, + every check would restart the app (`mendix_app run` restarts a running app), + which costs a minute each time and loses the session. +- **One command, one verdict.** Agents and `verify-in-runtime.md` already know + `playwright check`. A second command with the same output would split the + docs, the tests and the habits built on them. The browser is a transport + detail, the same way `--mcp` is for model writes. +- **They compose.** `mxcli exec fix.mdl --mcp URL --mcp-run` followed by + `mxcli playwright check /p/orders --mcp URL` is two commands with two clear + results. + +**Spelling.** `--mcp` is already a root **persistent string flag**: the server +URL, with `--mcp-dial` beside it (`cmd/mxcli/main.go`). A boolean `--mcp` on +`playwright check` would collide with it. So the mode is selected by the existing +flag carrying its URL, exactly as model writes are: + +```bash +mxcli playwright check /p/customers /p/orders -p app.mpr \ + --mcp http://localhost:7782/mcp --mcp-dial host.docker.internal:7782 +``` + +The `--mcp` help text ("Route model writes…") is widened to cover this use. + +**Base URL.** Under `--mcp`, the pages are resolved against `mendix_app status`'s +`appUrl` (measured as `http://localhost:8080/`), not `.docker/.env` or a +`run --local` handshake. That `localhost` is Studio Pro's, which is the point. +An explicit `--base-url` still wins. The check **never starts the app**. If +status reports not running, it exits 2 with "start it with `--mcp-run`". + +## What the Check Needs from `execute_handler` + +### Handlers, Measured 2026-11-01 on 11.16.0 + +`query_handlers {}` (captured earlier the same day in +`mx-test-projects/runs/mcp-surface/query_handlers.txt`) lists these browser +handlers: + +| Handler (`mcp_studio-pro-playwright_` prefix omitted) | Description as served | Used by the check | +|---|---|---| +| `playwright_status` (no prefix) | whether Playwright is available, and why not | **gate**: first call | +| `browser_navigate` | Navigate to a URL | each page | +| `browser_snapshot` | accessibility snapshot of the current page | each page: the main signal source | +| `browser_find` | search the snapshot for text or a regexp, with context | `--assert-text`, login-form and alert detection without a whole snapshot | +| `browser_wait_for` | wait for text to appear or disappear, or for a time | settling | +| `browser_console_messages` | all console messages | `console-errors=` | +| `browser_network_requests` / `browser_network_request` | numbered list of requests since load / one request's details | `http=`, failed same-origin requests, settling | +| `browser_fill_form`, `browser_type`, `browser_click`, `browser_press_key` | form input | sign-in | +| `browser_handle_dialog` | handle a dialog | a native dialog blocking the page | +| `browser_close` | close the page | `--sign-out` cleanup | +| `browser_navigate_back`, `browser_hover`, `browser_select_option` | — | not needed | + +**Absent from the list, and load-bearing for the design:** +`browser_evaluate`, `browser_take_screenshot`, `browser_tabs`, and any +storage-state handler. Upstream `@playwright/mcp` has these. Studio Pro serves a +curated subset (and adds `browser_find`, which upstream does not have). + +### Input Schemas: Not Yet Captured + +`getDetails` is the only source of the payload shapes, and `execute_handler`'s +description requires it before a call. It **could not be read**. From 15:3x to +15:58 UTC on 2026-11-01, every `query_handlers` call on the 11.16.0 server +(`host.docker.internal:7785`, Host: localhost) failed with JSON-RPC `-32000 +"Request timed out"` after 30 s. That includes `{operation: list}`, a +`keyword: browser` list, and `getDetails` for `mendix_app` alone, which had +answered earlier that day. Other tools on the same connection (`tools/list`, +`glob`) answered in about 0.2 s. So the handler registry, not the connection, +was stuck. My guess, not measured, is that it waits on the Playwright subserver +while listing. + +Two consequences for the design, whatever the schemas turn out to be: + +1. **A `query_handlers` timeout is "could not check" (exit 2)**, with that + cause named. `requireHandler` currently caches only answers. It must not + cache a timeout as "no such handler", and it must not retry it the way + mutation timeouts are retried (`timeout.go`). +2. The schemas are **re-measured before implementation starts** (Open + Question 1). The payload field names below follow upstream `@playwright/mcp` + and are placeholders until then: `navigate {url}`, `click {element, ref}`, + `type {element, ref, text, submit?}`, `fill_form {fields[]}`, + `wait_for {text?, textGone?, time?}`, `console_messages {level?}`. The + implementation reads nothing from them until measured. Each call site cites + the captured `getDetails` reply, the way `AppStatus` cites its measured reply. + +## How Each Verdict Signal Maps + +The verdict is built from `PageSignals` (`pagecheck.go`), which `EvaluatePage` +and `formatPageVerdict` turn into the line. The MCP mode must **produce the same +`PageSignals`** so that evaluation and formatting are shared, not reimplemented. +The local probe collects them with `page.evaluate` and CSS selectors. Neither is +available here, so each signal comes from a different source: + +| Signal | Local probe | Through MCP | Fidelity | +|---|---|---|---| +| `finalUrl`, `title` | `p.url()`, `p.title()` | the "Page URL" / "Page Title" lines of the navigate/snapshot reply | same, if the reply carries them (measure) | +| `status` | `goto` response | the document entry in `browser_network_requests` | same | +| `headings` | `h1,h2` outside the demo-user switcher | snapshot `heading [level=1/2]` nodes outside the switcher's subtree | same | +| `loginForm` | `#usernameInput` | snapshot textbox named like the sign-in field, or `browser_find` | same, keyed on the accessible name (measure on 11.16's login.html) | +| `alerts`, `errorAlerts` | `.alert-danger`, `.mx-dialog-error`, … | snapshot `alert` / `dialog` nodes | **weaker**: the danger/warning/info split relies on classes the tree may not show. Measure, and fall back to "any alert fails" if no split survives | +| `rows`, `dataWidgets` | grid/list-view selectors | `row` nodes with `gridcell` children; `listitem` under a list | close for Data Grid 2 (role=row); **list views to measure** | +| `textLen`, `textFound` | `body.innerText` | the snapshot's text content; `--assert-text` via `browser_find` | close | +| `consoleErrors` | `console` + `pageerror` events | `browser_console_messages`, errors only, minus same-origin "Failed to load resource" | same, if level is reported | +| `httpErrors` | `response` events ≥ 400 | `browser_network_requests` entries ≥ 400, same filters | same | +| `unsettled`, `pendingRequests` | in-flight map + text stable ×3 | poll `browser_network_requests` and `browser_find` until both are unchanged across polls, within `--wait` | **weaker**: requests still open may not be visible (#845 is the case where it matters). Measure whether the list shows pending entries | +| `counts` (`--assert-count`) | `locator(css).count()` | **CSS cannot be evaluated** | see below | +| `screenshot` | `p.screenshot` | **no handler** | refused | + +**`--assert-count`.** With no evaluate, a CSS selector such as +`.mx-name-dgCustomers [role=row]>=2` has nothing to run against. In MCP mode the +check accepts an **accessibility selector**, which is `role` with an optional +`"name"`, counted in the snapshot: `row>=2`, `button "Save"==1`. It refuses a CSS +selector with exit 2 and names the role form. It never silently passes. Widget +names (`mx-name-*`) do not appear in the accessibility tree. Whether a scoped +form (`within grid "Customers"`) is worth adding is Open Question 3. + +**`--screenshot`** is refused under `--mcp` with exit 2: "Studio Pro's browser +has no screenshot handler; drop --mcp for a visual check". `verify-in-runtime.md` +already makes a screenshot the exception, so this costs least where it matters +least. + +## Sign-in and Sessions + +- **Credentials are resolved as today**: `--user/--password`, `--role R` and the + demo-user fallback read project security from `-p`. The sign-in itself is + `navigate login.html` → fill the two fields → click Sign in, and the result is + confirmed by the next snapshot showing no sign-in form. A refusal still reads + the runtime log for the cause, but through `read_file /app-logs/app.log` + (`appLogPath` in `app.go`), not `/.mxcli/runtime.log`. +- **No storage-state file.** The session lives in Studio Pro's browser context, + so it persists across checks only as long as that browser does. Without a + storage handler, mxcli cannot save it or restore it. The check therefore + starts each run by asking whether the first page already shows the sign-in + form. If it does not, it treats the browser as signed in, and the verdict + names the user only when this run signed in. `--fresh-login` signs out first. + `--sign-out` signs out and closes the page at the end. +- **The shared browser is the user's too.** An agent working alongside a human in + Studio Pro drives the same browser. The check navigates away from whatever was + open, and the help text says so. +- **Passwords cross the MCP connection** into Studio Pro, the same connection + model writes use. Demo-user passwords are generated per project, but a + `--password` for a real account may land in a Studio Pro log. This goes in the + help text, measured if possible (Open Question 4). + +## Fit with `verify-in-runtime.md` and `playwright check` + +- **`playwright check`**: one command, two probes. `RunPageCheck` already + isolates its browser behind seams (`probePagesFn`, `loginFn`, `signOutFn` in + `pagecheck_check.go`). The MCP mode becomes a second implementation of those + three, selected when `--mcp` is set. Everything above them is unchanged and + shared: credential resolution, the access-denied and wrong-page rules, + `EvaluatePage` and the verdict line. A new failure rule then reaches both + modes at once. +- **`verify-in-runtime.md`**: the table "Symptom lives in → Sufficient proof" + gains no row. The tier is the same, and it is still the running app. The + procedure gains one branch at step 4/5 ("Provision and boot", "Assert in the + browser"): *when the model was written with `--mcp` into Studio Pro 11.16+, + boot with `--mcp-run` and assert with `playwright check --mcp`, not + `run --local`*. Step 6, the A/B against the broken build, works the same way. + Two limits are stated there: no screenshot, and `--assert-count` takes the + role form. The skill's "assert in text" rule fits better here than locally, + because the alternative under MCP is snapshots in the agent's context. +- **`live-edit-with-studio-pro` skill**: its "after `--mcp-run`" step points to + `playwright check --mcp` instead of browser handlers called by hand. +- **`PROPOSAL_playwright_session_reuse`**: does not apply to this mode. There is + no session file to reuse, and the browser's lifetime belongs to Studio Pro. + +## BSON Structure + +Not applicable. The check reads nothing from the model and writes nothing to +it. It talks only to the running app, through Studio Pro's browser. + +## Proposed CLI + +No MDL syntax. The command, flags and exit codes are those of +`playwright check`, with: + +| Flag | Under `--mcp` | +|---|---| +| `--mcp URL`, `--mcp-dial ADDR` | select Studio Pro's browser (root flags, existing) | +| `--base-url` | defaults to `mendix_app status` `appUrl` | +| `--assert-text`, `--wait`, `--user/--password/--role`, `--fresh-login`, `--sign-out`, `--allow-console-errors` | same meaning | +| `--assert-count` | accessibility selector (`row>=2`); a CSS selector is exit 2 | +| `--screenshot` | exit 2 | + +Exit 2 is also returned when the server lacks `execute_handler` (before 11.16), +when `playwright_status` reports unavailable (its reason is printed verbatim), when +`query_handlers` times out, and when the app is not running. + +## Implementation Plan + +**Prerequisite:** `mdl/backend/mcp/app.go` (`requireHandler`, `executeHandler`, +`GetAppStatus`) exists today only on branch `claude/festive-driscoll-aa5aa4`, +commit 41698502e ("drop Concord"), and is not on `main`. This work starts after +that lands. + +### Files to modify/create + +| File | Change | +|---|---| +| `mdl/backend/mcp/browser.go` (new) | `PlaywrightStatus()`, and thin wrappers over `executeHandler` for the handlers in the table above, using the measured payload shapes, with the reply returned as text | +| `mdl/backend/mcp/app.go` | `requireHandler`: a `-32000` timeout is returned as its own error (`ErrHandlersUnavailable`), never cached as "absent" | +| `cmd/mxcli/docker/pagecheck_snapshot.go` (new) | pure parser from snapshot text, console text and request-list text to `PageSignals`, plus the accessibility-selector counter. It has no I/O and is where most of the tests go | +| `cmd/mxcli/docker/pagecheck_mcp.go` (new) | an MCP implementation of probe, login and sign-out behind a small interface, with no import of `mdl/backend/mcp`. `cmd/mxcli` passes in an adapter, so `docker` stays free of the backend | +| `cmd/mxcli/docker/pagecheck_check.go` | `PageCheckOptions` gains `Browser` (nil means the local probe). The three seams dispatch on it | +| `cmd/mxcli/docker/pagecheck_assert.go` | `ParseCountAssertion` gains the role form, tagged so each mode refuses the other's form | +| `cmd/mxcli/cmd_playwright.go` | read `--mcp`/`--mcp-dial`, build the backend and adapter, resolve the base URL from status, refuse `--screenshot`, extend the help text | +| `cmd/mxcli/main.go` | widen the `--mcp` flag description | +| `.claude/skills/verify-in-runtime.md`, `.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md`, `docs-site/src/tools/mcp-connect.md` | the branch and limits described above | + +Order: measure (Open Question 1) → snapshot parser with fixtures → `browser.go` → +dispatch in `RunPageCheck` → CLI → docs. + +## Version Compatibility + +Studio Pro **11.16+**, gated on **presence, not version**: the `execute_handler` +and `query_handlers` tools, then `playwright_status` by `getDetails`, and then +`playwright_status` itself must answer "available". Presence also depends on +Studio Pro's preferences and the servers connected to it +(`mcp-1116-surface.md`), so a version number alone would be wrong in both +directions. Before 11.16, the error names the version, as `--mcp-run` does. + +## Test Plan + +- **Snapshot parser (unit, the bulk).** Fixtures are snapshot, console and + request-list replies **captured from 11.16.0**, not hand-written, saved under + `cmd/mxcli/docker/testdata/mcp-snapshots/`. They cover: a page with a Data + Grid 2 that has rows; an empty list view; the sign-in form; an + `alert-danger`; an error dialog; the 403 landing; an unknown `/p/` that + redirects home with an info dialog (#998); and the demo-user switcher's + heading. Each asserts the `PageSignals` and the verdict line. +- **Backend wrappers (unit).** `newFakePED` in `mdl/backend/mcp/client_test.go` + answers `query_handlers` and `execute_handler`. The cases: the handler is + absent; `playwright_status` reports unavailable, and its reason is surfaced; a + `-32000` timeout gives `ErrHandlersUnavailable` and is not cached; + "Error executing tool handler: …" with `isError:false` is an error. +- **`RunPageCheck` dispatch (unit).** A fake `Browser` covers the sign-in + sequence, `--fresh-login`, `--sign-out`, an already-signed-in browser, CSS + `--assert-count` refused under MCP, and `--screenshot` refused. +- **Parity control (live, manual or `-tags integration` with an env-provided + URL).** One app, run by Studio Pro, with a passing page and a page that has a + seeded error banner. Check both pages with both probes, the local one through + a forwarded port. The verdicts must agree: the passing page must pass under + both and the banner page must fail under both. This is the control. A mode + that passes everything cannot pass it. +- **Fix-proven step.** For the settling rule, revert it and confirm that a page + whose list-view microflow sleeps 3 s reports `rows=0` instead of + STILL LOADING or its real rows. This is the #845 symptom, made to happen + through MCP. + +## Open Questions + +1. **Handler input schemas.** Re-run `query_handlers {operation: getDetails, + names: [playwright_status, mcp_studio-pro-playwright_browser_*]}` once the + registry answers again (a Studio Pro restart may be needed), and commit the + reply to `mx-test-projects/runs/mcp-surface/`. Also measure: does the navigate + or snapshot reply carry URL and title? Does `browser_network_requests` show + pending requests and the status of the document request? Does + `browser_console_messages` report the level? +2. **Why `query_handlers` hung.** Is it reproducible, does it follow the + Playwright subserver's start, and does it recover without a restart? If it is + the subserver, an exit 2 "could not check" is right, but the message should + say to restart Studio Pro. +3. **A scoped count.** Is `row>=2` enough without a "within grid X" scope? Pages + with two grids are common. +4. **Where the password goes.** Does Studio Pro log `execute_handler` payloads? +5. **Concurrent users of the browser.** Does Studio Pro's Maia chat share this + browser? If it does, a check run while Maia is browsing interleaves with it, + and the help text should say so. diff --git a/docs/plans/2026-10-30-git-merge-mx-folder-mode.md b/docs/plans/2026-10-30-git-merge-mx-folder-mode.md new file mode 100644 index 0000000000..992a22764c --- /dev/null +++ b/docs/plans/2026-10-30-git-merge-mx-folder-mode.md @@ -0,0 +1,59 @@ +# Rework `mxcli git merge` per ako's review (PR #1674) + +**Status:** Superseded in part. Section A did not hold on Linux: `mx merge` with folder arguments exits 129, and MINE as the working tree mid-merge crashes it, so the engine takes three `.mpr` paths over clean worktrees (the command's help and `docs-site/src/tools/outside-studio-pro.md` describe the shipped design). `--plan` no longer resolves from git's two sides either: it re-runs the engine and decides only the units the engine left open. + +Decisions (user, 2026-10-30): switch the engine call to `mx merge` (folder mode); +keep `--ours`/`--theirs` but limited to conflicted units with a hash gate; one +push after the rework passes `make ci-local`. + +## Review claims → verified + +1. --ours/--theirs take App.mpr wholesale → orphan/missing documents. TRUE (strategy path, cmd_git_merge.go). +2. --plan takes ours' App.mpr → refuses on theirs' adds; error paths leave MERGE_HEAD. TRUE (applyPlan; abort only on stale). +3. Every non-zero mx exit treated as decision-needed. TRUE (code != 0 → plan + decision error; 129 = crash). +4. mx git-merge broken on Linux ("Git not found", 129). Reviewer-measured; repo finding corroborates mx merge works (0/2/129). +5. Should-fix: upstream merge conflicts (DONE, 2ef7e1068); merge-plan.json not junk-pathed; mixed-conflict advice; PrepareMxCommand nit. + +## Changes + +### A. Engine interface (cmd_git_merge.go) +- `mxMergeRunner func(baseDir, mineDir, theirsDir string, shas [3]string) (int, string, error)` — folders. +- realMxMerge: `mx merge ` + docker.PrepareMxCommand (11.10 LD_PRELOAD nit). +- baseDir/theirsDir: temp worktrees (unchanged); project dir inside = filepath.Join(dir, projDirRel). +- mineDir = the working project dir (filepath.Dir(abs mpr)). + +### B. Exit-code honesty +- 0 → stage model paths → fixHashes gate (MISSING/ORPHAN → refuse + git merge --abort) → commitTheMerge. +- 2 → property report (mx diff) + plan template (units = engine JSON's manual units; fallback git's conflicted ids) + git merge --abort + decision error. +- other (129…) → "mx merge failed (exit N)" + git merge --abort. Never a plan, never a decision suggestion. + +### C. --ours/--theirs limited +- No engine call (unchanged). Conflicted UNITS only from git's list (skip the .mpr). +- App.mpr takes the flag's side; then fixHashes gate: MISSING/ORPHAN → refuse + git merge --abort (loud, Studio Pro pointer). No silent drops. + +### D. --plan aborts everywhere +- invalid JSON / invalid take / not-modified-on-both / fixHashes refusal → git merge --abort before returning. +- stale-plan path already aborts (keep). + +### E. Re-entry +- At entry (non-plan): if MERGE_HEAD already exists, skip `git merge` and go straight to conflict classification. Supports: mixed conflict → resolve text files → `git add` → re-run command finishes the merge. + +### F. Mixed-conflict advice +- Early-return kept, reworded: resolve the text files, `git add` them, then re-run `mxcli git merge ` (it resumes, resolves the model, commits). + +### G. merge-plan.json is junk +- isJunkPath: `.merge-plan.json` never staged by `mxcli git commit`. + +### H. Words +- Long help, CHANGELOG git-merge entry, docs-site outside-studio-pro.md, teamserver-git SKILL.md "aborts the merge cleanly" — match the new reality. + +### I. Tests (gitmerge_test.go) +- Runner fixtures → folder form (assert dirs end in the project folder, contain App.mpr). +- exit 129 → no plan text, MERGE_HEAD gone, tree restored, error names mx. +- --theirs with ours-added unit → refuse + abort (no ORPHAN commit, MERGE_HEAD gone). +- --plan error paths → MERGE_HEAD gone. +- re-entry: staged text resolution + MERGE_HEAD → finishes. +- isJunkPath: x.merge-plan.json → junk. + +## Gate +- go vet ./cmd/mxcli/; focused go test; then make ci-local; then one push. diff --git a/mdl-examples/bug-tests/535-custom-content-column-empty-header.mdl b/mdl-examples/bug-tests/535-custom-content-column-empty-header.mdl new file mode 100644 index 0000000000..edbeab8b95 --- /dev/null +++ b/mdl-examples/bug-tests/535-custom-content-column-empty-header.mdl @@ -0,0 +1,36 @@ +mdl 1; +-- ============================================================================ +-- ako/mxcli#535: a custom-content column with an empty caption showed its MDL +-- column name as the header +-- ============================================================================ +-- +-- Symptom: `column colTriage (Caption: '') { … }` in a Data grid 2 written +-- through the generic `pluggablewidget` form rendered "colTriage" in the column +-- header. mx check, check and lint all passed; only a browser showed it. +-- +-- Cause: the writer filled an empty header on an attribute-less column with the +-- column's name. Mendix stores no name for a column, and Data grid 2 renders the +-- header text as is (`header.trim() || ' '` in Datagrid.mjs, 11.15.0). +-- +-- The generic form needs a name on every column (MDL-WIDGET35), which is how a +-- name was there to leak. +-- +-- After fix: both custom-content columns below store an empty header, as the keyword +-- `datagrid` path already did; `describe page` prints `Caption: ''` for each, +-- and mx check reports 0 errors (11.15.0). + +create module Bug535; + +create persistent entity Bug535.Ticket (Title: String(100)); + +create page Bug535.Tickets (Title: 'Tickets', Layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.datagrid.Datagrid' dgTickets (DataSource: database from Bug535.Ticket) { + column colTitle (Attribute: Title, Caption: 'Title') + column colTriage (Caption: '') { + dynamictext dtTriage (Content: 'triage') + } + column colNoCaption { + dynamictext dtNoCaption (Content: 'nocap') + } + } +}; diff --git a/mdl-examples/bug-tests/ledger-54-custom-content-column.mdl b/mdl-examples/bug-tests/ledger-54-custom-content-column.mdl index 09a0220ffc..bcb78fc887 100644 --- a/mdl-examples/bug-tests/ledger-54-custom-content-column.mdl +++ b/mdl-examples/bug-tests/ledger-54-custom-content-column.mdl @@ -9,13 +9,16 @@ mdl 1; -- still failed `mx check` with CE0463. A custom-content column requires a -- non-empty header, and the first fix had no attribute name to fall back to. -- --- After fix: the header falls back to the COLUMN'S OWN NAME when there is no --- bound attribute. The fallback is gated on the item template having a `header` --- slot, so header-less object-list items (chart series, accordion groups) are --- never given a spurious header. +-- After fix: the header falls back to the bound attribute's name; a column with +-- no attribute gets an EMPTY header (an empty Forms$ClientTemplate, the shape +-- update-widgets writes), which Data grid 2 renders blank. Round 2 used the +-- column's own name instead, which showed end users "colActionsEmpty" — Mendix +-- stores no name for a column (ako/mxcli#535). The fallback is gated on the item +-- template having a `header` slot, so header-less object-list items (chart +-- series, accordion groups) are never given a spurious header. -- --- Verified: `mx check` → 0 errors on Mendix 11.12.1 for both the empty-caption --- and no-caption custom-content columns (previously CE0463). +-- Verified: `mx check` → 0 errors on Mendix 11.12.1 (column-name header) and +-- 11.15.0 (empty header) for both the empty-caption and no-caption columns. -- -- NOTE: requires the project's generated widget defs (`mxcli widget init`), so -- this is exercised via exec/mx-check, not just syntax check. @@ -43,11 +46,11 @@ create or modify page L54c.Grid ( DataSource: database from L54c.Thing ) { column colName (Attribute: Name, Caption: 'Name') - -- Custom-content column, empty caption → header falls back to the column name + -- Custom-content column, empty caption → empty header (#535) column colActionsEmpty (Caption: '') { actionbutton btnA (Caption: 'Go', Action: call microflow L54c.RowAction(Thing = $currentObject)) } - -- Custom-content column, no caption → header falls back to the column name + -- Custom-content column, no caption → empty header (#535) column colActionsBare { actionbutton btnB (Caption: 'Go2', Action: call microflow L54c.RowAction(Thing = $currentObject)) } diff --git a/mdl/backend/mcp/app.go b/mdl/backend/mcp/app.go new file mode 100644 index 0000000000..031b477139 --- /dev/null +++ b/mdl/backend/mcp/app.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "encoding/json" + "fmt" + "io" + "strings" +) + +// Running the app goes through a Studio Pro 11.16 handler, not a tool: the +// server advertises execute_handler and query_handlers, and mendix_app (run, +// stop, status) is one of the handlers behind them. Studio Pro 11.12 has +// neither tool, so every call here is gated on their presence. + +const ( + mendixAppHandler = "mendix_app" + // appRunTimeoutSeconds is the longest wait mendix_app accepts (schema max + // 120). A timeout does not cancel the run; status still reports it. + appRunTimeoutSeconds = 120 + // appLogPath is where Studio Pro serves the runtime log (read_file). + appLogPath = "/app-logs/app.log" +) + +// AppStatus is mendix_app's status reply, measured on 11.16.0: +// {"running":false,"launching":false,"appUrl":"http://localhost:8080/"}. +type AppStatus struct { + Running bool `json:"running"` + Launching bool `json:"launching"` + AppURL string `json:"appUrl"` +} + +// requireHandler reports an actionable error unless the server exposes the +// named handler. Presence is asked once per session with query_handlers +// getDetails, which execute_handler's own description requires before a call. +func (b *Backend) requireHandler(name string) error { + if b.client == nil || !b.client.HasTool("execute_handler") || !b.client.HasTool("query_handlers") { + return fmt.Errorf("%s needs Studio Pro 11.16+ (execute_handler); this Studio Pro's MCP server has no handlers", name) + } + if ok, cached := b.handlers[name]; cached { + if !ok { + return fmt.Errorf("this Studio Pro offers no %s handler", name) + } + return nil + } + res, err := b.client.CallTool("query_handlers", map[string]any{ + "operation": "getDetails", + "names": []string{name}, + }) + if err != nil { + return err + } + text := pedStripReminder(res.Text) + if res.IsError { + return fmt.Errorf("query_handlers: %s", text) + } + var details struct { + Handlers []struct { + Name string `json:"name"` + } `json:"handlers"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &details); err != nil { + return fmt.Errorf("query_handlers: parsing result: %w", err) + } + found := false + for _, h := range details.Handlers { + found = found || h.Name == name + } + if b.handlers == nil { + b.handlers = map[string]bool{} + } + b.handlers[name] = found + if !found { + return fmt.Errorf("this Studio Pro offers no %s handler", name) + } + return nil +} + +// executeHandler runs a handler and returns its reply text. A handler that +// throws answers "Error executing tool handler: …", which CallTool already +// classifies as an error. +func (b *Backend) executeHandler(name string, payload map[string]any) (string, error) { + if err := b.requireHandler(name); err != nil { + return "", err + } + res, err := b.client.CallTool("execute_handler", map[string]any{"name": name, "payload": payload}) + if err != nil { + return "", err + } + text := pedStripReminder(res.Text) + if res.IsError { + return "", fmt.Errorf("%s: %s", name, strings.TrimSpace(text)) + } + return text, nil +} + +func parseAppStatus(text string) (*AppStatus, bool) { + var s AppStatus + if err := json.Unmarshal([]byte(strings.TrimSpace(text)), &s); err != nil { + return nil, false + } + return &s, true +} + +// GetAppStatus reports whether the app is running or launching, and its URL, +// without disturbing a running session. +func (b *Backend) GetAppStatus() (*AppStatus, error) { + text, err := b.executeHandler(mendixAppHandler, map[string]any{"action": "status"}) + if err != nil { + return nil, err + } + s, ok := parseAppStatus(text) + if !ok { + return nil, fmt.Errorf("%s status: unexpected reply: %s", mendixAppHandler, strings.TrimSpace(text)) + } + return s, nil +} + +// RunApp starts (or restarts) the app — Studio Pro's Run button — and waits up +// to timeoutSeconds (0 means appRunTimeoutSeconds) for it to become ready. +// +// mendix_app refuses to run a project with model errors and lists them instead. +// Its run reply has not been observed live, so a reply that is not a status is +// settled by looking: the run took iff status then says running or launching. +func (b *Backend) RunApp(timeoutSeconds int) (*AppStatus, error) { + if timeoutSeconds <= 0 { + timeoutSeconds = appRunTimeoutSeconds + } + text, err := b.executeHandler(mendixAppHandler, map[string]any{"action": "run", "timeoutSeconds": timeoutSeconds}) + if err != nil { + return nil, fmt.Errorf("%w (runtime log: read_file %s)", err, appLogPath) + } + if s, ok := parseAppStatus(text); ok { + return s, nil + } + if s, serr := b.GetAppStatus(); serr == nil && (s.Running || s.Launching) { + return s, nil + } + return nil, fmt.Errorf("%s run: %s (runtime log: read_file %s)", mendixAppHandler, strings.TrimSpace(text), appLogPath) +} + +// StopApp stops the running app — Studio Pro's Stop button. +func (b *Backend) StopApp() error { + _, err := b.executeHandler(mendixAppHandler, map[string]any{"action": "stop"}) + return err +} + +// writeAppStatus prints one line on where the app stands after a run. +func writeAppStatus(w io.Writer, s *AppStatus) { + switch { + case s.Running: + fmt.Fprintf(w, "app running at %s\n", s.AppURL) + case s.Launching: + fmt.Fprintf(w, "app still launching at %s (check again with mendix_app status)\n", s.AppURL) + default: + fmt.Fprintf(w, "app is not running (runtime log: read_file %s)\n", appLogPath) + } +} diff --git a/mdl/backend/mcp/app_test.go b/mdl/backend/mcp/app_test.go new file mode 100644 index 0000000000..d27692b627 --- /dev/null +++ b/mdl/backend/mcp/app_test.go @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +import ( + "strings" + "testing" +) + +// The 11.16 surface that carries handlers; 11.12 advertises neither tool. +var handlerTools = map[string][]string{ + "query_handlers": {"operation", "keyword", "names"}, + "execute_handler": {"name", "payload"}, +} + +// mendixAppDetails is the shape of 11.16.0's getDetails reply (handlers-detail +// probe), trimmed to what requireHandler reads. +const mendixAppDetails = `{"handlers":[{"name":"mendix_app","description":"Controls the Mendix application runtime."}],"errors":[]}` + +// fakeMendixApp answers query_handlers and execute_handler mendix_app with the +// given reply per action. +func fakeMendixApp(t *testing.T, replies map[string]string) *fakePED { + f := newFakePED(t, func(name string, args map[string]any) (string, bool) { + switch name { + case "query_handlers": + return mendixAppDetails, false + case "execute_handler": + if args["name"] != "mendix_app" { + return "Error executing tool handler: Handler '" + args["name"].(string) + "' not found.", false + } + payload, _ := args["payload"].(map[string]any) + action, _ := payload["action"].(string) + if r, ok := replies[action]; ok { + return r, false + } + } + return "unexpected " + name, true + }) + f.tools = handlerTools + return f +} + +func TestGetAppStatus_ParsesMeasuredReply(t *testing.T) { + f := fakeMendixApp(t, map[string]string{ + "status": `{"running":false,"launching":false,"appUrl":"http://localhost:8080/"}`, + }) + b := &Backend{client: f.connectClient(t)} + + s, err := b.GetAppStatus() + if err != nil { + t.Fatalf("GetAppStatus: %v", err) + } + if s.Running || s.Launching || s.AppURL != "http://localhost:8080/" { + t.Errorf("status = %+v", s) + } + q, ok := f.callByName("query_handlers") + if !ok || q.Args["operation"] != "getDetails" { + t.Fatalf("handler presence must be asked with getDetails first, got %+v", q) + } + call, _ := f.callByName("execute_handler") + if payload, _ := call.Args["payload"].(map[string]any); payload["action"] != "status" { + t.Errorf("payload = %v, want action status", call.Args["payload"]) + } +} + +func TestRunApp_SendsTimeoutAndReportsURL(t *testing.T) { + f := fakeMendixApp(t, map[string]string{ + "run": `{"running":true,"launching":false,"appUrl":"http://localhost:8080/"}`, + }) + b := &Backend{client: f.connectClient(t)} + + s, err := b.RunApp(0) + if err != nil { + t.Fatalf("RunApp: %v", err) + } + if !s.Running { + t.Errorf("status = %+v, want running", s) + } + call, _ := f.callByName("execute_handler") + payload, _ := call.Args["payload"].(map[string]any) + if payload["action"] != "run" || payload["timeoutSeconds"] != float64(appRunTimeoutSeconds) { + t.Errorf("payload = %v, want run with timeoutSeconds %d", payload, appRunTimeoutSeconds) + } + var sb strings.Builder + writeAppStatus(&sb, s) + if sb.String() != "app running at http://localhost:8080/\n" { + t.Errorf("report = %q", sb.String()) + } +} + +// mendix_app refuses to run a project with model errors and lists them instead; +// that text is not a status, and status then shows nothing running. +func TestRunApp_RefusalIsAnError(t *testing.T) { + f := fakeMendixApp(t, map[string]string{ + "run": "The app cannot be run: the project has 2 errors.", + "status": `{"running":false,"launching":false,"appUrl":"http://localhost:8080/"}`, + }) + b := &Backend{client: f.connectClient(t)} + + _, err := b.RunApp(0) + if err == nil || !strings.Contains(err.Error(), "the project has 2 errors") || !strings.Contains(err.Error(), appLogPath) { + t.Fatalf("a refused run should carry the reply and point at the log, got: %v", err) + } +} + +// A run reply that is not a status but did start the app is settled by status. +func TestRunApp_UnrecognisedReplySettledByStatus(t *testing.T) { + f := fakeMendixApp(t, map[string]string{ + "run": "Started.", + "status": `{"running":false,"launching":true,"appUrl":"http://localhost:8080/"}`, + }) + b := &Backend{client: f.connectClient(t)} + + s, err := b.RunApp(0) + if err != nil { + t.Fatalf("RunApp: %v", err) + } + if !s.Launching { + t.Errorf("status = %+v, want launching", s) + } +} + +func TestStopApp_HandlerFailureSurfaces(t *testing.T) { + f := fakeMendixApp(t, map[string]string{"stop": handlerSessionError}) + b := &Backend{client: f.connectClient(t)} + + if err := b.StopApp(); err == nil || !strings.Contains(err.Error(), "Session 'mcp-server' not found") { + t.Fatalf("a handler that throws must be an error, got: %v", err) + } +} + +func TestAppHandler_AbsentOn1112(t *testing.T) { + f := newFakePED(t, nil) + f.tools = map[string][]string{"ped_create_document": {"documents"}} // 11.12 surface + b := &Backend{client: f.connectClient(t)} + + if _, err := b.RunApp(0); err == nil || !strings.Contains(err.Error(), "needs Studio Pro 11.16+") { + t.Fatalf("without execute_handler RunApp should name the version, got: %v", err) + } + if len(f.calls) != 0 { + t.Errorf("no tool may be called on a server without handlers, got %+v", f.calls) + } +} + +func TestAppHandler_NotOffered(t *testing.T) { + f := newFakePED(t, func(name string, _ map[string]any) (string, bool) { + return `{"handlers":[],"errors":[]}`, false + }) + f.tools = handlerTools + b := &Backend{client: f.connectClient(t)} + + if _, err := b.GetAppStatus(); err == nil || !strings.Contains(err.Error(), "no mendix_app handler") { + t.Fatalf("got: %v", err) + } + if _, ok := f.callByName("execute_handler"); ok { + t.Error("execute_handler must not be called for a handler query_handlers did not list") + } + // Cached: a second call asks nothing more. + n := len(f.calls) + _, _ = b.GetAppStatus() + if len(f.calls) != n { + t.Errorf("handler presence should be cached for the session") + } +} + +func TestCheckProject(t *testing.T) { + listing := "Listing problems 1-2 (out of 2). Check ID: 6\n" + + "'MyFirstModule.Zz_BrokenEnum' (Enumerations$Enumeration):\n" + + "- [error] Missing enumeration values. (at locations: -)\n" + + "'MyFirstModule' (DomainModels$DomainModel):\n" + + "- [error] Duplicate name\n" + f := newFakePED(t, func(string, map[string]any) (string, bool) { return listing, false }) + f.tools = map[string][]string{"ped_check_errors": {"filters", "pagination"}} + b := &Backend{client: f.connectClient(t)} + + got, err := b.CheckProject() + if err != nil { + t.Fatalf("CheckProject: %v", err) + } + if len(got) != 4 || got[0] != "'MyFirstModule.Zz_BrokenEnum' (Enumerations$Enumeration):" { + t.Fatalf("listing = %q", got) + } + call, _ := f.callByName("ped_check_errors") + if len(call.Args) != 0 { + t.Errorf("a project-wide check sends no filters, got %v", call.Args) + } + var sb strings.Builder + writeCheckReport(&sb, got) + if !strings.Contains(sb.String(), " - [error] Duplicate name") { + t.Errorf("report:\n%s", sb.String()) + } +} + +func TestCheckProject_NeedsFilters(t *testing.T) { + f := newFakePED(t, nil) + f.tools = map[string][]string{"ped_check_errors": {"documents"}} // up to 11.14 + b := &Backend{client: f.connectClient(t)} + + if _, err := b.CheckProject(); err == nil || !strings.Contains(err.Error(), "11.15+") { + t.Fatalf("got: %v", err) + } +} diff --git a/mdl/backend/mcp/backend.go b/mdl/backend/mcp/backend.go index 2964535b7d..819c2497f4 100644 --- a/mdl/backend/mcp/backend.go +++ b/mdl/backend/mcp/backend.go @@ -45,20 +45,13 @@ type Backend struct { path string server ServerInfo - // concord is an optional second MCP client to the Concord extension server, - // used ONLY for capabilities the built-in PED server lacks (delete, save, - // validate, run). nil unless configured via WithConcord. PED stays the - // authoring path; Concord is the gap-filler. - concordURL string - concordDial string - concord *Client - // On-Disconnect Concord actions (the gap-fillers PED lacks). saveOnExit flushes - // via save_all (--mcp-save), checkOnExit validates via check_model - // (--mcp-check), runOnExit starts the app via run_app and prints its URL - // (--mcp-run). - saveOnExit bool + // On-Disconnect actions. checkOnExit runs a project-wide ped_check_errors + // (--mcp-check); runOnExit starts the app through the mendix_app handler and + // prints its URL (--mcp-run). checkOnExit bool runOnExit bool + // handlers caches query_handlers getDetails answers by handler name. + handlers map[string]bool // schemaFetched records element types already fetched via ped_get_schema // this session (the contract asks for a schema fetch before create/add). @@ -173,14 +166,10 @@ func New(mcpURL, dial string) *Backend { } } -// ConcordConfig configures the optional second MCP client to the Concord -// extension server (gap-filler for capabilities PED lacks) and which of its -// on-Disconnect actions to run. -type ConcordConfig struct { - URL, Dial string - SaveOnExit bool // run save_all (--mcp-save) - CheckOnExit bool // run check_model (--mcp-check) - RunOnExit bool // run run_app (--mcp-run) +// ExitActions selects what Disconnect runs after the command's writes. +type ExitActions struct { + Check bool // project-wide ped_check_errors (--mcp-check) + Run bool // execute_handler mendix_app run (--mcp-run) } // WithTracer attaches a tool-call tracer (--mcp-verbose / --mcp-trace). The same @@ -191,13 +180,10 @@ func (b *Backend) WithTracer(t *backend.Tracer) *Backend { return b } -// WithConcord enables the Concord client. Returns the receiver for chaining. -func (b *Backend) WithConcord(cfg ConcordConfig) *Backend { - b.concordURL = cfg.URL - b.concordDial = cfg.Dial - b.saveOnExit = cfg.SaveOnExit - b.checkOnExit = cfg.CheckOnExit - b.runOnExit = cfg.RunOnExit +// WithExitActions sets what Disconnect runs. Returns the receiver for chaining. +func (b *Backend) WithExitActions(a ExitActions) *Backend { + b.checkOnExit = a.Check + b.runOnExit = a.Run return b } @@ -229,19 +215,6 @@ func (b *Backend) Connect(path string) error { b.server = si b.path = path - // Optional Concord gap-filler client. A failure here is fatal only because the - // user explicitly asked for it (--mcp-concord); PED authoring already succeeded. - if b.concordURL != "" { - cc, err := NewClient(ClientOptions{URL: b.concordURL, Dial: b.concordDial}) - if err != nil { - return fmt.Errorf("create Concord client: %w", err) - } - cc.trace = b.tracer - if _, err := cc.Initialize(); err != nil { - return fmt.Errorf("connect to Concord MCP server %q: %w", b.concordURL, err) - } - b.concord = cc - } return nil } @@ -253,37 +226,31 @@ func (b *Backend) ServerInfo() ServerInfo { return b.server } // --------------------------------------------------------------------------- func (b *Backend) Disconnect() error { - // Persist PED's in-memory writes via Concord before tearing down (PED has no - // save tool). A save failure must not be silent — surface it on stderr — but - // it does not block teardown (the writes still live in Studio Pro's memory). - if b.saveOnExit && b.concord != nil { - if err := b.SaveAll(); err != nil { - fmt.Fprintf(os.Stderr, "warning: --mcp-save failed, changes remain unsaved in Studio Pro: %v\n", err) - } - } - // Validate the (in-memory) model after writes if requested. Diagnostic, so it - // prints to stderr and never blocks teardown. - if b.checkOnExit && b.concord != nil { - if r, err := b.CheckModel(""); err != nil { + // PED persists every write to disk as it lands (measured on 11.12.0 and + // 11.16.0), so there is nothing to save here. + // + // Validate the model after writes if requested. Diagnostic, so it prints to + // stderr and never blocks teardown. + if b.checkOnExit && b.client != nil { + if problems, err := b.CheckProject(); err != nil { fmt.Fprintf(os.Stderr, "warning: --mcp-check failed: %v\n", err) } else { - writeCheckReport(os.Stderr, r) + writeCheckReport(os.Stderr, problems) } } // Start the app (build + deploy current model) and report its URL, for a // change-then-run loop. - if b.runOnExit && b.concord != nil { - if err := b.RunApp(); err != nil { + if b.runOnExit && b.client != nil { + if s, err := b.RunApp(0); err != nil { fmt.Fprintf(os.Stderr, "warning: --mcp-run failed: %v\n", err) - } else if s, err := b.GetAppStatus(); err == nil && s.Data.RunningURL != "" { - fmt.Fprintf(os.Stderr, "app running at %s\n", s.Data.RunningURL) + } else { + writeAppStatus(os.Stderr, s) } } if b.reader != nil { err := b.reader.Disconnect() b.reader = nil b.client = nil - b.concord = nil b.path = "" return err } diff --git a/mdl/backend/mcp/businessevent.go b/mdl/backend/mcp/businessevent.go index aa42daef08..33e83ffab9 100644 --- a/mdl/backend/mcp/businessevent.go +++ b/mdl/backend/mcp/businessevent.go @@ -12,8 +12,8 @@ import ( // the document type outright ("Document type 'BusinessEvents$BusinessEventService' // cannot be created.") — it is off the create whitelist (and the schema is a // $element, not a $constructor). So CREATE / CREATE OR MODIFY are rejected; reads -// (SHOW / DESCRIBE) delegate to the local .mpr; DROP of an existing service goes -// through Concord, like other standalone documents. +// (SHOW / DESCRIBE) delegate to the local .mpr; DROP of an existing service uses +// ped_delete_document (11.16+), like other standalone documents. // // The domain model a business-events setup relies on — a published-event entity // (`extends BusinessEvents.PublishedBusinessEvent`, generalization is supported by @@ -43,8 +43,8 @@ func (b *Backend) UpdateBusinessEventService(svc *model.BusinessEventService) er return errCreatePathUnbuilt("business event service", svc.Name) } -// DeleteBusinessEventService drops an existing service via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteBusinessEventService drops an existing service via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteBusinessEventService(id model.ID) error { services, err := b.reader.ListBusinessEventServices() if err != nil { diff --git a/mdl/backend/mcp/capabilities.go b/mdl/backend/mcp/capabilities.go index 89256b1e43..f069f2efb2 100644 --- a/mdl/backend/mcp/capabilities.go +++ b/mdl/backend/mcp/capabilities.go @@ -52,10 +52,9 @@ type Capability struct { // the user's Studio Pro preferences and configured MCP servers, not just with // versions (ADR-0006 Revision). type Capabilities struct { - ServerName string - ServerVersion string - ProjectVersion string - ConcordConnected bool + ServerName string + ServerVersion string + ProjectVersion string // Tools are the Studio Pro tools present, from the live probe. Tools []string // FederatedTools are tools Studio Pro proxies from MCP servers the user has @@ -184,7 +183,7 @@ func errCreatePathUnbuilt(kind, name string) error { // capabilities builds the effective capability set: the table resolved against // the project's Mendix version, narrowed by the live tool probe, plus live -// identity/Concord. +// identity. // // Cached for the session: every authoring gate calls this, and the probe is a // network round-trip. tools.listChanged means the surface *can* move mid-session, @@ -195,9 +194,8 @@ func (b *Backend) capabilities() Capabilities { return *b.capsCache } caps := Capabilities{ - ServerName: b.server.Name, - ServerVersion: b.server.Version, - ConcordConnected: b.concord != nil, + ServerName: b.server.Name, + ServerVersion: b.server.Version, } var pv *types.ProjectVersion if b.reader != nil { @@ -242,14 +240,21 @@ func (b *Backend) CapabilityReport() string { sb.WriteString("========================\n") fmt.Fprintf(&sb, "Studio Pro MCP server : %s %s\n", orUnknown(caps.ServerName), orUnknown(caps.ServerVersion)) fmt.Fprintf(&sb, "Project Mendix version: %s\n", orUnknown(caps.ProjectVersion)) - concord := "not connected — DROP of standalone docs (enum/microflow/page/…) is unavailable" + drop := "unavailable — needs Studio Pro 11.16+ (ped_delete_document)" if slices.Contains(caps.Tools, "ped_delete_document") { - concord = "not connected — DROP of standalone docs uses ped_delete_document" + drop = "ped_delete_document" } - if caps.ConcordConnected { - concord = "connected" + fmt.Fprintf(&sb, "DROP of documents : %s\n", drop) + check := "unavailable — needs Studio Pro 11.15+ (ped_check_errors filters)" + if b.client != nil && b.client.SupportsToolArg("ped_check_errors", "filters") { + check = "ped_check_errors, project-wide" } - fmt.Fprintf(&sb, "Concord gap-filler : %s\n\n", concord) + fmt.Fprintf(&sb, "--mcp-check : %s\n", check) + run := "unavailable — needs Studio Pro 11.16+ (execute_handler mendix_app)" + if slices.Contains(caps.Tools, "execute_handler") { + run = "execute_handler mendix_app" + } + fmt.Fprintf(&sb, "--mcp-run : %s\n\n", run) sb.WriteString("Authorable over MCP:\n") for _, c := range caps.Features { diff --git a/mdl/backend/mcp/capabilities.yaml b/mdl/backend/mcp/capabilities.yaml index d961bc5ff9..1542273b96 100644 --- a/mdl/backend/mcp/capabilities.yaml +++ b/mdl/backend/mcp/capabilities.yaml @@ -51,7 +51,7 @@ features: feature: "Enumerations" available: true requires_tools: [ped_create_document] - note: "CREATE / DROP (DROP via ped_delete_document on Studio Pro 11.16+, else Concord)" + note: "CREATE / DROP (DROP needs Studio Pro 11.16+, ped_delete_document)" - key: constants feature: "Constants" available: true diff --git a/mdl/backend/mcp/check_errors.go b/mdl/backend/mcp/check_errors.go index 3859231893..17d22264aa 100644 --- a/mdl/backend/mcp/check_errors.go +++ b/mdl/backend/mcp/check_errors.go @@ -4,6 +4,7 @@ package mcp import ( "fmt" + "io" "regexp" "strconv" "strings" @@ -68,7 +69,9 @@ func (b *Backend) checkDocumentNow(docType, docName string) error { } for attempt := 0; ; attempt++ { - problems, stale, err := b.collectCheckErrors(base, docType, docName) + problems, stale, err := b.collectCheckErrors(base, docName, func(text string) []string { + return scopeCheckListing(text, docType, docName) + }) if err != nil { return err } @@ -86,9 +89,10 @@ func (b *Backend) checkDocumentNow(docType, docName string) error { } // collectCheckErrors runs one complete check (first response plus every page) and -// returns the problems listed for the document itself. stale reports that a page -// window went stale and the whole check must be asked again. -func (b *Backend) collectCheckErrors(base map[string]any, docType, docName string) (problems []string, stale bool, err error) { +// returns the problem lines scope keeps from each listing page. docName names what +// was checked in errors. stale reports that a page window went stale and the +// whole check must be asked again. +func (b *Backend) collectCheckErrors(base map[string]any, docName string, scope func(string) []string) (problems []string, stale bool, err error) { res, err := b.client.CallTool("ped_check_errors", base) if err != nil { return nil, false, err @@ -111,7 +115,7 @@ func (b *Backend) collectCheckErrors(base map[string]any, docType, docName strin to, _ := strconv.Atoi(m[2]) total, _ := strconv.Atoi(m[3]) checkID, _ := strconv.Atoi(m[4]) - problems = scopeCheckListing(trimmed, docType, docName) + problems = scope(trimmed) for offset := to; offset < total; { args := make(map[string]any, len(base)+1) @@ -137,7 +141,7 @@ func (b *Backend) collectCheckErrors(base map[string]any, docType, docName strin break } next, _ := strconv.Atoi(pm[2]) - problems = append(problems, scopeCheckListing(page, docType, docName)...) + problems = append(problems, scope(page)...) if next <= offset { break // no progress; never loop } @@ -171,3 +175,50 @@ func scopeCheckListing(text, docType, docName string) []string { } return out } + +// CheckProject runs ped_check_errors over the whole project (--mcp-check) and +// returns its listing: each unit header followed by that unit's problems. Only +// the 11.15+ shape can ask project-wide — the older `documents` argument is +// required and names the documents to check. +func (b *Backend) CheckProject() ([]string, error) { + if b.client == nil || !b.client.SupportsToolArg("ped_check_errors", "filters") { + return nil, fmt.Errorf("a project-wide check needs Studio Pro 11.15+ (ped_check_errors filters)") + } + for attempt := 0; ; attempt++ { + problems, stale, err := b.collectCheckErrors(map[string]any{}, "the project", projectCheckListing) + if err != nil { + return nil, err + } + if !stale { + return problems, nil + } + if attempt >= checkErrorsMaxRefetch { + return nil, fmt.Errorf("the project check did not settle: Studio Pro's error list kept changing between pages") + } + } +} + +// projectCheckListing keeps every line of an 11.15 listing but its page header. +func projectCheckListing(text string) []string { + var out []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, "\r") + if checkListingHeader.MatchString(line) || strings.TrimSpace(line) == "" { + continue + } + out = append(out, line) + } + return out +} + +// writeCheckReport prints a project check's listing, or that it is clean. +func writeCheckReport(w io.Writer, listing []string) { + if len(listing) == 0 { + fmt.Fprintln(w, "model check: no errors found") + return + } + fmt.Fprintln(w, "model check:") + for _, line := range listing { + fmt.Fprintf(w, " %s\n", line) + } +} diff --git a/mdl/backend/mcp/concord.go b/mdl/backend/mcp/concord.go deleted file mode 100644 index 65ba261e73..0000000000 --- a/mdl/backend/mcp/concord.go +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mcp - -import ( - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/mendixlabs/mxcli/model" -) - -// Concord is a Studio Pro extension whose MCP server provides capabilities the -// built-in PED server lacks (delete, save, validate, run). The MCP backend uses -// PED for authoring by default and reaches for Concord ONLY for these gaps. The -// Concord client is optional (nil unless configured via WithConcord); every -// Concord-backed operation errors clearly when it is not configured. - -// concordCall invokes a Concord tool, returning the result text. It errors if -// Concord is not configured or the tool reports failure. Concord tools report -// failure either via the MCP isError flag or a JSON body with "error"/"success": -// false in the result text. -func (b *Backend) concordCall(tool string, args map[string]any) (string, error) { - if b.concord == nil { - return "", fmt.Errorf("%s requires the Concord MCP server — pass --mcp-concord (Concord provides capabilities the built-in server lacks)", tool) - } - res, err := b.concord.CallTool(tool, args) - if err != nil { - return "", err - } - text := pedStripReminder(res.Text) - if res.IsError || concordFailed(text) { - return "", fmt.Errorf("%s: %s", tool, text) - } - return text, nil -} - -// concordFailed reports whether a Concord result body signals failure. Concord -// returns JSON like {"error":"..."} or {"success":false,...} on failure and -// {"success":true,...}/{"status":"ok",...} on success. -func concordFailed(text string) bool { - t := strings.TrimSpace(text) - return strings.Contains(t, `"error"`) || strings.Contains(t, `"success":false`) || strings.Contains(t, `"success": false`) -} - -// SaveAll persists every unsaved change in Studio Pro (Concord save_all, the -// equivalent of Ctrl+S). PED has no save tool, so this is the only way to flush -// PED-authored in-memory writes to disk from mxcli. -func (b *Backend) SaveAll() error { - _, err := b.concordCall("save_all", map[string]any{}) - return err -} - -// concordDeleteDocument removes a standalone document (enumeration, microflow, -// page) via Concord's delete_document — the DROP path for servers without -// ped_delete_document (before 11.16), and deleteDocument's fallback. -func (b *Backend) concordDeleteDocument(moduleName, docName string) error { - _, err := b.concordCall("delete_document", map[string]any{ - "module_name": moduleName, - "document_name": docName, - }) - return err -} - -// CheckItem is one domain-model consistency finding from check_model. -type CheckItem struct { - Module string `json:"module"` - Entity string `json:"entity"` - Code string `json:"code"` - Message string `json:"message"` -} - -// CheckResult is the parsed result of Concord's check_model. -type CheckResult struct { - Success bool `json:"success"` - Healthy bool `json:"healthy"` // true == zero errors (NOT zero warnings) - Summary struct { - TotalItems int `json:"totalItems"` - ErrorCount int `json:"errorCount"` - WarningCount int `json:"warningCount"` - InfoCount int `json:"infoCount"` - } `json:"summary"` - Errors []CheckItem `json:"errors"` - Warnings []CheckItem `json:"warnings"` -} - -// CheckModel runs Concord's domain-model consistency checker, optionally scoped to -// one module (""=whole project). A non-empty Errors slice is a *result*, not a -// tool failure, so this bypasses the flat success/error heuristic and parses the -// structured body directly. -func (b *Backend) CheckModel(moduleName string) (*CheckResult, error) { - if b.concord == nil { - return nil, fmt.Errorf("check_model requires the Concord MCP server — pass --mcp-concord") - } - args := map[string]any{} - if moduleName != "" { - args["module"] = moduleName - } - res, err := b.concord.CallTool("check_model", args) - if err != nil { - return nil, err - } - text := pedStripReminder(res.Text) - if res.IsError { - return nil, fmt.Errorf("check_model: %s", text) - } - var r CheckResult - if err := json.Unmarshal([]byte(text), &r); err != nil { - return nil, fmt.Errorf("check_model: parsing result: %w", err) - } - return &r, nil -} - -// writeCheckReport prints a concise consistency report. Errors and warnings are -// both shown — "healthy" (zero errors) does NOT mean zero warnings. -func writeCheckReport(w io.Writer, r *CheckResult) { - fmt.Fprintf(w, "model check: %d error(s), %d warning(s)\n", r.Summary.ErrorCount, r.Summary.WarningCount) - for _, it := range r.Errors { - fmt.Fprintf(w, " ERROR %s.%s [%s]: %s\n", it.Module, it.Entity, it.Code, it.Message) - } - for _, it := range r.Warnings { - fmt.Fprintf(w, " warn %s.%s [%s]: %s\n", it.Module, it.Entity, it.Code, it.Message) - } -} - -// AppStatus is the parsed result of Concord's get_app_status. -type AppStatus struct { - Status string `json:"status"` - Data struct { - ProjectPath string `json:"projectPath"` - ProjectName string `json:"projectName"` - Running string `json:"running"` - RunningURL string `json:"runningUrl"` - } `json:"data"` -} - -// GetAppStatus returns whether the loaded app is running and its runtime URL -// (Concord get_app_status — read-only, the "what state is the project in" probe). -func (b *Backend) GetAppStatus() (*AppStatus, error) { - if b.concord == nil { - return nil, fmt.Errorf("get_app_status requires the Concord MCP server — pass --mcp-concord") - } - res, err := b.concord.CallTool("get_app_status", map[string]any{}) - if err != nil { - return nil, err - } - text := pedStripReminder(res.Text) - if res.IsError { - return nil, fmt.Errorf("get_app_status: %s", text) - } - var s AppStatus - if err := json.Unmarshal([]byte(text), &s); err != nil { - return nil, fmt.Errorf("get_app_status: parsing result: %w", err) - } - return &s, nil -} - -// RunApp starts the loaded Mendix app on its run profile (Concord run_app, -// equivalent to the green Run button — builds + deploys the current model). -func (b *Backend) RunApp() error { - _, err := b.concordCall("run_app", map[string]any{}) - return err -} - -// StopApp stops the running Mendix runtime (Concord stop_app, the red Stop button). -func (b *Backend) StopApp() error { - _, err := b.concordCall("stop_app", map[string]any{}) - return err -} - -// moduleNameForContainer resolves a container (module) ID to its module name, -// session-aware so freshly created modules resolve too. -func (b *Backend) moduleNameForContainer(containerID model.ID) (string, error) { - // Folder-aware: a document's container may be a folder, not the module directly. - name, _, err := b.resolveDocContainer(containerID) - return name, err -} diff --git a/mdl/backend/mcp/concord_test.go b/mdl/backend/mcp/concord_test.go deleted file mode 100644 index a39b02f648..0000000000 --- a/mdl/backend/mcp/concord_test.go +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package mcp - -import ( - "strings" - "testing" -) - -func TestConcordFailed(t *testing.T) { - cases := map[string]bool{ - `{"success":true,"message":"saved"}`: false, - `{"status":"ok","data":{}}`: false, - `{"error":"not allowed"}`: true, - `{"success":false,"message":"nope"}`: true, - `{"success": false }`: true, - `Module 'X' created successfully.`: false, - } - for text, want := range cases { - if got := concordFailed(text); got != want { - t.Errorf("concordFailed(%q) = %v, want %v", text, got, want) - } - } -} - -func TestConcordCall_NotConfigured(t *testing.T) { - b := &Backend{} // no Concord client - if err := b.SaveAll(); err == nil { - t.Fatal("SaveAll without Concord should error") - } - if _, err := b.concordCall("save_all", nil); err == nil { - t.Fatal("concordCall without Concord should error with an actionable message") - } - if _, err := b.CheckModel(""); err == nil { - t.Fatal("CheckModel without Concord should error") - } - if _, err := b.GetAppStatus(); err == nil { - t.Fatal("GetAppStatus without Concord should error") - } - if err := b.RunApp(); err == nil { - t.Fatal("RunApp without Concord should error") - } - if err := b.StopApp(); err == nil { - t.Fatal("StopApp without Concord should error") - } -} - -func TestWriteCheckReport(t *testing.T) { - r := &CheckResult{Healthy: true} - r.Summary.ErrorCount = 1 - r.Summary.WarningCount = 1 - r.Errors = []CheckItem{{Module: "M", Entity: "E", Code: "bad_ref", Message: "broken"}} - r.Warnings = []CheckItem{{Module: "M", Entity: "W", Code: "no_attributes", Message: "empty"}} - var sb strings.Builder - writeCheckReport(&sb, r) - out := sb.String() - if !strings.Contains(out, "1 error(s), 1 warning(s)") || - !strings.Contains(out, "ERROR M.E [bad_ref]: broken") || - !strings.Contains(out, "warn M.W [no_attributes]: empty") { - t.Fatalf("report:\n%s", out) - } -} diff --git a/mdl/backend/mcp/constant.go b/mdl/backend/mcp/constant.go index abf673a083..eaa860b18c 100644 --- a/mdl/backend/mcp/constant.go +++ b/mdl/backend/mcp/constant.go @@ -102,8 +102,8 @@ func (b *Backend) liveConstantType(qn string) (string, error) { return k, nil } -// DeleteConstant drops a constant via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteConstant drops a constant via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteConstant(id model.ID) error { c, err := b.GetConstant(id) if err != nil { diff --git a/mdl/backend/mcp/delete.go b/mdl/backend/mcp/delete.go index a0f63fe2be..c1eb35650e 100644 --- a/mdl/backend/mcp/delete.go +++ b/mdl/backend/mcp/delete.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "github.com/mendixlabs/mxcli/model" ) // The document types DROP addresses that no create path names. @@ -18,23 +20,23 @@ const ( // deleteDocument drops a standalone named document (constant, enumeration, // microflow, nanoflow, page, workflow, business-event service). // -// Studio Pro 11.16 added ped_delete_document, so a server that advertises it is -// asked directly; older servers have no delete tool and need Concord. A native -// failure falls back to Concord when one is connected: on 11.16.0 the tool fails -// for every call made outside Studio Pro's own agent session ("Session -// 'mcp-server' not found"), measured live. +// Studio Pro 11.16 added ped_delete_document; older servers have no delete tool, +// so DROP over --mcp needs 11.16+. On 11.16.0 the tool fails for every call made +// outside Studio Pro's own agent session ("Session 'mcp-server' not found"), +// measured live — that failure is surfaced, not retried elsewhere. func (b *Backend) deleteDocument(docType, moduleName, docName string) error { if b.client == nil || !b.client.HasTool("ped_delete_document") { - return b.concordDeleteDocument(moduleName, docName) - } - err := b.pedDeleteDocument(docType, moduleName, docName) - if err == nil || b.concord == nil { - return err - } - if cerr := b.concordDeleteDocument(moduleName, docName); cerr != nil { - return fmt.Errorf("%w; Concord fallback: %v", err, cerr) + return fmt.Errorf("DROP of %s.%s needs Studio Pro 11.16+ (ped_delete_document); this Studio Pro's MCP server has no delete tool — delete it in Studio Pro", moduleName, docName) } - return nil + return b.pedDeleteDocument(docType, moduleName, docName) +} + +// moduleNameForContainer resolves a container (module) ID to its module name, +// session-aware so freshly created modules resolve too. +func (b *Backend) moduleNameForContainer(containerID model.ID) (string, error) { + // Folder-aware: a document's container may be a folder, not the module directly. + name, _, err := b.resolveDocContainer(containerID) + return name, err } // pedDeleteDocument calls ped_delete_document for one document. Its success diff --git a/mdl/backend/mcp/delete_test.go b/mdl/backend/mcp/delete_test.go index 61206591cb..e59900f232 100644 --- a/mdl/backend/mcp/delete_test.go +++ b/mdl/backend/mcp/delete_test.go @@ -16,7 +16,7 @@ func TestDeleteDocument_NativeWhenAdvertised(t *testing.T) { return "unexpected", true }) f.tools = map[string][]string{"ped_delete_document": {"documents"}} - b := &Backend{client: f.connectClient(t)} // no Concord: native must not need it + b := &Backend{client: f.connectClient(t)} if err := b.deleteDocument(constantDocType, "MyFirstModule", "Gone"); err != nil { t.Fatalf("deleteDocument: %v", err) @@ -80,33 +80,14 @@ func TestDeleteDocument_NativeFailureSurfaces(t *testing.T) { } } -func TestDeleteDocument_NativeFailureFallsBackToConcord(t *testing.T) { - ped := newFakePED(t, func(name string, _ map[string]any) (string, bool) { - if name == "ped_find_document" { - return `{"foundDocuments":[{"qualifiedName":"MyFirstModule.Gone"}]}`, false - } - return handlerSessionError, false - }) - ped.tools = map[string][]string{"ped_delete_document": {"documents"}} - concord := newFakePED(t, func(string, map[string]any) (string, bool) { return `{"success":true}`, false }) - b := &Backend{client: ped.connectClient(t), concord: concord.connectClient(t)} - - if err := b.deleteDocument(constantDocType, "MyFirstModule", "Gone"); err != nil { - t.Fatalf("deleteDocument should succeed through Concord: %v", err) - } - if _, ok := concord.callByName("delete_document"); !ok { - t.Fatal("Concord delete_document was not called after the native delete failed") - } -} - -func TestDeleteDocument_NoNativeToolNeedsConcord(t *testing.T) { +func TestDeleteDocument_NoNativeToolNeeds1116(t *testing.T) { f := newFakePED(t, nil) f.tools = map[string][]string{"ped_create_document": {"documents"}} // pre-11.16 surface b := &Backend{client: f.connectClient(t)} err := b.deleteDocument(constantDocType, "MyFirstModule", "Gone") - if err == nil || !strings.Contains(err.Error(), "--mcp-concord") { - t.Fatalf("without ped_delete_document the error should point at --mcp-concord, got: %v", err) + if err == nil || !strings.Contains(err.Error(), "needs Studio Pro 11.16+ (ped_delete_document)") { + t.Fatalf("without ped_delete_document the error should name the version it needs, got: %v", err) } if _, ok := f.callByName("ped_delete_document"); ok { t.Error("ped_delete_document must not be called when the server does not advertise it") diff --git a/mdl/backend/mcp/enumeration.go b/mdl/backend/mcp/enumeration.go index 10d02d1b12..cc1f2885e2 100644 --- a/mdl/backend/mcp/enumeration.go +++ b/mdl/backend/mcp/enumeration.go @@ -43,8 +43,8 @@ func (b *Backend) UpdateEnumeration(enum *model.Enumeration) error { return fmt.Errorf("modifying enumeration %q is not yet supported by the MCP backend (create a new one, or edit it in Studio Pro)", enum.Name) } -// DeleteEnumeration drops an enumeration via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteEnumeration drops an enumeration via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteEnumeration(id model.ID) error { enum, err := b.GetEnumeration(id) if err != nil { diff --git a/mdl/backend/mcp/enumeration_test.go b/mdl/backend/mcp/enumeration_test.go index 5c5041e198..92ae20b630 100644 --- a/mdl/backend/mcp/enumeration_test.go +++ b/mdl/backend/mcp/enumeration_test.go @@ -84,9 +84,9 @@ func TestListEnumerations_SessionTakesPrecedence(t *testing.T) { } } -func TestDeleteEnumeration_RequiresConcord(t *testing.T) { - // DROP routes to Concord's delete_document; without --mcp-concord it must - // give an actionable error (not "no delete tool ever"). Use a session module +func TestDeleteEnumeration_NeedsNativeDelete(t *testing.T) { + // DROP routes to ped_delete_document; without a connected server that has it, + // it must give an actionable error naming the version. Use a session module // + enum so the resolve path needs no local reader. b := &Backend{} mod := &model.Module{Name: "M"} @@ -99,7 +99,7 @@ func TestDeleteEnumeration_RequiresConcord(t *testing.T) { b.sessionEnums = append(b.sessionEnums, enum) err := b.DeleteEnumeration(enum.ID) - if err == nil || !strings.Contains(err.Error(), "Concord") { - t.Fatalf("DROP without Concord should error pointing at --mcp-concord, got: %v", err) + if err == nil || !strings.Contains(err.Error(), "11.16+") { + t.Fatalf("DROP without ped_delete_document should name Studio Pro 11.16+, got: %v", err) } } diff --git a/mdl/backend/mcp/microflow.go b/mdl/backend/mcp/microflow.go index 705f4a0d5e..4d7c18c4a3 100644 --- a/mdl/backend/mcp/microflow.go +++ b/mdl/backend/mcp/microflow.go @@ -372,8 +372,8 @@ func (b *Backend) ListMicroflows() ([]*microflows.Microflow, error) { return out, nil } -// DeleteMicroflow drops a microflow via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteMicroflow drops a microflow via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteMicroflow(id model.ID) error { mf, err := b.GetMicroflow(id) if err != nil { diff --git a/mdl/backend/mcp/nanoflow.go b/mdl/backend/mcp/nanoflow.go index 1a04317ee8..5d0372ada1 100644 --- a/mdl/backend/mcp/nanoflow.go +++ b/mdl/backend/mcp/nanoflow.go @@ -14,7 +14,7 @@ import ( // Did you mean: Microflows$Microflow?") — its create whitelist covers microflows // but not nanoflows, despite the two sharing a structure. CREATE / CREATE OR MODIFY // are therefore rejected with an actionable error. DROP of an existing nanoflow -// still works via Concord's delete_document, like microflows; reads delegate to the +// uses ped_delete_document (11.16+), like microflows; reads delegate to the // local .mpr. // CreateNanoflow is gated on the capability model (capabilities.yaml / @@ -35,8 +35,8 @@ func (b *Backend) UpdateNanoflow(nf *microflows.Nanoflow) error { return errCreatePathUnbuilt("nanoflow", nf.Name) } -// DeleteNanoflow drops an existing nanoflow via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteNanoflow drops an existing nanoflow via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteNanoflow(id model.ID) error { nf, err := b.GetNanoflow(id) if err != nil { diff --git a/mdl/backend/mcp/page.go b/mdl/backend/mcp/page.go index 99e5ea13d3..d497fc1090 100644 --- a/mdl/backend/mcp/page.go +++ b/mdl/backend/mcp/page.go @@ -105,8 +105,8 @@ func (b *Backend) ListPages() ([]*pages.Page, error) { return out, nil } -// DeletePage drops a page via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeletePage drops a page via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeletePage(id model.ID) error { page, err := b.GetPage(id) if err != nil { diff --git a/mdl/backend/mcp/workflow.go b/mdl/backend/mcp/workflow.go index 08f089d6e1..793e163cb6 100644 --- a/mdl/backend/mcp/workflow.go +++ b/mdl/backend/mcp/workflow.go @@ -281,8 +281,8 @@ func (b *Backend) upsertSessionWorkflow(wf *workflows.Workflow) { b.sessionWorkflows = append(b.sessionWorkflows, wf) } -// DeleteWorkflow drops a workflow via deleteDocument: ped_delete_document where Studio Pro has it (11.16+), else -// Concord (--mcp-concord). +// DeleteWorkflow drops a workflow via deleteDocument: ped_delete_document +// (Studio Pro 11.16+). func (b *Backend) DeleteWorkflow(id model.ID) error { wf, err := b.GetWorkflow(id) if err != nil { diff --git a/mdl/executor/cmd_lint.go b/mdl/executor/cmd_lint.go index d19b7dc296..9dfeb00c63 100644 --- a/mdl/executor/cmd_lint.go +++ b/mdl/executor/cmd_lint.go @@ -35,7 +35,7 @@ func execLint(ctx *ExecContext, s *ast.LintStmt) error { rules.NewDomainModelSizeRule(), rules.NewValidationFeedbackRule(), rules.NewImageSourceRule(), - rules.NewLegacyImageWidgetRule(), + rules.NewReactUnsupportedWidgetRule(), rules.NewSnippetCallAppearanceRule(), rules.NewMissingTranslationsRule(), rules.NewRequiredCaptionDefaultLanguageRule(), @@ -153,7 +153,7 @@ func listLintRules(ctx *ExecContext) error { lint.AddRule(rules.NewDomainModelSizeRule()) lint.AddRule(rules.NewValidationFeedbackRule()) lint.AddRule(rules.NewImageSourceRule()) - lint.AddRule(rules.NewLegacyImageWidgetRule()) + lint.AddRule(rules.NewReactUnsupportedWidgetRule()) lint.AddRule(rules.NewSnippetCallAppearanceRule()) lint.AddRule(rules.NewMissingTranslationsRule()) lint.AddRule(rules.NewRequiredCaptionDefaultLanguageRule()) diff --git a/mdl/executor/widget_defs_test.go b/mdl/executor/widget_defs_test.go index 49a97d7e24..242ed78b71 100644 --- a/mdl/executor/widget_defs_test.go +++ b/mdl/executor/widget_defs_test.go @@ -729,7 +729,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "attribute", Operation: "attribute", AttributePath: "Mod.Ent.Foo"}, }, } - applyColumnHeaderFallback(spec1, "col1", true) + applyColumnHeaderFallback(spec1, true) if len(spec1.Properties) != 2 { t.Errorf("Case 1 (header present): expected 2 properties, got %d", len(spec1.Properties)) } @@ -741,7 +741,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "staticName", Operation: "primitive", PrimitiveVal: "x"}, }, } - applyColumnHeaderFallback(spec2, "series1", false) + applyColumnHeaderFallback(spec2, false) if len(spec2.Properties) != 1 { t.Errorf("Case 2 (no header slot): expected 1 property, got %d", len(spec2.Properties)) } @@ -752,7 +752,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "attribute", Operation: "attribute", AttributePath: "Mod.Ent.OrderNumber"}, }, } - applyColumnHeaderFallback(spec3, "col3", true) + applyColumnHeaderFallback(spec3, true) if len(spec3.Properties) != 2 { t.Fatalf("Case 3 (fallback): expected 2 properties, got %d", len(spec3.Properties)) } @@ -767,7 +767,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "attribute", Operation: "attribute", AttributePath: "BareName"}, }, } - applyColumnHeaderFallback(spec4, "col4", true) + applyColumnHeaderFallback(spec4, true) if len(spec4.Properties) != 2 || spec4.Properties[1].TextTemplate != "BareName" { t.Errorf("Case 4: fallback for unqualified path = %v", spec4.Properties) } @@ -783,7 +783,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "attribute", Operation: "attribute", AttributePath: "Mod.Ent.Amount"}, }, } - applyColumnHeaderFallback(spec5, "col5", true) + applyColumnHeaderFallback(spec5, true) if len(spec5.Properties) != 2 { t.Fatalf("Case 5 (empty caption): expected 2 properties (no duplicate), got %d", len(spec5.Properties)) } @@ -799,38 +799,42 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "attribute", Operation: "attribute", AttributePath: "Mod.Ent.Amount"}, }, } - applyColumnHeaderFallback(spec6, "col6", true) + applyColumnHeaderFallback(spec6, true) if spec6.Properties[0].TextTemplate != "" { t.Errorf("Case 6: param-bearing header must not be overwritten, got TextTemplate=%q", spec6.Properties[0].TextTemplate) } - // Case 7 (ledger #54, custom-content column): no attribute, empty header — - // fall back to the COLUMN NAME in place (a custom-content column has nothing - // to derive a header from, and an empty header is CE0463). + // Case 7 (ako/mxcli#535): a custom-content column with `Caption: ''` and no + // attribute gets an EMPTY header, never the column's name. Mendix stores no + // name for a column, and Data grid 2 renders an empty header blank — so + // writing the name put an internal identifier ("colTriage") in front of end + // users. The empty header is the empty Forms$ClientTemplate update-widgets + // writes (ChipCoV8 #11), which mx check accepts (0 errors on 11.15.0, the + // keyword `datagrid` path has always written it). spec7 := &backend.ObjectListItemSpec{ Properties: []backend.ObjectListItemProperty{ {PropertyKey: "header", Operation: "texttemplate", TextTemplate: ""}, {PropertyKey: "showContentAs", Operation: "primitive", PrimitiveVal: "customContent"}, }, } - applyColumnHeaderFallback(spec7, "colActions", true) - if h := spec7.Properties[0]; h.PropertyKey != "header" || h.TextTemplate != "colActions" { - t.Errorf("Case 7: empty custom-content header not filled with column name: %+v", spec7.Properties[0]) + applyColumnHeaderFallback(spec7, true) + if h := spec7.Properties[0]; h.PropertyKey != "header" || h.TextTemplate != "" || !h.EmptyTemplate { + t.Errorf("Case 7: empty custom-content header = %+v, want an empty texttemplate with EmptyTemplate=true (not the column name)", spec7.Properties[0]) } - // Case 8 (ledger #54): custom-content column with NO header prop at all — - // append a header carrying the column name (absent header is CE0463 too). + // Case 8 (ako/mxcli#535): custom-content column with NO header prop at all — + // append an empty header, the same as Case 7. spec8 := &backend.ObjectListItemSpec{ Properties: []backend.ObjectListItemProperty{ {PropertyKey: "showContentAs", Operation: "primitive", PrimitiveVal: "customContent"}, }, } - applyColumnHeaderFallback(spec8, "colActions", true) + applyColumnHeaderFallback(spec8, true) if len(spec8.Properties) != 2 { t.Fatalf("Case 8 (absent custom-content header): expected 2 properties, got %d", len(spec8.Properties)) } - if h := spec8.Properties[1]; h.PropertyKey != "header" || h.TextTemplate != "colActions" { - t.Errorf("Case 8: appended header = %+v, want TextTemplate=colActions", spec8.Properties[1]) + if h := spec8.Properties[1]; h.PropertyKey != "header" || h.TextTemplate != "" || !h.EmptyTemplate { + t.Errorf("Case 8: appended header = %+v, want an empty texttemplate with EmptyTemplate=true", spec8.Properties[1]) } // Case 9 (ChipCoV8 #11): an UNNAMED column with no caption and no attribute @@ -844,7 +848,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "showContentAs", Operation: "primitive", PrimitiveVal: "customContent"}, }, } - applyColumnHeaderFallback(spec9, "", true) + applyColumnHeaderFallback(spec9, true) if len(spec9.Properties) != 2 { t.Fatalf("Case 9 (unnamed, empty caption): expected 2 properties (no duplicate), got %d", len(spec9.Properties)) } @@ -859,7 +863,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "showContentAs", Operation: "primitive", PrimitiveVal: "customContent"}, }, } - applyColumnHeaderFallback(spec10, "", true) + applyColumnHeaderFallback(spec10, true) if len(spec10.Properties) != 2 { t.Fatalf("Case 10 (unnamed, absent header): expected 2 properties, got %d", len(spec10.Properties)) } @@ -874,7 +878,7 @@ func TestApplyColumnHeaderFallback(t *testing.T) { {PropertyKey: "staticName", Operation: "texttemplate", TextTemplate: ""}, }, } - applyColumnHeaderFallback(spec11, "", false) + applyColumnHeaderFallback(spec11, false) if len(spec11.Properties) != 1 || spec11.Properties[0].EmptyTemplate { t.Errorf("Case 11 (no header slot): must be untouched, got %+v", spec11.Properties) } diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index cfb0770f17..0b30c6b527 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -2299,11 +2299,10 @@ func (e *PluggableWidgetEngine) buildObjectListItem(mapping *ObjectListMapping, // DataGrid column header convention: when the user provides no Caption, // fall back to the bound attribute name — or, for a custom-content column - // with no attribute, the column's own name. Mirrors buildDataGrid2ColumnObject - // in datagrid_builder.go. Without this, an empty/absent column header trips - // Studio Pro's CE0463 (ledger #54). Only applies to items whose template has - // a `header` slot (datagrid columns), never to header-less object-list items - // like chart series. + // with no attribute, an empty header. Mirrors the keyword path + // (datagrid_column.go). Only applies to items whose template has a `header` + // slot (datagrid columns), never to header-less object-list items like chart + // series. hasHeaderSlot := false for _, ip := range mapping.ItemProperties { if strings.EqualFold(ip.PropertyKey, "header") { @@ -2311,7 +2310,7 @@ func (e *PluggableWidgetEngine) buildObjectListItem(mapping *ObjectListMapping, break } } - applyColumnHeaderFallback(&spec, child.Name, hasHeaderSlot) + applyColumnHeaderFallback(&spec, hasHeaderSlot) // DataGrid column sortable convention: attribute-less columns (typical // "Actions" custom-content columns) default to sortable=false, since @@ -2400,12 +2399,20 @@ func (e *PluggableWidgetEngine) buildObjectListItem(mapping *ObjectListMapping, // item: the slot keyed "content" if present, otherwise the first slot. // Returns "" if there are no widgets-typed slots. // applyColumnHeaderFallback synthesizes a texttemplate spec for the `header` -// property when the spec has an attribute binding but no header text. Mirrors -// the keyword path's `if caption == "" { caption = col.Attribute }` fallback -// in datagrid_builder.go. The check is conservative: only fires when the -// header slot is genuinely empty, so it's safe to call for any object-list -// item kind. -func applyColumnHeaderFallback(spec *backend.ObjectListItemSpec, columnName string, hasHeaderSlot bool) { +// property when the spec has no header text: the bound attribute's leaf name, or +// — for a column with no attribute — an empty header. Mirrors the keyword path's +// `if caption == "" { caption = col.Attribute }` in datagrid_column.go. The check +// is conservative: only fires when the header slot is genuinely empty, so it's +// safe to call for any object-list item kind. +// +// A custom-content column used to fall back to the column's own NAME (ledger +// #54 round 2), on the belief that an empty header is CE0463. It is not: the +// CE0463 was a null TextTemplate, and the empty Forms$ClientTemplate that +// update-widgets writes builds clean (ChipCoV8 #11; 0 errors on 11.15.0, where +// the keyword path writes exactly that). The name, meanwhile, is one Mendix +// does not store for a column, and Data grid 2 renders the header text as is — +// so `Caption: ”` showed end users "colTriage" (ako/mxcli#535). +func applyColumnHeaderFallback(spec *backend.ObjectListItemSpec, hasHeaderSlot bool) { // Only items whose template has a `header` slot (datagrid columns) get a // header fallback; header-less object-list items (chart series, accordion // groups) are left untouched. @@ -2420,10 +2427,7 @@ func applyColumnHeaderFallback(spec *backend.ObjectListItemSpec, columnName stri case "header": headerIdx = i // An explicit `Caption: ''` reaches here as a texttemplate with no - // text and no params. Studio Pro rejects an empty column header with - // CE0463 "widget definition changed" (ledger #54) — the same failure - // an absent header would cause without this fallback. Treat empty as - // absent so the fallback applies either way. + // text and no params; treat it like an absent header. headerEmpty = p.Operation == "texttemplate" && p.TextTemplate == "" && len(p.Parameters) == 0 case "attribute": attrPath = p.AttributePath @@ -2433,23 +2437,17 @@ func applyColumnHeaderFallback(spec *backend.ObjectListItemSpec, columnName stri if headerIdx >= 0 && !headerEmpty { return } - // Fallback header text: the bound attribute's leaf name (Module.Entity.Attr → - // Attr), or — for a custom-content / action column with no attribute — the - // column's own name. Every datagrid column needs a NON-empty header; an empty - // or absent one is CE0463 (ledger #54), and a custom-content column has no - // attribute to derive one from, so the column name is the sensible default. + // Fallback header text: the bound attribute's leaf name + // (Module.Entity.Attr → Attr). fallback := attrPath if idx := strings.LastIndex(fallback, "."); idx >= 0 { fallback = fallback[idx+1:] } if fallback == "" { - fallback = columnName - } - if fallback == "" { - // An unnamed column with no caption and no attribute has nothing to - // derive a header from. It must still not serialize as TextTemplate: - // null — `mx update-widgets` writes an empty Forms$ClientTemplate for - // the header, so null is CE0463 (ChipCoV8 #11). Flag the header + // No attribute to name the column after: an empty header, which Data + // grid 2 renders blank. It must not serialize as TextTemplate: null — + // `mx update-widgets` writes an empty Forms$ClientTemplate for the + // header, so null is CE0463 (ChipCoV8 #11). Flag the header // EmptyTemplate; scoped by the hasHeaderSlot gate above to datagrid // columns, never to other texttemplates. if headerIdx >= 0 { diff --git a/mdl/linter/report.go b/mdl/linter/report.go index 104c12f2c3..96d6630030 100644 --- a/mdl/linter/report.go +++ b/mdl/linter/report.go @@ -56,6 +56,7 @@ var categoryMapping = map[string]string{ "SEC008": "Security", "SEC009": "Security", "SEC010": "Security", + "SEC011": "Security", "CONV006": "Security", "CONV007": "Security", "CONV008": "Security", diff --git a/mdl/linter/rules/legacy_image_widget.go b/mdl/linter/rules/react_unsupported_widget.go similarity index 50% rename from mdl/linter/rules/legacy_image_widget.go rename to mdl/linter/rules/react_unsupported_widget.go index 03f2e4d81a..a7a774993e 100644 --- a/mdl/linter/rules/legacy_image_widget.go +++ b/mdl/linter/rules/react_unsupported_widget.go @@ -8,30 +8,35 @@ import ( "github.com/mendixlabs/mxcli/mdl/linter" ) -// LegacyImageWidgetRule reports Mendix's two legacy image widgets, neither of -// which the React client supports. +// ReactUnsupportedWidgetRule reports the built-in widgets the React client does +// not support: the two legacy image widgets, the classic drop-down, the legacy +// data grid and the reference selector (ako/mxcli#564). // // # Why this is correctness, not style // // The React client arrived in Mendix 10.7, and mxbuild reports CE0582 on each -// of these widgets whenever it is enabled — -// measured on 11.12.1, where a page carrying one is a build ERROR: +// of these widgets whenever it is enabled — measured on 11.12.1, where a page +// carrying one is a build ERROR: // // [error] [CE0582] "Widget static image is not supported in React client. // Right-click this error to convert it to an alternative // widget type." at Static image 'imgLogo' // -// Studio Pro offers the conversion from that error's context menu; the -// replacement is the pluggable Image widget, which mxcli spells `image` and -// which takes the same `Image:` reference. +// Studio Pro offers the conversion from that error's context menu. Which +// widgets are in the set is measured, not taken from the documentation (see +// reactUnsupportedWidgets). // // # Why mxcli writes them at all // // Because a project being converted UP already contains them, and mxcli's job -// there is to round-trip the model rather than quietly rewrite it. Both widgets -// are authorable and both round-trip through DESCRIBE. What was missing was -// anyone saying so at author time: `mxcli check` is silent on them, and the only -// signal was CE0582 at the far end of a build. +// there is to round-trip the model rather than quietly rewrite it. The image +// widgets and the drop-down are authorable and round-trip through DESCRIBE; the +// legacy data grid and the reference selector come from Studio Pro. What was +// missing was anyone saying so: `mxcli check` is silent on them, and the only +// signal was CE0582 at the far end of a build. The drop-down is the exception +// at author time — `check --references` refuses it in a script (MDL-WIDGET40) +// — but not for the drop-downs a project already holds, which only this rule +// reports. // // # Why the LINTER and not `mxcli check` // @@ -50,54 +55,78 @@ import ( // (notPlatformModule excludes any module with a Source), which is what keeps the // rule off the Studio Pro-authored static images a blank app inherits from // FeedbackModule — content the reader cannot fix and an update would replace. -type LegacyImageWidgetRule struct{} +type ReactUnsupportedWidgetRule struct{} -// NewLegacyImageWidgetRule creates a new legacy image widget rule. -func NewLegacyImageWidgetRule() *LegacyImageWidgetRule { - return &LegacyImageWidgetRule{} +// NewReactUnsupportedWidgetRule creates the MPR012 rule. +func NewReactUnsupportedWidgetRule() *ReactUnsupportedWidgetRule { + return &ReactUnsupportedWidgetRule{} } -func (r *LegacyImageWidgetRule) ID() string { return "MPR012" } -func (r *LegacyImageWidgetRule) Name() string { return "LegacyImageWidget" } -func (r *LegacyImageWidgetRule) Category() string { return "correctness" } -func (r *LegacyImageWidgetRule) DefaultSeverity() linter.Severity { return linter.SeverityWarning } +func (r *ReactUnsupportedWidgetRule) ID() string { return "MPR012" } +func (r *ReactUnsupportedWidgetRule) Name() string { return "ReactUnsupportedWidget" } +func (r *ReactUnsupportedWidgetRule) Category() string { return "correctness" } +func (r *ReactUnsupportedWidgetRule) DefaultSeverity() linter.Severity { return linter.SeverityWarning } // RequiredCatalogMode: ctx.Widgets() reads widgets_data, which only a full // catalog build fills; under the default fast build the rule found nothing. -func (r *LegacyImageWidgetRule) RequiredCatalogMode() linter.CatalogMode { return linter.CatalogFull } +func (r *ReactUnsupportedWidgetRule) RequiredCatalogMode() linter.CatalogMode { + return linter.CatalogFull +} -func (r *LegacyImageWidgetRule) Description() string { - return "Checks for the legacy static/dynamic image widgets, which the React client does not support (CE0582)" +func (r *ReactUnsupportedWidgetRule) Description() string { + return "Checks for built-in widgets the React client does not support — static/dynamic image, drop-down, legacy data grid, reference selector (CE0582)" } -// LegacyImage describes one of the two widgets, in the words the author will -// meet elsewhere: Mendix's own term (which CE0582 uses) and the MDL keyword. -type LegacyImage struct { - MendixTerm string - MDLKeyword string +// ReactUnsupported describes one widget of the set, in the words the author +// will meet elsewhere: Mendix's own term (which CE0582 uses), the MDL keyword +// (empty for a widget MDL cannot write), and what to use instead. +type ReactUnsupported struct { + MendixTerm string + MDLKeyword string + Replacement string } -// legacyImageWidgets is deliberately a deny-list of exactly two storage names. -// An allow-list would make every unrecognised widget a violation, and the one -// widget it must never fire on is the pluggable Image — the replacement. +// reactUnsupportedWidgets is deliberately a deny-list of measured storage +// names. An allow-list would make every unrecognised widget a violation, and +// the widgets it must never fire on are the pluggable replacements. +// +// Measured with mxbuild on projects set to the React client: +// - 11.12.1: static image; 11.14.0: drop-down (MDL-WIDGET40); +// - 11.16.0 (FactoryManagement): data grid, reference selector, and the +// reference selector with a select page — the same Forms$ReferenceSelector; +// static and dynamic image again. +// +// And measured silent, so left out: navigation tree, menu bar, simple menu bar, +// file manager, scroll container (11.16.0); image uploader (11.13.0); text box, +// text area, check box, date picker, radio buttons, list view, group box, +// navigation list (11.15.0). The template grid and the reference set selectors +// were on no web page available to measure, so they are not claimed either way. // // DocumentTemplates$StaticImageViewer is a different type in a different // document kind and is not covered: document templates are not pages, the React // client does not render them, and CE0582 does not mention them. -var legacyImageWidgets = map[string]LegacyImage{ - "Forms$StaticImageViewer": {MendixTerm: "static image", MDLKeyword: "staticimage"}, - "Forms$ImageViewer": {MendixTerm: "dynamic image", MDLKeyword: "dynamicimage"}, +var reactUnsupportedWidgets = map[string]ReactUnsupported{ + "Forms$StaticImageViewer": {"static image", "staticimage", + "Replace `staticimage` with the pluggable `image` widget (same `Image:` reference)"}, + "Forms$ImageViewer": {"dynamic image", "dynamicimage", + "Replace `dynamicimage` with the pluggable `image` widget (same `Image:` reference)"}, + "Forms$DropDown": {"drop-down", "dropdown", + "Replace `dropdown` with `combobox` over the same attribute"}, + "Forms$DataGrid": {"data grid", "", + "Replace the legacy data grid with Data grid 2 (`datagrid`)"}, + "Forms$ReferenceSelector": {"reference selector", "", + "Replace the reference selector with `combobox` over the same association"}, } -// LegacyImageWidget reports whether widgetType is one of the two legacy image -// widgets, and how to name it. -func LegacyImageWidget(widgetType string) (LegacyImage, bool) { - got, ok := legacyImageWidgets[widgetType] +// ReactUnsupportedWidget reports whether widgetType is one the React client +// does not support, and how to name and replace it. +func ReactUnsupportedWidget(widgetType string) (ReactUnsupported, bool) { + got, ok := reactUnsupportedWidgets[widgetType] return got, ok } -// Check reports one violation per legacy image widget found. -func (r *LegacyImageWidgetRule) Check(ctx *linter.LintContext) []linter.Violation { +// Check reports one violation per React-unsupported widget found. +func (r *ReactUnsupportedWidgetRule) Check(ctx *linter.LintContext) []linter.Violation { var violations []linter.Violation native := ctx.NativePages() classic := runsClassicClient(ctx) @@ -106,7 +135,7 @@ func (r *LegacyImageWidgetRule) Check(ctx *linter.LintContext) []linter.Violatio if ctx.IsExcluded(w.ModuleName) { continue } - legacy, ok := LegacyImageWidget(w.WidgetType) + legacy, ok := ReactUnsupportedWidget(w.WidgetType) if !ok { continue } @@ -141,9 +170,7 @@ func (r *LegacyImageWidgetRule) Check(ctx *linter.LintContext) []linter.Violatio DocumentName: docNameFromQualified(w.ContainerQualifiedName), DocumentID: w.ContainerID, }, - Suggestion: fmt.Sprintf( - "Replace `%s` with the pluggable `image` widget (same `Image:` reference), or convert it in Studio Pro from the CE0582 error's context menu", - legacy.MDLKeyword), + Suggestion: legacy.Replacement + ", or convert it in Studio Pro from the CE0582 error's context menu", }) } diff --git a/mdl/linter/rules/react_unsupported_widget_564_test.go b/mdl/linter/rules/react_unsupported_widget_564_test.go new file mode 100644 index 0000000000..1dfd85e15a --- /dev/null +++ b/mdl/linter/rules/react_unsupported_widget_564_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "sort" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/model" +) + +// ako/mxcli#564: MPR012 named only the two legacy image widgets, but CE0582 is +// a whole set. Measured with mxbuild on a project switched to the React client +// (Web UI → UseOptimizedClient = Yes): +// +// - 11.16.0, FactoryManagement: CE0582 on "data grid", "reference selector" +// and "reference selector (with select page)" — both are +// Forms$ReferenceSelector — beside the static and dynamic image; +// - 11.14.0: CE0582 on the classic drop-down (MDL-WIDGET40's measurement). +// +// The controls are measured too, and must stay silent: in the same 11.16 build +// the navigation tree, menu bar, simple menu bar, file manager and scroll +// container raised nothing; on 11.13.0 (JTSBootLogboek) neither did the image +// uploader; on a fresh 11.15.0 app neither did a text box, text area, check +// box, date picker, radio buttons, file manager, list view, group box or +// navigation list. The template grid and the reference set selectors were on +// no web page available to measure, so they are not claimed either way. +func TestReactUnsupportedWidgets_CoverTheMeasuredCE0582Set(t *testing.T) { + cat, err := catalog.New() + if err != nil { + t.Fatal(err) + } + defer cat.Close() + + unsupported := map[string]string{ // storage type -> mxbuild's term in CE0582 + "Forms$StaticImageViewer": "static image", + "Forms$ImageViewer": "dynamic image", + "Forms$DropDown": "drop-down", + "Forms$DataGrid": "data grid", + "Forms$ReferenceSelector": "reference selector", + } + supported := []string{ + "Forms$NavigationTree", "Forms$MenuBar", "Forms$SimpleMenuBar", "Forms$FileManager", + "Forms$ScrollContainer", "Forms$ImageUploader", "Forms$TextBox", "Forms$TextArea", + "Forms$CheckBox", "Forms$DatePicker", "Forms$RadioButtonGroup", "Forms$ListView", + "Forms$GroupBox", "Forms$NavigationList", + "CustomWidgets$CustomWidget", // the replacements: image, combobox, datagrid + // unmeasured, so not claimed + "Forms$TemplateGrid", "Forms$ReferenceSetSelector", "Forms$InputReferenceSetSelector", + } + + stmts := []string{ + `INSERT INTO modules_data (Id, Name, QualifiedName, ModuleName, Source) VALUES + ('m1', 'MyFirstModule', 'MyFirstModule', 'MyFirstModule', '')`, + `INSERT INTO pages_data (Id, Name, QualifiedName, ModuleName, LayoutRef) VALUES + ('p1', 'P_Legacy', 'MyFirstModule.P_Legacy', 'MyFirstModule', 'Atlas_Core.Atlas_Default')`, + } + n := 0 + add := func(widgetType string) { + n++ + stmts = append(stmts, `INSERT INTO widgets_data (Id, Name, WidgetType, ContainerId, ContainerQualifiedName, ContainerType, ModuleName) + VALUES ('w`+strings.Repeat("x", n)+`', '`+widgetType+`', '`+widgetType+`', 'p1', 'MyFirstModule.P_Legacy', 'PAGE', 'MyFirstModule')`) + } + for wt := range unsupported { + add(wt) + } + for _, wt := range supported { + add(wt) + } + for _, stmt := range stmts { + if _, err := cat.CatalogDB().Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + + reader := settingsReader{ps: &model.ProjectSettings{WebUI: &model.WebUISettings{UseOptimizedClient: "Yes"}}} + vs := NewReactUnsupportedWidgetRule().Check(linter.NewLintContext(cat, reader)) + + var reported []string + for _, v := range vs { + // The widget is named after its storage type in this fixture, so the + // message says which one it was. + hit := "" + for wt := range unsupported { + if strings.Contains(v.Message, "'"+wt+"'") { + hit = wt + } + } + if hit == "" { + t.Errorf("reported a widget outside the measured CE0582 set: %q", v.Message) + continue + } + reported = append(reported, hit) + // mxbuild's own words, so the lint finding and the build error match. + if !strings.HasPrefix(v.Message, unsupported[hit]+" ") { + t.Errorf("%s: message %q does not open with mxbuild's term %q", hit, v.Message, unsupported[hit]) + } + if v.Suggestion == "" { + t.Errorf("%s: no suggestion — the replacement is the useful half of the finding", hit) + } + } + sort.Strings(reported) + var want []string + for wt := range unsupported { + want = append(want, wt) + } + sort.Strings(want) + if strings.Join(reported, ",") != strings.Join(want, ",") { + t.Errorf("reported %v, want exactly %v", reported, want) + } +} diff --git a/mdl/linter/rules/legacy_image_widget_client_1287_test.go b/mdl/linter/rules/react_unsupported_widget_client_1287_test.go similarity index 95% rename from mdl/linter/rules/legacy_image_widget_client_1287_test.go rename to mdl/linter/rules/react_unsupported_widget_client_1287_test.go index a133b9a197..0a503629aa 100644 --- a/mdl/linter/rules/legacy_image_widget_client_1287_test.go +++ b/mdl/linter/rules/react_unsupported_widget_client_1287_test.go @@ -25,7 +25,7 @@ func (r settingsReader) GetProjectSettings() (*model.ProjectSettings, error) { r // on an app at 0 errors (mendixlabs/mxcli#1226, ako/mxcli#1287). Measured on 11.14.0 for the same CE0582 on a classic // drop-down: an error under "Yes", clean under "No" and "MigrationMode" // (usesReactClient in mdl/executor). -func TestLegacyImageWidgetRule_FollowsTheProjectsWebClient(t *testing.T) { +func TestReactUnsupportedWidgetRule_FollowsTheProjectsWebClient(t *testing.T) { cat, err := catalog.New() if err != nil { t.Fatal(err) @@ -45,7 +45,7 @@ func TestLegacyImageWidgetRule_FollowsTheProjectsWebClient(t *testing.T) { } check := func(reader linter.LintReader) linter.Violation { t.Helper() - vs := NewLegacyImageWidgetRule().Check(linter.NewLintContext(cat, reader)) + vs := NewReactUnsupportedWidgetRule().Check(linter.NewLintContext(cat, reader)) if len(vs) != 1 { t.Fatalf("want one MPR012 for img1, got %d: %v", len(vs), vs) } diff --git a/mdl/linter/rules/legacy_image_widget_test.go b/mdl/linter/rules/react_unsupported_widget_test.go similarity index 88% rename from mdl/linter/rules/legacy_image_widget_test.go rename to mdl/linter/rules/react_unsupported_widget_test.go index 18a3cf89fc..9b96083c08 100644 --- a/mdl/linter/rules/legacy_image_widget_test.go +++ b/mdl/linter/rules/react_unsupported_widget_test.go @@ -22,7 +22,7 @@ import ( // mxcli can author both (it must: a project being converted up already contains // them), and nothing told the author they were reaching for a widget their app // cannot build. This rule is that telling. -func TestLegacyImageWidget_NamesBothTypesAndTheReplacement(t *testing.T) { +func TestReactUnsupportedWidget_NamesTheImageTypesAndTheReplacement(t *testing.T) { cases := []struct { widgetType string wantTerm string @@ -33,7 +33,7 @@ func TestLegacyImageWidget_NamesBothTypesAndTheReplacement(t *testing.T) { } for _, c := range cases { t.Run(c.widgetType, func(t *testing.T) { - got, ok := LegacyImageWidget(c.widgetType) + got, ok := ReactUnsupportedWidget(c.widgetType) if !ok { t.Fatalf("%s not recognised as a legacy image widget", c.widgetType) } @@ -49,11 +49,11 @@ func TestLegacyImageWidget_NamesBothTypesAndTheReplacement(t *testing.T) { } } -// The CONTROL, and the reason this is a deny-list of two rather than anything +// The CONTROL, and the reason this is a deny-list rather than anything // broader: every other widget must be silent. A rule that fires on the // PLUGGABLE image — the widget it tells people to move TO — would be worse than // no rule at all. -func TestLegacyImageWidget_IsSilentOnEverythingElse(t *testing.T) { +func TestReactUnsupportedWidget_IsSilentOnTheReplacements(t *testing.T) { for _, widgetType := range []string{ "CustomWidgets$CustomWidget", // the pluggable image lives here "Forms$DynamicText", @@ -61,8 +61,8 @@ func TestLegacyImageWidget_IsSilentOnEverythingElse(t *testing.T) { "DocumentTemplates$StaticImageViewer", // a document template, not a page "", } { - if got, ok := LegacyImageWidget(widgetType); ok { - t.Errorf("%q was reported as a legacy image widget (%+v)", widgetType, got) + if got, ok := ReactUnsupportedWidget(widgetType); ok { + t.Errorf("%q was reported as React-unsupported (%+v)", widgetType, got) } } } @@ -70,8 +70,8 @@ func TestLegacyImageWidget_IsSilentOnEverythingElse(t *testing.T) { // The rule's identity is part of its contract: an ID that collides with another // rule's silently shadows it in the config, and the category decides what // `--category` filters it into. -func TestLegacyImageWidgetRule_Identity(t *testing.T) { - r := NewLegacyImageWidgetRule() +func TestReactUnsupportedWidgetRule_Identity(t *testing.T) { + r := NewReactUnsupportedWidgetRule() if r.ID() != "MPR012" { t.Errorf("ID = %q, want MPR012", r.ID()) } @@ -90,7 +90,7 @@ func TestLegacyImageWidgetRule_Identity(t *testing.T) { // platform is what tells them apart — note that the native layout lives in a // Marketplace module (Atlas_Core), so the lookup must not apply the // platform-module filter the iterators use. The web page is the control. -func TestLegacyImageWidgetRule_SkipsNativePages(t *testing.T) { +func TestReactUnsupportedWidgetRule_SkipsNativePages(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) @@ -122,7 +122,7 @@ func TestLegacyImageWidgetRule_SkipsNativePages(t *testing.T) { t.Fatalf("%s: %v", stmt, err) } } - violations := NewLegacyImageWidgetRule().Check(linter.NewLintContextFromDB(catalog.WrapSqlDB(db))) + violations := NewReactUnsupportedWidgetRule().Check(linter.NewLintContextFromDB(catalog.WrapSqlDB(db))) if len(violations) != 1 || violations[0].Location.DocumentName != "Logboek_Images" { var got []string for _, v := range violations { diff --git a/mdl/linter/rules/xpath_association_access.go b/mdl/linter/rules/xpath_association_access.go new file mode 100644 index 0000000000..2462908eba --- /dev/null +++ b/mdl/linter/rules/xpath_association_access.go @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "sort" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// XPathAssociationAccessRule (SEC011) reports a page data source whose XPath +// crosses an association that a user role able to open the page may not read +// (ako/mxcli#945, #1337). +// +// At Production security the runtime checks member access on every association +// step of a data-source XPath, on the entity the step starts from, and refuses +// the whole retrieve when the role cannot read it — the page opens to an error +// dialog and the log says +// +// SecurityRuntimeException: No access rights for Shop$Account/Shop.Order_Account +// +// mxbuild reports 0 errors (it checks bound paths, CE2729, not paths inside an +// XPath filter), and so did check, exec and lint. +// +// # What was measured (11.15.0, Production, a user role holding one module role) +// +// - An association is a member of its FROM entity's access rules, and of its +// TO entity's too when its owner is Both. Traversed from an entity it is a +// member of, with that member at None: refused — from the TO side of an +// owner-Both association and from the FROM side of a default one alike. +// - Traversed from an entity it is NOT a member of (the TO side of a default +// association): the page opens. Nothing to check, so nothing is reported. +// - The same XPath inside an entity ACCESS RULE whose own rule leaves the +// member at None: the page opens. Access-rule XPaths are left out. +// - Control: with the members granted ReadOnly, every page opens. +// +// Left out because unmeasured: a step whose membership comes from a +// generalization of the entity it starts on; snippets (which roles open them +// depends on the pages that call them); microflow retrieves, which apply entity +// access only when asked to and are not page data sources. +// +// A role that may not read the step's starting entity at all is not reported +// here: for the widget's own entity that is SEC010's finding, and the step +// itself is not where the runtime stops. +type XPathAssociationAccessRule struct{} + +// NewXPathAssociationAccessRule creates the SEC011 rule. +func NewXPathAssociationAccessRule() *XPathAssociationAccessRule { + return &XPathAssociationAccessRule{} +} + +func (r *XPathAssociationAccessRule) ID() string { return "SEC011" } +func (r *XPathAssociationAccessRule) Name() string { return "XPathAssociationNotReadable" } +func (r *XPathAssociationAccessRule) Category() string { return "security" } +func (r *XPathAssociationAccessRule) DefaultSeverity() linter.Severity { return linter.SeverityError } + +func (r *XPathAssociationAccessRule) Description() string { + return "Checks that a page data source's XPath crosses only associations the roles opening the page may read (at Production the page fails to open: \"No access rights for …\")" +} + +// RequiredCatalogMode: xpath_expressions, widgets and permissions are written +// by REFRESH CATALOG FULL. +func (r *XPathAssociationAccessRule) RequiredCatalogMode() linter.CatalogMode { + return linter.CatalogFull +} + +// assocEnds is one association's ends and owner, as the catalog records them. +type assocEnds struct { + from, to, owner string +} + +// memberOf reports whether the association is a member of entity's access +// rules: of its FROM entity always, of its TO entity when the owner is Both. +func (a assocEnds) memberOf(entity string) bool { + return entity == a.from || (strings.EqualFold(a.owner, "Both") && entity == a.to) +} + +// other returns the entity a step from entity lands on. +func (a assocEnds) other(entity string) string { + if entity == a.from { + return a.to + } + if entity == a.to { + return a.from + } + return "" +} + +// hop is one association step and the entity it starts from. +type hop struct { + entity, association string +} + +// Check reports each (page data source, association step, user role) where the +// role opens the page, reads the step's starting entity, and not the member. +func (r *XPathAssociationAccessRule) Check(ctx *linter.LintContext) []linter.Violation { + reader := ctx.Reader() + if reader == nil { + return nil + } + ps, err := reader.GetProjectSecurity() + if err != nil || ps == nil || ps.SecurityLevel != security.SecurityLevelProduction || len(ps.UserRoles) == 0 { + return nil + } + + assocs := map[string]assocEnds{} + if rows, err := ctx.Query(`SELECT QualifiedName, COALESCE(FromEntity, ''), COALESCE(ToEntity, ''), COALESCE(Owner, '') FROM associations`); err == nil { + for rows.Next() { + var qn string + var a assocEnds + if rows.Scan(&qn, &a.from, &a.to, &a.owner) == nil { + assocs[qn] = a + } + } + rows.Close() + } + if len(assocs) == 0 { + return nil + } + + pageRoles := map[string][]string{} + readsEntity := map[string]map[string]bool{} // entity -> module role -> any READ rule + readsMember := map[string]map[string]bool{} // entity + "\x00" + member -> module role + for p := range ctx.Permissions() { + switch { + case p.ElementType == "PAGE" && p.AccessType == "VIEW": + pageRoles[p.ElementName] = append(pageRoles[p.ElementName], p.ModuleRoleName) + case p.ElementType == "ENTITY" && p.AccessType == "READ" && p.MemberName == "": + addRole(readsEntity, p.ElementName, p.ModuleRoleName) + case p.ElementType == "ENTITY" && p.AccessType == "MEMBER_READ" && p.MemberName != "": + addRole(readsMember, p.ElementName+"\x00"+p.MemberName, p.ModuleRoleName) + } + } + + widgetsOn := map[string]map[string][]string{} // page -> entity -> data widget names + for w := range ctx.Widgets() { + if w.EntityRef == "" || !strings.EqualFold(w.ContainerType, "page") { + continue + } + if _, ok := dataWidgetLabel(w.WidgetType); !ok { + continue + } + if widgetsOn[w.ContainerQualifiedName] == nil { + widgetsOn[w.ContainerQualifiedName] = map[string][]string{} + } + widgetsOn[w.ContainerQualifiedName][w.EntityRef] = append(widgetsOn[w.ContainerQualifiedName][w.EntityRef], w.Name) + } + + var violations []linter.Violation + for x := range ctx.XPathExpressions() { + if x.DocumentType != "PAGE" || x.ComponentType != "WIDGET" || x.TargetEntity == "" { + continue + } + roles := pageRoles[x.DocumentQualifiedName] + if len(roles) == 0 { + continue // no role may open it: MPR007 territory + } + for _, h := range associationHops(x.XPathExpression, x.TargetEntity, assocs) { + if !assocs[h.association].memberOf(h.entity) { + continue // not a member there: measured, the runtime does not refuse it + } + var blind []string + for _, ur := range ps.UserRoles { + if !sharesRole(ur.ModuleRoles, roles) || !anyRole(readsEntity[h.entity], ur.ModuleRoles) { + continue + } + if anyRole(readsMember[h.entity+"\x00"+h.association], ur.ModuleRoles) { + continue + } + blind = append(blind, ur.Name) + } + if len(blind) > 0 { + violations = append(violations, r.violation(x, h, blind, widgetsOn[x.DocumentQualifiedName][x.TargetEntity])) + } + } + } + return violations +} + +func (r *XPathAssociationAccessRule) violation(x linter.XPathExpressionEntry, h hop, blind, widgets []string) linter.Violation { + sort.Strings(blind) + source := "A data source" + if len(widgets) == 1 { + // Named only when the page has exactly one data widget over the entity; + // the catalog does not tie an XPath to its widget. + source = fmt.Sprintf("The data source of '%s'", widgets[0]) + } + entityName := h.entity[strings.LastIndex(h.entity, ".")+1:] + module := moduleFromQualified(h.entity) + return linter.Violation{ + RuleID: r.ID(), + Severity: r.DefaultSeverity(), + Message: fmt.Sprintf("%s on page %s filters through %s from %s, which user role%s %s can open the page but may not read on %s — "+ + "at Production the page fails to open: \"No access rights for %s$%s/%s\"", + source, x.DocumentQualifiedName, h.association, h.entity, plural(blind), quoteList(blind), h.entity, + module, entityName, h.association), + Location: linter.Location{ + Module: moduleFromQualified(x.DocumentQualifiedName), + DocumentType: "page", + DocumentName: docNameFromQualified(x.DocumentQualifiedName), + DocumentID: x.DocumentID, + }, + Suggestion: fmt.Sprintf("Grant read on the %s member in %s's access rule for a module role those user roles hold "+ + "(e.g. GRANT READ (…, %s) ON ENTITY %s TO , repeating that rule's XPath so the grant merges into it), or filter without crossing it", + h.association, h.entity, h.association[strings.LastIndex(h.association, ".")+1:], h.entity), + } +} + +func addRole(m map[string]map[string]bool, key, role string) { + if m[key] == nil { + m[key] = map[string]bool{} + } + m[key][role] = true +} + +func anyRole(granted map[string]bool, roles []string) bool { + for _, r := range roles { + if granted[r] { + return true + } + } + return false +} + +// associationHops returns every association step in constraint, each with the +// entity it starts from, walking from target. A group mxcli cannot parse, and +// any step whose entity the walk loses (a variable root, an unknown name), is +// skipped: the rule reports only steps it can place. +func associationHops(constraint, target string, assocs map[string]assocEnds) []hop { + var hops []hop + for _, group := range visitor.SplitXPathPredicateGroups(constraint) { + expr, ok := visitor.ParseXPathConstraint(group) + if !ok || expr == nil { + continue + } + walkHops(expr, target, assocs, &hops) + } + return hops +} + +func walkHops(expr ast.Expression, cur string, assocs map[string]assocEnds, hops *[]hop) { + switch e := expr.(type) { + case *ast.XPathPathExpr: + for _, st := range e.Steps { + next := "" + if q, ok := st.Expr.(*ast.QualifiedNameExpr); ok && cur != "" { + qn := q.QualifiedName.String() + if a, ok := assocs[qn]; ok { + if next = a.other(cur); next != "" { + *hops = append(*hops, hop{entity: cur, association: qn}) + } + } else { + next = qn // an entity step names where the path is + } + } + for _, pred := range st.Predicates { + walkHops(pred, next, assocs, hops) + } + cur = next + } + case *ast.BinaryExpr: + walkHops(e.Left, cur, assocs, hops) + walkHops(e.Right, cur, assocs, hops) + case *ast.UnaryExpr: + walkHops(e.Operand, cur, assocs, hops) + case *ast.ParenExpr: + walkHops(e.Inner, cur, assocs, hops) + case *ast.FunctionCallExpr: + for _, a := range e.Arguments { + walkHops(a, cur, assocs, hops) + } + } +} diff --git a/mdl/linter/rules/xpath_association_access_test.go b/mdl/linter/rules/xpath_association_access_test.go new file mode 100644 index 0000000000..08e1fbe420 --- /dev/null +++ b/mdl/linter/rules/xpath_association_access_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/sdk/security" +) + +// ako/mxcli#945, #1337. Measured on 11.15.0 at Production security, signed in +// as a user role holding only Shop.RoleA, each case below a Data grid 2 over a +// database source whose XPath crosses one association: +// +// - owner Both, traversed from its TO entity (Account), whose rule leaves the +// association at None: the page fails, "No access rights for +// Shop$Account/Shop.Order_Account". +// - owner Default, traversed from its FROM entity, member at None: fails, +// "No access rights for Shop$Order3/Shop.Order3_Account". +// - owner Default, traversed from its TO entity: the association is not a +// member there, and the page opens. +// - the same XPath inside an entity ACCESS RULE whose own member is at None: +// the page opens — the runtime does not check member access there. +// +// Control: with both members granted ReadOnly, all pages open. + +func (f *readAccessFixture) association(qn, from, to, owner string) { + f.n++ + mod, name, _ := strings.Cut(qn, ".") + f.exec(`INSERT INTO associations_data (Id, Name, QualifiedName, ModuleName, FromEntity, ToEntity, Owner, ProjectId, SnapshotId) + VALUES (?,?,?,?,?,?,?,?,?)`, "a"+string(rune('a'+f.n)), name, qn, mod, from, to, owner, "default", "s1") +} + +func (f *readAccessFixture) xpath(documentType, document, componentType, xpath, target string) { + f.n++ + f.exec(`INSERT INTO xpath_expressions_data (Id, DocumentType, DocumentId, DocumentQualifiedName, ComponentType, ComponentId, + XPathExpression, TargetEntity, UsageType, ModuleName, ProjectId, SnapshotId) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + "x"+string(rune('a'+f.n)), documentType, "d-"+document, document, componentType, "c"+string(rune('a'+f.n)), + xpath, target, "DATASOURCE", moduleFromQualified(document), "default", "s1") +} + +func (f *readAccessFixture) checkXPathAccess() []linter.Violation { + f.t.Helper() + ctx := linter.NewLintContext(f.cat, navSecurityReader{sec: f.sec}) + return NewXPathAssociationAccessRule().Check(ctx) +} + +func shopUser() *security.UserRole { + return &security.UserRole{Name: "RoleAUser", ModuleRoles: []string{"System.User", "Shop.RoleA"}} +} + +// shopFixture is the measured project: Account and Order, Order_Account owner +// Both, RoleA reading Account.Code only and all of Order. +func shopFixture(t *testing.T, level string) *readAccessFixture { + f := newReadAccessFixture(t, level, shopUser()) + f.entity("Shop.Account", "") + f.entity("Shop.Order", "") + f.association("Shop.Order_Account", "Shop.Order", "Shop.Account", "Both") + f.read("Shop.Account", "Shop.RoleA", "") + f.memberRead("Shop.Account", "Shop.RoleA", "", "Shop.Account.Code") + f.read("Shop.Order", "Shop.RoleA", "") + f.memberRead("Shop.Order", "Shop.RoleA", "", "Shop.Order.IsActive") + f.memberRead("Shop.Order", "Shop.RoleA", "", "Shop.Order_Account") + f.page("Shop.Portal", "Shop.RoleA") + f.widget("Shop.Portal", "dgOrders", dataGrid, "Shop.Account") + f.xpath("PAGE", "Shop.Portal", "WIDGET", "[Shop.Order_Account/Shop.Order/IsActive = true()]", "Shop.Account") + return f +} + +func TestXPathAssociationAccess_OwnerBothFromTheToSideFails(t *testing.T) { + v := oneViolation(t, shopFixture(t, security.SecurityLevelProduction).checkXPathAccess()) + if v.RuleID != "SEC011" || v.Severity != linter.SeverityError { + t.Errorf("rule/severity = %s %v, want SEC011 error — the page fails to open", v.RuleID, v.Severity) + } + for _, want := range []string{"'dgOrders'", "Shop.Portal", "Shop.Order_Account", "Shop.Account", "'RoleAUser'", "No access rights for Shop$Account/Shop.Order_Account"} { + if !strings.Contains(v.Message, want) { + t.Errorf("message %q does not mention %s", v.Message, want) + } + } + if v.Location.DocumentType != "page" || v.Location.DocumentName != "Portal" || v.Location.Module != "Shop" { + t.Errorf("location = %+v", v.Location) + } + if !strings.Contains(v.Suggestion, "Order_Account") || !strings.Contains(v.Suggestion, "Shop.Account") { + t.Errorf("suggestion %q does not say which member to grant on which entity", v.Suggestion) + } +} + +// The control: the member granted on the entity the step starts from. +func TestXPathAssociationAccess_GrantedMemberIsSilent(t *testing.T) { + f := shopFixture(t, security.SecurityLevelProduction) + f.memberRead("Shop.Account", "Shop.RoleA", "", "Shop.Order_Account") + if vs := f.checkXPathAccess(); len(vs) != 0 { + t.Errorf("want nothing once RoleA reads Order_Account on Account, got %+v", vs) + } +} + +// Granted by another of the user role's module roles counts too: what a person +// logs in as is the user role. +func TestXPathAssociationAccess_AnyModuleRoleOfTheUserRoleCounts(t *testing.T) { + f := shopFixture(t, security.SecurityLevelProduction) + f.sec.UserRoles[0].ModuleRoles = append(f.sec.UserRoles[0].ModuleRoles, "Shop.Auditor") + f.memberRead("Shop.Account", "Shop.Auditor", "[Code != empty]", "Shop.Order_Account") + if vs := f.checkXPathAccess(); len(vs) != 0 { + t.Errorf("want nothing, Shop.Auditor reads the member: %+v", vs) + } +} + +func TestXPathAssociationAccess_FromTheFromSideFails(t *testing.T) { + f := newReadAccessFixture(t, security.SecurityLevelProduction, shopUser()) + f.entity("Shop.Account", "") + f.entity("Shop.Order3", "") + f.association("Shop.Order3_Account", "Shop.Order3", "Shop.Account", "Default") + f.read("Shop.Order3", "Shop.RoleA", "") + f.memberRead("Shop.Order3", "Shop.RoleA", "", "Shop.Order3.IsActive") + f.read("Shop.Account", "Shop.RoleA", "") + f.page("Shop.P_Forward", "Shop.RoleA") + f.widget("Shop.P_Forward", "dgForward", dataGrid, "Shop.Order3") + f.xpath("PAGE", "Shop.P_Forward", "WIDGET", "[Shop.Order3_Account/Shop.Account/Code = 'x']", "Shop.Order3") + + v := oneViolation(t, f.checkXPathAccess()) + if !strings.Contains(v.Message, "No access rights for Shop$Order3/Shop.Order3_Account") { + t.Errorf("message = %q", v.Message) + } +} + +// Measured: a Default-owner association is not a member of its TO entity, and +// traversing it from there opened the page. Must stay silent. +func TestXPathAssociationAccess_DefaultOwnerFromTheToSideIsSilent(t *testing.T) { + f := newReadAccessFixture(t, security.SecurityLevelProduction, shopUser()) + f.entity("Shop.Account", "") + f.entity("Shop.Order2", "") + f.association("Shop.Order2_Account", "Shop.Order2", "Shop.Account", "Default") + f.read("Shop.Account", "Shop.RoleA", "") + f.memberRead("Shop.Account", "Shop.RoleA", "", "Shop.Account.Code") + f.read("Shop.Order2", "Shop.RoleA", "") + f.page("Shop.P_Default", "Shop.RoleA") + f.widget("Shop.P_Default", "dgDefault", dataGrid, "Shop.Account") + f.xpath("PAGE", "Shop.P_Default", "WIDGET", "[Shop.Order2_Account/Shop.Order2/IsActive = true()]", "Shop.Account") + + if vs := f.checkXPathAccess(); len(vs) != 0 { + t.Errorf("want nothing, got %+v", vs) + } +} + +// Measured: the runtime does not check member access inside an access rule's +// own XPath. This pins the measured outcome; an access rule's XPath has no page +// roles, so it is also silent before the rule's ComponentType filter is reached. +func TestXPathAssociationAccess_AccessRuleXPathIsSilent(t *testing.T) { + f := newReadAccessFixture(t, security.SecurityLevelProduction, shopUser()) + f.entity("Shop.Account4", "") + f.entity("Shop.Order4", "") + f.association("Shop.Order4_Account4", "Shop.Order4", "Shop.Account4", "Both") + f.read("Shop.Account4", "Shop.RoleA", "[Shop.Order4_Account4/Shop.Order4/IsActive = true()]") + f.xpath("DOMAIN_MODEL", "Shop.DomainModel", "ACCESS_RULE", "[Shop.Order4_Account4/Shop.Order4/IsActive = true()]", "Shop.Account4") + + if vs := f.checkXPathAccess(); len(vs) != 0 { + t.Errorf("want nothing, got %+v", vs) + } +} + +// Below Production the runtime does not apply entity access, so nothing is +// refused. +func TestXPathAssociationAccess_PrototypeIsSilent(t *testing.T) { + if vs := shopFixture(t, security.SecurityLevelPrototype).checkXPathAccess(); len(vs) != 0 { + t.Errorf("want nothing below Production, got %+v", vs) + } +} + +// A role that cannot open the page is not asked; one that cannot read the +// starting entity at all is SEC010's finding, not this rule's. +func TestXPathAssociationAccess_OnlyRolesThatOpenThePageAndReadTheEntity(t *testing.T) { + f := shopFixture(t, security.SecurityLevelProduction) + f.sec.UserRoles = append(f.sec.UserRoles, &security.UserRole{Name: "Outsider", ModuleRoles: []string{"System.User", "Shop.Other"}}) + if vs := f.checkXPathAccess(); len(vs) != 1 || strings.Contains(vs[0].Message, "Outsider") { + t.Errorf("want only RoleAUser reported, got %+v", vs) + } + + g := shopFixture(t, security.SecurityLevelProduction) + g.exec(`DELETE FROM permissions WHERE ElementName = 'Shop.Account'`) + if vs := g.checkXPathAccess(); len(vs) != 0 { + t.Errorf("RoleA cannot read Shop.Account at all (SEC010's finding); want nothing here, got %+v", vs) + } +} + +// Every hop is checked from the entity it starts on: the second step here +// starts on Order, whose rule leaves Order_Customer at None. +func TestXPathAssociationAccess_SecondHopStartsOnTheIntermediateEntity(t *testing.T) { + f := shopFixture(t, security.SecurityLevelProduction) + f.memberRead("Shop.Account", "Shop.RoleA", "", "Shop.Order_Account") + f.entity("Shop.Customer", "") + f.association("Shop.Order_Customer", "Shop.Order", "Shop.Customer", "Default") + f.read("Shop.Customer", "Shop.RoleA", "") + f.xpath("PAGE", "Shop.Portal", "WIDGET", "[Shop.Order_Account/Shop.Order[Shop.Order_Customer/Shop.Customer/Name = 'x']]", "Shop.Account") + + v := oneViolation(t, f.checkXPathAccess()) + if !strings.Contains(v.Message, "Shop$Order/Shop.Order_Customer") { + t.Errorf("message = %q, want the hop from Shop.Order", v.Message) + } +} diff --git a/modelsdk/mpr/merge_conflicts.go b/modelsdk/mpr/merge_conflicts.go new file mode 100644 index 0000000000..afc16112b5 --- /dev/null +++ b/modelsdk/mpr/merge_conflicts.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "fmt" + "strings" +) + +// Merge conflict markers in the unit index. +// +// When Mendix's merge engine (`mx merge`, Studio Pro's Merge Changes Here) +// meets a change it cannot decide, it still writes a result, and marks the +// undecided units in their Unit row: ContentsConflicts holds a JSON list of +// the conflicting changes instead of '' (measured on mx 11.13: every other row +// holds '', never NULL), and TreeConflict is non-zero when the conflict is +// about whether the document exists at all. `mx check` reports CE0137 ("There +// are merge conflicts in this project") for as long as a marker is present. +// The index's other tables carry no conflict state. + +// UnitConflict is a unit whose index row carries a merge conflict marker. +type UnitConflict struct { + UnitID string // Mendix UUID form + Contents string // ContentsConflicts as stored ("" when only Tree is set) + Tree int64 // TreeConflict as stored (0 when only Contents is set) +} + +// UnitConflicts lists the units whose index row carries a merge conflict +// marker. Read-only. +func (r *Reader) UnitConflicts() ([]UnitConflict, error) { + rows, err := r.db.Query(`SELECT UnitID, COALESCE(ContentsConflicts, ''), COALESCE(TreeConflict, 0) FROM Unit + WHERE COALESCE(ContentsConflicts, '') != '' OR COALESCE(TreeConflict, 0) != 0`) + if err != nil { + return nil, fmt.Errorf("query merge conflict markers: %w", err) + } + defer rows.Close() + var out []UnitConflict + for rows.Next() { + var blob []byte + var c UnitConflict + if err := rows.Scan(&blob, &c.Contents, &c.Tree); err != nil { + return nil, fmt.Errorf("scan merge conflict marker: %w", err) + } + c.UnitID = blobToUUID(blob) + out = append(out, c) + } + return out, rows.Err() +} + +// ClearContentsConflicts empties the ContentsConflicts marker of the given +// units — the step that records a contents conflict as decided, once the +// unit's file holds the chosen content — leaving the row as the engine writes +// every unit it merged without a decision. A TreeConflict is left alone: which +// documents exist is not a contents decision. One SQLite transaction, with +// _Transaction.LastTransactionID bumped as every other write does, under the +// writer's Studio Pro open-project guard. Returns the number of rows cleared. +func (w *Writer) ClearContentsConflicts(unitIDs []string) (int, error) { + if len(unitIDs) == 0 { + return 0, nil + } + if err := w.guardWrite(); err != nil { + return 0, err + } + tx, err := w.reader.db.Begin() + if err != nil { + return 0, err + } + n := 0 + for _, id := range unitIDs { + res, err := tx.Exec(`UPDATE Unit SET ContentsConflicts = '' WHERE UnitID = ? AND COALESCE(ContentsConflicts, '') != ''`, uuidToBlob(id)) + if err != nil { + _ = tx.Rollback() + return 0, fmt.Errorf("clear the merge conflict marker of %s: %w", id, err) + } + if c, _ := res.RowsAffected(); c > 0 { + n += int(c) + } + } + if _, err := tx.Exec(`UPDATE _Transaction SET LastTransactionID = ?`, generateUUID()); err != nil && + !strings.Contains(err.Error(), "no such table") { + _ = tx.Rollback() + return 0, fmt.Errorf("update _Transaction: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, err + } + w.reader.InvalidateCache() + return n, nil +} diff --git a/modelsdk/mpr/merge_conflicts_test.go b/modelsdk/mpr/merge_conflicts_test.go new file mode 100644 index 0000000000..8b95a55d2a --- /dev/null +++ b/modelsdk/mpr/merge_conflicts_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "errors" + "os" + "testing" +) + +// A merge engine that stops on a decision (`mx merge` exit 2) leaves its +// marker in the unit's index row: ContentsConflicts holds a JSON list instead +// of an empty string, and `mx check` reports CE0137 until it is gone. +const engineMarker = `{"Conflicts":[{"ID":"00000000-0000-0000-0000-000000000000","HeadModification":"Change","MineModification":"Change"}]}` + +func setMarker(t *testing.T, w *Writer, unitID, contents string, tree int) { + t.Helper() + if _, err := w.reader.db.Exec(`UPDATE Unit SET ContentsConflicts = ?, TreeConflict = ? WHERE UnitID = ?`, + contents, tree, uuidToBlob(unitID)); err != nil { + t.Fatal(err) + } +} + +func TestUnitConflictsCleanControl(t *testing.T) { + w, _ := newV2WriterForCommitTest(t, hashTestUnit, unitDoc(t, "Stored")) + setMarker(t, w, hashTestUnit, "", 0) // the value every unmarked row holds + got, err := w.ConcreteReader().UnitConflicts() + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("an unmarked project lists conflicts: %+v", got) + } +} + +func TestUnitConflictsListsContentsAndTreeMarkers(t *testing.T) { + w, _ := newV2WriterForCommitTest(t, hashTestUnit, unitDoc(t, "Stored")) + setMarker(t, w, hashTestUnit, engineMarker, 0) + got, err := w.ConcreteReader().UnitConflicts() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].UnitID != hashTestUnit || got[0].Contents != engineMarker { + t.Fatalf("contents marker not listed: %+v", got) + } + setMarker(t, w, hashTestUnit, "", 1) + got, err = w.ConcreteReader().UnitConflicts() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Tree != 1 { + t.Fatalf("tree marker not listed: %+v", got) + } +} + +func TestClearContentsConflictsLeavesTheRowLikeAnUnmarkedOne(t *testing.T) { + w, _ := newV2WriterForCommitTest(t, hashTestUnit, unitDoc(t, "Stored")) + hash := storedHash(t, w, hashTestUnit) + setMarker(t, w, hashTestUnit, engineMarker, 0) + n, err := w.ClearContentsConflicts([]string{hashTestUnit}) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("cleared %d row(s), want 1", n) + } + var contents string + if err := w.reader.db.QueryRow(`SELECT ContentsConflicts FROM Unit WHERE UnitID = ?`, uuidToBlob(hashTestUnit)).Scan(&contents); err != nil { + t.Fatal(err) + } + if contents != "" { + t.Fatalf("ContentsConflicts = %q, want '' (what the engine writes on every unmarked row)", contents) + } + if got := storedHash(t, w, hashTestUnit); got != hash { + t.Fatal("clearing the marker touched ContentsHash") + } + if got, _ := w.ConcreteReader().UnitConflicts(); len(got) != 0 { + t.Fatalf("still listed after clearing: %+v", got) + } +} + +// A tree conflict (deleted on one side, changed on the other) is a different +// decision — which documents exist — and is never cleared by this. +func TestClearContentsConflictsLeavesTreeConflicts(t *testing.T) { + w, _ := newV2WriterForCommitTest(t, hashTestUnit, unitDoc(t, "Stored")) + setMarker(t, w, hashTestUnit, engineMarker, 1) + if _, err := w.ClearContentsConflicts([]string{hashTestUnit}); err != nil { + t.Fatal(err) + } + got, _ := w.ConcreteReader().UnitConflicts() + if len(got) != 1 || got[0].Tree != 1 { + t.Fatalf("tree conflict cleared or lost: %+v", got) + } +} + +func TestClearContentsConflictsRefusedWhileStudioProOpen(t *testing.T) { + w, _ := newV2WriterForCommitTest(t, hashTestUnit, unitDoc(t, "Stored")) + setMarker(t, w, hashTestUnit, engineMarker, 0) + if err := os.WriteFile(w.ConcreteReader().Path()+".lock", nil, 0644); err != nil { + t.Fatal(err) + } + _, err := w.ClearContentsConflicts([]string{hashTestUnit}) + var open *StudioProOpenError + if !errors.As(err, &open) { + t.Fatalf("want StudioProOpenError, got %v", err) + } + if got, _ := w.ConcreteReader().UnitConflicts(); len(got) != 1 { + t.Fatal("marker cleared despite the guard") + } +}