Skip to content

feat(git): git merge — model-aware merging with a review step: property-level conflict report and per-unit plan - #1674

Open
engalar wants to merge 56 commits into
mendixlabs:mainfrom
engalar:feat/git-merge-review
Open

engalar wants to merge 56 commits into
mendixlabs:mainfrom
engalar:feat/git-merge-review

Conversation

@engalar

@engalar engalar commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1670 (closes the merge half of #1669).

What happened

#1670 was merged from an earlier state of its branch: the four commits pushed after 6a5c942fa never landed on main — the mendix.commits-since-gc doctor fix, the staged-files summary fix (F4), and the entire mxcli git merge feature. This PR re-carries exactly those four commits, rebased onto current main (all cherry-picks clean, tests green).

What mxcli git merge <ref> does

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 a dedicated lab repo with Studio Pro 11.12.6: both files, every time). Studio Pro resolves that with a model-aware merge, and the same engine ships as a command — mx git-merge BASE MINE THEIRS BASE_COMMIT MINE_COMMIT THEIRS_COMMIT.

mxcli git merge <ref> -p app.mpr runs git merge first; a merge git resolves itself (disjoint files, fast-forward) is annotated like mxcli git commit does. On a model conflict the base and theirs revisions are checked out into temporary worktrees and mx git-merge decides (mx resolved for the project's Mendix version — PATH, mxcli cache, Studio Pro installation):

  • auto-resolvable → staged and committed as the normal two-parent merge commit with the mx_metadata note. Measured end-to-end on a two-sided domain-model change: AutoResolved, both sides kept, note identical in shape to Studio Pro's own merge commit's.
  • ManualResolutionRequired → a review step, never a silent choice. The merge aborts (working tree exactly as before), and two things are produced for the human:
    • a property-level conflict report from mx diff, both sides: New type > Length: 200 → 180 (ours) / → 250 (theirs), per conflicted unit;
    • a plan template beside the .mpr (<mpr>.merge-plan.json, one take: ours|theirs entry per conflicted unit, ours by default).
      The human edits the plan and re-runs with --plan <file>: each unit is taken from its chosen side, the .mpr index is repaired from the mixed files (files are the truth), and the merge is committed with the note. Plan validation refuses units that are not modified-on-both-sides (added/deleted/moved conflicts) — those, and any resolution that must mix both sides inside one unit, stay in Studio Pro (Version Control › Merge Changes Here). --ours/--theirs take one side wholesale without consulting mx. Non-model conflicts (text sources) are left like plain git.

Also re-landed (were in the branch but missing from main):

  • git doctor judges only core.autocrlf; the mendix.* keys are backfilled when missing and their values are never judged — measured on a real Studio Pro checkout, mendix.commits-since-gc counts up (was 2, not 0) and a healthy checkout must pass (fix 1);
  • git commit's staged-files summary lists what the commit holds, not the junk just unstaged (fix 2).

Tests

TDD, real git repositories + real SQLite .mpr fixtures (cmd/mxcli/gitmerge_test.go, gitdoctor_test.go): clean merges annotated; model conflicts auto-resolved via an injected mx runner (worktree paths and base/ours/theirs shas asserted); manual conflicts reported and aborted (MERGE_HEAD gone, tree restored); plans applied per unit with hashes repaired; stale plans refused; non-model conflicts left alone; --ours/--theirs deterministic without mx; already-up-to-date. All green locally with the real mx on the lab repository (auto-resolve, report→plan→land, --theirs).

Docs

CHANGELOG (git merge entry under Unreleased), docs-site "Working Outside Studio Pro" › Merging branches, and the teamserver-git skill's model-merge rule.

claude and others added 30 commits October 28, 2026 17:49
ako#1753)

Symptom, from the issue: "On the after-startup test runner, a test with
more than one `@expect` is reported **PASS** when an earlier assertion
fails and a later one passes. `mxcli test` exits 0." Affected: every
Docker run and `--local --legacy-runner`; the endpoint runner is not.

Cause: writeExpectAssertion logs one verdict line per assertion,
MXTEST:PASS:<id> or MXTEST:FAIL:<id>:..., and ParseLogResults let every
line overwrite the test's status. The last line won, and the PASS case
also cleared the failure message. The generated flow returned
$AllPassed = false, but nothing reads it.

Fix: the parser folds a test's verdict lines with a sticky failure
(applyVerdictLine). Once a test has a FAIL or an ERROR, no later PASS or
SKIP line clears it. The first failure's message is reported, with
"(and N more failure(s) in this test)" when more followed. This matches
the endpoint runner, which evaluates an assertion only while $Verdict is
PASS and whose skipped-assertions guard never replaces a failure. The
skipped-assertions FAIL (mendixlabs#1448), logged after the PASS lines of the
assertions that ran, still wins over them: a sticky failure, not "first
line wins". A RUN line resets the fold, so a failure cannot outlive its
run. The fix is in the parser, so it also covers logs written by runners
generated before it. The generator is unchanged: logging one verdict per
test would change a flow whose verdicts were measured on 11.13.0, and it
would cover nothing more, because parser and generator ship together.

Measurement (unit level, the symptom's layer: the parser reads a log).
sticky_failure_test.go, run before the fix existed and again with
results.go reverted:

  --- FAIL: TestLaterPassDoesNotClearEarlierFailure
      FAIL then PASS: status = PASS, want FAIL
      FAIL then PASS: message = "", want "Expected $r = 1, actual: 2"
      FAIL then PASS: the suite passed, so `mxcli test` exits 0
  --- FAIL: TestFirstFailureMessageIsReported
      message = "Expected $r = 4, actual: 2", want "Expected $r = 1, actual: 2 (and 2 more failures in this test)"
  --- FAIL: TestLaterPassDoesNotClearEarlierError
      ERROR then PASS:test_1: got PASS "", want ERROR "Setup failed: Shop.Seed"

TestSkippedAssertionsFailureStillWins and the PASS-then-FAIL and
PASS-then-PASS cases pass both before and after; they are the controls.
With the RUN reset stubbed out, TestRunLineStartsVerdictAfresh fails.
mdl-examples/bug-tests/test-runner-1753-earlier-expect-fails.test.mdl is
the runtime reproduction. It was not run against an app for this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…he after-startup runner

Symptom, from ako#1753's sibling note: "on this runner,
`assignPattern` matches only upper-case `CALL|CREATE`. So mdl 1's
`$r = call …` is not renamed per test, and two tests that both use `$r`
fail injection (MDL063 / CE0111, measured)." Affected: every Docker run
and `--local --legacy-runner`. The endpoint runner gives each test its own
flow and does not rename.

Cause: the legacy runner compiles every test into one microflow, so it
gives each test's variables a `_N` suffix. assignPattern decides which
variables are renamed, and it matched only `CALL|CREATE` in upper case.
MDL keywords are case-insensitive, and mdl 1 and `fmt` write them in
lower case, so `$r = call microflow …` stayed `$r` in every test.

Fix: assignPattern matches `call|create` in any case, followed by a word
boundary so `$r = callback` does not count. No other pattern in
cmd/mxcli/testrunner makes the upper-case assumption: the rest lower-case
their input or use EqualFold.

TestThrowsCatchesEveryCallKind and TestVerdictCatchesEveryCallKind
matched the legacy output against the unrenamed lower-case statement, so
they passed only because of the defect. Their legacy assertions now
expect the renamed statement (legacyStmt).

The 1448 bug-test's note that the legacy runner "needs a distinct
variable name per test" is updated.

Measurement. rename_keyword_case_test.go with the old pattern (the
upper-case control subtest passes):

  --- FAIL: TestLegacyRunnerRenamesLowerCaseAssignments/call_microflow,_mdl_1
      test 1: no "$r_1 = call microflow" — the assignment was not renamed
      an assertion still reads the unrenamed $r
      MDL063: '$r' is created twice in this microflow — first by call
      microflow, then by call microflow. ... mxbuild rejects this with
      CE0111 "Duplicate variable name"
  (and the same for mdl 0, call java action, create object, create list,
  mixed case)
  --- FAIL: TestAssignPatternNeedsAWholeKeyword
      control: $r = call microflow was not picked up: map[]

I also swept every non-.fail `.test.mdl` in mdl-examples/ (16 files)
through GenerateTestRunner and executor.ValidateProgram. Before the fix,
10 files drew MDL063, among them the 1448, 1514 and 923 bug-tests and
doctype-tests/microflow-spec (86). After it, 2 do. Those 2 come from
`retrieve $Brands` and from `retrieve $Request` / `$Request = head(…)`.
They are a separate gap: assignPattern has never matched `retrieve`,
`declare`, list operations, `send rest request` or
`execute database query` in any case. This change does not address it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
… omits it

Symptom (ako#1218): a bare `create or modify module role M.R;`
reported "Modified module role: M.R" and erased the role's stored
description. `describe` -> `exec` was unaffected because describe prints
the description; grants were untouched.

Cause: CreateModuleRoleStmt.Description was a plain string, so "no
description clause" and `description ''` were the same value, and the OR
MODIFY branch of execCreateModuleRole passed it to AddModuleRole, which
overwrites the existing role in place.

Fix: Description is a *string, nil when the statement has no clause, as
CreateUserRoleStmt's properties have been since ako#707. OR MODIFY
writes the stored description when it is nil, so the write is elided
and the run reports "Unchanged"; `description ''` clears it. Adopting an
auto-provisioned role still drops its marker description. Docs (skill,
docs-site reference, `mxcli syntax`) state the rule.

Measurement:
- Unit test (mdl/executor/issue1218_module_role_description_test.go)
  fails before the fix, and with the fix forced back, with the reported
  symptom: description written = "", want "Read the published OData
  services. Nothing else." / Modified module role: Shop.ApiUser.
- Real app, Mendix 11.13.0: role created with a description; bare
  statement with the fixed build -> "Unchanged module role", describe
  keeps the description, describe -> exec "Unchanged"; with the pre-fix
  build -> "Modified module role" and the description is empty.
  `description ''` with the fixed build clears it. mx check: 0 errors in
  every state, the same as the blank app, fault included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…fold at 10.0

Symptom (ako#1163, 02-microflow-examples on 9.24.30): every
count/sum/average/minimum/maximum in a microflow was refused with
"this Mendix 9.24.30 project cannot store
Microflows$AggregateAction.AggregateFunction (Mendix 10.0.0+)".

Cause: not an older storage shape. The vendored generated version data
gave aggregateFunction the row of reduceReturnDataType
({Introduced 10.0.0, Required}) and had no row for reduceReturnDataType,
so the property floor refused the key every aggregate carries and let the
fold's return type through unrefused.

Evidence: mendixmodelsdk 4.117.0 has aggregateFunction with no version
(its default even changes at 9.24.0) and reduceReturnDataType introduced
10.0.0. Studio Pro 9.24.0 stores AggregateFunction on 10 of 10 aggregates
in Workflow Commons 2.6.0. A floor-off build's aggregate survives
`mx convert` 9.24.30 with every key.

Fix: correct the Microflows$AggregateAction row by hand (gen cannot be
regenerated here) and regenerate the copy in modelsdk/version.

Measured: bug-test 1163-aggregate-on-mendix9.mdl on a fresh 9.24.30 app:
exec writes all five aggregates, mx check 0 errors, mx convert strips
nothing. Before, the first was refused. Control: the new tests fail on
the unfixed table with the reported refusal. 02-microflow-examples on
9.24 now stops at the expression aggregate (`sum … of …`, UseExpression
and Expression, Mendix 10.0+), which is correct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1163, 25-rename on 9.24.30): every workflow was
refused with "this Mendix 9.24.30 project cannot store
Workflows$Workflow.PersistentId (Mendix 10.21.0+)". The same held on
10.0-10.20.

Cause: the workflow writer, and the ALTER WORKFLOW mutator for the
activities it inserts, mint a fresh PersistentId on the workflow and on
every activity, outcome and boundary event. mendixmodelsdk introduces
PersistentId in 10.21.0 on all four (Workflow, and the abstract
WorkflowActivity, Outcome and BoundaryEvent). The floor refused the
workflow's own. It never saw the others, which are declared on supertypes
it does not look up. `mx convert` 9.24.30 strips the PersistentId from
all 6 element types a floor-off build wrote.

Fix: canon.LowerToProjectShape drops PersistentId from every Workflows$
element below 10.21. It runs on every unit write before the floor, so
CREATE, CREATE OR MODIFY and wfmutator's raw inserts are all covered.
The ID is dropped rather than refused because the writer mints it; no
script states it, and Studio Pro 9.24 stores none.

Measured: bug-test 1163-workflow-on-mendix9.mdl on a fresh 9.24.30 app:
exec ok, mx check 0 errors, `mx convert -p` strips no key. 25-rename on
9.24: FAIL (the refusal) -> PASS. Controls: 25-rename and
24b-workflow-mendix10 PASS on 10.24.24 and 11.13.0. The unit test fails
on the unfixed code with the reported refusal.

Not fixed: a parallel split writes Workflows$EndOfParallelSplitPathActivity,
a 10.21 type, and 9.24 then does not load the project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (found while measuring ako#1163): with PersistentId
lowered, `mx convert -p` 9.24.30 strips nothing from an mxcli-written
workflow but adds WorkflowOnStateChangeEvent: null and
UsertaskOnStateChangeEvent: null. Studio Pro's next save then differs in
property names, which Mendix's merge engine refuses to compare (mendixlabs#1373).

Cause: the two defaults (mendixmodelsdk: introduced 9.12.0, removed
11.0.0) were measured on 10.24.24 only, and an entry is added only from
the lowest version it was measured on.

Fix: lower their measured floor to 9.24.30, where `mx convert` adds both
with the same null value.

Measured: bug-test 1163-workflow-on-mendix9.mdl on a fresh 9.24.30 app:
mx check 0 errors, and `mx convert -p` now neither strips nor adds a key
on the workflow. Before, it added these two. The test fails on the
previous floor (present false on 9.24). The 11.13 case stays absent as
the control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1163, 14-project-settings on 9.24.30):
`alter settings constant @M.C value '...' in configuration 'Default'`
was refused with "this Mendix 9.24.30 project cannot store
Settings$ConstantValue.SharedOrPrivateValue (Mendix 10.9.0+)". The same
held on 10.0-10.8.

Cause: the settings overlay writes a new override as a Settings$SharedValue
nested under SharedOrPrivateValue. From 10.9 that is the only shape Mendix
reads. Below 10.9 an override is {$ID, $Type, ConstantId, Value} with a
flat string (mendixmodelsdk: Value deleted and SharedOrPrivateValue
introduced in 10.9.0). With the floor off, `mx convert` 9.24.30 turns the
nested form into Value: "" and the override silently becomes empty.

Fix: canon.LowerToProjectShape replaces SharedOrPrivateValue with the flat
Value in its place, or fills the empty flat sibling the overlay leaves.
A Settings$PrivateValue has no flat form, so it is refused, naming the
constant. The reader already preferred a flat Value; a backend test now
pins reading and updating a 9.x flat override.

Measured: bug-test 1163-constant-override-on-mendix9.mdl on a fresh
9.24.30 app: describe settings shows 'broker:9092', mx check 0 errors,
and mx convert keeps the value and changes no key in the settings unit.
14-project-settings on 9.24: FAIL (the refusal) -> PASS. Controls:
10.24.24 and 11.13.0 PASS. The unit tests fail on the unfixed code with
the reported refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1163, 29-datagrid DG12 on 9.24.30): a page passing
`$currentObject` to a microflow or nanoflow was refused with
"this Mendix 9.24.30 project cannot store
Forms$MicroflowParameterMapping.Expression (Mendix 10.10.0+)". The same
held on 10.0-10.9.

Cause: bindParameterMappingValue writes such an argument as Expression
text with Variable null. Expression is 10.10.0 on both mapping types.
Before 10.10 a mapping holds only Variable, a Forms$PageVariable that is
required there. Measured with the floor off: 9.24 does not load the page
at all ("An error occurred when trying to set the 'Variable' property of
a Microflow parameter mapping ... Value cannot be null"). Studio Pro 9
stores no mapping for an argument that is the enclosing object; it
infers it. The 9.24.30 starter app and Workflow Commons 2.6.0 (9.24.0)
have 82 flow calls taking 1-2 parameters and no mapping on any of them.

Fix: below 10.10, canon.LowerToProjectShape leaves a $currentObject (or
empty) argument out of every ParameterMappings list. An argument bound
through Variable is kept; the floor drops its empty Expression. Any other
expression has no form there, so it is refused, naming the parameter.

Measured: bug-test 1163-flow-arguments-on-mendix9.mdl on a fresh 9.24.30
app: exec ok, mx check 0 errors, mx convert changes no key on the page,
and describe -> exec is Unchanged. 29-datagrid on 9.24 now stops at DG15
(page variables, 10.17, a correct refusal); the DG2 NullReferenceException
in mx check is the separate CE0463 gap. Controls: 29-datagrid PASS on
10.24.24 and 11.13.0. The unit tests fail on the unfixed code with the
reported refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1163, 10-odata and 18-folder on 9.24.30): a published
OData service was refused at write time with "this Mendix 9.24.30 project
cannot store ODataPublish$PublishedODataService2.IncludeMetadataByDefault
(Mendix 10.8.0+)". By then the script's earlier statements were already
written.

Cause: not the property. mxcli writes the service as
ODataPublish$PublishedODataService2, a document type mendixmodelsdk
introduces in 10.4.0. Before 10.4 a published OData service is a
Rest$PublishedODataService, which is deleted in 10.4.0: a different
document with its own children, and mxcli does not write it. The
generated version data has property floors only, so the floor tripped
over the first versioned key. Measured with the floor off: mx check
9.24.30 cannot load the project ("TypeCacheUnknownTypeException: The
type cache does not contain a type with qualified name
ODataPublish$PublishedODataService2"). Lowering the property would have
made an unloadable app.

Fix: a version gate. `create [or modify] published odata service` needs
integration.published_odata_service, 10.4.0. `check -p` and exec's
pre-flight report it as MDL-VERSION01 before anything is written, with
a hint naming the 9.x document. Writing Rest$PublishedODataService for
9.x is not attempted here.

Measured: bug-test 1163-published-odata-below-10-4.mdl with `check -p`
on a fresh 9.24.30 app reports MDL-VERSION01. 10-odata and 18-folder on
9.24 now stop at the gate. Controls: both PASS on 10.24.24 and 11.13.0.
The new gate cases fail without the gate (0 violations).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
The 9.24 doctype run for ako#1163 split five refusals three ways:
a wrong floor in the generated data (AggregateFunction), a document type
the version lacks (PublishedODataService2, where lowering would have
produced an unloadable project), and an older shape that stores nothing
(a flow argument that is the enclosing object). Record what settled each
one: the SDK's version data, Studio Pro-authored bytes from a Marketplace
.mpk, and `mx convert -p` on the older version against a floor-off build,
comparing values as well as keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Fix test-runner false pass, module role description loss, and Mendix 9/10 property rows
`git remote get-url` applies url.<base>.insteadOf, and doctorEnv left
the system, global and GIT_CONFIG_COUNT/KEY/VALUE configuration in
place. In a sandbox that rewrites git@github.com: to https://github.com/
through the environment, the SSH remote the tests add already read back
as HTTPS, and TestGitDoctor_SSHRemoteRewrittenByFix and
TestGitDoctor_SSHURLFormRewrittenByFix failed `make test` on main (mendixlabs#1670)
while CI was green. A ~/.gitconfig rewriting https to git@ fails the
same two tests the other way.

doctorEnv now sets GIT_CONFIG_GLOBAL to the null device,
GIT_CONFIG_NOSYSTEM=1 and GIT_CONFIG_COUNT=0. Control: before the fix
both tests fail under each rewrite separately (environment, and HOME
with an https->git@ insteadOf); with an empty HOME and no environment
rewrite they pass. After it, both rewrites pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
fix(test): git doctor tests no longer read the developer's git config
go.mod's toolchain line makes `go` (and `make fmt`) switch to go1.27.3,
but fmt-check ran a bare `gofmt`, which is whatever Go is installed. With
go1.24.7 installed, `make lint` failed main on a file CI accepts
(mdl/backend/mfmutator/splice_handler_test.go) and passed a file CI then
rejected (ako#1759).

GOFMT defaults to $(go env GOROOT)/bin/gofmt. Control: `make fmt-check
GOFMT=/usr/local/go/bin/gofmt` still fails on splice_handler_test.go;
the default passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
`make build test lint` covers less than CI's build-and-test job gates
on: check-mdl, check-findings, check-skill-index, check-wiki-pages and
the rest ran only in CI unless someone remembered them. ci-local runs
the job's steps in its order (check-skill-mdl standing for its three
skill/docs MDL steps), without govulncheck, which the job installs
@latest over the network, and without the integration jobs, which need
mxbuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Holds this repository's conventions for driving a PR Claude opened to
green: the user reviews before the PR is opened and it is merged on
green by default (merge commit, expectedHeadSha, branch restarted from
main afterwards); `make ci-local` before every push; the macOS
tunnel-seam job that is cancelled before it gets a runner, and so gets
one re-run; the conflicts this repo keeps hitting (CHANGELOG, generated
indexes and tables); and how to treat the AI PR Review comment's
Critical / Moderate / Minor sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
`git clone` never fetches refs/notes/*, so on a fresh clone or a CI
checkout `git doctor` reported every commit as "without a note" and
exited 1, while the same report said `[x] origin has
refs/notes/mx_metadata`. `--fix` then wrote computed notes over commits
the remote already annotates, and the push it recommends was rejected
as non-fast-forward. Anyone who forced that push would overwrite the
real notes on the remote.

The remote check already reads the remote's notes commit with
ls-remote; it is now passed on. If the local ref is not, or does not
contain, that commit, the commits are not judged. The finding is the
unfetched ref, given with the command that fetches it. `--fix` fetches
it as a fast-forward only, so a note that exists only locally is never
dropped, and stops if that fetch is refused.

Test: TestGitDoctor_UnfetchedNotesAreNotMissing (bare remote, pushed
note, fresh clone). Before the fix it fails with the reported symptom:
"[ ] 9450425668 without a note" next to "[x] origin has
refs/notes/mx_metadata", and --fix replaced the remote's note on HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82tNcDLefAskVSzcKPm
…rewrite

`git doctor` read the remote with `git remote get-url`, which applies
url.<base>.insteadOf. With a global https -> git@ rule (a common
developer setup), an HTTPS remote read as SSH and the doctor exited 1.
`--fix` then set the configured URL to HTTPS on every run, which the
rule rewrote straight back, and dropped the URL's `.git` suffix along
the way. ako#1765 isolated the tests from this and left the
product question open; this answers it.

The check now reads `git config --get remote.<name>.url`, the value
--fix actually writes. An SSH remote is still found and rewritten when
a rule makes it read as HTTPS. When the effective URL differs, the
report gives it as an [i] line naming the insteadOf rule, which --fix
leaves alone. It is not counted as an issue: the rule applies to every
git command on the machine, and changing the remote cannot undo it.

Tests: TestGitDoctor_InsteadOfRewriteIsNotAnSSHRemote and
TestGitDoctor_ConfiguredSSHRemoteFoundBehindRewrite. Both use a rule in
the test repository's own config, on a host nobody rewrites. Before the
fix: "origin: git@git.example.com:org/app.git — SSH; Studio Pro needs
HTTPS", --fix changed the configured URL to
https://git.example.com/org/app, and the configured SSH remote was
missed. The two existing SSH tests also pass now in an environment that
rewrites github.com URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82tNcDLefAskVSzcKPm
`mxcli git commit --dry-run` staged everything in the real index to
report it, then undid that with `git reset -q`. A reset goes back to
HEAD, not to the state before the run, so anything the user had staged
was unstaged. The docs promise that a dry run writes nothing.

A dry run now stages into a temporary copy of the index
(GIT_INDEX_FILE), so the real index is never touched: staged changes,
stat data and intent-to-add entries all stay as they were. A real
commit still uses the real index, as before. `gitOutInEnv` is the git
helper with extra environment.

Test: TestGitCommit_DryRunKeepsTheUsersStaging. Before the fix:
`staged after --dry-run = "", want exactly what the user had staged
(staged.txt)`. The existing dry-run test started from an empty index,
where the bug and the correct behaviour look the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82tNcDLefAskVSzcKPm
`mxcli git commit` dropped any path with a segment named
`.mendix-cache`, `deployment`, `theme-cache` or `.mxcli`, at any depth.
So `resources/deployment/endpoints.xml` was silently left out of the
commit. The only trace was a count in "Left out 1 file(s) Studio Pro
never commits".

Those folders are the project's own: Studio Pro, mxbuild and mxcli
create them directly beside the .mpr. isJunkPath now matches them there
only. When the project sits in a subfolder of the repository, that
subfolder is the anchor, taken from gitNoteRepo.MPRPath. The lock,
backup and user-settings file names still match at any level.

Tests: TestGitCommit_NestedFolderNamedLikeJunkIsCommitted and
TestGitCommit_JunkAnchoredAtProjectInSubfolder. Before the fix both
fail: "resources/deployment/endpoints.xml is not the project's own
folder and must be committed", and the same for
javascriptsource/theme-cache/, app/resources/deployment/ and
tools/deployment/. The project's own deployment/ and theme-cache/ stay
out, as the existing spaced-path test also checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82tNcDLefAskVSzcKPm
Add the steward skill and `make ci-local`; fmt-check uses the toolchain's gofmt
fix(git): doctor and commit — unfetched notes, insteadOf, dry-run staging, nested build folders
…fig hide rules

A pluggable widget's enumeration that its editorConfig hides must hold its
default, or mx check reports CE0463. mxcli refused that for the one Timeline
rule it read (`groupEvents: false`), but `widget describe timeline` showed
"2 of 6 editor hide-rules recognized". So `customVisualization: true,
groupByDayOptions: 'dayMonth'` and `groupByKey: 'day', groupByMonthOptions:
'monthYear'` passed check and exec and failed mx check (ako#1762,
mendixlabs#1668).

Two shapes were unread:

- terser folds the statements before a `switch` into its head,
  `switch(A ? hide : hide, B || hide, e.groupByKey)`, so the THEN branch's
  guard is bounded by the `switch(` paren, which parseGuard took for a
  conditional group. A `switch(`/`if(` head directly in a function body is
  now statement-level; a call's paren still is not.
- a hide in a `case`/`default` arm had no reading. switchArmCondition now
  states the arm's values as eq/in, or ne/notin over the other cases for a
  default arm, following fall-through and grouped labels. Anything it
  cannot state exactly (a guarded hide inside the arm, a non-literal
  label, an arm whose exit is unclear, a switch under an `if`) yields no
  rule, as before.

Timeline is 6 of 6 (3.1.1, 3.2.2, 3.2.3), so each hidden enumeration now
gets MDL-WIDGET10's existing verdict: an error for a non-default value,
which exec's preflight refuses before writing; a warning at the default;
written at the default when left out. Across every installed package
dumped before and after, only Timeline and HeatMap gained rules (HeatMap
folds an `if(` head the same way). MDL-WIDGET10 names an in/notin set
instead of "matches". WidgetDefGeneratorVersion goes to 20 so cached
definitions regenerate.

The custom-mode warning on title/description/timeIndication came from the
hand-written fallback table, whose comment claimed only TextTemplates can
cause CE0463. That comment, and the writer's, are corrected.

File Uploader's ternary-wrapped hidePropertiesIn (ako#258 item 1)
was already lifted by the else-if chain support of mendixlabs#1583.
It is pinned by a test against File Uploader 2.5.0.

Measured on Mendix 11.13.0 with Timeline 3.2.2, one widget per variant:
the six hidden variants were CE0463 and the three controls 0 errors before
the fix; check now refuses exactly those six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…default

Once the Timeline's hide rules are read (ako#1762), one of its two
format enumerations is always hidden: by customVisualization, or by the
groupByKey switch. The generic pluggable-widget describe printed every
stored primitive, so each Timeline came back with a hidden property at its
default and drew an MDL-WIDGET10 warning on mxcli's own output (11 warnings
on six measured pages). The same happened before for any widget hiding an
enumeration by a rule the extractor already read, such as a Timeline with
`groupEvents: false`.

Such a property carries nothing: exec writes a hidden property the script
leaves out at that same default. extractExplicitProperties now drops a
top-level property that the widget's own rules hide in the stored
configuration while it equals the stored schema's default. The rules are
the registry's, the ones `check` evaluates, so the two agree about what is
hidden. A hidden property holding a non-default value is still printed,
because that widget fails mx check with CE0463, and printing it lets
`check` say so.

describe -> check -> exec on three Timeline configurations: no MDL-WIDGET10,
and the stored page is byte-identical afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
… place

Re-running `create or modify microflow|nanoflow` with only a filter's
expression changed replaced the activity: the activity, its action and its
operation all got new $IDs, while a decision whose condition changed the
same way kept its own, and exec reported both as `spliced: 1 replaced`.
Studio Pro compares and merges by $ID, so it showed the edit as one activity
removed and another added (ako#1761, mendixlabs#1667).

A splice Replace by one activity of the same activity and action $Type now
edits the stored activity: the replacement is written into the stored slot,
at the stored size, and its elements are paired structurally with the stored
ones (same $Type at the same key; list entries by the member, parameter or
language they name; unnamed entries by position only for equal $Type
sequences). The stored $IDs go on at Bytes, rewriting every reference in the
same pass, so the executor can still name the fragment's activity until
then. This applies at top level, in a loop body and to an activity with a
custom error handler, for create or modify and for alter ... replace.

A run of adjacent statements each changed in its own place, pairwise of one
plain kind, is diffed one-for-one instead of as a replace-and-drop of the
whole run, so each is edited in place. The report tells the two outcomes
apart: `edited in place` for a node that kept its $ID (an activity of the
same kind, a decision's condition, a return's value, a node's notes) and
`replaced with new element IDs` for one that did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
The alter syntax help, the alter-microflow reference page and the microflow
and nanoflow skills now say that an activity replaced by one of the same kind
is the stored activity edited, keeping its $ID and those of its action and
the parts that pair up, and how exec reports it (`edited in place` against
`replaced with new element IDs`) (ako#1761).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…w Mendix 10.21

Symptom (ako#1163): on Mendix 9.x and 10.0-10.20 a workflow with a
`parallel split` made the project unloadable. exec reported "Created
workflow", then mx check died with "TypeCacheUnknownTypeException: The
type cache does not contain a type with qualified name
Workflows$EndOfParallelSplitPathActivity".

Cause: the builder and the ALTER mutator end every path with that marker,
because from 10.21 the engine runs a path only up to it. The type itself
was introduced in 10.21.0 (mendixmodelsdk 4.117.0). The generated version
data holds property floors only, so nothing compared a written $Type with
the project's version.

Older shape, measured: Studio Pro 9.4.0 (the Marketplace's "Example
project workflow extension" 2.0.0) stores a split whose
ParallelSplitOutcome is {$ID,$Type,Flow} and whose paths hold their
activities and nothing more. Lowering produces exactly that key set.

Fix: canon.LowerToProjectShape drops the marker from every Workflows$Flow
below 10.21, for every writer at once (create, create or modify, and a
path alter workflow inserts).

Measured on fresh apps, the bug-test exec'd then mx check:
- 9.24.30.51827: before TypeCacheUnknownTypeException, after 0 errors;
  mx convert 9.24.30 changes no key; describe -> exec "Unchanged".
- 10.12.19.81612: before TypeCacheUnknownTypeException, after 0 errors;
  mx convert 10.12.19 changes no key.
- 10.24.24 and 11.13.0: 4 markers, 0 errors, key sets identical to the
  unfixed build.
Control: stubbing the lowering fails
TestNormalize_ParallelSplitPathsBelowMendix10_21 on 9.24 and 10.20 with
the marker still in each path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…Mode their floors

Symptom (ako#1163): on Mendix 9.0-9.19 every pluggable widget's
schema lost the Type of its return types and kept an AssignableTo that
version does not have (49 return types across the embedded templates).
A return type reaching the floor there was refused with "this Mendix
9.18.0 project cannot store CustomWidgets$WidgetReturnType.Type (Mendix
9.20.0+)".

Cause: the vendored generator gave a property the row of the next
property in the SDK's versionInfo and left that one out, as it did
AggregateFunction. mendixmodelsdk 4.117.0 has WidgetReturnType
`type: {}`, `assignableTo: {introduced 9.20.0}`, and pages.GroupBox
`caption: {}`, `headerMode: {introduced 8.10.0}`. widgetTypeForVersion
drops undeclared keys from the widget schema by the same data, so it
dropped the right key.

Evidence: Studio Pro stores {EntityProperty, IsList, Type} on 208 of 208
return types in a 9.1.1 app and 326 of 326 in a 9.4.0 app (Marketplace
samples), and adds AssignableTo on 57 of 57 in a 9.24.3 one.

The group box row changes nothing mxcli writes: the caption is stored
as CaptionTemplate, which the table never keyed, and 8.10 is below
every supported version. Workflows$CallMicroflowTask.boundaryEvents was
already 10.14.0, as in the SDK, and stays. Measured on 10.12.19 with
that floor removed: mxcli writes BoundaryEvents [2] on every call and
mx convert strips it, so the floor's drop-empty is right. A test now
pins it; removing the row fails it.

Fix: correct both rows by hand and regenerate modelsdk/version. Only
those two rows change in the copy.

Measured: the bug-test on fresh 10.24.24 and 11.13.0 apps checks at 0
errors before and after, with identical key sets. 9.24.30 stores Studio
Pro 9.24.3's return type keys both times (its mx check stops on a
separate pre-existing NullReferenceException in WidgetValue.IsDefault).
Control: the new tests fail on the old rows (49 wrong key sets at 9.18;
the Type refusal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…e Studio Pro's own (measured on the lab repo, S0)
…iff) and a plan file the human edits before landing (lab S5c)
@engalar

engalar commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

ako and others added 17 commits October 29, 2026 07:35
Sync ako/mxcli: git doctor/commit follow-ups to mendixlabs#1670, Mendix 9/10 writer gaps, module role description, test-runner false pass
Fix Timeline hidden enumerations, in-place activity edits, and the parallel split below Mendix 10.21
The pattern `.mxcli/unit-types.idx` has a slash in the middle, so git
anchors it to the repository root: the index mxcli writes next to a
fixture a test opens in place (testdata/testapp-views/.mxcli/) showed
up as untracked, though the comment above the line says it is meant to
be ignored there. `**/` matches it at any depth: `git check-ignore`
did not match the nested copy before, and matches it and the root one
after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Without @position a custom error handler is a row 130px under its
activity, and the main line carries on one spacing after the activity
however long the row is. When the next activity had a handler too, its
row started at its own x, inside the first: the first handler's end
event and the second handler's first activity were both stored at
680;330 (ako#1768, mendixlabs#1672).

The lane model from mendixlabs#1154 already made an activity with a handler wait
for a reserved lane; the handler row was the occupant that never
reserved it. addErrorHandlerFlow now reserves the lane to the row's
measured right edge plus the activity gap, and measureStatements does
the same and counts the row in its extent, so a branch or loop holding
handler rows is measured as built. Without the measurer half, 12 of the
new shapes put a merge on an activity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
The start event goes one spacing left of the first statement when that
statement carries @position. The pre-scan took the first statement that
had one, wherever it was, and every unplaced statement before it
follows the start: `declare $A …; @position(840, 200) declare $B …`
stored both at 840;200 (ako#1768 case 2, mendixlabs#1672).

Only the first statement moves the start now, as alter's patch
(patchDiff.startEvent, declared[0]) and the syntax help already
assumed. The help and the language reference now say that statements
before a placed one keep their derived places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
The overlap rule built its canvas from activities, loops, decisions and
merges only, so an activity lying on an error handler's end event was
never reported — the shape ako#1768 reported, two handler rows on
top of each other, passed MPR008 until an activity happened to land on
an activity.

Start, end, error, break and continue events and inheritance splits are
on the canvas too, each judged with its stored size; an event that
stores none is taken as Studio Pro's 20x20 rather than as an activity.
On the 292 Studio Pro-authored flows of PedApp and ako/TestApp the wider
set adds no finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1769, mendixlabs#1673, Mendix 11.12.4): `alter
microflow … { replace loop … with begin … end; }` wrote `$X = find $List
by Code = …` in the loop's body as Microflows$FindByExpression with the
bare text `Code = …` when the fragment did not know $List's entity.
check --references and exec reported nothing; mx check failed CE0117.
The same find inserted on the main line was refused (ako#891).

Cause: confirmed. mendixlabs#891's qualifiedMembersOnly is set on the fragment's
builder, but the loop body, the while body and the error-handler body
are each built by a fresh flowBuilder whose fields the site listed by
hand, and none listed the flag. So every member-bearing statement
nested in a fragment (find, filter, sort, aggregate, change, at any
depth) was written bare. `replace body of loop` went through the same
loop builder and had the same hole.

Fix: one construction helper, flowBuilder.nestedBuilder, for all three
sites; it inherits the flag with the rest of the shared state. The
error handler still sets its return type and label table itself, as
before. TestNestedBuilder_InheritsSharedState classifies every
flowBuilder field as inherited or the body's own and checks the helper
against it by reflection, so the next field cannot be left out
silently.

With this alone the reported alter is refused, with nothing written,
because the fragment does not type a list retrieved over an
association; the next commit types it.

Control: with the flag left out of nestedBuilder, the nested-guard test
reports every statement in every nested shape written instead of
refused, the PedApp test reports exec writing the replaced loop and
replace body, and the structural test names the missing field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1769, mendixlabs#1673): after the previous
commit, the reported alter — a replaced loop finding by member in
$Lines, retrieved over an association before the loop — is refused
with "cannot qualify member \"Code\"" where `create microflow` writes
Microflows$Find on MyModule.OrderLine.Code.

Cause: alterFlowContext.storedVariables, the variables a fragment
starts from, typed parameters, declares, creates, database retrieves
and calls, and left a retrieve over an association "Unknown": which end
it yields depends on the end it starts from. A stored loop's iterator
was not typed either, so a member of it in a fragment inserted in the
loop body was refused too.

Fix: storedVariables types both from the variable they start from, to a
fixed point, so the order the stored flow lists them in does not
matter. The retrieve's type is flowBuilder.associationRetrieveType:
lookupAssociation, entityIsSubtypeOf and associationRetrieveYieldsList,
the answer the full build and check --references already take. The
parent is the FROM end and the child the TO end, so from the TO end
(Order) a reference yields the list of FROM objects (OrderLine), from
the FROM end the one TO object; a reference set is a list either way,
an owner-Both reference one object either way. A self-reference, an
unknown association, or a name two loops or retrieves give different
origins stays untyped, and its member is refused rather than guessed.

Controls: with storedVariables reverted, the association-typing tests
and the PedApp by-attribute tests fail with the refusal; with the
ambiguity guard off, the shared iterator name and the retrieve from it
are typed. Measured on Mendix 11.13.0 (scratch app, real mx): before,
FindByExpression "Code = $Lookup/Code" and CE0117; the previous commit
alone, refused with exit 1 and the stored flow byte-identical; with
both, Microflows$Find on MyModule.OrderLine.Code, mx check 0 errors,
describe → exec "Unchanged microflow".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
…11.2

Symptom (ako#1771): on Mendix 10.12 to 11.1 every user task mxcli
wrote failed mx check with CE1859 "XPath constraint is required". That
included tasks targeted by a microflow, and multi-user tasks. exec and
mxcli check were clean. `mx convert -p` 10.24.24 then stored an empty
XPathBasedUserSource in place of each targeting, so the targeting was
silently lost.

Cause: userTaskToGen writes UserTargeting, which Mendix introduced in
11.2.0 (mendixmodelsdk: UserTaskActivity.userSource deleted and
userTargeting introduced in 11.2.0). It is declared on the abstract
UserTaskActivity, and the property floor looks up the concrete type only,
so the key went into every version. 10.24 ignores it and fills the
required UserSource with its default.

Older shape, measured: two Studio Pro-authored Marketplace samples,
Employee Onboarding (9.1.1, XPath) and Multilevel Dynamic Workflow
(9.24.3, microflow), converted 9.24.30 -> 10.24.24 -> 11.13.0. The
targeting keeps its $ID and value byte for byte. Only its $Type and the
key holding it change:
XPathBasedUserSource <-> XPathUserTargeting,
MicroflowBasedUserSource <-> MicroflowUserTargeting, and
EmptyUserSource -> NoUserTargeting (this fix's 10.24 output converted up).

Fix: canon.LowerToProjectShape renames the targeting to the user source
below 11.2, in its place, for every writer. Group targeting is 11.2 and
has no older form, so it is refused, naming the task.

Measured on fresh apps, exec of the bug-test then mx check:
- 10.24.24.119653: before CE1859 x3, after 0 errors. mx convert 10.24.24
  changes no key and no value. describe -> exec "Unchanged workflow".
- 11.13.0: 0 errors, and the key sets match the unfixed build.
Control: stubbing the lowering fails
TestNormalize_UserTaskTargetingBelowMendix11_2 24 times, with
UserTargeting still on the task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1771): on Mendix 9.x a workflow with a user task made
the project unloadable. exec reported "Created workflow", then mx check
9.24.30 died with "TypeCacheUnknownTypeException: The type cache does
not contain a type with qualified name Workflows$SingleUserTaskActivity".

Cause: mxcli writes Workflows$SingleUserTaskActivity and
MultiUserTaskActivity. Mendix introduced both types in 10.12.0 and
deleted Workflows$UserTask there (mendixmodelsdk 4.117.0). The generated
version data holds property floors only, so nothing compared a written
$Type with the project's version.

Older shape, measured: Studio Pro 9.24.3 (the Marketplace's Multilevel
Dynamic Workflow) stores {$ID, $Type, Annotation,
AutoAssignSingleTargetUser, Caption, DueDate, Name, OnCreatedEvent,
Outcomes, TaskDescription, TaskName, TaskPage, UserSource,
UserTaskCompletion}. The completion (9.22) says which kind of task it is.
A single-user task has a bare SingleInputCompletion. A multi-user task
has a MultiInputCompletion holding its CompletionCriteria, its
TargetUserInput and, from 10.2, AwaitAllUsers.

Fix: canon.LowerToProjectShape rewrites the task as that UserTask below
10.12. The multi-user settings move into the completion, and the outcomes
stay on the task, so the rule's outcome pointers still resolve. The
completion's $ID is derived from the task's. What the older type cannot
hold is refused, naming the task: a multi-user task below 9.22, majority
and threshold below 10.0, a microflow rule below 10.3, `await all users`
below 10.2, and a boundary event on a user task below 10.14. An empty
BoundaryEvents list is dropped below 10.14; it is declared on the
abstract UserTaskActivity, which the floor never saw. describe reads a
9.x multi-user task's completion, and the create-or-modify guard now sees
its rule and participants too, so a rewrite no longer resets them
silently.

Measured on fresh apps, exec of the bug-test then mx check:
- 9.24.30.51827: before TypeCacheUnknownTypeException, after 0 errors.
  mx convert 9.24.30 changes no key and no value. describe -> exec
  "Unchanged workflow". After mx convert 10.24.24, the user tasks are
  identical in keys and values to mxcli's direct 10.24 output.
- 10.24.24 and 11.13.0: 0 errors.
Control: stubbing lowerUserTaskType fails the three normalize tests on
9.24 with the 10.12 types in place. The read test and the guard test
failed before their fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1771): on Mendix 9.x to 11.1,
`alter workflow M.W { set (Targeting: xpath [...]) on Task; }`, and the
deprecated `set activity 'Task' targeting ...` form, reported "Altered
workflow" and changed nothing. describe still showed the old targeting,
and mx check stayed clean.

Cause: SetActivityProperty set UserTargeting with bsonnav.DSet, which
only replaces a key that already exists. Below 11.2 a stored user task
holds its targeting as UserSource, so no key matched and nothing changed.

Fix: setUserTargeting replaces UserTargeting or UserSource in place, or
adds the targeting when the task has neither. It writes the current
shape; canon.LowerToProjectShape stores it as the project's version does,
as it does for every other writer.

Measured on fresh apps, the bug-test exec'd then described and checked:
- 9.24.30.51827 and 10.24.24.119653: with the committed mutator (a
  scratch build), "Altered workflow" x2 and `[Name = 'admin']` kept.
  With the fix, `[Name = 'reviewer']` and ACT_GetReviewers, 0 errors,
  and the version's key set (Workflows$UserTask with UserSource on 9.24).
- 11.13.0: the new targeting both ways, 0 errors.
Control: TestWorkflowMutator_SetTargetingReplacesAStoredUserSource and
...AddsAMissingTargeting failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Symptom (ako#1771): `mxcli check -p` passed scripts with user-task
features the project's version cannot store, and exec wrote them. Group
targeting on 10.24 failed mx check with CE1859, and mx convert emptied
it. Once the user task is lowered to its pre-10.12 shape, a multi-user
task's later rules were refused only at write time, part-way through a
script.

Cause: no version gate looked inside a workflow user task. The
generated version data floors properties, never types, and these are
type-level introductions (mendixmodelsdk 4.117.0 StructureVersionInfo):
the multi-user task's UserTaskCompletion 9.22, Majority and
ThresholdCompletionCriteria 10.0, MultiInputCompletion.awaitAllUsers
10.2, MicroflowCompletionCriteria 10.3, and XPath/MicroflowGroupTargeting
11.2.

Fix: userTaskGates walks the activities a create workflow states, and
the ones alter workflow inserts. It adds an MDL-VERSION01 gate for each
clause the version lacks. A consensus or veto multi-user task with user
targeting passes from 9.22. The floors are new sdk/versions entries:
workflows.multi_user_task, multi_user_task_majority_threshold,
multi_user_task_await_all, multi_user_task_microflow_rule and
group_targeting.

Measured with the bug-test (check -p, then exec, then mx check):
- 9.24.30.51827: WF_Majority, WF_Await, WF_Microflow and WF_Groups
  reported; exec "Refusing to execute: 4 error(s) above. Nothing was
  written."
- 10.24.24.119653: WF_Groups only.
- 11.13.0: none; exec writes all five, mx check 0 errors.
Control: TestCheckVersionGates_UserTaskFeatures failed for every case
before the gates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
24-workflow-examples.mdl, the only doctype script with user tasks, is
gated 11.0+ as a whole. CI's 10.24 leg therefore never wrote a user task.
That hid ako#1771: every user task mxcli wrote on 10.12 to 11.1
failed mx check with CE1859.

24b-workflow-mendix10-examples.mdl (10.0+) now writes user tasks by
XPath, by a microflow and with no targeting. It also writes multi-user
tasks with the consensus, veto, majority and threshold rules and
participants, which 10.0 has. Then it inserts a task with alter workflow
and changes a task's targeting.

Measured with TestMxCheck_DoctypeScripts/24b:
- 10.24.24.119653: PASS, mx check 0 errors. Control: with the targeting
  lowering stubbed, it FAILS with CE1859 on all 8 user tasks.
- 11.13.0: PASS (24, 24b and 25 all pass, on both versions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
The 10.0-10.11 claims rested on mendixmodelsdk's version data and on the
9.24 measurements. They are now measured on 10.11.0.37074, the last
version before the 10.12 type boundary (mxbuild downloaded for this, then
deleted):
- With the user-task lowering stubbed, a user task makes the project
  unloadable (TypeCacheUnknownTypeException ...
  Workflows$SingleUserTaskActivity).
- With the fix, the 1771 bug-tests give 0 errors. The multi-user tasks'
  MultiInputCompletion holds AwaitAllUsers (10.2+). mx convert 10.11
  changes nothing, and describe -> exec reports "Unchanged workflow".
  Majority, await all users and a microflow rule check clean, and
  alter workflow's targeting edit lands.

1771-user-task-features-by-version.mdl's group-targeted task used a
microflow returning System.WorkflowGroup, an entity Mendix introduced in
11.2. Written on 10.11 it was CE1613, unrelated to the user task. The
task now targets groups by XPath. Re-measured: check -p reports the
expected gates on 9.24.30, 10.11.0 and 10.24.24. On 11.13.0 exec writes
all five workflows with 0 errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82sUVQEB3K5W5vh85wM
Mendix 11.16.0 is published on the CDN (mxbuild-11.16.0.tar.gz, 27 Oct
2026) but was not exercised by CI.

Added as a seventh entry rather than replacing an older one: 11.13.0 is
the measured floor for timer-started workflow event sub-processes, and
11.15.0 is the first version with the MessageDefinition2 document shape
(ako#987), so each keeps a job running at its own gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oK82teqHJLFTo5pLuHMt1
Fix finds in nested fragment bodies, user tasks below Mendix 11.2, and overlapping error-handler rows
@ako

ako commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

My verdict on #1674 is request changes. Merging from the command line with Mendix’s own merge engine, plus a review step, is a good idea. As it stands, though:

--ours/--theirs silently drop documents.
Several failure paths leave the repository halfway through a merge.
On Linux, the main path can’t run at all.

How I checked it: the permission policy blocked building and running the PR’s own code, because it comes from an external contributor. So I replayed its git steps with my own commands, and called Mendix’s mx (11.13, on the pedapp fixture) exactly as the PR calls it. Its mx git-merge success path on Windows or macOS is therefore not something I could verify.

Must fix
--ours/--theirs drop the other side’s new documents and commit without checking.
They take one whole side of every conflicted model file, and that includes App.mpr, which lists every document in the project.
In the most common case, each side adding a document, I replayed --theirs. It committed a merge in which ours’ new microflow is a file on disk but not part of the project: ORPHAN … ACT_Ours (file, not indexed). Only ACT_Theirs is visible.
Documents the other side deleted stay listed with no file behind them.
The PR’s tests only change units that already exist on both sides, so App.mpr lists the same units on both sides and this never shows up.
These two flags are also exactly what the PR’s error message suggests next.
--plan refuses the normal case, and leaves the merge half-done.
It takes ours’ App.mpr, so any document theirs added becomes an orphan, and its own hash check then refuses to commit. I reproduced that refusal in a scenario where both sides change the same attribute and theirs adds a microflow.
It returns without git merge --abort: MERGE_HEAD remains and App.mpr stays unmerged. That’s the state in which Studio Pro re-runs the merge every time it opens the project, which is how Jonathan’s checkout kept crashing.
The path where mx can’t be found or started doesn’t abort either.
The skill text tells agents that such a merge “aborts the merge cleanly”, which isn’t true in these cases.
A crash is reported as a merge decision.
The engine exits 2 when conflicts need a decision. It prints a JSON list per unit (ManualResolutionRequired versus AutoResolved) and writes conflict markers into App.mpr.
It exits 129 on an exception. One example is the customer’s case: a merge base with access rules an older mxcli wrote incomplete.
The PR treats every non-zero exit as “needs a decision”, writes a plan and suggests --ours/--theirs.
It should handle only exit 2 as a conflict. It could also use the engine’s own JSON list rather than git’s list of conflicted files. Any other exit should be reported as mx failing, followed by a clean abort.
On Linux, mx git-merge doesn’t work.
With the mxbuild that mxcli setup mxbuild installs, it exits 129 with “Git not found”, even though git is on PATH, and also with a git.exe symlink there.
Its git lookup appears to be Windows-oriented: the assembly contains “Could not find a path to git.exe” and Windows registry environment keys.
Linux devcontainers are where agents run mxcli, and there, combined with point 3, every model conflict ends up steered towards point 1.
The tests use a fake mx, so CI can’t catch this.
mx merge (the same engine, taking three project folders) did run here: exit 0 when clean, 2 on conflicts, 129 on an exception. It also wrote the merge result, including theirs’ added document, into MINE. It may be the more portable choice.
Should fix
The PR conflicts with main in CHANGELOG.md, cmd_git_doctor.go and the docs page. Also, cmd_git_commit.go merges without a textual conflict but calls isJunkPath(f), while main has isJunkPath(f, projDir) since my ako/mxcli#1767, so the result wouldn’t compile.
The plan file .merge-plan.json is written into the working tree and isn’t ignored, so the next mxcli git commit would commit it.
When model and non-model files both conflict, it tells the user to resolve the non-model files and then git add and git commit. But the model files are still unmerged, and adding them commits ours’ App.mpr (the same problem as point 1).
Nit: it calls mx directly rather than through docker.PrepareMxCommand, which applies the LD_PRELOAD workaround that 11.10 needs.
What’s good
The doctor change (only core.autocrlf is judged; the mendix.* counters pass whenever they’re present) is sensible and measured.
The staged-files summary fix is correct, once the isJunkPath call is updated.
The stale-plan check on commit SHAs, the property-level report from mx diff, the refusal while Studio Pro is open, and the “modified on both sides” rule for plans are all good ideas.

The common fix for points 1 and 2: never take a whole side of App.mpr. Either merge its list of units three ways, or refuse and abort whenever the hash check finds orphaned or missing units.

ako and others added 6 commits October 29, 2026 18:19
ci: add Mendix 11.16.0 to the nightly test matrix
…he merge said — CI's modelConflictRepo saw no unresolved files on ubuntu, and the fixture threw the merge's output away
…jDir-scoped junk paths; keep the model-aware doctor wording and the git-merge entry
…ates every resolution on hashes, and aborts cleanly on every failure

ako's review of mendixlabs#1674: --ours/--theirs took a whole side including the .mpr,
so the other side's added units became files no longer in the project and
deleted ones stayed listed with no file; --plan refused exactly that case and
its error paths left MERGE_HEAD behind (Studio Pro re-runs the half-done
merge on every open); every non-zero engine exit was read as a merge
decision, so a crash (exit 129) wrote a plan and pointed at side-taking; and
mx git-merge's file form does not run on Linux at all (its git lookup is
Windows-bound).

- the engine is called as mx merge BASE MINE THEIRS over the three project
  folders (Linux devcontainers included) through docker.PrepareMxCommand,
  and it owns the .mpr: its result carries both sides' document sets, so a
  unit one side added while the other changed existing ones is kept
- exit 2 alone is a decision (report + plan template + clean abort); any
  other exit, and an mx that will not start, is reported as the engine
  failing and aborted cleanly
- --ours/--theirs stay but their commit is hash-gated: when the sides'
  document sets differ the commit is refused (orphan/missing units) and the
  merge aborts — a whole side never silently drops documents
- every --plan refusal aborts the merge (stale already did; invalid take,
  unreadable snapshots and the hash refusal now do too)
- a merge already in progress is resumed instead of re-run, so the
  mixed-conflict advice (resolve text files, git add, re-run) works
- <mpr>.merge-plan.json is junk for mxcli git commit
@engalar
engalar force-pushed the feat/git-merge-review branch from bd7ce18 to 4fa453f Compare October 30, 2026 06:55
@engalar

engalar commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Every Must fix verified against the code and addressed on the branch (fa86038cf). Outcome per point:

1. --ours/--theirs dropped documents — fixed by the hash gate you proposed. The flags still resolve the conflicted units, but the commit now runs the ContentsHash check first: when the sides' document sets differ (one side added or deleted a unit), the commit is refused — "the ContentsHash index has MISSING or ORPHAN entries … committing would drop or orphan units" — and the merge aborts cleanly. Reproduced as a test: both sides add a microflow, --theirs refuses and aborts with MERGE_HEAD gone and the tree restored (TestGitMerge_SideTakingRefusesWhenDocumentSetsDiffer). Nothing is committed half-way.

2. Half-done merges — every refusal now aborts. git merge --abort runs on: stale plan (already did), invalid take, unreadable snapshots, the hash refusal, an mx that will not start, and any non-2 engine exit; where aborting itself fails the error says so and points at Studio Pro. TestGitMerge_PlanRefusalAborts and the crash tests assert MERGE_HEAD gone and the tree restored. The plan still refuses when the sides' document sets differ (the modified-on-both rule), but as your common fix allows: refuse and abort, never a half-done state.

3. Exit codes — only 2 is a decision now. Exit 0 stages, hash-checks and commits; exit 2 prints the property-level report plus the plan template and aborts; any other exit (129, a crash) is reported as mx merge failed (exit N) and aborted cleanly — no plan, no side-taking suggestion (TestGitMerge_EngineCrashAborts, TestGitMerge_EngineStartFailureAborts). I did not switch the conflict list to the engine's JSON: the schema is not verifiable from this machine, so the engine's stdout is printed verbatim for the human and the plan template is built from git's conflicted units — a superset that errs toward showing work rather than hiding it.

4. Linux — the engine is now called as mx merge BASE MINE THEIRS over the three project folders, per your measurement (0/2/129, result written into MINE, portable). The base and theirs revisions are still checked out into temporary worktrees; MINE is the working tree, which the engine owns — the .mpr included, so a unit one side added while the other changed existing ones is kept instead of orphaned. mx is now invoked through docker.PrepareMxCommand (the 11.10 LD_PRELOAD workaround); same for mx diff.

Should fix: upstream/main is merged in — CHANGELOG, the cmd_git_doctor.go wording (both sides kept: the model-aware config judgment and the insteadOf-aware remote check), the docs page, and the isJunkPath(f, projDir) signature; the tree compiles and the package's git tests pass. <mpr>.merge-plan.json is junk for mxcli git commit now. The mixed-conflict advice no longer steers into committing ours' .mpr: text conflicts are resolved and staged by hand, then the command is re-run — a merge already in progress is resumed, not re-run (TestGitMerge_ResumesAMergeAlreadyInProgress), and it resolves the model and commits.

Still open, said plainly: real mx could not be run from this Windows machine, so the Linux mx merge behavior rests on your measurement, and CI still drives the engine through a fake runner. A real-mx integration test in the integration (other) suite (which has mxbuild) would close that gap — proposed as a fast-follow on this PR.

…774 type floors; keep the model-aware doctor judgment and the git-merge entry
@ako

ako commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Correction to my earlier review: I wrote that mx merge "taking three project folders" ran. It didn't. Every run I measured passed the three .mpr files. With folder arguments, mx 11.13 on Linux exits 129 ("The specified file does not exist"), even on clean copies. You had to rely on my measurement, so the folder mode in 6550296 is on me. Fix: pass <dir>/<name>.mpr for base, mine and theirs.

Second blocker: with MINE set to the working tree in the middle of git merge, mx merge crashes (129, "Could not find a part of the path …") when theirs deleted a document. What works: use a clean worktree at HEAD as MINE, copy the merged App.mpr and mprcontents back, then git add -A, run the hash gate, and commit. Measured: exit 0, a consistent index, and mx check reports the same errors as before the merge.

The engine's output, since you couldn't check it from Windows:

  • Exit codes: 0 = merged, 2 = conflicts that need a decision, 129 = exception.
  • Conflict list: printed only with -v. The exit code is 2 either way. It is one JSON array on one stdout line:
[{"UnitId":"0131b342-…","UnitName":null,"ModuleName":"FeedbackModule","ContainmentName":"DomainModel","Type":"MergeContents","TreeConflict":"None","ConflictResolutionDetails":"ManualResolutionRequired"},
 {"UnitId":"6a912436-…","UnitName":"ACT_Theirs","ModuleName":"MyFirstModule","ContainmentName":"Documents","Type":"AddFromHead","TreeConflict":"None","ConflictResolutionDetails":"AutoResolved"}]

Only ManualResolutionRequired entries need a decision; AutoResolved ones are already merged. Git's list can differ from the engine's. If one side's ContentsHash index is stale, the engine content-merges a unit that git sees as changed on one side only.

Smaller points:

  • The exit-0 path stages before the hash gate runs.
  • The PR description still describes mx git-merge and taking a whole side with --ours/--theirs.
  • The reply cites fa86038cf, which isn't on the branch. The fix is 69a1fdf.

On the real-mx integration test: this blocker came from exactly the gap that test would cover, so I'd add it in this PR rather than a follow-up. One fixture merged through mx merge in the integration job is enough, covering exit 0 and exit 2.


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants