Skip to content

fix(diff): diff runs exec on a scratch copy and reports exactly the units it writes (#907, #807) - #913

Merged
ako merged 23 commits into
mainfrom
fix/diff-follows-exec
Oct 1, 2026
Merged

ako merged 23 commits into
mainfrom
fix/diff-follows-exec

Conversation

@ako

@ako ako commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #907, closes #807. Also fixes the "diff runs no earlier statements" part of #856. Part of #714.

What changes

mxcli diff used to predict exec by rendering each statement as MDL and comparing that text with the stored document's describe, without running anything. Every normalisation exec applies had to be copied into that renderer, and the copies drifted. Rehearsal 3 found phantom changes on 66 scripts and no report on 37 scripts that did write.

diff now runs exec. The new package mdl/scriptdiff:

  1. copies the project to a scratch folder. It skips .git, deployment, releases and node_modules, and deletes the folder afterwards;
  2. runs the script there with exec's own code: the same pre-flight (execPreflight, moved out of exec into one shared function), ExecuteProgram or the continue-on-error run, under the script's language header;
  3. compares the copy with the project unit by unit, both by bytes and by the .mpr Unit-table container. Without the container check, a move that rewrites no unit bytes would not show up;
  4. renders each changed unit with DESCRIBE before and after. A domain model is shown per entity and association; module security per module role; project security per user role and demo user. If a unit is written but describes the same, diff lists the properties that change (changed: CanvasHeight, …), or the folder it moves to.

The units diff reports are therefore the units exec writes. There is no second verdict left to keep in step with exec.

What this fixes:

Design choices

  • The scratch run always uses the file engine, also under --mcp. The script is executed for real, so only the copy may receive it.
  • diff gets --no-check, --continue-on-error and --deprecations, matching exec, so it can predict a given exec invocation exactly. A new --exec-output flag also prints what exec reports while it runs.
  • New summary format: Summary: N new, N modified, N removed — exec would write K unit(s) (or — exec would write nothing). The old unchanged count was per statement, and that has no meaning once the verdict is per unit.
  • The statement-based differ is deleted (cmd_diff_mdl.go, cmd_diff_render.go, spliceVerdict), together with its unit tests. Two of them were in cmd_associations_storage_test.go. diff-local keeps the shared output formatters.
  • File changes outside the model are listed (New file: javasource/…). mxcli's own caches (.mxcli/ and the generated widget docs in .claude|.ai-context/skills/widgets) are excluded, because any mxcli run may refresh them.
  • Exit status is unchanged (0) when exec would refuse or stop. The verdict is in the output.
  • Cost: about 0.4 s on PedApp and 1.7 s on TestApp (56 MB) for the repro.

No change of MDL meaning and no new rejection: diff writes nothing. The twice-exec rule is checked from diff's side. After an exec, diff reports exactly what a second exec writes, which is usually nothing.

Test plan

What I ran:

  • go test ./mdl/scriptdiff/ (new, PedApp fixture, Studio Pro-authored):
  • go test -tags integration ./mdl/scriptdiff/ -run TestDiffFollowsExec_Examples (new property; PedApp; 9 doctype scripts: domain model, microflows, pages, security, constants, navigation, settings, folders, languages). Each script runs twice. diff's written-unit set equals exec's on both runs. 132 s.
  • MXCLI_DIFF_LEGS=… go test -tags integration ./mdl/scriptdiff/ -run TestDiffFollowsExec_Legs on copies of the rehearsal-3 work trees: demo2, rest-head, captrack, ledger-head, f1 back and front, demo2-mdl0, rest-mdl0. 162 of 162 scripts agree; exec wrote in 26 of them. (Manual run, not CI.)
  • go test -tags integration ./mdl/roundtrip/ -run 'TestPedAppSpliceParity_|TestFlowVerdictAgreement_|TestSpliceRerun_NoFolderClause' passes. These parity/verdict tests now go through the new diff, and their expected strings were updated.
  • go test ./mdl/executor/, go test ./cmd/mxcli/ (skill line budgets), make build, make lint (includes check-conformance), make check-findings, make check-wiki-pages: all pass.

Revert checks

Follow-ups (not in this PR)

  • diff now shows real exec writes that the old differ hid. For example, re-executing PedApp's MyFirstModule.Home_Web describe rewrites the page (CanvasHeight/Width, PhoneWeight/PreviewWidth), and run 2 of several doctype scripts still writes units. Those are exec idempotency issues and are now visible through diff.
  • Interrupting diff leaves its scratch folder in $TMPDIR.

🤖 Generated with Claude Code

ako and others added 11 commits October 1, 2026 19:24
… reporting success with nothing stored (mendixlabs#1214)

A list view / data view stores Editable as a boolean; the setter only wrote
strings, so `set Editable = true on lvRows` returned nil. The stored value's
type now decides the vocabulary (true/false vs Always/Never), the input enum is
canonicalised, anything else is refused, and EditableIf writes the
Conditional enum beside its settings element as CREATE does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…627)

The fluent API's AttributeModifier.Apply() rebuilt the attribute with
raw == nil and kept only its $ID, so the codec minted GUID = $ID - the
mendixlabs#1119 data-loss class. The write guard refused it, leaving the API
unusable on any Studio Pro-authored attribute. Carry the stored raw
bytes and export level via carryStoredAttribute, now shared with the
entity rewrite's carryAttributeIdentity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#528, mendixlabs#293)

A data view's footer is a region: its widgets live in the data view's
FooterWidgets and the footer has no stored Name, so the name a script wrote
and describe's invented footer1 both named nothing ALTER could find.

- ALTER PAGE: <dv>.footer resolves as a region; INSERT INTO appends, REPLACE
  swaps the whole content (a describe-style footer { } block is unwrapped),
  DROP empties it. A not-found names the footer addresses the page has.
- REPLACE may reuse the names of the widgets it removes (the target's
  descendants), which was refused as a duplicate.
- A name on a data view footer is MDL-DEPR005 (unstored widget name), like a
  layout grid row's; describe prints footer { } unnamed. The AST is
  identical, so writes do not change. Docs/examples/skills rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion (#906, #558, #563)

Seven places where check -p passed a statement exec then refused part-way
(or both accepted a write that broke the project). Each prediction now
calls the decision exec makes instead of restating it:

- demo user that exists: into the create registry (stmtCreateKind/setFor)
- jar dependency that exists: alter module jar actions replayed through
  applyJarDepAction on a copy of the stored settings, in script order
- translations into the source language / plain create of a language with
  translations: translationsRefusal, shared with execCreateTranslations
- task queue created earlier in the script satisfies `in queue`
  (mendixlabs#1211); one created later is MDL-ORDER01 or, for a later
  create or modify, reported against the project (validateForwardDefRefs)
- page/snippet widget naming a microflow/nanoflow created later:
  MDL-ORDER01 via eagerDefRefs (mendixlabs#1212)
- variable passed to a Microflow-typed Java action parameter:
  microflowParamArgRefusal, used by the flow builder and the reference pass
  (mendixlabs#1210, unloadable project: refused under both versions)
- referenceselector: MDL-WIDGET38 from formsWidgetsWithoutWriter, which the
  builder's fall-through uses too (no more `widget init` hint)
- retrieve constraints (mendixlabs#1213): MDL047 also matches
  `!= empty`, MDL091 startsWith()/endsWith(), unknown bare members and
  CreatedDate-for-createdDate are reference errors. Each measured CE0161 on
  mxbuild 11.13.0 against a clean control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MoveEntity scanned only the plain associations of the source unit, so a
cross-association created by an earlier move was invisible: moving its
second endpoint left a ParentPointer naming an element absent from its
unit and Studio Pro could not open the project. Handle the three shapes:
convert back to a plain association when both endpoints share a module
(raw transform, GUID carried), let an own cross-association travel with
its FROM entity, and re-point a cross-association in another module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he refusal

exec's pre-flight (semantic checks, references, name clashes) moves out of
the command into execPreflight, which prints what the checks report and
returns the message exec exits with. No change in behaviour; diff is about
to run the same checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…writes (#907, #807)

mxcli diff answered "what would exec write?" a second way: it rendered each
statement as MDL and compared the text with the stored document's describe
output, without running anything. Every normalisation exec applies had to be
re-derived by that renderer, so it reported phantom changes (Boolean against
Boolean default false, String against String(unlimited), positions, flow
layout), did not compare pages, translations or layouts at all, said
unchanged for a plain create exec refuses (#807), and could not diff a
statement that depends on an earlier one (#856).

The new mdl/scriptdiff package copies the project to a scratch folder, runs
the script there with exec's own code (the same pre-flight, ExecuteProgram or
the continue-on-error run, under the script's header), and compares the copy
with the project unit by unit, the .mpr's container rows included so a move
is seen. Each changed unit is rendered by DESCRIBE before and after; a domain
model per entity and association, module and project security per role and
demo user. A unit that is written but describes the same is listed with the
properties that change. The scratch run always uses the file engine.

The statement-based differ (cmd_diff_mdl.go, cmd_diff_render.go,
spliceVerdict) and its tests are removed; diff gains --no-check,
--continue-on-error, --deprecations and --exec-output as exec has them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…endixlabs#849)

Studio Pro does not reload the model from disk, so a write mxcli makes
while the project is open is silently discarded by Studio Pro's next
save. The writer now refuses any write that would reach storage while
Studio Pro's <project>.mpr.lock is beside the .mpr (matched without
regard to case, as Studio Pro lower-cases it). Reads and writes elided
as no-ops are never refused; exec --force or
MXCLI_ALLOW_STUDIO_PRO_OPEN=1 override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its mappings and its commit option (#571)

create published rest service wrote only the path's {name} placeholders as
operation parameters, each a String: a query or body microflow parameter
failed mx check with CE0350, an Integer {id} with CE6539. import mapping,
export mapping and commit parsed and were thrown away.

- Parameters are derived from the microflow as Studio Pro derives them
  (path name -> Path, object/list -> Body, HttpRequest/HttpResponse -> none,
  else Query), each with the microflow parameter's type, merged over the
  stored parameters so a header / renamed / described one survives.
- Mappings and commit go AST -> model -> BSON and back; describe prints
  them and notes parameters MDL cannot state. An unknown commit option is
  refused by exec and check (MDL-REST03).
- create or modify carries the restated operation's summary, documentation
  and object handling; the service rewrite carries the stored keys MDL cannot
  state (authentication, CORS, documentation). List markers as Studio Pro
  writes them.

TestApp's Services.OrdersRestApi leaves the round-trip allowlist.

Fixes mendixlabs#1206

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff executes the script for real on a scratch copy. A headerless script
that connected to the -p project wrote into the real project and reported
that exec writes nothing; a connect to another project, also inside an
execute script, wrote that one; sql queries and import ran against real
databases. Every statement now passes Executor.SetStatementGuard: a connect
to the project is redirected to the copy, anything else outside it is
refused with an error. The copy also skips the scratch folder when TMPDIR
lies inside the project, instead of copying itself into itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ako

ako commented Oct 1, 2026

Copy link
Copy Markdown
Owner Author

Independent review: pushed b7a48cc. diff ran the script for real on the copy, but a headerless script with connect local '<-p project>' wrote into the real project and reported exec would write nothing. A connect to another project (also from a nested execute script) wrote that project, and sql queries and import ran against real databases. Fix: Executor.SetStatementGuard runs on every statement in Execute, nested scripts included. diff redirects a connect to the project onto the copy and refuses anything else outside the copy with an error. Also, with TMPDIR inside the project, the scratch copy copied itself until 'file name too long'. Tests: mdl/scriptdiff/outside_test.go. Each test failed with the reported symptom before the fix. Re-ran: scriptdiff unit + integration, the roundtrip diff tests (PedAppSpliceParity, FlowVerdictAgreement incl. TestApp corpus, SpliceRerun handler/folder), go test ./mdl/executor/ ./cmd/mxcli/, make lint (incl. conformance), check-findings. The #907/#807 repros on a TestApp copy: diff matches exec, and the project was untouched.

🤖 Generated with Claude Code

ako and others added 12 commits October 1, 2026 21:00
…e binding (mendixlabs#1235)

Studio Pro stores an input bound to a page variable as SourceVariable
{LocalVariable} with no AttributeRef (measured over MCP on TestApp). MDL
refused `Attribute: $ShowAll` (MDL-WIDGET34) and describe printed the
widget unbound, so describe -> exec cut the binding silently.

- builders map a bare `$name` naming a declared page variable to that
  SourceVariable; check validates it against the document's Variables;
  ALTER sees the stored variables
- describe prints `Attribute: $name` for that shape
- text box / text area bound to a variable get MaxLengthCode 0 (unlimited);
  -1 is mx check CE6553

Also wires ALTER REPLACE's stored-widget description (used by the next
commit) through cmd_alter_page.go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he statement does not state (mendixlabs#1247)

A pluggable widget is rebuilt from its template, so a REPLACE reset every
property MDL has no word for. On TestApp's Studio Pro-authored
Rules.BusinessRule_NewEdit, adding a sort to comboBox1 also turned its stored
Editable Never into Always and an expression property's PrimitiveValue into
'false'; the reporter lost a translated placeholder and readOnlyStyle.

For one pluggable widget replaced by one of the same package, the executor
builds the stored widget as describe prints it beside the replacement;
the mutator keeps the stored Type and every property/field the two builds
agree on, and grafts the differing values with their TypePointers re-aimed
at the stored Type by key path. Anything that cannot be lined up falls back
to the plain replace. Running the same REPLACE twice writes nothing.

Also: findings, mutator-addressing pattern page, alter-page skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- mendixlabs#591: list activities and cast carry the flow flavour's
  error handling (Abort in a nanoflow, where "Rollback" was CE6035); create,
  commit, call nanoflow and call microflow in a nanoflow accept only a handler
  without rollback (measured), refused otherwise; check now reports the
  nanoflow rules exec's build enforces (MDL091) and the annotation rules.
- mendixlabs#698: the MCP mapper writes errorHandlingType and refuses a
  custom handler / error-handler flow PED cannot express.
- mendixlabs#991: @anchor and @Curve inside an error handler are applied;
  describe emits the handler body's layout annotations.
- mendixlabs#992: `@anchor(true: (to: top))` parsed as a division; the
  paren value now wins, unusable @anchor parameters are refused (MDL092), and
  `@curve(true: …)` is refused on nanoflows too.
- mendixlabs#870: lock/unlock name their workflow; `pause all` /
  `unpause all` is Studio Pro's "(Un)pause instances"; bare `all` is refused
  (MDL-WF17); describe no longer turns a Studio Pro lock into `all`.
- mendixlabs#175: `call workflow … on error continue` refused (MDL076),
  MDL076 exec-enforced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s too (mendixlabs#1247)

Restating TestApp's dataGrid2_1 with one column caption changed fell
back to the template rebuild: the filter widgets nested in the columns
value point into their own Type, which the graft could not re-aim, so
itemSelectionMethod, onClickTrigger and every column's unmapped
properties were reset. A nested pluggable widget is now grafted as
built, and an object list that lines up with the baseline is merged
object by object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hint told authors to declare `Variables: { … }`, the brace form
MDL-DEPR123 deprecates; it now writes `Variables: ( … )`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
@ako
ako merged commit 23202c7 into main Oct 1, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant