Skip to content

mdl 1: strict parsing (property keys, ';', no '/', '' escapes); trailing commas everywhere (#732) - #741

Merged
ako merged 25 commits into
mainfrom
feature/732-strict-parsing
Sep 27, 2026
Merged

ako merged 25 commits into
mainfrom
feature/732-strict-parsing

Conversation

@ako

@ako ako commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Closes #732. Plan item 2.2 of PROPOSAL_mdl_beta_syntax_freeze.md; implements ADR-0010 R11 behind the mdl 1; header (ADR-0011). Part of #714.

What changes

Item Under mdl 1; Without the header (mdl 0)
1. Property keys In the REST client (service, basic auth, operation), published REST service, business event service, model, knowledge base, consumed MCP service, agent and agent body blocks: an unknown key, or a value its key does not take (Response: json from $X, Body: status as $Y, Path: 42, Model: GPT), is an error that names the key, the list, the known keys and the key it most likely meant. Read as before (ignored, or read by its shape); MDL-V1-PROP / MDL-V1-PROPVALUE warnings.
2. Terminators A statement without ; is an error, and so is the SQL*Plus / line. Accepted; MDL-V1-SEMI / MDL-V1-SLASH.
3. Escapes '' is the only escape; a backslash is an ordinary character ('C:\temp' is that path, 'C:\' is complete). Unchanged (\n \r \t \\ \' are escapes); MDL-V1-ESCAPE for each literal whose value would change.
4. Trailing commas Allowed in every bracketed list. Same (additive).

mdl 1 stays a preview (langver.Frozen = V0), so no headerless script changes behaviour.

Design choices the ADRs did not settle

  • Trailing commas are one lexer rule, not COMMA? in ~70 list rules. I did the grammar version first (a script added COMMA? after every bracketed (COMMA x)*). It made every list's loop decision LL(2), so an unknown item after a comma was reported as no viable alternative at input ',Height' instead of expecting {…}, which lost the annotation-property hints (Feature request: expose annotation HEIGHT in the domain-model annotation surface mendixlabs/mxcli#1014) and would degrade every list's errors. The lexer version (TRAILING_COMMA: ',' {p.isTrailingComma()}? -> skip) drops a comma whose next significant character (skipping whitespace and comments) closes (), {} or [], and not one that follows an opener or another comma, so () stays the only empty list and (,) / (a,,) stay errors. It also covers expression argument lists; f(a,) means f(a).
  • The escape rule is decided before lexing. It changes where a literal ends, so the lexer needs it. langver.ScanHeader reads the header from the source (same trivia rules as the grammar). An mdl 1 script is lexed from a parser.StrictEscapeStream (declared in the lexer's @members, since mdl/grammar/parser is generated), which a predicate on STRING_LITERAL checks. Every token keeps its stream, so the visitor reads each literal under the rule it was lexed with: the 242 unquoteString(x.GetText()) calls became unquoteStringLit(x) mechanically, and TestStringLiteralsAreReadUnderTheirEscapeRule fails on any new unquoteString( call in the visitor. The alternatives were worse: a package-level flag is not safe for concurrent Builds, and rewriting token text would double backslashes in the places that pass raw token text through.
  • Terminators are read off the statement's last two tokens, not its own SEMICOLON/SLASH: the microflow, nanoflow and workflow rules end in SEMICOLON? SLASH? themselves, and create java action … as $$…$$; in SEMICOLON?. I found this by running PedApp's describe output through check under mdl 1;.
  • Schemas list what each visitor reads, plus what describe writes. For example, the agent tool block accepts ToolType and Document, which describe writes and create ignores (see follow-ups). Shapes follow what the visitor actually reads: the model's Provider takes a name only, because a string there was dropped, while the knowledge base's reads either.
  • Did-you-mean moved from the executor's OData check into mdl/suggest (refactor commit). It gained a two-edit fallback that counts a swap of neighbouring letters as one edit, for names of five letters or more (Verison, Passwd, Enabeld). The OData did-you-mean gets this too.
  • Codes: MDL-V1-SEMI, MDL-V1-SLASH, MDL-V1-ESCAPE, MDL-V1-PROP, MDL-V1-PROPVALUE, declared as langver.Changes next to their code, as Microflow list operations as one statement per Studio Pro activity; set mandatory (mdl 1) #733 and create or modify as the one idempotent create; view-entity identity carry; if not exists everywhere; describe emits create or modify #731 do.
  • Under mdl 1 a \' that ends a literal gets a hint naming the doubled apostrophe, because the parse errors that follow point elsewhere.

Test plan (what I ran)

  • make build; make lint (Go + TS), both passed.
  • go test ./mdl/visitor/ ./mdl/executor/ ./mdl/grammar/ ./mdl/langver/ ./mdl/suggest/ ./cmd/mxcli/syntax/, plus (lexer change) ./cmd/mxcli/ ./cmd/mxcli/testrunner/ ./mdl/formatter/ ./mdl/linter/... ./mdl/repl/ ./mdl/deprecation/: all pass.
  • go test -tags integration ./mdl/roundtrip/: passes. No allowlist entries added or struck.
  • New tests. Parse tests run under both versions; exec tests run ValidateProgram and ExecuteProgram under mdl 0 and Build under mdl 1:
    • mdl/visitor/trailing_comma_test.go: 17 lists, each checked under both versions to build the same AST as the form without the comma, plus the (,)/(a,,) controls.
    • mdl/visitor/strict_terminators_test.go: missing ;, the / line, a rule that ends in its own ;, and end; + / on a microflow. Terminated scripts are the controls.
    • mdl/visitor/string_escapes_test.go: values under both versions, 'C:\data' and 'it''s' as controls, where a literal ends, warning line, the source guard, and the hint.
    • mdl/visitor/strict_properties_test.go: 15 unknown or mis-shaped cases across all listed documents. The control accepts every key and shape each list reads, and describe's forms, under mdl 1 with no warning. mdl 0 keeps Response: json from $X as the body.
    • mdl/executor/strict_parsing_test.go: each item under both versions through check and exec. mdl 0 warns with the code and runs every statement; mdl 1 refuses with the item's message; exec stores C:<tab>emp under mdl 0 and C:\temp under mdl 1.
    • mdl/langver TestScanHeader, mdl/suggest TestClosest.
  • Revert checks (each change removed, its tests seen failing, then restored):
    • Trailing commas: the old lexer made 14 of the 36 subtests fail. Published REST with a trailing comma even panics in the visitor on the error-recovered tree.
    • Terminators: renaming ExitStatement failed the visitor tests and 6 executor subtests. The first implementation (statement-level SEMICOLON/SLASH) fails TestTerminatorsOfRulesThatEndInTheirOwn.
    • Escapes, three separate reverts:
      • the old STRING_LITERAL rule fails "where a literal ends";
      • forcing unquoteStringLit to mdl 0 fails the mdl 1 values;
      • skipping noteBackslashEscapes fails every mdl 0 warning assertion.
    • Properties: an early return in checkProperty made 16 visitor subtests and 4 executor subtests fail.
  • By hand: the proposal's "Before" script passes check with 5 warnings (SEMI, SLASH, SEMI, ESCAPE, PROP did you mean 'Path') and is refused under mdl 1;.
  • No Studio Pro check: nothing here changes what is written to a model, only what parses and what a string literal's value is under mdl 1.

Follow-ups (not in this PR)

  • describe output is refused under mdl 1. It writes a / line after about 20 document types, and no ; after a page's }. Under mdl 0 that output now warns MDL-V1-SLASH / MDL-V1-SEMI. This belongs to canonical describe (2.6) or fmt --upgrade (1.3), and must land before describe emits the header.
  • The agent tool block's ToolType and Document, which describe writes, are ignored by create agent, so a microflow tool loses its document on a round trip. Separately, create agent resolves a tool's document only when ToolType == "mcp", but the visitor sets "MCP".
  • Strict keys for the other property lists (R11 "then everywhere"): OData already refuses (MDL-ODATA01); pages and widgets, settings, and alter … set lists do not.
  • QuoteString (visitor) still writes \\ for a backslash. That is right for the headerless MDL it builds, and must change once generated MDL carries mdl 1;.

🤖 Generated with Claude Code

ako and others added 22 commits September 27, 2026 08:33
…t mandatory under mdl 1

Each Studio Pro List operation / Aggregate list activity is one statement
whose operand is a variable, so nesting cannot be written:

  $Open = filter $Orders by Status = M.Status.Open;
  $Big  = filter $Orders where $currentObject/Total > 1000;
  $N    = count $Open;
  $Csv  = reduce $Orders from '' as String using $currentResult + $currentObject/Name;

The statement forms parse under every version. The call forms keep
parsing: a respelling everywhere except find/contains, registered as
MDL-DEPR003 (list operations) and MDL-DEPR004 (aggregates), both building
the same AST. find(...)/contains(...) clash with the string functions, so
they, a list call after `set`, and a nested call are version-gated
(MDL-V1-LIST): kept and warned under mdl 0, refused under mdl 1, where
`set $x = find(...)` is always the string function. A reassignment without
`set` is refused under mdl 1 and warned under mdl 0 (MDL-V1-SET).

`where` is always Find/Filter by expression; `by` and the call form keep
the by-member reading when the condition is `Member = value`
(ast.IsMemberEquality, shared by visitor and flow builder).

Part of #733.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
describe writes the language of the newest frozen version, or of the
script it runs in when that is newer. While mdl 1 is a preview a plain
describe keeps the call form; inside an `mdl 1;` script it prints the
statement form, which executes back to the same microflow (PedApp test).

Part of #733.

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

Part of #733.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lementIDs, Writer.UpdateRawUnitPatch)

TransplantIDs pairs elements by type and position, which re-pairs the
surviving flows of a patched microflow onto their neighbours' $IDs after a
drop. A write that started from the stored bytes skips it; elision and the
storage-GUID guard still apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, replace, drop)

Splices a fragment into the raw stored unit: appends only the fragment's
objects and flows, rewires the flow around the target by its pointers and
the rewired end, moves nodes past the insertion point to make room, and
never rewrites an $ID. Save refuses a unit in which anything still points at
a removed element or two elements share an $ID. Refuses what it cannot do
safely: after a decision, before a join, inside a loop body, drop/replace of
a decision or of an activity with an error handler, and a placement that
would overlap an object. The modelsdk backend writes through
UpdateRawUnitPatch (canon.Reconcile).

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

alter microflow|nanoflow M.F { insert after|before <target> { … }
replace <target> with { … } drop <target>; }. Targets are the #713 content
addresses, resolved against the stored flow before any operation runs.
Fragments are built with the create-microflow builder, seeded with the
flow's variables, and cut out of their start and end events. A fragment
variable that clashes with one the flow has, or one it reads that is not
declared upstream of the insertion point, is an error; so is dropping an
activity whose output is still read. Acceptance on PedApp VAL_Feedback:
only the new log, the two flows around it and the shifted positions differ;
an empty alter writes nothing.

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

A handle has to parse as an alter target, and a target ends at the { of a
fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the shared splice on the stored flow and sends the difference as
ped_update_document path operations in one update, after checking the live
document still matches the .mpr (PED addresses entries by index). Drop and
replace are refused: PED does not roll back a removal when an update fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR-0010 R11, additive, so under every language version. One lexer rule
drops a comma whose next significant character closes a (), {} or [],
instead of a COMMA? in each of ~70 list rules: that kept every list's
error messages LL(1). Written into the parser, an unknown item after a
comma is reported as 'no viable alternative at input ,X' rather than
naming what the list expects, which lost e.g. the annotation-property
hint (mendixlabs#1014).

A comma still needs an item before it: (,) and (a,,) stay errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loop body flows are stored in the unit's Flows list, not in the loop, so
removing a Studio Pro loop with two or more body activities left them
pointing at removed objects and Save refused the unit
(TestApp ACT_ConflictedWorkflowHelper_ApplyJumpTo). mx check 11.14 on the
dropped and replaced copies gives the baseline error list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each operation was checked against the flow as stored only. Measured
with mx check 11.14 on TestApp: two fragments declaring the same
variable gave CE0111, and a fragment reading a variable a drop in the
same statement removed gave CE0109, after "Altered microflow". The
context now tracks what earlier operations declared, read and removed.
Also count a fragment loop's iterator as the fragment's own, so loop
fragments are no longer refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR-0010 R11. Both are new rejections, so they apply only under the
`mdl 1` header (ADR-0011): a headerless script parses as before and
check warns MDL-V1-SEMI / MDL-V1-SLASH for each occurrence.

A statement rule that consumes its own `;` (create java action … as
$$…$$;) counts as terminated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cter (#732)

ADR-0010 R11. Under mdl 0 \n, \t, \r, \\ and \' in a string literal
are escapes, which contradicts Mendix expressions and makes 'C:\temp' a
tab. It changes what text means, so it is tied to the header
(ADR-0011): a headerless script keeps the old value, and check warns
MDL-V1-ESCAPE for each literal whose value would change.

The rule decides where a literal ends ('C:\' is complete under mdl 1,
unterminated under mdl 0), so it is fixed before lexing:
langver.ScanHeader reads the header from the source, and an mdl 1
script is lexed from a StrictEscapeStream, which a lexer predicate on
STRING_LITERAL checks. Every token keeps its stream, so the visitor
reads each literal under the rule it was lexed with: the 242
unquoteString(x.GetText()) calls become unquoteStringLit(x), and a test
keeps a new call from reading token text with mdl 0's escapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The visitor needs the same suggestion for unknown property keys (#732)
and cannot import the executor. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR-0010 R11. The REST client (service, basic auth and operation
lists), published REST service, business event service, and the agent
editor's model, knowledge base, consumed MCP service, agent and agent
body blocks accepted any `Key: value` and read only the keys they knew,
so `pathh: '/users'` parsed, checked and executed with the path
missing. A value was also read by its shape rather than its key:
`Response: json from $X` set the request body.

Each list now has a schema (the keys its visitor reads, the value
shapes each takes, and what describe writes). Under mdl 1 a property
outside it is an error naming the key, the list and the nearest known
key; under mdl 0 the list is read as before and check warns
MDL-V1-PROP / MDL-V1-PROPVALUE.

suggest.Closest now also tries two edits, counting a neighbour swap as
one, for names of five letters or more (Verison, Passwd), which the
OData did-you-mean gets too.

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

`first` is Mendix's "First object" range in every language version. Under
the mdl 1 header a bare `limit 1` is a Custom range, a list of one; without
it the alpha meaning (the object) is kept and warns MDL-V1-LIMIT1. The
visitor resolves the meaning into RetrieveStmt.First, so the writer, the
variable typing and MDL-RETRIEVE01 no longer read limit text. describe
prints the object range as `first`. `first` on an association retrieve
is refused (that source has no range).

MDL-RETRIEVE01 keys on the object range and names CE0100 for a loop, as
measured with mx check 11.13.

Tests: visitor per version, builder range per version, check per version,
describe spelling, and a PedApp round trip of both forms with a headerless
control.

Part of #734.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nder mdl 1

CLAUDE.md idiom 5, the quick reference, the syntax topic, the skills and
docs-site pages that taught `limit 1` for an object now write `first`
and say what `limit 1` means per language version. CHANGELOG entry.

Part of #734.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The microflow, nanoflow and workflow rules end in `SEMICOLON? SLASH?`
themselves, so `end;` followed by `/` left the statement's own SLASH
empty: the `/` was not reported, and the missing `;` was reported at
the `/`. Found by checking PedApp's describe output under mdl 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Statement termination no longer recommends the / terminator, and the
string-literal section no longer claims backslash escapes are
unsupported: they are, until mdl 1.

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

Under mdl 1 'it\'s' ends at the quote, and the errors that follow
point at the rest of the line. Name the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ako and others added 2 commits September 27, 2026 11:39
ADR-0010 R11/R12: describe output must mean the same under mdl 0 and
mdl 1, and mdl 1 makes `;` the only terminator. About twenty document
types printed a SQL*Plus `/` line after their statement, and pages,
snippets, layouts and some OData statements ended without `;`.

- Drop every `/` line from describe (agents, knowledge bases, MCP
  services, models, constants, contracts, associations, DB connections,
  entities, enumerations, image collections, modules, OData, published
  REST, security, microflows, nanoflows, rules).
- Pages, snippets and layouts end with `};`; a workflow with
  `end workflow;`.
- A published OData service ends with `;` after its property list,
  authentication clause or entity block, whichever is last; an external
  entity without attributes after its property list.
- A published REST service with no resources prints an empty `{ };`
  block: the block is mandatory in the grammar, so the bare `;` it
  printed before did not parse.

Tests: TestPedAppDescribeIsValidMdl1 checks the terminators of every
PedApp document type (plus each module, with and without `with all`,
navigation and settings) on the parse tree and re-parses the output under
an `mdl 1;` header; executor tests cover the types PedApp does not
contain, and every Describe*_Mock / #707 re-parse test now asserts the
same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`diff` and `diff-local` render MDL for the compared side; with describe
no longer printing `/`, keeping it here would show a spurious line on
every statement and render text that is invalid under mdl 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ako added a commit that referenced this pull request Sep 27, 2026
… (valid under mdl 0 and mdl 1, required by #741)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ako
ako merged commit 509560c into main Sep 27, 2026
17 checks passed
ako added a commit that referenced this pull request Sep 27, 2026
…forms

On top of #740/#745/#741/#738, `fmt --upgrade --header` refused almost every
example script: those PRs gated new constructs on the header without a
rewrite. Each construct with a mechanical, meaning-preserving rewrite now has
one, computed from the parse tree by the visitor that records it (ast.Fix,
rune-offset edits, mdl/visitor/visitor_upgrade_fixes.go):

- MDL-V1-SEMI: add the missing `;`. MDL-V1-SLASH: delete the `/` line.
- MDL-V1-ESCAPE: write the literal's mdl 0 value with '' as the only escape;
  no edit inside an expression stored as written (mdl 0 already passed the
  backslash through); an escaped line break in a re-rendered expression is
  reported, since writing it into the literal changes what the builder
  stores (measured: a log message becomes a `{1}` template parameter).
- MDL-V1-LIMIT1: `limit 1` -> `first`. MDL-V1-SET: add `set`.
- MDL-DEPR003/004 and MDL-V1-LIST: call form -> statement form; find and
  contains on a declared String keep the call and gain `set`; a nested call,
  or an operand whose type the script does not state, is reported.
- MDL-V1-REPLACE02: `create or replace user role|demo user` -> `create`.

MDL-V1-PROP, MDL-V1-PROPVALUE and MDL-V1-REPLACE01 have no mechanical
rewrite; they are listed in `unrewritable` (may only shrink) and block the
header with HeaderBlockedError, which names each construct and why.
TestGatedRegistryIsComplete holds every visitor.LanguageChanges() code to
exactly one of the two lists.

The examples test lists the two corpus scripts that keep mdl 0
(keepsItsVersion) and the two whose AST differs but whose model is the same
(buildsTheSameModelNotTheSameAST), both may only shrink. The execute-both
property test upgrades a blocked script without the header, and by default
skips scripts whose only edits are the header and terminators, which never
reach the AST: 249 scripts execute (6 min); MXCLI_UPGRADE_ALL=1 runs all 701,
587 of them executing to the same model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Strict parsing under mdl 1: unknown property keys, required ';', no '/', '' as the only escape; trailing commas everywhere

1 participant