diff --git a/.claude/lint-rules/conv010_act_microflow_content.star b/.claude/lint-rules/conv010_act_microflow_content.star index ecd97bac6d..323dea7ebd 100644 --- a/.claude/lint-rules/conv010_act_microflow_content.star +++ b/.claude/lint-rules/conv010_act_microflow_content.star @@ -44,12 +44,20 @@ ALLOWED_ACTIONS = ( ) # Allowed activity types (non-action activities) +# +# ExclusiveMerge is here because an `if` produces BOTH a split and a merge. The +# list allowed the split and forbade the join it necessarily creates, so an ACT_ +# microflow that guards anything — "do not open a page with an empty parameter" — +# could not be written cleanly: the guard was permitted and its own closing brace +# was reported. Measured on a microflow whose ONLY violation was the merge, and +# 122 times over on one real project. ALLOWED_ACTIVITY_TYPES = ( "SubMicroflow", "MicroflowCallAction", "StartEvent", "EndEvent", "ExclusiveSplit", + "ExclusiveMerge", "Annotation", ) diff --git a/.claude/lint-rules/missing_documentation.star b/.claude/lint-rules/missing_documentation.star index 745cf2da08..5d80ddca5d 100644 --- a/.claude/lint-rules/missing_documentation.star +++ b/.claude/lint-rules/missing_documentation.star @@ -5,7 +5,7 @@ # covers every document type a user authors, not just the domain model. # # Documents swept generically (one option each, all default True): -# Module, Entity, Page, Snippet, BuildingBlock, Layout, Enumeration, +# Entity, Page, Snippet, BuildingBlock, Layout, Enumeration, # JavaScriptAction, ImageCollection, DataTransformer, Workflow, # BusinessEventService, RestClient, PublishedRestService, Constant, # JsonStructure, ImportMapping, ExportMapping @@ -41,11 +41,6 @@ SEVERITY = "info" # A new Mendix document type is covered by adding a row in Go's # documentableSources and a row here — not by writing another loop. _DOC_KINDS = { - "Module": ( - "check_modules", - "Module", - "Document what the module is for: it is the first thing a newcomer opens.", - ), "Entity": ( "check_entities", "Entity", diff --git a/.claude/lint-rules/orphaned_elements.star b/.claude/lint-rules/orphaned_elements.star index 47a608ccd2..b8486e925f 100644 --- a/.claude/lint-rules/orphaned_elements.star +++ b/.claude/lint-rules/orphaned_elements.star @@ -28,7 +28,7 @@ ENTRY_PAGE_PATTERNS = ["Home", "Login", "Index", "Dashboard"] # Reference kinds that mean "something causes this microflow to run". These are # catalog RefKind values (mdl/catalog/builder_references.go); a kind missing here # turns a live document into a false "not called from anywhere" finding. -MICROFLOW_ENTRY_KINDS = ["call", "schedule", "datasource", "action", "calculate"] +MICROFLOW_ENTRY_KINDS = ["call", "schedule", "datasource", "action", "calculate", "settings"] # Reference kinds that mean "something opens this page". PAGE_ENTRY_KINDS = ["show_page", "home_page", "login_page", "menu_item", "action"] @@ -70,9 +70,16 @@ def check(): # datasource a page or widget uses it as a data source # action a widget button calls it # calculate a calculated attribute computes with it + # settings a project setting names it (after-startup, before-shutdown, + # health-check) — the RUNTIME calls it, and nothing in the + # model does # # The banking-app report hit the 'datasource' case: DS_CurrentCustomer and - # DS_MyAccounts are both page data sources and both were flagged. + # DS_MyAccounts are both page data sources and both were flagged. The + # CapTrackV4 report hit 'settings': the project's own AfterStartupMicroflow + # was reported as "not called from anywhere. Remove if unused", and taking + # that advice left a dangling name mx check did not catch either — only the + # runtime refused to start. has_callers = False for ref in refs: if ref.ref_kind in MICROFLOW_ENTRY_KINDS: diff --git a/.claude/skills/fix-issue/findings/mdl-backend.jsonl b/.claude/skills/fix-issue/findings/mdl-backend.jsonl index bdac0746c0..fc1a2e7945 100644 --- a/.claude/skills/fix-issue/findings/mdl-backend.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-backend.jsonl @@ -82,3 +82,6 @@ {"area": "mdl/backend", "date": "2026-08-27", "symptom": "A rebuilt mapping drops `MessageDefinition2`, so a document written by Studio Pro 11.10+ never round-trips", "cause": "The key is version-introduced — `modelsdk/gen/mappings/version.go` records `messageDefinition2` as `Introduced: \"11.10.0\"` — and gen generates **no accessor** for it, so nothing read or wrote it. A blank 11.13 app's own mappings carry it as `\"\"`; none of the older pinned fixtures has it at all", "file": "`model.ImportMapping`/`ExportMapping` (`MessageDefinition2 *string`), `mdl/backend/modelsdk/mapping_read.go` (`messageDefinition2FromRaw`) + `mapping_write.go`, `sdk/mpr/parser_*_mapping.go` + `writer_*_mapping.go`, `mdl/executor/cmd_import_mappings.go` + `cmd_export_mappings.go`", "insight": "**Carry it, do not derive it.** A pointer, because nil (absent) is NOT the same as present-and-empty — writing the key onto a pre-11.10 document is the overlay-rule mistake CLAUDE.md warns about, which mxbuild tolerates and Studio Pro refuses to open. The executor decides: carry `existing.MessageDefinition2` on an update, apply the version gate only on a CREATE where there is nothing to read it off. A plain version gate in the writer looks equivalent and is not — it re-adds the key to every older document a rewrite touches, which turned four previously-clean fixtures red. gen having no accessor means the read goes to raw BSON, the route `parameterEntityFromRaw` takes. **`MappingSourceReference` is the same family and deliberately NOT carried**: its gate (10.16) predates every project in the field, and the codec emits it through a package-level `TypeDefaults` registration, so making it conditional would touch every mapping write to preserve one pre-10.16 fixture. Issue ako/mxcli#279", "refs": ["ako/mxcli#279"]} {"area": "mdl/backend", "cause": "The **reader**, not the writer: `mdl/backend/modelsdk/navigation_read.go` type-asserted only the `$Type`s `modelsdk/gen` declares for those two slots, and neither is what the documents carry \u2014 `LoginPageSettings` is stored as `Forms$FormSettings` with the page under `Form` (gen expects `Navigation$NavigationProfileLoginFormSettings` / `LoginPage`), and `NotFoundHomepage` as `Navigation$HomePage` (gen and `generated/metamodel` both expect `Navigation$NotFoundHomePage`). A failed type assertion leaves the field empty, so the loss is silent", "date": "2026-09-01", "file": "`mdl/backend/modelsdk/navigation_read.go` (`navLoginPageOf`, `navNotFoundPageOf`), cross-check `generated/metamodel/types.go` `NavigationNavigationProfile`", "insight": "**The other engine is the control.** Legacy read the same bytes correctly throughout, which is what identifies a reader bug: `describe navigation X` on both engines must agree, and a disagreement localises the defect to the one that reads through gen. Accept the `$Type` the documents actually carry and keep gen's as a fallback branch. Note the two slots fail in **opposite directions** and want opposite fixes: for the login page a real Studio Pro document and `generated/metamodel` agree with the writers, so **gen** is wrong; for the not-found page \u2014 Studio Pro's **\"Fallback page\"** \u2014 metamodel and gen agree with each other and the three mxcli **writers** are the odd one out, emitting `Navigation$HomePage` where Studio Pro stores `Navigation$NotFoundHomePage`. ako/TestApp supplied the reference document that settled it. **Correction (2026-09-01): mxbuild does NOT accept either.** Measured on 11.13 against a build emitting the old spelling, `mx check` and `mxbuild --target=deploy` both exit 1 with \"Object of type 'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type '...NotFoundHomePage'\" -- the project will not LOAD, so every downstream check is lost. What actually let it through is that nothing ever BUILT a project with a fallback page set: the automated mx-check coverage is doctype-tests/ only and no script there sets one, so the first was added by the fix itself. **Generalisable: 'the build tolerates it' is a claim that needs the same control as the fix** -- revert the writer, rebuild, and run the tool, or the reason a bug escaped gets recorded backwards and sends the next reader looking in the wrong place. Keep reading both `$Type`s regardless, but for the repair path rather than round-tripping: a pre-fix project does not build, and mxcli reads BSON directly, so accepting the old spelling is what lets it open and fix one. Repro `mdl-examples/bug-tests/navigation-describe-profile-pages.mdl`", "symptom": "`DESCRIBE NAVIGATION` prints `home page` and the menu but **silently omits `login page` and `not found page`**, so pasting its output back (the documented copy workflow) deletes both from the profile. The clauses are on disk and `MXCLI_ENGINE=legacy` prints them"} {"area": "mdl/backend", "date": "2026-09-07", "symptom": "CE0066 \"Entity access is out of date\" after `ALTER ENTITY M.Gen ADD ATTRIBUTE ...` where a SPECIALISATION of Gen also has an access rule — and `UPDATE SECURITY`, project-wide or scoped, reported \"All entity access rules are up to date\" and changed nothing (mendixlabs/mxcli#1047, reported against 0.21.0 on MPR v2, reproduced on 11.12.1 on BOTH engines). The reporter's own tool named the one missing member — `+ Spec: ProbeSecond.Gen.AfterSpec (ReadWrite)` — and took mx check 1 -> 0.", "cause": "ReconcileMemberAccesses computes the same-module ancestor set (sameModuleAncestors -> ownerIDs) and then uses it ONLY for the association pass. The attribute pass walked the entity's OWN attributes (ent.AttributesItems() / entityDoc[\"Attributes\"]), so a specialisation's expected member set never contained an inherited attribute: nothing looked missing, nothing was added, and `modified` stayed 0 — which the command prints as \"up to date\". Fixed by walking the chain for attributes too, qualifying each against the entity that DECLARES it, with a nearer entity's attribute shadowing an ancestor's of the same name.", "file": "`mdl/backend/modelsdk/domainmodel_security_write.go` (ReconcileMemberAccesses, collectAttrs) and `sdk/mpr/writer_security.go` (entityAttrsInChain, ownAttrsOf, generalizationRefOf); tests `mdl/backend/modelsdk/reconcile_inherited_attr_test.go`, `sdk/mpr/writer_security_inherited_test.go`; example `mdl-examples/bug-tests/1047-inherited-member-reconcile.mdl`", "insight": "The bug was one line of REUSE that never happened: the ancestor set was already computed two lines above the attribute loop and only the association loop consumed it. When a function computes a set and uses it for one of two symmetric passes, check the other pass. Three things worth carrying: (1) A count that drives a message is part of the contract — reporting 0 modified is what turned a broken model into the words \"All entity access rules are up to date\", and a false success is worse than an error because it ends the investigation. Assert the count, not just the stored state. (2) Legacy keyed coverage by BARE attribute name while preserving any ref not qualified against this entity, so naively adding inherited attributes would have preserved AND re-added the same member — the duplicate only shows on the second reconcile, so the regression test runs it twice. (3) The repair path cannot be proven by an .mdl file once the write path is fixed: ALTER ENTITY reconciles as it writes, so the script ends clean either way. Break the project with a PRE-FIX binary and repair it with the fixed one — that finally showed `update security` taking a real CE0066 from 1 to 0, which an earlier fix had recorded as unproven.", "ce": ["CE0066"]} +{"area": "mdl/backend", "date": "2026-09-08", "symptom": "A property Studio Pro writes on an offline entity config (CompatibilityMode) was read from the model and silently discarded, so any future write path would have dropped it with no error and no mx check failure", "cause": "types.NavOfflineEntity carried three of the four properties Studio Pro actually writes. TestFieldCountDrift, which exists to catch exactly this on hand-copied structs, did not list NavOfflineEntity or NavigationProfile — so adding the field left the guard passing vacuously", "file": "`mdl/types/navigation.go`, `mdl/backend/mpr/convert.go`, `mdl/backend/mpr/convert_roundtrip_test.go`", "insight": "A drift guard is only worth what its list covers, and a guard that passes on a struct it does not know about is worse than none — it reads as coverage. Check the guard names your type before trusting a green run. Also: measure which properties are actually WRITTEN before deciding what to carry — gen declared six here and the reference document had four, with DownloadMode and ShouldDownload occurring zero times, so the risk was inverted from the expected one (writing a property Studio Pro fills in on load, not dropping one)", "refs": ["ako/TestApp", "ako/mxcli#413"]} +{"area": "mdl/backend", "date": "2026-09-08", "symptom": "An offline navigation profile authored by mxcli builds, routes and installs as a PWA, and shows an empty app — every gate green", "cause": "MDL had no syntax for offline synchronization, so a created offline profile got an empty OfflineEntityConfigs list. A Mendix offline profile downloads nothing until each entity has a sync mode; `mx check` reports 0 errors either way because an empty list is valid", "file": "`mdl/grammar/MDLParser.g4` (navSyncDef), `mdl/backend/modelsdk/navigation_write.go`, `sdk/mpr/writer_navigation.go`", "insight": "Creating a document kind is not the same as being able to configure it, and the gap is invisible to every static check — the symptom is an empty screen at runtime. When adding a profile/document kind, ask what makes it DO anything, not just what makes it exist. The write is an overlay keyed by entity so CompatibilityMode (stored, unauthorable) survives; building the element from the spec alone would clear it silently, the access-rule defect again", "refs": ["ako/TestApp", "PROPOSAL_offline_sync_configuration.md"]} +{"area": "mdl/backend", "date": "2026-09-08", "symptom": "`alter page P { set PageSize = 10 on }` errors `pluggable property \"PageSize\" not found` on a grid that `create page \u2026 (PageSize: 20)` had just written and the app really pages at. `mxcli check --references` passes the script, so it fails only at exec, after earlier statements have landed; DESCRIBE PAGE prints the same capitalised `PageSize:`, so describe \u2192 edit \u2192 exec produced a script mxcli refused to run", "cause": "A pluggable property key is lowerCamel in the widget template (`pageSize`). CREATE resolves the author's spelling case-INsensitively (widget engine `lookupProperty`, and `WidgetV3.GetStringProp` before it); ALTER went through `setPluggableWidgetPropertyMut`, which compared the template key byte-for-byte, so only the exact `pageSize` worked. Direct sequel to Findings #1 (2026-07-27), which fixed the same class for FIRST-CLASS props and deliberately left the pluggable fallback case-sensitive with the comment 'template keys must match the template exactly'", "file": "`mdl/backend/pagemutator/mutator.go` (`setPluggableWidgetPropertyMut`)", "insight": "`strings.EqualFold` against the widget's own PropertyTypes. **The disproven belief is the reusable part**: keys are STORED case-sensitively, which is not a reason to MATCH them that way \u2014 the resolver searches one object type's PropertyTypes, and across every shipped template and definition that scope holds no two keys differing only in case (96 scopes, 1208 keys, 0 collisions). Measure the ambiguity before assuming it; here there was none, and the assumption cost a whole verb. **Cheapest localiser**: run the failing statement with the template's own casing \u2014 `set pageSize` succeeded where `set PageSize` failed, in ONE measurement, on the same widget in the same project. Both engines share `pagemutator`, so an engine split says nothing here (verified: modelsdk and legacy both fixed by the one change). Tests `TestSetPluggableProperty_MatchesTemplateKeyRegardlessOfCase` (+ typo-still-errors control, + `TestPluggablePropertyKeysAreUniqueIgnoringCase` pinning the no-collision argument); repro `mdl-examples/bug-tests/alter-page-pluggable-property-casing.mdl`; verified 0 errors on `mx check` 11.13.0. Two reporter claims did NOT hold: `describe page` DOES emit PageSize, but only when it differs from the widget default 20 (deliberate, so describe round-trips) \u2014 at the default it is omitted, which reads as 'describe cannot show it'. Still open and separate: `check --references` does not resolve pluggable property names at all, so a genuine typo (`PagSize`) still checks clean and fails at exec", "refs": ["mendixlabs/mxcli#1069"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index e6f9cedaab..0c92d00938 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -555,4 +555,14 @@ {"area": "mdl/executor", "date": "2026-09-06", "symptom": "CE0463 \"the definition of this widget has changed\" on a Gallery authored with a non-default pagination (`loadMore` or `virtualScrolling`); the default `buttons` gallery is clean, and `mxcli docker check` reports 0 errors because it runs `mx update-widgets` first (mendixlabs/mxcli#1035)", "cause": "gallery.def.json stored `pagingPosition: \"below\"`. The Gallery .mpk declares the enumeration as {bottom|top|both}, where `bottom`'s caption is \"Below grid\" — so \"below\" was the caption's first word, not a member key. It stayed hidden for a year because `pagingPosition` is hidden when pagination is `buttons` and the hidden-property pass resets a hidden unnamed property to its declared default, so only a non-default pagination let the wrong literal reach disk", "file": "`sdk/widgets/definitions/gallery.def.json`, `modelsdk/widgets/definitions/gallery.def.json`, `.mxcli/widgets/gallery.def.json` (three copies, all needed)", "insight": "For any CE0463 on a widget mxcli authored, diff the stored properties against an `mx update-widgets` copy AFTER mapping TypePointer -> PropertyKey — the isolated difference here was one enum string. Then check the literal against the .mpk's `` set, not its caption: both sides are plain strings, so nothing else compares them. `TestDefJSONEnumLiteralsAreDeclaredMPKKeys` (mdl/executor) now does it for every definition. Two measurement traps: `mxcli docker check` needs `--no-update-widgets` to see CE0463 at all, and a widget whose wrong value is hidden in the default configuration will not reproduce — vary the property that unhides it", "ce": ["CE0463"]} {"area": "mdl/executor", "date": "2026-09-06", "symptom": "`create or modify persistent entity Mod.Thing ( ... )` on an EXISTING entity deleted every DomainModels$AccessRule on it. Output was `Modified entity: Mod.Thing` and nothing else; `mxcli check` passed; a byte-identical re-run stripped them just as completely. Measured on ako/CapTrackV3, both engines, mxbuild 11.14.0: one domain-model script over eight entities took 174 access rules to 132 and 1352 member entries to 1071 (ako/mxcli-rest FINDINGS).", "cause": "Structural, not a missing case: the CreateOrModify branch of execCreateEntity built a fresh domainmodel.Entity from the AST and swapped it in, so every field MDL has no words for went with it — access rules, the view/external-source fields, the OData remote properties. Documentation and indexes had each been carried back individually after their own earlier defect report, so the carry list was already two entries long and access rules would have been the third. Fixed by inverting the direction: mergeDeclaredOntoStoredEntity starts from the STORED entity and overwrites only the fields the statement declares (entityFieldsDeclaredByStatement), with pruneMemberAccessesForDroppedAttributes removing the member entries of attributes the rewrite actually removed.", "file": "`mdl/executor/cmd_entities.go` (mergeDeclaredOntoStoredEntity, entityFieldsDeclaredByStatement, pruneMemberAccessesForDroppedAttributes, droppedEntityMembers); tests `mdl/executor/entity_modify_preserves_test.go`; example `mdl-examples/bug-tests/entity-modify-preserves-access-rules.mdl`", "insight": "The report said `mx check` stays clean, and that is TRUE and misleading: it is clean on an entity nothing is bound to yet, and the same script at scale gave 288 errors, every one CE2729. The silence is exactly where nothing depends on the access, so the loss surfaces later, on someone else's change. Idempotence is not a defence either — the rebuilt document genuinely differs, so ADR-0008 does not elide it. Two design points. (1) Dropping the attributes a statement omits and REBUILDING the ones it names are independent: an entity can be modified in place, which is what turns an endless carry list into a closed one. A reflection test (TestMergeDeclaredOntoStoredEntity_EveryFieldHasADecision) now fails on any Entity field in neither group, and under the stubbed fix it names all 19 fields the wholesale replace was discarding. (2) The prune must run on POSITIVE evidence of removal (stored-minus-declared), not on absence from the rebuilt entity's attributes: an entity's rules also govern INHERITED members, which never appear in its own Attributes list, so the obvious predicate emptied all five rules of CapTrack.ExportDocument (extends System.FileDocument, owns no attributes) and produced CE0066 — a regression the first version of this fix shipped and only a specialisation exposes."} {"area": "mdl/executor", "date": "2026-09-06", "symptom": "A repeatable widget property written as a property value had two failure modes: `attributes: [(attributeName: 'x')]` (single key) checked CLEAN, exec'd successfully and vanished from storage; `[(k: v, k2: v2)]` (multi key) died as `missing ')' at ','`. Reported upstream as mendixlabs/mxcli#999 against FileUploader allowedFileFormats / customButtons.", "cause": "propertyValueV3's array alternative is a list of EXPRESSIONS. `(k: v)` happens to be a valid expression, so the single-key form parsed and the visitor flattened it to []string{\"(attributeName:'x')\"} — a value no widget writer claims, hence the silent drop. `(k: v, k2: v2)` is not an expression, hence the parse error. The two symptoms had ONE cause and looked unrelated.", "file": "mdl/executor/validate_widget_object_property.go", "insight": "The dangerous half was invisible to the one rule that might have caught it: MDL-WIDGET07 ('not recognized, will be silently dropped') fires only when the property is UNKNOWN, so it warned without a project and stayed correctly silent with the widget definition present — i.e. it went quiet in exactly the real-world case. Measuring a diagnostic without -p and concluding it covers the case is the recurring trap. Fix shape: parse the bad form deliberately so BOTH shapes reach one semantic error, rather than leaving the multi-key one as a cryptic parse failure — the grammar alternative exists only to be rejected, and is ordered before the expression array so the single-key form stops being flattened. Do NOT wire it to the object-list builder: the container form already works, and two spellings for one construct is the anti-pattern the design guide names. Make the message rewrite the author's own entry into the working form, so the error carries its remedy. Corpus diff was 0 of 532 scripts, which is necessary and NOT sufficient — it compares diagnostics and cannot see an ordinary array captured into the wrong AST type, so assert the untouched shapes directly.", "issue": "mendixlabs/mxcli#999"} +{"area": "mdl/executor", "date": "2026-09-07", "symptom": "`mxcli check --references` under-reported: a plain CREATE of an association, rule or javascript action that already exists in the project passed clean, then failed at exec part-way through the script, leaving the earlier statements already written. Measured on a 3-statement script: check said 'Check passed!', exec created the first entity, died on the rule, and never reached the third statement. Separately (pre-existing), `create entity if not exists` on an existing entity WAS reported as a conflict for a statement exec cleanly skips.", "cause": "Two switches in validate_duplicates.go that nothing compared: stmtCreateInfo classified 24 doc types, projectNameSets.setFor knew 20 — association, javascriptaction, module and rule fell through to `return nil`, which the caller reads as 'no conflicts for that type'. Separately stmtCreateInfo's idempotent flag was `s.CreateOrModify` only, missing the third spelling IF NOT EXISTS, so a re-runnable domain script failed its own second run at check time.", "file": "mdl/executor/validate_duplicates.go", "insight": "Three of the four gaps were real; `module` is a deliberate exemption because CREATE MODULE is idempotent at exec (prints 'already exists', exits 0) and `create module M;` opens nearly every script — so the fix is not 'wire up everything the other list has', it is 'decide each one and record the decision where the guard can see it'. The guards read BOTH lists out of the Go source with go/parser rather than restating them, because a guard that keeps a third copy of the list joins the defect class instead of ending it; each fails vacuously (t.Fatal on an empty extraction) if the extraction ever stops finding anything. Writing the mirror guard paid immediately: it found that stmtDropInfo had no DropRuleStmt case, so making rules project-checked would have turned `drop rule X; create rule X;` into a false positive — a defect my own fix was about to introduce. Measurement trap hit twice: the first corpus sweep reported '0 of 512 changed' because the fixture contained none of the corpus's objects (zero conflicts on BOTH sides — vacuous), and the second reported 0 because $f was a relative path inside a subshell that had cd'd away. Always assert the sweep's positive control (old side non-zero) before reading its diff. Final sweep: 621 conflicts before, 724 after — 105 added, all verified to trace to a plain CREATE, and 2 REMOVED, which were the pre-existing IF NOT EXISTS false positives.", "issue": "ako/mxcli"} +{"area": "mdl/executor", "date": "2026-09-07", "symptom": "MDL-WIDGET11 warned that Compact / Hover / Striped are \"not defined for this widget type\" on every `datagrid`, and suggested Style and Row size. Taking the advice replaced 16 warnings with 17 mxbuild CE6083 errors \"Design property ... is not supported by your theme\" (ako/CapTrackV4 010).", "cause": "mdlKeywordToDesignPropsKey mapped `datagrid` to Atlas Core's `DataGrid` — the DEPRECATED data grid (Style, Hover style, Row size) — while MDL's `datagrid` writes Data grid 2 from DataWidgets (Borders, Compact, Hover, Striped). Three more keywords named keys no web design-properties.json defines (`combobox`->ReferenceSelector, `gallery`->Gallery, `image`->Image), so the registry lookup missed and those widgets were skipped entirely. Fixed by deriving the key from keywordDispatchTable plus the embedded widget definitions, leaving the hand-written table for native widgets only.", "file": "`mdl/executor/theme_reader.go` (pluggableKeywordIDs, resolveDesignPropsKey, mdlKeywordToDesignPropsKey); tests `mdl/executor/validate_design_properties_test.go`", "insight": "Two tables inside mxcli disagreed about what one keyword writes, and only one of them was consulted by the validator — so the tool contradicted itself with confidence. Measure which widget a keyword actually writes (exec the page, then read WidgetType out of CATALOG.WIDGETS) rather than reading the builder; that settled all four pairings in one run. The quiet failures are the ones to look for: a wrong key that happens to EXIST produces a visibly wrong warning, while a wrong key that does not exist disables validation silently, and three of the four were the second kind. The overlap test is what keeps the two halves from drifting again — a keyword in both tables means the native entry is dead code."} +{"area": "mdl/executor", "date": "2026-09-07", "symptom": "`icon glyph 57562` on a navigation menu item passed `mxcli check` AND `mx check` at 0 errors, then failed `mxbuild --target=deploy` with \"One or more errors occurred. (An exception occurred while exporting layout 'CapTrack.App_Default'.)\" — naming a layout nobody had touched and never mentioning navigation. Three build cycles to bisect (ako/CapTrackV4 007, R2).", "cause": "A glyph code is a bare integer that nothing resolves. mxbuild resolves it through Forms.Icons.GlyphFont.GetClass(Int32), a LINQ .First(...) over its glyph table, which throws InvalidOperationException \"Sequence contains no matching element\" on an absent code. Added MDL078, checking against the cmap of the font Atlas_Core ships (glyphicons-halflings-regular.woff): 247 codes in 35 runs.", "file": "`mdl/executor/validate_glyph_codes.go` (glyphRanges, glyphCodeDefined, validateMenuItemGlyphCodes); wired in `mdl/executor/validate_program.go`; tests `mdl/executor/validate_glyph_codes_test.go`; example `mdl-examples/bug-tests/navigation-glyph-code.mdl`", "insight": "The font file in the project IS the table: extracting the cmap from the WOFF (zlib per table, format-4 subtable) gives the answer without guessing, and it agreed with mxbuild on both measured points — 0xEBDA fails, 0xE021 builds. Two full deploy builds settled it, and the control build is what proves the table is not simply refusing everything. The set is 35 runs with real gaps, not one range, so a first-to-last bounds check would accept every bad code in a hole — the tests name the gaps and the single-code runs deliberately. Severity is warning rather than error because the table is a snapshot of a Mendix asset: refusing a code Mendix later adds would be worse than the gap being closed. A property that is an unchecked integer rather than a model reference is worth auditing generally — this one had no resolver at any layer."} {"area": "mdl/executor", "date": "2026-09-07", "symptom": "`describe workflow` emitted MDL that `mxcli check` refused: 6 of the 14 workflows in the 9 demo apps in mx-test-projects/ failed describe -> check. Two rules fired: MDL-WF03 on decision outcomes like 'FactoryManagement.ENUM_InvestigationType.Engineering', and MDL-WF05 on `jump to decision1` / `jump to split1`.", "cause": "Two independent defects behind one symptom. (1) wfOutcomeIdentRe required a BARE identifier, but every Workflows$EnumerationValueConditionOutcome in the corpus stores the QUALIFIED form Module.Enum.Value (7 of 7 non-empty) — the rule was written to catch free text like 'Confirmed closed' and rejecting the dot was collateral, so the describer was right and the validator wrong. (2) MDL had a name slot only on `user task`; every other builder did act.Name = act.Caption, while Mendix resolves JumpToActivity.TargetActivity by activity NAME and Studio Pro names activities by type and ordinal (decision1, split1, callMicroflow1, userTask1, waitForNotification1, timer1) with no relation to the caption. The stored name had nowhere to be emitted to.", "file": "mdl/executor/validate_workflow.go, mdl/grammar/domains/MDLWorkflow.g4, mdl/executor/cmd_workflows.go, mdl/executor/cmd_workflows_write.go", "insight": "The second half was NOT a describer bug, which is what it looked like at first: the grammar had no name slot, so the fix ran grammar -> AST -> visitor -> builder -> describer. Two measurements settled the design and neither was guessable from the code. Studio Pro's stored outcome value decided which side of defect 1 to change — changing the describer to emit the last segment would have 'fixed' the check and written a document unlike every real one. And TargetActivity turned out to hold a NAME STRING, not an ID pointer, which is why a lost name degrades to a jump-to-itself and surfaces as CE6681 'not possible to jump to end activities or jump-to activities' — an error naming a different fault entirely. Emit the name only when it is not derivable (name != caption and != sanitizeActivityName(caption); for call activities, != the called document's short name), so mxcli-authored workflows describe unchanged and the clause appears exactly where it carries information. The control is what makes this provable: the pre-fix binary, built from HEAD~1 in a throwaway worktree, reproduces 6/14 failing where the fixed one is 0/14, and the round-tripped document was read back to confirm decision1..3 / split1 / callMicroflow1..6 landed and both jump targets resolve — mx check at 0 errors alone would NOT have shown that, since a workflow with a jump to itself is perfectly valid.", "issue": "ako/mxcli#408"} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "A workflow DECISION whose outcome was not fully qualified ('OutcomeA', or 'Status.OutcomeA') passed `mxcli check`, exec printed 'Created workflow', and `describe workflow` round-tripped it — but the project then failed to LOAD in Studio Pro / mxbuild: Mendix.Modeler.Storage.StorageLoadException, \"The text 'OutcomeA' is not a valid EnumerationValueIdentifier\", thrown by UnitLoader. NO CE number and no 'The app contains: N errors.' line at all. Reported as mendixlabs/mxcli#1031 and mendixlabs/mxcli#1065.", "cause": "buildConditionOutcome writes any non-True/False/Default outcome verbatim into EnumerationValueConditionOutcome.Value, and MDL-WF03 accepted a bare identifier. Mendix parses that field with EnumerationValueIdentifier.FromString at LOAD time, which requires exactly Module.Enumeration.Value.", "fix": "Tighten MDL-WF03 to require the 3-segment qualified form (wfOutcomeQualifiedRe) on CREATE WORKFLOW and on ALTER WORKFLOW ... INSERT CONDITION (same field via wfmutator.InsertBranch). MDL-WF03 is an error and `exec` refuses a script with errors, so the corrupting write becomes impossible. Empty '' (the 'none of the above' enum outcome) and True/False/Default stay accepted.", "file": "mdl/executor/validate_workflow.go", "insight": "A load-time validator is a different failure class from a build-time one, and looking only for CE numbers hides it: mxbuild prints a stacktrace and NO error-count line, so a harness that greps for 'The app contains:' reads the run as inconclusive rather than failed. The measurement that mattered was the 2-SEGMENT row: 1 segment failing and 3 passing does not tell you whether the rule is 'must contain a dot' or 'must be Module.Enum.Value', and same-module shortening is exactly what an author tries. Also a caution about widening a rule: ako/mxcli#408 relaxed this same regex to accept qualified names and left bare ones accepted, reasoning the rule was only there to catch free text — the bare form was the corruption all along. When a rule's threshold is in doubt, the arbiter is what the LOADER accepts, not what looks well-formed."} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "`mxcli check --references` reported EVERY enumeration as missing — `attribute 'CriticalPathStation': enumeration not found: Approval.StationKey` — while `DESCRIBE ENUMERATION` returned its values, `SHOW ENUMERATIONS` listed it, `exec` wrote the attribute and mxbuild built the project at 0 errors (mendixlabs/mxcli#1071). A pure false negative: the only broken thing was the checker.", "cause": "`enumerationExists` (mdl/executor/helpers.go) matched containers directly — `enum.ContainerID == module.ID` — which only holds for an enumeration sitting in the module ROOT; one inside a FOLDER has the folder as its container. Every other command resolves through the container hierarchy (`h.GetModuleName(h.FindModuleID(e.ContainerID))`), so the reference checker was the only one that could not see inside a folder. Fixed by deferring to `findEnumeration`, deleting the duplicate rather than patching the copy.", "file": "`mdl/executor/helpers.go` (enumerationExists); call sites `mdl/executor/validate.go:447` (CREATE ENTITY) and `:660` (ALTER ENTITY ADD ATTRIBUTE); tests `mdl/executor/validate_enum_folder_test.go`; example `mdl-examples/bug-tests/1071-foldered-enum-references.mdl`", "insight": "This is upstream #976 a second time. That fix corrected DROP's container matching and did NOT sweep for the other callers asking the same question, so the identical bug sat in the reference checker for months — and its own test file already spelled out the class (\"SHOW, DESCRIBE and ALTER all use the container hierarchy... DROP was the one command of the four\"). When a fix is 'this command resolved containers wrongly', grep for every other place that resolves the same containers before closing it; the enumeration existed in TWO implementations and only the interactive one was ever exercised. Two measurement notes: the report read as 'enumerations are never resolved' because the reporter's module keeps them in folders, so the discriminator (root vs foldered, same module, same script) had to be built before anything else made sense; and the blast radius was larger than reported — CREATE ENTITY fails identically and the report only showed ALTER, so the test covers both call sites.", "ce": []} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "CREATE WORKFLOW: a `call microflow` activity nested in a decision's ENUM outcome (or in any boundary-event body) reaches mxbuild with no parameter mappings and no outcomes \u2014 CE6685 (\"The parameters of the selected microflow have changed\") + CE6686 (\"The current outcomes ... do not match the configured microflow\"), one of each per nested activity. `mxcli check` passes, `exec` reports success, and the IDENTICAL activity in the workflow's MAIN flow is wired correctly and checks at 0 errors", "cause": "`autoBindActivitiesInFlow` enumerated the flows to recurse into with a per-outcome type switch handling `BooleanConditionOutcome` and `VoidConditionOutcome` only. `EnumerationValueConditionOutcome` was absent from BOTH the decision and the call-microflow arm, and no boundary event (`UserTask`, `CallMicroflowTask`, `CallWorkflowActivity`, `WaitForNotificationActivity`) was entered at all \u2014 so nothing generated the ParameterMappings or the default outcome for activities inside them. `deduplicateActivityNamesInFlow` walks the same tree from a second, independently maintained switch and had the boundary-event half of the same gap", "file": "`mdl/executor/cmd_workflows_write.go` (new `nestedFlows` helper; `autoBindActivitiesInFlow` and `deduplicateActivityNamesInFlow` now recurse through it); tests `mdl/executor/cmd_workflows_nested_autobind_test.go`; example `mdl-examples/bug-tests/workflow-417-nested-call-microflow-autowire.mdl`", "insight": "The discriminator was already in the reporter's own script and nearly went unread: the decision's `''` (void) branch was fine and its two `'Module.Enum.Value'` branches were not, which says the bug is per-OUTCOME-KIND, not per-nesting-depth \u2014 the obvious reading (\"nested activities are skipped\") would have sent the search to the builder instead of the walker. Second, `ConditionOutcome` already exposes `GetFlow()`, so the partial type switch was never necessary; a hand-enumerated switch over an interface's implementations is a standing invitation for exactly this, and the repo had TWO of them over the same tree, each missing a different subset (auto-bind: enum + boundary; dedup: boundary). Enumerating the nested flows ONCE is the fix that survives the next outcome kind \u2014 patching the two switches would have left the third walk to be written wrong. Scope was 3x the report: 6 of 9 nesting sites were unwired, and only 1 was reported. Measurement note: the MAIN-flow control belongs INSIDE every subtest \u2014 auto-bind needs a resolvable target microflow from ctx.Backend, so a fixture mistake produces exactly the same 'no parameter mappings' failure as the bug, and without the control a green run proves nothing.", "ce": ["CE6685", "CE6686", "CE0495"]} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "`describe navigation` on a project with an offline profile emits a broken comment: the line ends after `where '[` and several lines of raw XPath follow as if they were MDL", "cause": "The offline-entity comment interpolated the stored constraint verbatim. Studio Pro writes an offline sync constraint multi-line, with indentation and Mendix's doubled-quote escaping, and a newline inside a `--` comment ends the comment", "file": "`mdl/executor/cmd_navigation.go` (singleLine)", "insight": "A value that is single-line in every fixture can be multi-line in every real document. This survived because no local project had a populated offline config at all — the constraint had never been rendered with real content. When a describe path interpolates a stored string into a line-oriented format, fold it; the fixture that would have caught this is one carrying a value copied out of a real project rather than typed into a test", "refs": ["ako/TestApp", "PROPOSAL_offline_sync_configuration.md"]} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "`describe navigation` output for an offline profile fails to round-trip: the emitted constraint ends its own MDL string mid-XPath", "cause": "An offline sync constraint routinely contains quoted literals — the reference document's is contains(ActionValue, '''abc''') — so emitting it without doubling every quote terminates the MDL string early. The stored value is ALREADY escaped for Mendix, so the correct MDL is six consecutive quotes, which looks wrong and is right", "file": "`mdl/executor/cmd_navigation.go` (syncModeMDL, escapeMDLString)", "insight": "When a stored value carries its own escaping, the emitter's escaping composes with it rather than replacing it — and the result looks like a bug. Do not eyeball it: round-trip describe -> exec -> describe and diff. Six quotes verified correct that way, byte-identical, where reasoning about the count would have talked me out of it", "refs": ["ako/TestApp"]} +{"area": "mdl/executor", "date": "2026-09-08", "refs": ["mendixlabs/mxcli#1073", "mendixlabs/mxcli#1020"], "ce": ["CE7252"], "symptom": "A `call external action` on an OData action that has a NULLABLE parameter is CE7252 \"The parameters for remote action '' have changed\", with no MDL that clears it. Reported as a missing syntax for an empty/null binding: `= null`, `= empty`, a bare `= )` and omitting the parameter were all tried. Persists after upgrading past the #1020 fix, because it is a different missing field behind the same CE code.", "file": "mdl/executor/cmd_microflows_builder_calls.go (externalParamKind.canBeEmpty, paramCanBeEmpty, resolveExternalActionParameterKinds, addCallExternalActionAction); engine-agnostic - the fix is in the shared semantic builder, so both modelsdk and legacy are covered by one change", "cause": "Microflows$ExternalActionParameterMapping.CanBeEmpty was never set, so it was Go's false on every mapping mxcli wrote. Mendix compares it against the contract's Nullable on every build and reports the disagreement as CE7252. mdl/types.EdmActionParameter ALREADY parsed Nullable as a three-state *bool; the value was simply dropped between the parser and the mapping builder, so the fix is to carry it - no new parsing. The default is the subtle half: CSDL makes Nullable optional on and defaults it to TRUE, the opposite of Go's zero value.", "insight": "**The reported premise was wrong and the bug was real; they were not the same thing.** A Studio Pro reference document settled both at once: on ako/TestApp 11.14.0 the mappings are {command, Argument \"empty\", CanBeEmpty false} and {additional, Argument \"empty\", CanBeEmpty true}. So (a) `Argument` is the EXPRESSION `empty`, never an empty string - an unfilled argument in Studio Pro is the Mendix null literal, so `additional = empty` was always correct MDL and already wrote a byte-identical Argument; and (b) the thing that actually differed was CanBeEmpty, which no MDL syntax reaches because it is derived from the contract, not typed by the developer. **Two hypotheses died on that one document**: that DESCRIBE's `additional = )` output (real - formatAction appends unconditionally where the java-action branch guards on empty) was what users hit, and that the empty-Argument state was reachable at all. It is not: DESCRIBE round-trips the real document correctly as `command = empty, additional = empty`. **Do not attribute a CE code to the last bug that produced it.** #1020 produced CE7252 from a missing ParameterType and was fixed in v0.21.0, which made \"upgrade\" look like the answer; the same code from a different field on HEAD was only found by building the reporter's shape and running mxbuild. **Verify version claims in the ISSUE against the grammar, not the changelog**: the three rules involved (callArgument, literal, callExternalActionStatement) are byte-identical at v0.20.0, so the reported parse errors for `= null`/`= empty` never happened at any version - an unrelated error elsewhere in the script (a missing microflow parameter list produces `missing '(' at 'begin'`) reads as an argument error and cost a probe here too. **Controls**: reverting only `mapping.CanBeEmpty = pk.canBeEmpty` takes the 4-statement repro from 0 to 4 errors, one CE7252 per call; separately, defaulting an ABSENT Nullable to false (rather than true) reproduces CE7252 on the Annotate action alone, which is what pins the CSDL default. Studio Pro's own microflow in the same project is the 0-error control. **Left unfixed on purpose**: Studio Pro also writes empty marker arrays AdditionalAttributes and IncludedAssociations (marker 2) on the call and each mapping; mxbuild 11.14 builds at 0 errors without them and they are unverified against Studio Pro, so they are recorded rather than guessed at. Repro mdl-examples/bug-tests/odata-1073-external-action-nullable-params.mdl"} +{"area": "mdl/executor", "date": "2026-09-08", "symptom": "CI build-and-test fails on a newly added mdl-examples/doctype-tests/ script with `Execution error: ... needs the modelsdk engine (run without MXCLI_ENGINE=legacy)` — while `mxcli check` and a local exec both pass", "cause": "TestMxCheck_DoctypeScripts runs every doctype script through exec + mx check on BOTH engines. A script using a modelsdk-only capability (creating a navigation profile, menu/rule/layout authoring) cannot pass on legacy, where the backend refuses by design rather than approximating the document", "file": "`mdl/executor/roundtrip_doctype_test.go` (engineScriptSkip)", "insight": "A doctype example is a two-engine test, not a one-engine one, and nothing local tells you: `mxcli check` needs no engine and a local exec uses the default (modelsdk). Before adding an example, ask whether anything in it is modelsdk-only — the refusals are deliberate and listed in mdl/backend/mpr/backend.go. The remedy is an engineScriptSkip entry naming WHY the engine refuses, not weakening the script; and note separately whether the feature under test is itself dual-engine, since here only the profile CREATION was modelsdk-only while the SYNC block works on both and is unit-tested on each", "refs": ["ako/mxcli#420"]} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl index 8bed9f5ccc..8a67e077f8 100644 --- a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl @@ -53,3 +53,5 @@ {"area": "mdl/grammar", "date": "2026-08-31", "symptom": "`DESCRIBE MICROFLOW` emits `reduce($list, expr)` (or `all(...)` / `any(...)`) and mxcli's own checker then rejects its own output: \"set 'X' calls 'reduce()', which is not a Mendix expression function [MDL044]\". Note the word **set** — the parser did not reject the call, it read the line as a Change Variable whose value happened to be a function call, and MDL044 was right about the rest", "cause": "DESCRIBE rendered an aggregate as `strings.ToLower(storedEnumValue)`, assuming every value of Mendix's `AggregateFunction` was also an MDL keyword. Mendix has eight, the grammar had five. Underneath sat a quieter defect: Mendix stores a Reduce's seed and result type in `ReduceInitialValueExpression` / `ReduceReturnDataType` and the semantic model had no field for either, so a grammar-only fix would have round-tripped the syntax while deleting the fold", "file": "`mdl/grammar/MDLLexer.g4` (REDUCE/ANY/INITIAL + the `keyword` rule so they stay usable as identifiers), `mdl/grammar/domains/MDLMicroflow.g4` (`listAggregateOperation` + `reduceFoldOptions`), `mdl/ast/ast_microflow.go`, `mdl/visitor/visitor_microflow_actions.go`, `mdl/executor/cmd_microflows_builder_actions.go`, `mdl/executor/cmd_microflows_format_action.go` (`mdlAggregateKeyword`), plus all four read/write paths: `sdk/mpr/parser_microflow.go`, `sdk/mpr/writer_microflow_actions.go`, `mdl/backend/modelsdk/microflow_read_actions.go`, `mdl/backend/modelsdk/microflow_write.go`", "insight": "**A renderer that stringifies an enum outgrows its grammar silently** — the sibling `formatListOperation` switches on concrete types and cannot, which is the shape to prefer. The guard is a describe→parse loop over `microflows.AllAggregateFunctions` (`TestDescribedAggregateParsesBack`), so a ninth Mendix function fails a test rather than a user's script. **Get a reference document before believing the vendor docs**: Mendix's reference guide says a return type is \"not applicable\" to All/Any, but Studio Pro writes `ReduceReturnDataType` as Boolean on both, and `Attribute` as `\"\"` when unused — all three activities now re-serialize byte-identically to Studio Pro's. `mx check` is no help here (0 errors before and after); the controls are the origin/main parse (`reduce`/`all`/`any` → Change Variable, with `sum` → aggregate as the positive control) and reverting the write path (`TestReduceFoldReachesStorage` then reports the two keys missing). #1004", "refs": ["#1004"], "rules": ["MDL044"]} {"area": "mdl/grammar", "date": "2026-09-04", "symptom": "`container c ()` / `dynamictext t ()` / `pluggablewidget 'id' pw ()` are parse errors, reported at the `)` as though the widget were wrong, while bare `container c` and `container c (x: 'y')` both parse.", "cause": "widgetPropertiesV3 was `LPAREN widgetPropertyV3 (COMMA widgetPropertyV3)* RPAREN` — at least one property required.", "file": "mdl/grammar/domains/MDLPage.g4", "insight": "An empty property list is what an LLM writes for a widget that needs no properties, and the error points at the paren rather than the cause. One-character fix (wrap the list in `( … )?`). Found while measuring something else — the first run of a keyword-parse survey used `()` throughout and mis-scored every keyword as rejected, including ones that worked. If a whole measurement comes back uniformly negative, suspect the harness before the subject.", "refs": ["mendixlabs/mxcli#1036"]} {"area": "mdl/grammar", "date": "2026-09-05", "symptom": "After adding a generic (IDENTIFIER | keyword) alternative to widgetTypeV3, `slot body` parsed as a widget of type `slot` and `placeholder Main { ... }` as a widget named Main. Both still parsed, `mxcli check` still exited 0, and a diff of check output across all 515 mdl-examples scripts showed ZERO difference.", "cause": "pageBodyV3 listed widgetV3 FIRST, before useFragmentRef / placeholderBlockV3 / slotMarkerV3. SLOT, PLACEHOLDER and USE are all inside the `keyword` rule (655 tokens), so the generic widget alternative matched them before the specific alternative could.", "file": "mdl/grammar/domains/MDLPage.g4", "insight": "Put the specific alternatives BEFORE widgetV3 in pageBodyV3, the same ordering fix widgetV3 already applies internally for `template for`. The transferable lesson is about the MEASUREMENT, not the grammar: a corpus diff of `mxcli check` output compares DIAGNOSTICS, so it is blind to a construct that parses into the wrong AST shape. 515 scripts said nothing; two visitor unit tests caught it immediately. When a grammar change could reinterpret existing syntax rather than reject it, assert on the AST, not on the diagnostics. CONTROL: reorder pageBodyV3 back and TestSpecificPageBodyFormsWinOverTheGenericWidget / TestSlotMarkerWinsOverTheGenericWidget fail.", "issue": "mendixlabs/mxcli#1036"} +{"area": "mdl/grammar", "date": "2026-09-07", "symptom": "`drop demo user` and `drop user role` had no idempotent form, so a one-time cleanup either broke every later run of its slice script or had to be commented out (ako/CapTrackV4 R5, 024).", "cause": "Neither statement accepted IF EXISTS, though the grammar already had an ifExists rule used by ALTER ENTITY's DROP ATTRIBUTE / DROP INDEX / DROP VALUE. Added `ifExists?` to both statements, an IfExists field to each AST node, and a skip-with-message branch in each handler.", "file": "`mdl/grammar/domains/MDLSecurity.g4`; `mdl/ast/ast_security.go`; `mdl/visitor/visitor_security.go`; `mdl/executor/cmd_security_write.go`; tests `mdl/visitor/drop_if_exists_test.go`; example `mdl-examples/bug-tests/drop-security-if-exists.mdl`", "insight": "The check that finds this whole class is one loop — `for f in $(ls mdlsource/*.mdl | sort); do mxcli exec \"$f\" -p app.mpr; done` — which is exactly what a fresh clone does and what nobody runs. It found five non-idempotent statement forms in the reporting project; four already had a `create or modify` spelling, and the two with no idempotent form at all are the ones that ended up commented out. When adding a DROP statement, check whether the grammar's existing ifExists rule should apply rather than deciding idempotence is the author's problem."} +{"area": "mdl/grammar", "date": "2026-09-08", "symptom": "Adding lexer tokens for a new feature broke an unrelated, previously-passing MDL example: `editable: never` on a list view stopped parsing after NEVER became a keyword", "cause": "A new lexer token steals every existing use of that word as an identifier or property value unless it is also added to the `keyword` rule in MDLSettings.g4. NEVER, ONLINE, SYNC and PRESERVE were added for offline sync; NEVER was already a real page property value", "file": "`mdl/grammar/domains/MDLSettings.g4` (keyword rule)", "insight": "Before adding a lexer token, grep the examples for that word as a value or name — the collision is with EXISTING scripts, so nothing in the new feature's own tests can find it. TestKeywordRuleCoverage catches the omission but only asserts the rule LISTS the token; add a test that the word still parses as an identifier, which is the property that actually matters. Here the two failures had one cause: the coverage test named the tokens and check-mdl named the victim file, and the file name (maint2-editable-never-create-page.mdl) said which word", "refs": ["PROPOSAL_offline_sync_configuration.md"]} diff --git a/.claude/skills/fix-issue/findings/mdl-other.jsonl b/.claude/skills/fix-issue/findings/mdl-other.jsonl index e4aa5b8b03..93bf4c6122 100644 --- a/.claude/skills/fix-issue/findings/mdl-other.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-other.jsonl @@ -58,3 +58,5 @@ {"area": "mdl/executor", "date": "2026-09-05", "symptom": "An unqualified CREATE passed `check --references` and was refused by exec: `create association Order_Probe from Bench.Order to Bench.Customer` -> \"module name is required: objects must be created within a module\" (mendixlabs/mxcli#1050). Separately, `RETURNS void AS $result` passed check and wrote `return $result` into a flow with no such variable -> [CE0109] \"Undefined variable 'result'.\" at End event (mendixlabs/mxcli#1041).", "cause": "Neither had a check-time rule. Added MDL074 (ValidateCreateIsQualified, driven off the existing stmtCreateInfo walker so it covers every document type rather than just associations, with CreateModuleStmt excluded) and MDL075 (ValidateVoidReturnAlias), both in the NO-PROJECT pass since both are answerable from the statement alone.", "file": "`mdl/executor/validate_create_shape.go` (new), `mdl/executor/validate_program.go` (wiring); tests `mdl/executor/validate_create_shape_test.go`", "insight": "The reportable cost of an unqualified CREATE is not the error message, it is that exec is NOT TRANSACTIONAL: the statements before it are already applied, so 're-run it' then fails with 'already exists' on those. That is what makes a check-time rule worth more than a better exec error. For the void alias, refusing beats repairing: emitting a bare `return` would also build, but an author who wrote an alias meant to return something, and silently dropping it leaves the flow returning nothing while the source still says otherwise. Reusing stmtCreateInfo was the leverage \u2014 validate_association_module.go had already documented this exact gap in a comment ('check does pass it, which is a check/exec divergence worth closing on its own') and closing it association-only would have left every other document type open."} {"area": "mdl/exprcheck", "date": "2026-09-05", "symptom": "`IF empty($Orders)` was rejected with a hint carrying NO code (printed as `[]`), no document and no microflow, and a fix line that named only glued keywords ('emptyor') \u2014 so the reader had to find the expression in a 40-line script by eye and was pointed at a cause that was not there (mendixlabs/mxcli#1042).", "cause": "The trailing-token hint in Parse was built by hand with a bare hints.Location holding only line/column, rather than through hintsLocation(ctx, pos) which carries File and Microflow. Gave it code E014, a registry entry, hintsLocation, and a fix line naming the real cause first: `empty` is a Mendix KEYWORD, not a function, so the parser consumed it and stopped at the '('.", "file": "`mdl/exprcheck/parser.go` (the trailing-token hint), `mdl/exprcheck/hints/registry.go`", "insight": "The line and column are offsets into the EXPRESSION FRAGMENT, not into the file, so they cannot be made into a file position without the adapter setting Context.File/Line \u2014 the microflow name is the achievable locator and the example says so rather than implying more. Separately, a tier-ordering fact worth knowing when reproducing any E0xx: the reference check runs BEFORE expression checking and exits on its first error, so one unrelated mistake anywhere in a file hides every expression hint in it. That cost real time here \u2014 a control microflow in the new example had `set $out = \u2026` without declaring $out, and the resulting reference error made the E014 perturbation look like it was not firing at all. I guessed at two wrong explanations (needs no project; duplicate-CREATE conflict) before bisecting."} {"area": "mdl/linter", "date": "2026-08-31", "symptom": "`mxcli lint` reports a nanoflow or a rule as a microflow — \"Microflow 'Rule1' has no activities [MPR002]\" about a rule, \"Microflow 'Nanoflow' …\" about a nanoflow. The same wrong noun reaches the JSON and SARIF `documentType` field, where it is not merely cosmetic", "cause": "Microflows, nanoflows and rules share one catalog table (`microflows`, discriminated by `MicroflowType`; the `nanoflows` and `rules` views are filters over it), so `LintContext.Microflows()` yields all three. Eleven rule call sites hardcoded `DocumentType: \"microflow\"`, four of them also putting the word in the message", "file": "`mdl/linter/context.go` (`Microflow.DocumentNoun`/`DocumentNounTitle`), then the call sites in `mdl/linter/rules/`: `empty.go` (MPR002), `conv_loop_commit.go`, `conv_error_handling.go`, `conv_split_caption.go`, `flow_irreducible_graph.go`, `mpr008_overlapping_activities.go`, `mpr011_loop_child_containment.go`, `naming.go`, `validation_feedback.go`", "insight": "**A shared table means a shared iterator, and an iterator yielding three doctypes needs the noun derived, never literal.** Grep `ctx.Microflows()` before assuming a rule is microflow-only — nine rules use it. Fix the labelling only; which documents a rule applies to is a separate question from what the report calls them. Two traps: widening a format string is **not** a compile error (`fmt.Sprintf` is variadic), so `go vet` is the gate that catches the missing argument, not `go build` — it caught two here. And the pre-existing fixtures in `empty_test.go` spell the type title-case (`\"Microflow\"`) where the catalog writes uppercase (`\"MICROFLOW\"`, `mdl/catalog/builder_microflows.go`); harmless while nothing read the column, but a fixture with the wrong case now exercises the fallback instead of the mapping. Control: revert the two lines in `empty.go` and `TestEmptyMicroflowRule_NamesTheDocumentType` reports the symptom verbatim. `mx check` and the build are irrelevant — this is mxcli's own output, not the model", "rules": ["MPR002"]} +{"area": "mdl/linter", "date": "2026-09-07", "symptom": "`mxcli lint` CONV010 reported \"ACT_ microflow 'X' contains 'ExclusiveMerge' activity\" on every ACT_ microflow containing an `if` — 122 times on one project, and on a minimal microflow whose ONLY violation was the merge (ako/CapTrackV4 R11).", "cause": "ALLOWED_ACTIVITY_TYPES in conv010_act_microflow_content.star listed ExclusiveSplit and not ExclusiveMerge. An `if` emits both, so the rule permitted the branch and flagged the join it necessarily creates. Fixed by adding ExclusiveMerge, with a test that asks the catalog's own labeller (getMicroflowObjectType) for the two names rather than hardcoding them.", "file": "`.claude/lint-rules/conv010_act_microflow_content.star`; tests `mdl/catalog/lint_rule_vocabulary_test.go`", "insight": "The codebase had already answered this in the other direction and the rule disagreed with it: countMicroflowActivities in mdl/catalog excludes ExclusiveMerge as structural, with a comment saying so. When a lint rule's vocabulary looks wrong, check whether another part of the same package has already classified the same thing. The control matters as much as the fix — LoopedActivity and InheritanceSplit must stay flagged, or a 'widen the list' fix passes the new test and guts the rule. Note also that `mxcli lint` loads Starlark rules ONLY from the project's own `.claude/lint-rules/`, never the embed, so a fixed built-in reaches an existing project only when it re-runs `mxcli init` — which is why a project can keep reporting a rule bug that is already fixed upstream."} +{"area": "mdl/catalog", "date": "2026-09-07", "symptom": "A microflow wired as the project's AfterStartupMicroflow reported \"no callers found\" and \"no references found\", and `mxcli lint` QUAL004 said \"is not called from anywhere. Remove if unused.\" Dropping it left a dangling name that `mx check` also missed; only the runtime refused to start (ako/CapTrackV4 049, R13).", "cause": "The runtime calls these, so nothing in the model does, and CATALOG.REFS had no edge kind for a project setting. Added RefKindSettings and extractProjectSettingsRefs, emitting one `settings` edge per setting that names a microflow (AfterStartupMicroflow, BeforeShutdownMicroflow, HealthCheckMicroflow), with the SETTING as the edge's source; added \"settings\" to QUAL004's MICROFLOW_ENTRY_KINDS.", "file": "`mdl/catalog/builder_references.go` (RefKindSettings, extractProjectSettingsRefs, projectSettingsMicroflowRefs); `.claude/lint-rules/orphaned_elements.star`; tests `mdl/catalog/lint_rule_vocabulary_test.go`", "insight": "Same class as the scheduled-event edge, and the rule's own comment had already named the failure mode — \"a kind missing here turns a live document into a false 'not called from anywhere' finding\" — which makes the list of entry kinds worth auditing whenever a new way to invoke a microflow is added. The dangerous half is not the missing reference but the lint rule built on it: QUAL004 does not merely fail to notice, it actively advises deleting the microflow whose deletion breaks the build. The settings list is a literal rather than reflection over ModelSettings, because most of that struct is strings that are not microflow names and a wrong entry would invent an edge rather than miss one."} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index b5ddf97143..2d752c7d5b 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -27,3 +27,5 @@ {"area": "mdl/visitor", "date": "2026-08-27", "symptom": "An attribute parses in `create entity` but the same name is rejected in a `grant … read (…) / write (…)` list, and mxcli's hint offers three RENAMES (`Title_`, `_Title`, `MyTitle`) — so a stored attribute gets renamed to work around a parser keyword", "cause": "The hint never mentioned quoting, which is the actual remedy and is right there in the raw ANTLR message's expectation set (`{IDENTIFIER, QUOTED_IDENTIFIER}`). Renaming an attribute that already exists is a schema change; quoting is free and keeps the name", "file": "`mdl/visitor/visitor.go` (`reservedKeywordHint`, `hintedKeywordSet`), `mdl/types/reserved_words.go` (`IsPlatformReserved`)", "insight": "**Two kinds of reserved word, and conflating them is expensive in both directions.** An MDL PARSER keyword is escaped by quoting and the model keeps the name. A name the PLATFORM reserves is not — the check strips the quotes and rejects the bare name (CE7247/MDL021), so advising quoting there produces something that parses and then fails the build. Branch on `types.IsPlatformReserved`. Measured when this was written: **38 of 41** hinted keywords are rescued by quoting, and exactly **3** (`Type`, `Default`, `Owner`) are not — the old blanket-rename advice was wrong for the 38 and right for the 3 by accident. The platform lists moved to `mdl/types` because the validator (`mdl/executor`) and the hinting (`mdl/visitor`) both need them and neither may import the other. Any test asserting the hint recommends quoting needs a **control that the quoted form actually parses**, or it passes against advice that does not work. ako/mxcli-maintenance §5", "ce": ["CE7247"], "rules": ["MDL021"]} {"area": "mdl/visitor", "date": "2026-08-31", "raw": "| A `RETRIEVE … WHERE a = $Var/Attr AND b = $Var/Attr` passes `mxcli check` and the build fails **CE0161** \"Error(s) in XPath constraint\" — but the same statement with **literals** on both sides of the same uppercase `AND` builds fine | `mdl/visitor/xpath_operators.go` (new, `NormalizeXPathOperators`), called from `mdl/visitor/xpath_format.go` | XPath 1.0 spells `and`/`or`/`not` in **lower case only**; MDL's lexer accepts any case (`AND: A N D;`). mxcli lowercased the operator on **one of two rendering paths**: `expressionToXPath` does it while walking the parse tree, but `buildRetrieveWhereExpression` freezes the **raw source** whenever the clause contains a `/` — which every variable path has — and `expressionToXPath`'s `SourceExpr` case hands that text back verbatim. So the casing survived exactly when a path was present, which is why the reporter's literal-only reproducer built cleanly and the report looked not-reproducible. **The correlation was the whole difficulty**: a workaround found under time pressure records what you changed, not what was wrong. Fixed at `FormatXPathConstraint`, the single choke point all three constraint writers share (retrieve, page data source, entity access rule — the latter two measured as broken the same way before the fix), and **before** its width test, because the short branch returns the caller's own bytes. The replacement is token-based and literal-aware for two reasons that are each a worse bug than the one being fixed: rewriting inside `'A AND B'` silently changes which rows match, and an identifier that merely contains the letters (`Brand`, `Andrew`, `NOTES`, `Order_Andon`, `Module.Handover`) is not an operator. `div`/`mod` are deliberately excluded — nothing in MDL emits them, and a rewrite nothing needs can only be wrong. Example `mdl-examples/bug-tests/f1-80-xpath-operator-case.mdl`. Unrelated and pre-existing, found alongside: `$currentUser/...` in a **page** data source constraint is CE0161 regardless of operator case. Reported as mxcli-formula1 FINDINGS §80 |", "ce": ["CE0161"]} {"area": "mdl/visitor", "symptom": "`Height: n` on a domain-model annotation is a bare parse error (\"mismatched input 'Height' expecting {POSITION, CAPTION, WIDTH}\"), which reads like a missing mxcli feature and gets reported as one. Filed as a feature request to expose annotation height.", "cause": "Not an mxcli gap: Mendix stores no annotation height. DomainModels$Annotation has exactly Caption, ExportLevel, Location and Width, so the note auto-sizes to its caption and there is nowhere to write a height. Adding the property would mean inventing a key the platform does not have.", "file": "`mdl/visitor/visitor.go` (`looksLikeAnnotationProperty`, `annotationHeightRe`, the two branches in `enhanceErrorMessage`); syntax topic `cmd/mxcli/syntax/features_domain_model.go`; type doc `mdl/ast/ast_annotation.go`; tests `mdl/visitor/annotation_height_hint_test.go`.", "insight": "`generated/metamodel` alone CANNOT settle \"does this property exist\" — it is an 11.6.0 snapshot, and the report was against 11.12.2, so a property added later would be invisible to it. Four sources were needed and three of them are cheap: `mx dump-mpr` (Mendix's own serializer) emits the four keys on real projects; `mx convert -p` is the decisive one, because it rewrites the model through Mendix's OWN object model and would materialise a property that merely had a default and was not stored — it added nothing; and the published Model SDK's domainmodels.Annotation lists caption/exportLevel/location/width. An absent key in stored BSON proves nothing on its own (it may just be unwritten), which is why dump-mpr alone is not enough. When a feature cannot exist, the deliverable is a parse error that SAYS so and names the levers that do exist (Width changes the wrapping and therefore the height; `ALTER ENTITY … SET POSITION` moves what the note overlaps) — the reporter's real goal was reachable already. Key the hint on the `expecting {POSITION, CAPTION, WIDTH}` token set, not on the word \"Height\": a page widget's Height is valid MDL, and a name-keyed hint would misfire on it (control: TestWidgetHeightIsNotAnnotationHinted).", "refs": ["#1014"], "date": "2026-09-01"} +{"area": "mdl/visitor", "date": "2026-09-08", "symptom": "`call microflow M.ACT with (Ctx = $WorkflowContext)` — an UNQUOTED value in a workflow parameter mapping — crashed the binary with SIGSEGV (nil pointer) in buildWorkflowCallMicroflow, on `check`, `check --references` and `exec` alike, with no diagnostic beyond the Go panic. Reported as mendixlabs/mxcli#1023.", "cause": "The grammar rule workflowParameterMapping requires STRING_LITERAL, but visitor.Build() walks the parse tree even when the parse FAILED (deliberately — that is what lets check report more than the first error). Under ANTLR error recovery the rule context exists with a nil STRING_LITERAL child, and the visitor read it unguarded. Same bug at the CALL WORKFLOW site.", "fix": "Factor both sites into buildWorkflowParameterMappings, nil-checking QualifiedName() and STRING_LITERAL() and skipping the mapping. The syntax error the listener already recorded ('mismatched input ... expecting STRING_LITERAL') becomes what the author sees.", "file": "mdl/visitor/visitor_workflow.go", "insight": "In this codebase a required grammar child is NOT a guarantee inside the visitor, because Build() walks a failed parse on purpose. Every ctx.X().GetText() on a required child is therefore a latent crash reachable from ordinary malformed input — grep 'STRING_LITERAL().GetText()' for the ones still unguarded. The other half of the finding is documentation-shaped: `mxcli syntax workflow call-microflow` omitted the WITH clause entirely, so an author had nothing to copy and reached for the bare-variable spelling used everywhere else in MDL. A crash on input the tool's own docs do not cover is a docs bug with a segfault attached."} +{"area": "mdl/visitor", "date": "2026-09-07", "symptom": "`create or modify snippet M.S (params: { $T: Mod.\"Thing\" })` failed at execution with `failed to resolve entity Mod.\"Thing\": entity not found`, while the identical quoted form in a PAGE parameter resolved fine (ako/CapTrackV4 019).", "cause": "buildSnippetParameterListAsPage re-split the parse node's TEXT (`parseQualifiedName(dt.GetText())`), and GetText() returns the source verbatim, quotes included. The page path walks the parse tree, where buildQualifiedName unquotes each part via identifierOrKeywordText. Fixed by walking the tree; the dead duplicate buildSnippetParameters — a correct implementation nothing called — was removed.", "file": "`mdl/visitor/visitor_page_v3.go` (buildSnippetParameterListAsPage); `mdl/visitor/visitor_page.go` (removed buildSnippetParameters); tests `mdl/visitor/snippet_param_quoted_entity_test.go`", "insight": "GetText() on an ANTLR context is the source text, not the resolved value, so any conversion built on it silently keeps quoting, whitespace and casing that the tree-walking helpers strip. Grep for `parseQualifiedName(.*GetText())` when a name resolves in one statement and not in a sibling. The asymmetry is also the diagnosis: when two statements accept the same syntax and only one works, compare their VISITORS before their executors — here both executor paths were identical and called the same resolveEntity. Two copies of one conversion with one of them dead is how they drifted, so the dead one is deleted rather than fixed."} diff --git a/.claude/skills/fix-issue/findings/modelsdk.jsonl b/.claude/skills/fix-issue/findings/modelsdk.jsonl index 55a49ec6eb..414ecedec1 100644 --- a/.claude/skills/fix-issue/findings/modelsdk.jsonl +++ b/.claude/skills/fix-issue/findings/modelsdk.jsonl @@ -15,3 +15,4 @@ {"area": "modelsdk/meta", "date": "2026-08-28", "symptom": "`GRANT` on an entity that extends **System.Image** fails the build with **CE1613** \"The selected association 'System.Thumbnail_Image' no longer exists\" at the access rule — and `describe entity` renders that rule without any broken member, so nothing inside mxcli shows it", "cause": "The virtual System module carries entities that exist only in the **runtime's** metamodel, not in the System module Studio Pro shows. They arrived with the block marked \"extracted from MDP (Phase 3)\". `Thumbnail_Image` is owned by BOTH ends, so System.Image counted as an owner through the CHILD side and every specialization inherited an access-rule member for it", "file": "`modelsdk/meta/system_module.go` (`RuntimeOnly`, `ModelerSystemEntities`, `ModelerSystemAssociations`), consumed by `mdl/backend/modelsdk/system_module_read.go`; the inheritance walk that reads it is `mdl/executor/cmd_security_write.go` (`inheritedAssociations`)", "insight": "**The legacy engine is a free control here**: its System list (`sdk/mpr/system_module.go`) never had the MDP block, so the same script under `--engine legacy` writes a clean rule — which pins the defect to the data rather than to the GRANT path reading it. Establish what the modeler actually has by MEASURING, not by reasoning about names: one microflow per candidate retrieving from `System.`, then one `mx check`. All eleven MDP rows came back CE1613 while the controls (Image, FileDocument, Session) came back clean, so the probe discriminates. Flag rather than delete — the runtime rows were harvested deliberately and a storage backend speaking to the runtime metamodel wants them — and DERIVE the association filter from the entity flag (an association is runtime-only exactly when one of its ends is), because two hand-kept lists drift and the one that falls behind writes a dangling by-name reference. Unrelated pre-existing gap found alongside: `extends ` is never reference-validated, for System or for a same-module name.", "ce": ["CE1613"]} {"area": "modelsdk/canon", "date": "2026-08-28", "symptom": "`mx check` fails with **`InvalidOperationException: … Duplicate Guid in unit page 'M.P'. Object types: Translation, Translation`** and the project will not LOAD at all — and every later edit of that page reports the same thing", "cause": "Two elements in one unit share an `$ID`. \"Guid\" in Mendix's message is the element `$ID`, not a `GUID` property — a `Texts$Translation` stores exactly four keys (`$ID`, `$Type`, `LanguageCode`, `Text`) and has no GUID of its own", "file": "`modelsdk/canon/duplicates.go` (`DuplicateElementIDs`, `DuplicateElementIDError`), wired at all four write choke points: `modelsdk/mpr/writer_core.go` (`insertUnit`, `updateUnit`) and `sdk/mpr/writer_units.go` (same two)", "insight": "**A guard is worth shipping without a root cause.** The reporter lost ~1h to one of these, proposed two causes, tested both, and withdrew both (ako/mxcli-captrack #2) — the trigger is still unknown. Refusing at the write turns an unopenable project into one message, and breaks the thing that made it expensive: the corruption is **STICKY**, so once a unit carries duplicate ids every later edit inherits them and each subsequent write looks like the culprit. Restore before re-testing, or the second experiment measures the first one's damage. Check BEFORE `canon.Reconcile`, not after — an elided write is not a safe one, only one that did not happen this time. Two controls are mandatory and both are cheap: **pointers are not duplicates** (an element's id is referenced by primitive properties like `ParentPointer` all over a normal document — counting those refuses every write), and a **normal translated page** with two translations differing only in id must still be accepted. False-positive control run at scale: **0 flagged out of 33,645 units across 90 projects**. Do NOT \"repair\" by deduplicating — an `$ID` is a pointer target, so choosing which element keeps its identity silently re-points references (ADR-0008).", "refs": ["#2"]} {"area": "modelsdk", "date": "2026-09-02", "symptom": "Every in-place edit of a page is refused with `refusing to write unit \u2026: 1 element id(s) are used more than once \u2026 held by [Texts$Translation \u00d78]` \u2014 `GRANT VIEW ON PAGE`, `ALTER PAGE \u2026 INSERT` \u2014 while a full `CREATE OR REPLACE PAGE` still works, so the page looks correct and only UPDATES are blocked. Surfaces after a second language is enabled.", "cause": "`canon.CarryTranslations` pairs a rebuilt text to its stored translations BY SOURCE STRING when the two documents' text paths differ, and `mergeText` appended the stored `Texts$Translation` element **verbatim** \u2014 deliberately, because keeping the stored `$ID` is what lets no-op elision fire. When several rebuilt texts share one source string (eight copies of the literal `'{1}'` on a page is ordinary), all of them resolve to the SAME stored set and every one got the same element, id included. `reuseSafeID` now gives the first use the stored id and derives a fresh deterministic one (SHA-256 of stored id + containment path + language) for each further copy; the visit order is sorted rather than map order, or which text keeps the stored id would vary per run and the document would churn.", "file": "`modelsdk/canon/translations.go` (`reuseSafeID`, `derivedID`, `elementIDs`, `sortedPaths`, `mergeText`), `modelsdk/canon/duplicates.go` (comment corrected \u2014 it recorded the cause as unestablished)", "insight": "**Re-identifying a copy is safe here in a way that deduplicating ids in general is not, and that distinction is the whole argument.** An `$ID` is a pointer target and rewriting one means finding every reference (ADR-0008) \u2014 which is exactly why `duplicates.go` refuses rather than repairs. Nothing references a `Texts$Translation`: it is a leaf child of a `Texts$Text` with four keys and no identity anything resolves by, so there are no references to miss. Only the COPIES are re-identified; the first use keeps the stored id, so an unchanged document still compares equal. **Verify elision explicitly after touching this** \u2014 the fix trades against the exact property the verbatim append existed for: measured, a second identical run still reports `Unchanged page` with the same sha and mtime. Controls, end-to-end on a real 11.13 project with de_DE enabled and three widgets sharing a caption: the pre-fix binary writes one id used 3\u00d7 and the next `ALTER PAGE` is refused with the reporter's message verbatim; the fixed binary writes 27 distinct ids for 27 elements, the `ALTER PAGE` succeeds, the German translation survives (the control against a 'fix' that just stops carrying), and `mx check` is 0 errors. Reported as CapTrackV2 FINDINGS \u00a730/\u00a717."} +{"area": "mdl/backend/modelsdk", "date": "2026-09-07", "symptom": "`mxcli lint` QUAL002 reported \"Page 'X' has no documentation\" against a page carrying a javadoc comment; the catalog's Description column was blank for every page and snippet; `describe page` emitted no documentation. The comment looked, from every angle, like it had been dropped (ako/CapTrackV4 R12).", "cause": "Nothing was dropped: the AST, executor and writer all carry it, and `mxcli bson dump --type page` shows Documentation with the right value. pageFromGen and the ListSnippets constructor in mdl/backend/modelsdk/page.go simply did not read it back, so on the DEFAULT engine every symptom downstream of the read was wrong at once. Fixed by carrying Documentation in both. Separately, QUAL002 stopped sweeping modules: a Mendix module HAS no documentation property (generated/metamodel's ProjectsModule declares none, modelsdk/gen's Module has no accessor, no stored Projects$ModuleImpl carries the key).", "file": "`mdl/backend/modelsdk/page.go` (pageFromGen, ListSnippets); `mdl/linter/context.go` (documentableSources); `.claude/lint-rules/missing_documentation.star`; tests `mdl/backend/modelsdk/page_documentation_test.go`", "insight": "When a value looks absent everywhere, check the WRITE first: `bson dump` showed it stored correctly and localised the bug to the read in one step, where chasing the reported symptom would have started at the visitor. The engine split is the second cheap discriminator — the legacy reader parsed it fine, so the defect was in the default engine alone. A stale catalog nearly hid that: an earlier per-engine comparison reused a cached catalog.db and showed both engines empty, so DELETE the catalog between engine comparisons rather than trusting `refresh catalog full`. Finally, page and snippet were 2 of 5 sibling readers in one file — layout, building block and page template all carried Documentation — which is the shape to look for when one document type behaves differently from its neighbours. And a rule asking for a property the platform does not have is not a gap in the language: three sources agreed before that row was removed."} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index 09468e42d1..ad5ef9e182 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -460,6 +460,16 @@ actions — cannot be set by ALTER at all. It refuses them and points at `create or replace page`, rather than writing a string where Mendix expects a reference. +**Widget property names are matched case-insensitively**, pluggable ones +included, so a spelling `CREATE PAGE` accepts is a spelling `ALTER PAGE` accepts +— `set PageSize = 10 on dgProducts` and `set pageSize = 10 on dgProducts` are the +same statement. This is what makes DESCRIBE output re-executable: `describe page` +prints the capitalised `PageSize:`, while the widget template stores `pageSize` +(mendixlabs/mxcli#1069). A property the widget does not declare is still an +error, and it is the only signal you get — `mxcli check --references` does not +resolve pluggable property names, so a typo checks clean and fails at exec, after +earlier statements in the script have already been written. + ## Common Mistakes | Mistake | Fix | @@ -467,6 +477,7 @@ reference. | Missing `on widgetName` for widget SET | Add `on widgetName` (only page-level properties — `Title`, `PopupWidth`, `PopupHeight`, `PopupResizable`, `Class`, `Style` — omit ON) | | `unsupported page-level property: title` | Page-level property names are case-sensitive — use `Title`, `PopupWidth`, `PopupHeight`, `PopupResizable`, `Class`, `Style` | | Using unquoted pluggable property names | Quote pluggable props: `set 'showLabel' = false on cb` | +| `pluggable property "X" not found` | The widget does not declare it — casing is not the problem (any casing resolves). Check the real name with `describe widget ` or `describe page` | | Wrong widget name | Use `describe page Module.Name` to see widget names | | SET on non-existent widget | Widget names are case-sensitive; check with DESCRIBE | | Missing semicolons between operations | Each operation inside `{ }` ends with `;` | diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 393e0cb139..694e057bef 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -54,6 +54,33 @@ icon or entity sailed through a command that had been handed the project. A run without a project now says what it did not check, so a pass is never read as more than it is. +### It also reports a name the PROJECT already has + +A plain `create` of a document the project already carries is a `check` error, +not something to discover at exec time: + +``` +statement 4: association already exists in project: Sales.Order_Customer — use CREATE OR MODIFY to update it +``` + +The reason it belongs in `check` is that **`exec` stops at the first one having +already written everything before it**. A script whose fourth statement +conflicts leaves three statements' worth of changes in the project and no +fourth — so "run it and see" is not a free experiment. `check` reports every +conflict in the script before anything is written. + +Three spellings say "fine if it already exists", and none is reported: +`create or modify`, `create or replace`, and `create … if not exists` (which +leaves the stored element untouched rather than rewriting it). `create module M;` +is never reported either — it is a no-op when the module exists, which is what +lets it open every script. + +The types covered are the ones `exec` refuses: entity, enumeration, constant, +association, microflow, nanoflow, rule, page, snippet, java action, javascript +action, workflow, and the integration/agent document types. If you find one that +`exec` refuses and `check` does not, that is a bug of exactly the shape +`TestEveryCreateDocTypeIsProjectChecked` exists to prevent. + ### It resolves MEMBER names too, where it can establish the entity Resolution does not stop at the entity. An attribute named in a **create** or diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index dfd6164ed5..5103d2657b 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -140,6 +140,17 @@ The **bare** form is the icon-collection icon and is what you normally want. Use navigation` emits it for you — and `image` for a picture from an image collection, which is a different document from an icon collection. +**Do not invent a glyph code.** It is a bare integer that nothing resolves, so a +code the Mendix font does not define passes `mxcli check` AND `mx check` at 0 +errors and then breaks `mxbuild --target=deploy` with *"An exception occurred +while exporting layout ''"* — a message naming a document that is +not the cause. `mxcli check` now warns (**MDL078**) against the 247 codes the +shipped font defines, but a glyph is still an unchecked number where an icon +collection reference is a resolved model reference. Browse the codes with `show glyphs` +(`show glyphs like 'star'` searches by name, `describe glyph 57350` goes the +other way), or use `icon Atlas_Core.Atlas.` and list the names with +`describe icon collection Atlas_Core.Atlas`. + The icon-collection form is a **qualified name** — a model reference, written like every other reference in MDL, not a string: @@ -179,6 +190,62 @@ menu item 'Close' page MyModule.Close; -- icon System.Images.Close (Forms$ImageIcon) is not reproducible by CREATE NAVIGATION; set it in Studio Pro ``` +### Offline Synchronization + +An offline profile downloads **nothing** until its entities are given a sync +mode. Without a `SYNC` block the app builds, routes and installs as a PWA — and +shows an empty screen. That is the single most common way an offline profile +looks broken while every check passes. + +```sql +create or replace navigation PhoneOffline + home page MyModule.Mobile_Dashboard + sync ( + sync MyModule.Setting online; + sync MyModule.Vehicle all; + sync MyModule.Trip where [Distance > 0]; + sync MyModule.AuditEntry never; + sync MyModule.Lookup none; + sync MyModule.Draft none preserve data; + ); +``` + +| MDL | Meaning | +|---|---| +| `online` | fetched from the server, never held on the device | +| `all` | every object downloaded | +| `where []` | only the objects the XPath selects | +| `never` | not synchronized | +| `none` | not downloaded; anything already on the device is dropped | +| `none preserve data` | not downloaded; what is on the device stays | + +**The words are not Studio Pro's captions.** Its dialog shows "All Objects" and +"By XPath"; neither is a value Mendix stores. `all` and `where` are. Copying a +caption out of the UI gives a parse error rather than a broken document, which +is deliberate. + +**`where` implies the constrained mode** rather than naming it, so a constraint +without a mode and a mode without a constraint are both unspellable. + +**Use the bracket form.** It takes the XPath verbatim — nothing inside is +escaped, so quoted literals stay readable: + +```sql +sync MyModule.Team where [contains(Name, 'abc')]; +``` + +A quoted `where ''` still parses, but every quote inside it must be +doubled — and a stored constraint already carries Mendix's own escaping, so the +two compose into runs of six quotes. `describe navigation` emits the bracket +form. This is the general problem tracked as `mendixlabs/mxcli#750`. + +**The block replaces the stored list**, the way `menu (...)` replaces the menu. +Omitting it leaves the stored configuration alone. + +**Compatibility mode has no syntax.** mxcli reads it, preserves it across a +rewrite, and `describe navigation` flags any entity that has it on — it is never +silently dropped. + ### Clear the Menu An empty `menu ()` block removes all menu items: diff --git a/.claude/skills/mendix/project-brain/SKILL.md b/.claude/skills/mendix/project-brain/SKILL.md index a41a997740..d626e6d81b 100644 --- a/.claude/skills/mendix/project-brain/SKILL.md +++ b/.claude/skills/mendix/project-brain/SKILL.md @@ -61,6 +61,23 @@ are working on. reading them all reinstates exactly the context cost the split removed. If you do not know which modules you are touching yet, read `project.md` and come back. +**`mxcli brain brief` produces that set for you**, so the rule above does not +depend on judgement: + +``` +mxcli brain brief --slice 07-planning # project + the modules that slice's + # requirements anchor into + its plan +mxcli brain brief --module Sales # project + Sales, no plan +``` + +The modules are *derived* from the slice's requirement anchors — you do not tell +it which modules the slice touches, because that is what you opened the brief to +find out. The pack goes to stdout and its size to stderr, so it pipes. + +This matters most when each slice runs in its own session or sub-agent: the pack +is then re-read from a cold start every slice, and reading the whole store +instead is roughly three times the tokens. + ## Writing to it An agent **captures**; a person **promotes**. Capturing is free and reversible; @@ -259,16 +276,32 @@ cap: the cap is what stops the store becoming a file nobody reads. |---|---| | `mxcli brain init -p app.mpr` | Creates `docs/brain/`. Refuses a `docs/brain/` it did not write | | `mxcli brain capture "" [-a @Anchor]…` | Queues an entry. Never commits | -| `mxcli brain staged` | Lists the queue with the shard each entry would land in | +| `mxcli brain staged [--since ] [--slice ] [--fail-if-empty]` | Lists the queue with the shard each entry would land in. `--since` is the slice boundary — see below | | `mxcli brain promote [--to ]` | Writes it into its shard. The human step | | `mxcli brain drop ` | Removes it from the queue or from its shard | | `mxcli brain capture "" --slice [-a @Anchor]…` | Queues a **requirement** of that slice | | `mxcli brain capture "" --open [-a @Anchor]…` | Queues an **open question**; its anchors are not checked | | `mxcli brain resolve ""` | Answers a question, turning it into a decision in place | -| `mxcli brain plan` | The roadmap: each slice's requirements counted against the model | +| `mxcli brain plan [--slice ]` | The roadmap: each slice's requirements counted against the model | +| `mxcli brain brief --slice \| --module ` | The reading pack: exactly the shards that work needs | | `mxcli brain check [--changed]` | Anchors still resolve, entries in the right shard, plus slice progress | | `mxcli brain show []` | Entries, lines and headroom per shard | +## Renaming + +`mxcli rename` updates the brain's anchors along with the model's own +cross-references, and says how many it touched. You do not have to fix them by +hand, and `--dry-run` previews the brain's share too. + +This is done at the rename because it cannot be done afterwards. A decision's +anchor points backward, so a stale one is reported `NOT FOUND` — but a +requirement's points forward, so a stale one just counts as `PLANNED`, which is +exactly what a forward anchor failing is supposed to mean. Once the old name is +gone there is no way to tell "never built" from "built, then renamed". + +If you rename an element some other way — in Studio Pro, or by hand — run +`mxcli brain check` afterwards and expect the plan's counts to have moved. + ## What not to record - Anything `show`, `describe` or the catalog answers — it will drift. @@ -278,3 +311,29 @@ cap: the cap is what stops the store becoming a file nobody reads. - Sprint chatter and task assignment. Requirements and their slices, yes; who is doing what this week, no — that belongs in an issue tracker. - A restatement of Mendix documentation. Record what is true *here*. + +## Handing a slice to another agent + +Every command above takes `--json`, so a dispatcher can act on the answers +rather than read them. Two shapes are worth knowing. + +**Give the agent its pack.** `mxcli brain brief --slice ` is one bounded +read instead of a directory the agent has to navigate. + +**Check that it recorded something.** With one agent per slice the brain stops +being a record and becomes the *only* channel between slices — the next agent +has no memory of this one, so a capture that never happened is a decision lost +rather than a note lost. Note the boundary before dispatching and ask afterwards: + +``` +before=$(mxcli brain staged --json | jq -r .last_id) +# ... the slice's agent runs, and captures ... +mxcli brain staged --since "$before" --fail-if-empty --json +``` + +`--since` rather than `--slice` is deliberate: `capture --slice` is what makes an +entry a *requirement*, so a decision found while building a slice carries no +slice at all — and a slice's findings are mostly decisions. The queue is +append-only, so its own order is the honest boundary. `last_id` comes back even +when nothing matched, so a slice that recorded nothing still hands the next one +a boundary. diff --git a/.claude/skills/mendix/write-workflows/SKILL.md b/.claude/skills/mendix/write-workflows/SKILL.md index 8e9a2ce058..26cb91777f 100644 --- a/.claude/skills/mendix/write-workflows/SKILL.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -86,13 +86,20 @@ begin with (Module.ACT_Validate.Item = '$WorkflowContext'); -- Decision: a boolean or enum exclusive split. The name is optional; give one - -- when a `jump to` targets it. Outcome values are enumeration value - -- identifiers — bare or qualified (Module.Enum.Value). + -- when a `jump to` targets it. decision decision1 '$WorkflowContext/Total > 1000' outcomes true -> { call microflow Module.ACT_Escalate; } false -> { call microflow Module.ACT_AutoApprove; }; + -- An enum decision: each outcome is a FULLY QUALIFIED enumeration value + -- (Module.Enumeration.Value), plus one '' outcome for "none of the above". + decision decision2 '$WorkflowContext/Status' + outcomes + 'Module.ENUM_Status.Approved' -> { } + 'Module.ENUM_Status.Rejected' -> { } + '' -> { }; + -- Parallel split: independent branches run concurrently parallel split split1 path 1 { call microflow Module.ACT_Notify; } @@ -280,6 +287,21 @@ documented in `system-module`. task (`CE1834`). Bind the page to `System.WorkflowUserTask`. - A user task / decision with a single outcome and no activity can trip `CE1876` — give each branch a body or a distinct outcome. +- **An enum decision's outcome must be `Module.Enumeration.Value`.** Mendix + stores it as an `EnumerationValueIdentifier` and parses it when the project is + **loaded**, before any consistency check — so a short name is not a build + error with a CE number, it leaves a project Studio Pro and mxbuild cannot open + (`StorageLoadException`). Measured: `'Approved'` and `'Status.Approved'` both + make the project unloadable; `'Sales.ENUM_Status.Approved'` checks at 0 + errors. Shortening it because the enumeration is in the same module does not + work. `mxcli check` refuses all three of these as `MDL-WF03`, and `exec` + refuses to run a script it flags. +- **An enum decision also needs one `'' -> { }` outcome** for "none of the + above" — Studio Pro writes it on every enum decision, and without it the build + fails `CE6686`. +- **A `with (...)` parameter value is a quoted string**, not a bare variable: + `with (Request = '$WorkflowContext')`. The unquoted spelling used elsewhere in + MDL is a syntax error here (it used to crash the binary — ako/mxcli#1023). - The context **Parameter entity must be persistent**. - Write the context variable as **`$WorkflowContext`**, matching the parameter name exactly. Mendix expressions are case-sensitive on 11.9+, so a lowercase diff --git a/CHANGELOG.md b/CHANGELOG.md index f7828dc860..a6d2845e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **`CREATE WORKFLOW` did not auto-wire a `call microflow` activity nested in a decision's enum branch** (ako/mxcli#417) — `mxcli check` passed, `exec` reported success, and mxbuild then gave **CE6685** *"The parameters of the selected microflow have changed"* and **CE6686** *"The current outcomes of the call microflow activity do not match the configured microflow"*, once each per nested activity. `describe workflow` showed them written with no `with (...)` and no `outcomes` clause, while the control — the identical statement in the workflow's MAIN flow — was auto-wired to `with (Ctx = '$WorkflowContext') outcomes DEFAULT -> { }` and checked at 0 errors. That contrast is what made the gap invisible: the feature demonstrably worked, one nesting level up. + + `autoBindActivitiesInFlow` enumerated the flows to recurse into with a per-outcome type switch that handled `BooleanConditionOutcome` and `VoidConditionOutcome` only. An `EnumerationValueConditionOutcome`'s flow was never entered, and no boundary-event body was entered at all — so a decision's `''` branch was wired and its `'Module.Enum.Value'` branches were not. `deduplicateActivityNamesInFlow` walks the same tree and had the boundary-event half of the same gap (CE0495, undetected name collisions inside a boundary event). + + Both walks now enumerate nested flows through one `nestedFlows` helper — condition outcomes via the `ConditionOutcome.GetFlow` interface, so no outcome kind can be skipped by omission; plus user-task outcomes, parallel-split paths, and boundary-event bodies. Six of nine nesting sites were unwired before (decision/enum, call-microflow/enum, and boundary events on user task, call microflow, call workflow and wait-for-notification); all nine are covered by `TestAutoBindReachesNestedCallMicroflow`, each subtest asserting the MAIN-flow control in the same run so a green result cannot come from a broken fixture. Measured on Mendix 11.10.0: the reporter's script goes 4 errors → **0**, and a binary built with the fix reverted reproduces exactly those 4. + +- **A workflow decision outcome that was not fully qualified made the project unopenable** (mendixlabs/mxcli#1031, mendixlabs/mxcli#1065) — `decision '…' outcomes 'OutcomeA' -> { }` passed `mxcli check`, `exec` printed *"Created workflow"*, and `describe workflow` round-tripped it, but the project then failed to **load** in Studio Pro and mxbuild. Mendix stores the value in `EnumerationValueConditionOutcome.Value` and parses it through `EnumerationValueIdentifier.FromString` in the **UnitLoader**, before any consistency check runs — so there is no CE number and no `N errors.` line, just a `StorageLoadException` and a project nothing can open. Every mxcli-side command reported success, which is what let the corruption survive an arbitrary number of subsequent scripts. + + What settled the threshold was three runs of the same workflow, one per copy of the same app, with the verdict read off the literal `mx check` line: `'OutcomeA'` (one segment) and `'Status.OutcomeA'` (two) both leave the project unloadable; `'WFP.Status.OutcomeA'` checks at **0 errors**. The two-segment row is the one worth keeping — "qualify it" is ambiguous without it, and shortening an enumeration that lives in the same module is exactly what an author would try. It agrees with the stored corpus, where every `EnumerationValueConditionOutcome` holds `Module.Enum.Value`. + + `MDL-WF03` now requires that form, on `CREATE WORKFLOW` and on `ALTER WORKFLOW … INSERT CONDITION`, which writes the same field through a different door. Because the rule is an error and `exec` refuses a script with errors, the corrupting write can no longer happen. This tightens the rule that ako/mxcli#408 had *widened*: that change accepted the qualified form the describer emits but left bare identifiers accepted, on the reasoning that the rule's job was to catch free text rather than pick a spelling — the loader measurement shows a bare identifier **is** the corruption, so both are refused now, with different advice. `mxcli syntax workflow decision` taught the broken form too (`'Under 1000' -> { }`) and now teaches the qualified one, along with the `'' -> { }` outcome an enumeration decision needs to avoid CE6686. + +- **An unquoted `with (…)` value in a workflow crashed the binary** (mendixlabs/mxcli#1023) — `call microflow M.ACT with (Ctx = $WorkflowContext)`, the bare-variable spelling used everywhere else in MDL, took the process down with a SIGSEGV in `buildWorkflowCallMicroflow` on `check`, `check --references` and `exec` alike, with no diagnostic beyond the Go panic. The grammar requires a string literal there, but the AST builder walks the parse tree **even when the parse failed** — that is what lets `check` report more than the first error — so under ANTLR error recovery the rule was visited with a nil `STRING_LITERAL` child and read unguarded. Both mapping sites (`CALL MICROFLOW` and `CALL WORKFLOW`) now nil-check and skip what the parser could not build, leaving the syntax error the listener already recorded as the thing the author is told about: `mismatched input '$WorkflowContext' expecting STRING_LITERAL`. The `WITH` clause was missing from `mxcli syntax`'s own entries for both statements, which is how an author arrived at the crashing spelling; it is documented, quoted, in both. + +- **`check --references` reported an enumeration in a folder as missing** (mendixlabs/mxcli#1071) — `attribute 'CriticalPathStation': enumeration not found: Approval.StationKey`, while `DESCRIBE ENUMERATION` returned its values, `SHOW ENUMERATIONS` listed it, `exec` wrote the attribute and mxbuild built the project at 0 errors. A pure false negative, and it read as *"enumerations are never resolved"* because the reporting project keeps its enumerations in folders. + + `enumerationExists` matched containers directly — `enum.ContainerID == module.ID` — which only ever holds for an enumeration sitting in the module **root**; one inside a folder has the folder as its container. Every other command resolves through the container hierarchy, which walks folders up to the module, so the reference checker was the only one that could not see inside one. It now defers to `findEnumeration`, deleting the duplicate rather than patching the copy — and picking up the live-over-excluded handling the copy never had. + + Both call sites are fixed: `ALTER ENTITY … ADD ATTRIBUTE` (reported) and `CREATE ENTITY` with an enumerated attribute, which fails identically and was not in the report. A genuinely missing enumeration is still reported, and a foldered one still does not answer for another module's name. + - **An attribute added to a generalization left every specialization's access rule short a member — and `update security` said the model was fine** (mendixlabs/mxcli#1047). `ALTER ENTITY M.Gen ADD ATTRIBUTE …`, where a specialization of `Gen` also has an access rule, produced **CE0066 "Entity access is out of date"**; `UPDATE SECURITY` — project-wide or scoped, the command that exists to repair exactly that — reported **"All entity access rules are up to date"** and changed nothing. `ReconcileMemberAccesses` computes the same-module ancestor set and then used it **only** for associations. The attribute pass beside it walked the entity's own attributes, so a specialization's expected member set never contained what it inherits: nothing looked missing, nothing was added, and the 0 it returned is what the command prints as "up to date". A false success, which is worse than an error — it ends the investigation. Both engines had it in the same shape and both are fixed; a fix in one of these parallel writers stays latent in the other until something switches engines. diff --git a/CLAUDE.md b/CLAUDE.md index e2187300eb..dc8c5c5677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -815,6 +815,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati ## Current Implementation Status **Implemented:** +- Offline synchronization (`CREATE NAVIGATION … SYNC (…)`): an offline navigation profile downloads **nothing** until each entity has a sync mode, so a profile mxcli created built, routed and installed as a PWA and showed an **empty app** — with `mxcli check`, `exec` and `mx check` all clean. The six mode words are the members Mendix stores, **not** Studio Pro's captions (its "All Objects" is `ALL`, its "By XPath" is `WHERE`), and a caption is refused rather than written — the CE0463 gallery defect wearing a different hat. `WHERE` takes the XPath in **brackets**, verbatim: the quoted form doubles every quote, and a stored constraint already carries Mendix's own escaping, so the two compose into runs of six (mendixlabs/mxcli#750, and `PROPOSAL_first_class_expressions.md`). The write is an **overlay keyed by entity**, so `CompatibilityMode` — stored, unauthorable — survives a rewrite; every reference config carries `false`, so only a synthetic `true` case distinguishes a correct writer from one that always emits `false`. `DownloadMode`/`ShouldDownload` are deliberately **not** written though gen declares them: zero occurrences in ako/TestApp, and a property Studio Pro fills in on load is one whose emission makes a document Studio Pro cannot open. Creating the *profile* stays modelsdk-only (a fourteen-key document pinned to a Studio Pro reference); the SYNC block works on both engines. See `.claude/skills/mendix/manage-navigation/SKILL.md` and `docs/11-proposals/PROPOSAL_offline_sync_configuration.md` - Project brain (`mxcli brain init/capture/staged/promote/drop/check/show`): an **opt-in** store in `docs/brain/` for the project knowledge mxcli cannot compute. The governing rule is that anything derivable from the model is answered by a command and never written down — a note that transcribes the model disagrees with it silently. Records shard by **anchor scope**: an entry's first anchor names its file (`@Sales.Order` → `modules/Sales.md`), an anchorless entry is cross-cutting (`project.md`), and there is no index to maintain because the module prefix *is* the file name. That is what makes the cap per-shard rather than a project-wide budget, and lets a session load `project.md` plus the modules it is touching. `check` answers two independent questions: each anchor is **resolved / not found / not indexable** — only the middle one fails, and the third exists because the catalog's `objects` view covers the describable types only, so a scheduled event would otherwise read as *missing* (separated with `FindDocumentUnit`, which cannot miss a kind because it never asks what kind anything is). Misfiling is a **second axis, not a fourth state**: every anchor can resolve and the entry still be in the wrong file, and it is only decided when something resolved — judging it on an all-not-indexable entry reintroduced the same false staleness through the other axis (caught by a test, with the guard stubbed as the control). An agent `capture`s to a git-ignored queue and a person `promote`s; the queue is deliberately **not** sharded, because routing it would force the file decision before a human has looked at the entry. `mxcli lint` prints the unpromoted-queue count, because a report only `brain check` prints is a report nothing demands. Sizes are computed by `brain show` and never written into a committed file. A second record kind, **requirement**, lives in `plan/.md` and inverts the anchor's meaning: a decision's anchor points backward (not resolving = stale, fails), a requirement's points forward (not resolving = not built yet, passes). Measured: filed as an ordinary entry, one unbuilt requirement takes `brain check` to exit 1 — which is why it is a separate kind rather than more entries in the same files. That inversion is also what makes `brain plan` a real progress report: a requirement is *built* when its anchors resolve, so creating the microflow it names moves the count with the plan file untouched (measured 0/1 → 1/0). A status written beside a requirement is therefore refused by the skill, not just discouraged. Slices are ordered by name (`01-accounts`), span modules by design (so misfiling does not apply), and carry a generous cap that enforces the slicing discipline — a slice too long to read should be split. A third kind, **open question** (`--open`), records what is *not* decided; its anchors are deliberately **not** checked, since the question is often whether the thing should exist at all — measured, the identical anchor exits 1 as a decision and 0 as a question. `brain resolve` converts one into a decision in place, keeping its id and position and starting to check its anchors, which is the transition the kind exists for. Unanswered questions are reported by `brain check` and by `mxcli lint`. The skill also gives capture a **trigger** rather than good intentions — a correction you have had to make twice — because the decisions half otherwise under-fills while the plan half fills at bootstrap. `bootstrap-app` asks for requirements at the interview and records them by default. Package: `cmd/mxcli/brain/`. See `docs-site/src/tools/project-brain.md` and `docs/11-proposals/PROPOSAL_project_brain.md` - Default styling + runtime theme switching (`mxcli theme list/show/create/apply/remove/switcher`, `mxcli new --theme`): three embedded themes (**signal** light-first, **ledger** light-first, **console** dark-first), each a palette in `theme/web/custom-variables.scss` + a shared Atlas wiring partial + a theme partial imported from `theme/web/main.scss` (which compiles last), plus vendored fonts. **No model changes**, so it hot-applies under `run --local --watch` and cannot affect a build. Generated regions are digest-fenced: a block carrying local edits is refused rather than overwritten. Applying a theme removes the previous one. `--variant auto` (default) ships both palettes — the app follows `prefers-color-scheme` before first paint and honours a `theme-light`/`theme-dark` class on ``; `light`/`dark` bakes one. `theme switcher install` is the only part that writes to the model (JS actions + a nanoflow for a toggle button). A project can add its own themes under `theme/mxcli-themes//` (committed, not compiled); `theme create [--from ]` scaffolds one from an existing theme, renaming the identifiers built from the name and optionally seeding the palette from `--mxt-*` declarations in a design artifact. A local theme shadows a built-in of the same name. Package: `cmd/mxcli/theme/`. See `docs/11-proposals/PROPOSAL_default_styling.md` - MPR v1/v2 reading and writing diff --git a/cmd/mxcli/brain/brief.go b/cmd/mxcli/brain/brief.go new file mode 100644 index 0000000000..0816df83d4 --- /dev/null +++ b/cmd/mxcli/brain/brief.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 + +// brief.go - the reading pack: exactly the shards a session needs, once. +// +// The store is sharded so a session can load project.md plus the modules it is +// touching instead of the whole thing, and the README says so. Nothing produced +// that pack, though: docs/brain/ is a directory, so a session either read all of +// it or guessed, and both are wrong in the same direction. Measured on a real +// 15-slice project, the whole store is 7,531 tokens and the correct pack for one +// slice is ~2,530. +// +// In one long session that is a rounding error — the store is read once and then +// cached. Under one sub-agent per slice it is a third of the context, because +// the pack is re-read per slice from a cold start, which is exactly the shape +// the sharding was designed for and the only one where it was not usable. +// +// Which module shards belong in the pack is DERIVED: they are the modules the +// slice's own requirements anchor into. Asking the caller which modules its +// slice touches would be asking it the thing it opened the brief to find out. +package brain + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// BriefShard is one shard's contents as they appear in a brief. +type BriefShard struct { + Shard string `json:"shard"` + Path string `json:"path"` + Body string `json:"body"` + Lines int `json:"lines"` +} + +// Brief is a reading pack: the shards a session needs, in the order it should +// read them, with the size it is paying. +type Brief struct { + // Slice is the plan shard the brief was built for, empty for a + // module-scoped brief. + Slice string `json:"slice,omitempty"` + Shards []BriefShard `json:"shards"` + Lines int `json:"lines"` + Bytes int `json:"bytes"` + // StoreLines is the whole store, for comparison. The reason the brief + // exists is the size of the alternative, so it reports both. + StoreLines int `json:"store_lines"` +} + +// Text renders the brief as one document, which is the form a session reads. +func (b Brief) Text() string { + var sb strings.Builder + for _, s := range b.Shards { + sb.WriteString(s.Body) + if !strings.HasSuffix(s.Body, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + return sb.String() +} + +// Brief builds the pack for one slice: project.md, the shards of the modules +// that slice's requirements anchor into, and the slice's own plan shard. +// +// Ordering is deliberate and not alphabetical by accident: project first +// because it is the unconditional read, then the modules, then the plan. A +// session reads the decisions it must not contradict before the scope it is +// about to build. +func (s *Store) Brief(slice string) (Brief, error) { + planShard := PlanShard(slice) + entries, _, err := s.LoadShard(planShard) + if err != nil { + return Brief{}, err + } + + seen := map[string]bool{} + var modules []string + for _, e := range entries { + for _, a := range e.ParsedAnchors() { + // Every anchor, not just the first. The first anchor decides where + // an entry is FILED; a requirement spanning two modules is worked in + // both, and a session that read only one of them is the case this + // exists to prevent. + if a.Module != "" && !seen[a.Module] { + seen[a.Module] = true + modules = append(modules, a.Module) + } + } + } + sort.Strings(modules) + + want := append([]string{ProjectShard}, modules...) + if len(entries) > 0 { + want = append(want, planShard) + } + b, err := s.briefOf(want) + if err != nil { + return Brief{}, err + } + b.Slice = slice + return b, nil +} + +// BriefForModules builds the pack for a session working named modules rather +// than a slice — maintenance rather than roadmap. No plan shard: nothing here +// says which slice the work belongs to, and guessing one would be inventing it. +func (s *Store) BriefForModules(modules []string) (Brief, error) { + sorted := append([]string(nil), modules...) + sort.Strings(sorted) + return s.briefOf(append([]string{ProjectShard}, sorted...)) +} + +// briefOf reads the named shards, skipping those that do not exist. A missing +// shard is not an error: a module with no recorded decisions is the normal +// case, and refusing would make the brief unusable on exactly the projects +// that have only started recording. +func (s *Store) briefOf(shards []string) (Brief, error) { + b := Brief{} + for _, shard := range shards { + path := s.ShardPath(shard) + body, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return Brief{}, err + } + text := string(body) + b.Shards = append(b.Shards, BriefShard{ + Shard: shard, + Path: path, + Body: text, + Lines: strings.Count(text, "\n"), + }) + } + b.Lines = strings.Count(b.Text(), "\n") + b.Bytes = len(b.Text()) + + total, err := s.Size() + if err != nil { + return Brief{}, err + } + b.StoreLines = total + return b, nil +} + +// Size is the whole store in lines — what a session pays for reading the +// directory instead of a brief. It is computed rather than recorded, like every +// other figure the store reports about itself. +func (s *Store) Size() (int, error) { + shards, err := s.ListShards() + if err != nil { + return 0, err + } + var total int + for _, shard := range shards { + body, err := os.ReadFile(s.ShardPath(shard)) + if os.IsNotExist(err) { + continue + } + if err != nil { + return 0, err + } + total += strings.Count(string(body), "\n") + } + return total, nil +} + +// Summary is the one line a brief prints to stderr, so the saving is visible +// without polluting the pack itself on stdout. +func (b Brief) Summary() string { + names := make([]string, 0, len(b.Shards)) + for _, s := range b.Shards { + names = append(names, shardFileName(s.Shard)) + } + return fmt.Sprintf("%d lines from %d shard(s) [%s]; whole store is %d", + b.Lines, len(b.Shards), strings.Join(names, " "), b.StoreLines) +} + +// shardFileName is the store-relative name, which is what a reader recognises. +func shardFileName(shard string) string { + switch { + case shard == ProjectShard: + return "project.md" + case IsPlanShard(shard): + return "plan/" + SliceOf(shard) + ".md" + default: + return "modules/" + shard + ".md" + } +} diff --git a/cmd/mxcli/brain/brief_test.go b/cmd/mxcli/brain/brief_test.go new file mode 100644 index 0000000000..b67588b948 --- /dev/null +++ b/cmd/mxcli/brain/brief_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "strings" + "testing" +) + +// The store's own README describes the scoping — "what lets a session load the +// shards for the modules it is touching instead of the whole store" — and +// nothing produces it. docs/brain/ is a directory, so a session either reads +// all of it or guesses which parts matter, and both are wrong in the same +// direction: measured on a real project, the whole store is 7,531 tokens and +// the correct pack for one slice is ~2,530. +// +// That is worth little in one long session, where the store is read once and +// then cached. It is a third of the context in the sub-agent shape, where the +// pack is re-read per slice from a cold start. +// +// Which module shards belong in the pack is DERIVED, not configured: they are +// the modules the slice's own requirements anchor into. Asking the caller which +// modules a slice touches would be asking it the thing it opened the brief to +// find out. +func TestBriefIsProjectPlusTheSlicesModulesPlusItsPlan(t *testing.T) { + s := newTestStore(t) + + // The slice under test anchors into Sales and Planning. + promote(t, s, mustRequirement(t, "roll up by cost centre", "07-planning", "@Planning.ACT_Rollup")) + promote(t, s, mustRequirement(t, "orders feed the rollup", "07-planning", "@Sales.Order")) + // Another slice, anchored into a module the first one does not touch. + promote(t, s, mustRequirement(t, "invoices are posted nightly", "02-billing", "@Billing.ACT_Post")) + + // Decisions in each of those modules, plus a cross-cutting one. + promote(t, s, mustEntry(t, "planning uses a snapshot, not live totals", "@Planning.Snapshot")) + promote(t, s, mustEntry(t, "orders are committed by Finance", "@Sales.Order")) + promote(t, s, mustEntry(t, "billing runs on its own schedule", "@Billing.ACT_Post")) + promote(t, s, mustEntry(t, "the whole app is single-tenant")) + + b, err := s.Brief("07-planning") + if err != nil { + t.Fatal(err) + } + + if got := shardNames(b); !equalStrings(got, []string{ProjectShard, "Planning", "Sales", PlanShard("07-planning")}) { + t.Fatalf("brief holds %v, want project + the slice's two modules + its plan shard", got) + } + + text := b.Text() + for _, want := range []string{ + "single-tenant", // project.md, always + "snapshot", // Planning: a module the slice anchors into + "committed by Finance", // Sales: likewise + "roll up by cost centre", // the slice's own plan + } { + if !strings.Contains(text, want) { + t.Errorf("the brief does not contain %q", want) + } + } + + // The point of the pack is what it LEAVES OUT. Without this the test would + // pass against a brief that simply concatenated the whole store. + for _, unwanted := range []string{ + "billing runs on its own schedule", // a module this slice does not touch + "invoices are posted nightly", // another slice's plan + } { + if strings.Contains(text, unwanted) { + t.Errorf("the brief contains %q, which belongs to a slice this one does not touch — "+ + "a pack that includes everything is the whole-store read it replaces", unwanted) + } + } +} + +// A slice with no requirements yet still gets project.md: a session picking it +// up needs the project's decisions before it has written anything down. +func TestBriefOfAnEmptySliceIsStillTheProjectShard(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "the whole app is single-tenant")) + + b, err := s.Brief("11-nothing-yet") + if err != nil { + t.Fatal(err) + } + if got := shardNames(b); !equalStrings(got, []string{ProjectShard}) { + t.Fatalf("brief holds %v, want just the project shard", got) + } + if !strings.Contains(b.Text(), "single-tenant") { + t.Error("project.md was not included") + } +} + +// Without a slice the brief is the modules' decisions and no plan at all — for +// a session doing maintenance rather than working the roadmap. +func TestBriefForNamedModules(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + promote(t, s, mustEntry(t, "billing runs nightly", "@Billing.ACT_Post")) + promote(t, s, mustEntry(t, "single-tenant")) + + b, err := s.BriefForModules([]string{"Planning"}) + if err != nil { + t.Fatal(err) + } + if got := shardNames(b); !equalStrings(got, []string{ProjectShard, "Planning"}) { + t.Fatalf("brief holds %v, want project + Planning", got) + } + if strings.Contains(b.Text(), "billing runs nightly") { + t.Error("a module that was not asked for is in the brief") + } +} + +// The brief reports its own size, because the reason it exists is the size of +// the alternative. A number nobody can see does not change anyone's behaviour. +func TestBriefReportsItsSizeAgainstTheWholeStore(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustRequirement(t, "roll up by cost centre", "07-planning", "@Planning.ACT_Rollup")) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + for _, e := range []Entry{ + mustEntry(t, "billing runs nightly and has a long tail of detail behind it", "@Billing.ACT_Post"), + mustEntry(t, "shipping is handled by a third party with its own SLA", "@Shipping.Carrier"), + mustRequirement(t, "invoices are posted nightly", "02-billing", "@Billing.ACT_Post"), + } { + promote(t, s, e) + } + + b, err := s.Brief("07-planning") + if err != nil { + t.Fatal(err) + } + whole, err := s.Size() + if err != nil { + t.Fatal(err) + } + if b.Lines >= whole { + t.Errorf("the brief is %d lines against a whole store of %d; it is not saving anything", + b.Lines, whole) + } + if b.Lines != strings.Count(b.Text(), "\n") { + t.Errorf("reported %d lines, text has %d", b.Lines, strings.Count(b.Text(), "\n")) + } +} + +func shardNames(b Brief) []string { + out := make([]string, 0, len(b.Shards)) + for _, s := range b.Shards { + out = append(out, s.Shard) + } + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + s := NewStore(t.TempDir()) + if _, err := s.Init(); err != nil { + t.Fatal(err) + } + return s +} + +func promote(t *testing.T, s *Store, e Entry) { + t.Helper() + if err := s.Promote(e, e.Shard()); err != nil { + t.Fatalf("promote %q: %v", e.Title, err) + } +} diff --git a/cmd/mxcli/brain/caps.go b/cmd/mxcli/brain/caps.go index f9521b7fc8..87cab61868 100644 --- a/cmd/mxcli/brain/caps.go +++ b/cmd/mxcli/brain/caps.go @@ -8,6 +8,8 @@ // most need the store (PROPOSAL_project_brain.md §4.1). package brain +import "encoding/json" + // Caps are measured in lines, because lines are what an agent pays for when a // shard is loaded into context. // @@ -45,10 +47,22 @@ func CapFor(shard string) int { // none of it is ever written into a committed file, because a figure in prose // is stale the next time anyone promotes (A6). type Usage struct { - Shard string - Entries int - Lines int - Cap int + Shard string `json:"shard"` + Entries int `json:"entries"` + Lines int `json:"lines"` + Cap int `json:"cap"` +} + +// MarshalJSON adds the two derived figures a reader would otherwise recompute, +// and which are the whole point of the command: headroom, and whether the shard +// is over its cap. +func (u Usage) MarshalJSON() ([]byte, error) { + type usage Usage + return json.Marshal(struct { + usage + Headroom int `json:"headroom"` + Over bool `json:"over_cap"` + }{usage(u), u.Headroom(), u.Over()}) } // Headroom is the number of lines still available. It goes negative for a shard diff --git a/cmd/mxcli/brain/check.go b/cmd/mxcli/brain/check.go index 0b06f969e9..6c78680d1d 100644 --- a/cmd/mxcli/brain/check.go +++ b/cmd/mxcli/brain/check.go @@ -13,7 +13,11 @@ // anchor can resolve perfectly and the entry still sit in the wrong file. package brain -import "sort" +import ( + "encoding/json" + "fmt" + "sort" +) // AnchorState is the outcome of resolving one anchor. type AnchorState int @@ -29,6 +33,32 @@ const ( NotIndexable ) +// MarshalJSON writes the state's name. The zero value of an int is a state +// here, so an ordinal contract would make "0" mean `resolved` today and +// something else the moment a state is inserted above it — a change nothing +// downstream could notice. The three states are the substance of the check, so +// they travel as names. +func (s AnchorState) MarshalJSON() ([]byte, error) { return json.Marshal(s.String()) } + +// UnmarshalJSON accepts what MarshalJSON writes, so a report round-trips. +func (s *AnchorState) UnmarshalJSON(b []byte) error { + var name string + if err := json.Unmarshal(b, &name); err != nil { + return err + } + switch name { + case "resolved": + *s = Resolved + case "not found": + *s = NotFound + case "not indexable": + *s = NotIndexable + default: + return fmt.Errorf("unknown anchor state %q", name) + } + return nil +} + func (s AnchorState) String() string { switch s { case Resolved: @@ -59,47 +89,48 @@ type Resolver interface { // AnchorFinding is one anchor's outcome. type AnchorFinding struct { - Shard string - EntryID string - Title string - Anchor string - State AnchorState - Kind string + Shard string `json:"shard"` + EntryID string `json:"entry_id"` + Title string `json:"title"` + Anchor string `json:"anchor"` + State AnchorState `json:"state"` + Kind string `json:"kind,omitempty"` } // OpenQuestion is something the project has not decided yet. type OpenQuestion struct { - Shard string - EntryID string - Title string + Shard string `json:"shard"` + EntryID string `json:"entry_id"` + Title string `json:"title"` } // MisfiledFinding is an entry sitting in a shard none of its anchors belong to. type MisfiledFinding struct { - Shard string - EntryID string - Title string - Belongs string // the shard it should be in, from its first resolved anchor + Shard string `json:"shard"` + EntryID string `json:"entry_id"` + Title string `json:"title"` + // Belongs is the shard it should be in, from its first resolved anchor. + Belongs string `json:"belongs,omitempty"` } // SliceProgress is a slice's requirements counted against the model. Every // figure is derived from resolving anchors, so nothing here is self-reported // and no one has to maintain a status column that will go stale. type SliceProgress struct { - Slice string + Slice string `json:"slice"` // Built is requirements whose anchors all resolve — the thing exists. - Built int + Built int `json:"built"` // Planned is requirements with at least one anchor that does not resolve // yet. Not a failure: that is what a requirement is until it is built. - Planned int + Planned int `json:"planned"` // Questions is open questions filed against this slice — scope that is not // settled. They are not requirements and are not counted as either built // or planned; counting an unanswered question as outstanding work would // overstate the slice. - Questions int + Questions int `json:"questions"` // Unanchored is requirements with no anchor at all. They cannot be // measured, and are counted apart rather than silently called planned. - Unanchored int + Unanchored int `json:"unanchored"` } // Total is every requirement in the slice. Open questions are excluded: they @@ -108,15 +139,33 @@ func (p SliceProgress) Total() int { return p.Built + p.Planned + p.Unanchored } // Report is what `brain check` prints and exits on. type Report struct { - Shards []string - Entries int - Anchors int - ResolvedN int - Findings []AnchorFinding // NotFound and NotIndexable only - Misfiled []MisfiledFinding - Malformed []string // entry blocks whose metadata line could not be read - Slices []SliceProgress - Open []OpenQuestion + Shards []string `json:"shards"` + Entries int `json:"entries"` + Anchors int `json:"anchors"` + ResolvedN int `json:"resolved"` + // Findings carries NotFound and NotIndexable anchors only; a resolved + // anchor is not a finding. + Findings []AnchorFinding `json:"findings"` + Misfiled []MisfiledFinding `json:"misfiled"` + // Malformed names entry blocks whose metadata line could not be read. + Malformed []string `json:"malformed"` + Slices []SliceProgress `json:"slices"` + Open []OpenQuestion `json:"open"` +} + +// MarshalJSON adds a derived "failed" alongside the report's contents. A +// consumer deciding whether to advance should not have to re-implement which of +// these states are defects and which are information — that rule lives in +// Failed() and is easy to get subtly wrong from outside (a not-indexable anchor +// and an open question both look like problems and neither is one). +// +// It is computed here rather than stored, so it cannot disagree with Failed(). +func (r Report) MarshalJSON() ([]byte, error) { + type report Report // shed the method, keep the tags + return json.Marshal(struct { + report + Failed bool `json:"failed"` + }{report(r), r.Failed()}) } // Failed reports whether the check should exit non-zero. diff --git a/cmd/mxcli/brain/frontmatter_test.go b/cmd/mxcli/brain/frontmatter_test.go new file mode 100644 index 0000000000..11d3819e51 --- /dev/null +++ b/cmd/mxcli/brain/frontmatter_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "os" + "strings" + "testing" +) + +// SaveShard re-renders a shard from the entries it parsed, so anything in the +// file that is not an entry was silently discarded on the next promote, drop, +// resolve or rename. YAML frontmatter is the case that matters: it is where +// every markdown tool in the ecosystem keeps its per-file metadata — Foam and +// Obsidian tags, Jekyll/Hugo front matter, a docs site's nav weight — and a +// store that eats it cannot be kept in one of them. +// +// Silently is the operative word. The file stays valid, the entries are all +// there, and the loss shows up whenever someone next looks. That is the failure +// mode ADR-0005's guard-don't-drop rule exists to prevent, and this writer was +// on the wrong side of it. +func TestFrontmatterSurvivesAPromote(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + addFrontmatter(t, s, "Planning", "---\ntags: [planning, architecture]\nweight: 3\n---\n") + + promote(t, s, mustEntry(t, "rollups run nightly", "@Planning.ACT_Rollup")) + + got := readShard(t, s, "Planning") + if !strings.HasPrefix(got, "---\ntags: [planning, architecture]\nweight: 3\n---\n") { + t.Fatalf("frontmatter did not survive the promote:\n%s", got) + } + // It has to stay frontmatter — first thing in the file, before the title — + // or it is inert text that every tool reads as body content. + if strings.Index(got, "---") > strings.Index(got, "# Planning") { + t.Error("frontmatter is no longer at the top of the file") + } + for _, want := range []string{"planning uses a snapshot", "rollups run nightly"} { + if !strings.Contains(got, want) { + t.Errorf("entry %q was lost while preserving frontmatter", want) + } + } +} + +// Every write path rewrites the whole file, so each has to be covered. Promote +// is the obvious one; these are the three that are easy to forget. +func TestFrontmatterSurvivesEveryWritePath(t *testing.T) { + t.Run("drop", func(t *testing.T) { + s := newTestStore(t) + keep := mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot") + gone := mustEntry(t, "rollups run nightly", "@Planning.ACT_Rollup") + promote(t, s, keep) + promote(t, s, gone) + addFrontmatter(t, s, "Planning", "---\ntags: [planning]\n---\n") + + if _, _, err := s.Drop(gone.ID); err != nil { + t.Fatal(err) + } + assertFrontmatter(t, s, "Planning", "tags: [planning]") + }) + + t.Run("resolve", func(t *testing.T) { + s := newTestStore(t) + q, err := NewQuestion("should planning own the totals", []string{"@Planning.Snapshot"}, "", day) + if err != nil { + t.Fatal(err) + } + promote(t, s, q) + addFrontmatter(t, s, "Planning", "---\ntags: [planning]\n---\n") + + answered, err := q.Resolve("yes, Finance reads them", day) + if err != nil { + t.Fatal(err) + } + if err := s.Replace("Planning", answered); err != nil { + t.Fatal(err) + } + assertFrontmatter(t, s, "Planning", "tags: [planning]") + }) + + t.Run("rename", func(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + addFrontmatter(t, s, "Planning", "---\ntags: [planning]\n---\n") + + if _, err := s.RenameAnchors("Planning.Snapshot", "Planning.Baseline"); err != nil { + t.Fatal(err) + } + assertFrontmatter(t, s, "Planning", "tags: [planning]") + }) +} + +// A module rename writes to a NEW path and deletes the old one, so there is no +// existing file at the destination to read the frontmatter back off. It has to +// be carried across the move explicitly — the one place the choke point alone +// does not cover. +func TestFrontmatterMovesWithARenamedModuleShard(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + addFrontmatter(t, s, "Planning", "---\ntags: [planning]\nweight: 3\n---\n") + + if _, err := s.RenameAnchors("Planning", "Forecasting"); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(s.ShardPath("Planning")); !os.IsNotExist(err) { + t.Error("the old shard survived the module rename") + } + assertFrontmatter(t, s, "Forecasting", "tags: [planning]") + assertFrontmatter(t, s, "Forecasting", "weight: 3") +} + +// Control: a shard nobody has added frontmatter to must not grow an empty +// block. Without this the fix could "pass" by emitting `---\n---` everywhere, +// which is a diff on every existing project and reads as metadata that is not +// there. +func TestShardWithoutFrontmatterStaysWithout(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + promote(t, s, mustEntry(t, "rollups run nightly", "@Planning.ACT_Rollup")) + + got := readShard(t, s, "Planning") + if !strings.HasPrefix(got, "# Planning") { + t.Errorf("a shard with no frontmatter no longer starts with its title:\n%s", got) + } + if strings.Contains(got, "---") { + t.Errorf("an empty frontmatter block was invented:\n%s", got) + } +} + +// An unterminated `---` is not frontmatter, and treating it as such would +// swallow the whole document — every entry in the file would be carried +// forward as opaque preserved text and then written back twice. Refusing to +// recognise it leaves the old behaviour for that one file, which is the safe +// direction. +func TestUnterminatedFrontmatterIsNotTreatedAsFrontmatter(t *testing.T) { + for _, tc := range []struct { + name, content string + }{ + {"no closing fence", "---\ntags: [planning]\n\n# Planning\n\n## a decision\n"}, + {"fence not at the start", "# Planning\n\n---\ntags: [x]\n---\n"}, + {"horizontal rule", "***\n\n# Planning\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + if fm := extractFrontmatter(tc.content); fm != "" { + t.Errorf("treated %q as frontmatter: %q", tc.content, fm) + } + }) + } +} + +func TestFrontmatterIsExtractedWhenWellFormed(t *testing.T) { + got := extractFrontmatter("---\ntags: [planning]\n---\n\n# Planning\n") + if got != "---\ntags: [planning]\n---\n" { + t.Errorf("extractFrontmatter = %q", got) + } +} + +// The cap is what a session pays to load the shard, and frontmatter is part of +// what it loads. `brain show` counts lines off the file on disk, so if the +// promote-time check ignored frontmatter the two would disagree — and the +// disagreement would appear exactly when a shard is near its limit. +func TestFrontmatterCountsTowardTheCap(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "planning uses a snapshot", "@Planning.Snapshot")) + + before := usageFor(t, s, "Planning") + addFrontmatter(t, s, "Planning", "---\ntags: [planning]\nweight: 3\n---\n") + promote(t, s, mustEntry(t, "rollups run nightly", "@Planning.ACT_Rollup")) + after := usageFor(t, s, "Planning") + + if after.Lines <= before.Lines+2 { + t.Errorf("shard was %d lines, now %d; the four frontmatter lines are not being counted", + before.Lines, after.Lines) + } +} + +func addFrontmatter(t *testing.T, s *Store, shard, fm string) { + t.Helper() + path := s.ShardPath(shard) + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append([]byte(fm+"\n"), body...), 0o644); err != nil { + t.Fatal(err) + } +} + +func readShard(t *testing.T, s *Store, shard string) string { + t.Helper() + b, err := os.ReadFile(s.ShardPath(shard)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func assertFrontmatter(t *testing.T, s *Store, shard, want string) { + t.Helper() + got := readShard(t, s, shard) + if !strings.HasPrefix(got, "---\n") || !strings.Contains(got, want) { + t.Errorf("%s lost its frontmatter (%q):\n%s", shard, want, got) + } +} + +func usageFor(t *testing.T, s *Store, shard string) Usage { + t.Helper() + usage, err := s.Usage() + if err != nil { + t.Fatal(err) + } + for _, u := range usage { + if u.Shard == shard { + return u + } + } + t.Fatalf("no usage for shard %s", shard) + return Usage{} +} diff --git a/cmd/mxcli/brain/json_test.go b/cmd/mxcli/brain/json_test.go new file mode 100644 index 0000000000..5f7dacee4c --- /dev/null +++ b/cmd/mxcli/brain/json_test.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "encoding/json" + "strings" + "testing" +) + +// An orchestrator dispatching one sub-agent per slice has to read the brain's +// output, not look at it: it decides whether to advance on what `staged` and +// `check` report. Every brain command prints for a human today, so that +// decision cannot be automated at all. +// +// AnchorState is the sharp edge. It is an int, so a plain marshal emits 0, 1 +// and 2 — a contract where the meaning of "1" lives in the order of a const +// block, and where inserting a state silently reassigns every existing value. +// The three states are the whole point of the check (only the middle one is a +// failure), so they travel as names. +func TestAnchorStateTravelsAsANameNotAnOrdinal(t *testing.T) { + for _, s := range []AnchorState{Resolved, NotFound, NotIndexable} { + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + got := string(b) + if !strings.HasPrefix(got, `"`) { + t.Errorf("AnchorState %v marshals as %s; an ordinal reassigns itself the next time a "+ + "state is inserted, and nothing downstream would notice", s, got) + } + if want := `"` + s.String() + `"`; got != want { + t.Errorf("AnchorState %v marshals as %s, want %s", s, got, want) + } + } +} + +// The report has to survive the round trip, or a consumer cannot tell a clean +// store from one it failed to parse. In particular a failing check must be +// recognisable from the JSON alone, without re-deriving Failed()'s rules. +func TestReportRoundTripsThroughJSON(t *testing.T) { + rep := Report{ + Shards: []string{"Sales", PlanShard("01-accounts")}, + Entries: 3, + Anchors: 4, + ResolvedN: 2, + Findings: []AnchorFinding{ + {Shard: "Sales", EntryID: "a1", Title: "why", Anchor: "@Sales.Gone", State: NotFound}, + {Shard: "Sales", EntryID: "a2", Title: "sched", Anchor: "@Sales.Nightly", State: NotIndexable}, + }, + Misfiled: []MisfiledFinding{{Shard: "Sales", EntryID: "a3", Title: "t", Belongs: "Finance"}}, + Malformed: []string{"Sales: unreadable block"}, + Slices: []SliceProgress{{Slice: "01-accounts", Built: 1, Planned: 2, Questions: 1, Unanchored: 0}}, + Open: []OpenQuestion{{Shard: "Sales", EntryID: "a4", Title: "should it exist"}}, + } + + b, err := json.Marshal(rep) + if err != nil { + t.Fatal(err) + } + var back Report + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("the report does not round-trip: %v", err) + } + + if len(back.Findings) != 2 || back.Findings[0].State != NotFound || back.Findings[1].State != NotIndexable { + t.Errorf("anchor states did not survive the round trip: %+v", back.Findings) + } + if !back.Failed() { + t.Error("a report with a NotFound anchor and a misfiled entry did not read as failed after a round trip") + } + if back.Slices[0].Built != 1 || back.Slices[0].Planned != 2 || back.Slices[0].Questions != 1 { + t.Errorf("slice progress did not survive: %+v", back.Slices[0]) + } + + // The keys are the contract. Renaming one breaks every caller silently, so + // the names are pinned here rather than left to whatever the field is called. + for _, key := range []string{ + `"findings"`, `"misfiled"`, `"malformed"`, `"slices"`, `"open"`, + `"state"`, `"anchor"`, `"entry_id"`, `"shard"`, `"built"`, `"planned"`, + `"failed":true`, // derived, so a consumer never re-implements Failed() + } { + if !strings.Contains(string(b), key) { + t.Errorf("report JSON has no %s key: %s", key, b) + } + } +} diff --git a/cmd/mxcli/brain/queue_concurrency_test.go b/cmd/mxcli/brain/queue_concurrency_test.go new file mode 100644 index 0000000000..9a6926777b --- /dev/null +++ b/cmd/mxcli/brain/queue_concurrency_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "fmt" + "os" + "sync" + "testing" + "time" +) + +// `brain capture` is append-only and was measured concurrency-safe: three +// simultaneous captures land as three well-formed lines. That measurement is +// right and it is also the whole of the safety, which is easy to over-read. +// +// `promote` and `drop` are NOT appends. Both do Load -> rebuild the file in +// memory -> write it back whole, so a capture that lands between the load and +// the write is silently overwritten. Nothing reports it: the queue stays +// well-formed, one line is simply gone. +// +// That matters far more under the shape the brain is being pointed at. In one +// long session a lost capture is a lost note the author could still recall. +// With one sub-agent per slice, the brain is the ONLY channel between slices — +// slice 8's agent has no memory of slice 7 — so capture is the agent's return +// value and a lost one is a lost decision. The orchestrator promoting slice 7's +// entries while slice 8's agent captures is exactly the interleaving here. +// +// The test asserts conservation: every entry captured is present afterwards, +// and every entry dropped is gone. It runs the interleaving repeatedly because +// the window is small — measured against the unlocked implementation it loses +// entries on every run, and the failure is reported below with the count. +func TestQueueDoesNotLoseACaptureRacingAPromote(t *testing.T) { + const rounds, capturesPerRound = 12, 6 + + for round := range rounds { + dir := t.TempDir() + q := NewQueue(dir) + + // Seed an entry for the "promote" side to remove. A promote is a Get + // followed by a Drop, and Drop is the half that rewrites the file. + seed, err := NewEntry(fmt.Sprintf("seed decision %d", round), nil, day) + if err != nil { + t.Fatal(err) + } + if _, err := q.Append(seed); err != nil { + t.Fatal(err) + } + + // The captures a slice agent is making while the dispatcher promotes. + captured := make([]Entry, capturesPerRound) + for i := range captured { + e, err := NewEntry(fmt.Sprintf("captured decision %d-%d", round, i), nil, day) + if err != nil { + t.Fatal(err) + } + captured[i] = e + } + + var wg sync.WaitGroup + start := make(chan struct{}) + wg.Add(len(captured) + 1) + + go func() { + defer wg.Done() + <-start + if _, err := NewQueue(dir).Drop(seed.ID); err != nil { + t.Errorf("drop: %v", err) + } + }() + for _, e := range captured { + go func() { + defer wg.Done() + <-start + if _, err := NewQueue(dir).Append(e); err != nil { + t.Errorf("append: %v", err) + } + }() + } + close(start) + wg.Wait() + + final, err := q.Load() + if err != nil { + t.Fatalf("round %d: queue is unreadable after concurrent use: %v", round, err) + } + present := map[string]bool{} + for _, e := range final { + present[e.ID] = true + } + + var lost []string + for _, e := range captured { + if !present[e.ID] { + lost = append(lost, e.Title) + } + } + if len(lost) > 0 { + t.Fatalf("round %d: %d of %d captures were silently lost by a concurrent promote: %v\n"+ + "Under one sub-agent per slice the brain is the only channel between slices, "+ + "so a dropped capture is a dropped decision and nothing reports it.", + round, len(lost), len(captured), lost) + } + if present[seed.ID] { + t.Fatalf("round %d: the dropped entry came back — a concurrent capture rewrote the queue "+ + "from a snapshot taken before the drop", round) + } + } +} + +// A crash or a full disk part-way through a rewrite must not leave a truncated +// queue behind: Load parses JSON per line and would report the survivor as a +// corrupt file, which reads like a bug in capture rather than an interrupted +// promote. Writing through a temp file and renaming makes the swap atomic, so +// a reader sees either the old queue or the new one. +func TestQueueRewriteIsAtomic(t *testing.T) { + dir := t.TempDir() + q := NewQueue(dir) + + var ids []string + for i := range 40 { + e, err := NewEntry(fmt.Sprintf("decision number %d with enough text to span bytes", i), nil, day) + if err != nil { + t.Fatal(err) + } + if _, err := q.Append(e); err != nil { + t.Fatal(err) + } + ids = append(ids, e.ID) + } + + // Read the queue continuously while it is rewritten underneath. Every read + // must parse; a partially written file would not. + stop := make(chan struct{}) + var readErr error + var readWG sync.WaitGroup + readWG.Add(1) + go func() { + defer readWG.Done() + for { + select { + case <-stop: + return + default: + } + if _, err := NewQueue(dir).Load(); err != nil { + readErr = err + return + } + } + }() + + for _, id := range ids[:20] { + if _, err := q.Drop(id); err != nil { + t.Fatal(err) + } + } + close(stop) + readWG.Wait() + + if readErr != nil { + t.Fatalf("a read during a rewrite saw a torn file: %v", readErr) + } +} + +// A process that dies holding the lock must not wedge the queue for every +// later capture. The lock is taken over once it is clearly abandoned, so the +// worst case is a delay rather than a project whose agents can no longer +// record anything. +func TestQueueLockIsTakenOverWhenAbandoned(t *testing.T) { + dir := t.TempDir() + q := NewQueue(dir) + + release, err := acquireQueueLock(q.Path) + if err != nil { + t.Fatal(err) + } + // Simulate the holder dying: the lock file stays, nothing releases it. + // Age it past the staleness threshold rather than waiting for it. + ageQueueLock(t, q.Path, staleQueueLock+time.Second) + + e, err := NewEntry("a capture after the holder died", nil, day) + if err != nil { + t.Fatal(err) + } + if _, err := q.Append(e); err != nil { + t.Fatalf("capture blocked forever on an abandoned lock: %v", err) + } + release() + + got, err := q.Load() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != e.ID { + t.Fatalf("entry did not land after taking over the abandoned lock: %v", got) + } +} + +// ageQueueLock backdates the lock file so the staleness path can be exercised +// without the test waiting out staleQueueLock. +func ageQueueLock(t *testing.T, queuePath string, by time.Duration) { + t.Helper() + lock := queueLockPath(queuePath) + old := time.Now().Add(-by) + if err := os.Chtimes(lock, old, old); err != nil { + t.Fatalf("could not backdate %s: %v", lock, err) + } +} diff --git a/cmd/mxcli/brain/queuelock.go b/cmd/mxcli/brain/queuelock.go new file mode 100644 index 0000000000..6c40883357 --- /dev/null +++ b/cmd/mxcli/brain/queuelock.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +// queuelock.go - serialising read-modify-write on the staged queue. +// +// Capture is an append and needs no help: a short O_APPEND write lands whole +// beside another one, which is why three simultaneous captures were measured +// arriving as three well-formed lines. Promote and drop are the problem. Both +// load the queue, rebuild it in memory and write it back, so a capture landing +// in that window is overwritten by a snapshot taken before it existed. The +// queue stays well-formed and one line is simply gone — there is nothing to +// notice, which is what makes it worth preventing rather than detecting. +// +// The cost of that is decided by what the queue is FOR. In one long session a +// lost capture is a note whose author can still recall it. Under one sub-agent +// per slice the queue is the only channel between slices, so a capture is the +// agent's return value and losing one loses the decision itself. +// +// A lock file rather than flock(2): the queue is a handful of short-lived CLI +// processes on one machine, mxcli ships for Windows as well as Linux, and +// O_EXCL is the one primitive that means the same thing everywhere without +// taking a dependency. The cost is that a process killed mid-write leaves the +// file behind, so the lock is taken over once it is old enough to be certain +// nobody is still working — a delay is recoverable, a permanently unwritable +// queue is not. +package brain + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "time" +) + +const ( + // staleQueueLock is how old a lock must be before it is assumed abandoned. + // Every operation under it is a load, an in-memory rebuild and a write of a + // file that holds tens of entries, so it is orders of magnitude longer than + // a real hold and still short enough not to strand an agent. + staleQueueLock = 30 * time.Second + // queueLockWait bounds the wait for a live holder. Reaching it means real + // contention rather than a crash, and failing is better than a capture that + // appears to have worked. + queueLockWait = 20 * time.Second + queueLockPoll = 5 * time.Millisecond +) + +func queueLockPath(queuePath string) string { return queuePath + ".lock" } + +// acquireQueueLock blocks until it owns the queue's lock, and returns the +// function that releases it. The returned function is safe to call more than +// once so a deferred release cannot double-remove a lock someone else has since +// taken. +func acquireQueueLock(queuePath string) (func(), error) { + lock := queueLockPath(queuePath) + if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { + return nil, err + } + + deadline := time.Now().Add(queueLockWait) + for { + f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + // The pid is for a human reading a stuck lock, not for the takeover + // decision: a pid means nothing across containers, and this queue is + // written from inside them. + fmt.Fprintf(f, "%d\n", os.Getpid()) + _ = f.Close() + released := false + return func() { + if released { + return + } + released = true + _ = os.Remove(lock) + }, nil + } + if !os.IsExist(err) { + return nil, err + } + + if st, err := os.Stat(lock); err == nil && time.Since(st.ModTime()) > staleQueueLock { + // Abandoned. Remove it and try again; if two processes both decide + // this, one of them still loses the O_EXCL race on the next pass. + _ = os.Remove(lock) + continue + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("%s is locked by another mxcli process (waited %s); "+ + "if nothing else is running, delete %s", filepath.Base(queuePath), queueLockWait, lock) + } + time.Sleep(queueLockPoll) + } +} + +// writeFileAtomic replaces path in one step, so a reader either sees the whole +// old file or the whole new one. Load parses a JSON object per line, so a +// half-written queue does not read as a shorter queue — it reads as a corrupt +// one, and blames capture for what an interrupted promote did. +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-"+strconv.Itoa(os.Getpid())+"-*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) // no-op once the rename has succeeded + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + // Durability matters more than speed here: the queue is the only copy of + // an agent's captures until someone promotes them. + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(name, 0o644); err != nil { + return err + } + return os.Rename(name, path) +} diff --git a/cmd/mxcli/brain/rename.go b/cmd/mxcli/brain/rename.go new file mode 100644 index 0000000000..63ca1ceaf6 --- /dev/null +++ b/cmd/mxcli/brain/rename.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 + +// rename.go - keeping anchors valid when the model moves under them. +// +// `mxcli rename` updates every cross-reference in the model. The brain's +// anchors are references to the same elements and were not among them, so a +// refactor silently invalidated them. +// +// `brain check` catches only half of that, and the missing half is inherent to +// the design rather than a gap in the check. A decision's anchor points BACKWARD +// at something that exists, so one that stops resolving is reported NOT FOUND. A +// requirement's points FORWARD at something intended, so one that stops +// resolving counts as PLANNED — which is exactly what a forward anchor failing +// is supposed to mean. There is no way to tell "never built" from "was built, +// then renamed" after the fact. The observed symptom was a plan's progress +// moving from 65/65 to 63/65 with nothing else to see. +// +// That ambiguity is the argument for fixing it at the rename, where both names +// are still known, rather than at the check, where neither is. +package brain + +import ( + "fmt" + "os" + "strings" +) + +// RenameAnchors rewrites every anchor naming old so it names new, and returns +// how many it changed. Both names are qualified: "Sales.Order", or "Sales" for +// a module. +// +// An entry's id is NOT re-derived. The id is content-derived and its content +// includes its anchors, so re-deriving is the obvious move and is wrong: the id +// is a handle (`brain promote `, `brain resolve `, prose that cites +// one), and invalidating every reference TO an entry in order to fix that +// entry's references to the model trades one dangling pointer for several. +func (s *Store) RenameAnchors(old, new string) (int, error) { + shards, err := s.ListShards() + if err != nil { + return 0, err + } + + // A module rename moves the module's own shard. Without that, every entry + // in modules/Old.md anchors into New the moment the rewrite lands and + // `brain check` reports the lot as misfiled — one false signal traded for + // another. Element renames never move anything: the module is unchanged. + moveShard := "" + if !strings.Contains(old, ".") { + moveShard = old + } + + var total int + for _, shard := range shards { + entries, malformed, err := s.LoadShard(shard) + if err != nil { + return total, err + } + if len(malformed) > 0 { + // Refuse rather than rewrite around it: SaveShard re-renders the + // whole file from the entries it parsed, so writing back a shard + // with an unreadable block would delete that block. + return total, fmt.Errorf("%s has %d entry block(s) that cannot be parsed; "+ + "fix them before renaming, or the rewrite would drop them: %s", + shardFileName(shard), len(malformed), strings.Join(malformed, "; ")) + } + + var changed int + for i := range entries { + for j, a := range entries[i].Anchors { + if rewritten, ok := rewriteAnchor(a, old, new); ok { + entries[i].Anchors[j] = rewritten + changed++ + } + } + } + if changed == 0 { + continue + } + total += changed + + if shard == moveShard { + // Write the new shard first, then drop the old one, so an + // interruption leaves a duplicate rather than nothing. + // + // The frontmatter is read from the OLD path and passed explicitly: + // the destination has no file yet, so the usual preserve-on-write + // has nothing to read it off, and the block would be lost precisely + // when the shard is being moved rather than edited. + if err := s.saveShardWith(new, entries, s.Frontmatter(shard)); err != nil { + return total, err + } + if err := os.Remove(s.ShardPath(shard)); err != nil && !os.IsNotExist(err) { + return total, err + } + continue + } + if err := s.SaveShard(shard, entries); err != nil { + return total, err + } + } + return total, nil +} + +// RenameQueueAnchors does the same to the staged queue. Captures that have not +// been promoted yet are the ones most likely to name something just renamed — +// nobody has looked at them, so nobody has noticed. +func RenameQueueAnchors(q *Queue, old, new string) (int, error) { + release, err := acquireQueueLock(q.Path) + if err != nil { + return 0, err + } + defer release() + + entries, err := q.Load() + if err != nil { + return 0, err + } + var changed int + for i := range entries { + for j, a := range entries[i].Anchors { + if rewritten, ok := rewriteAnchor(a, old, new); ok { + entries[i].Anchors[j] = rewritten + changed++ + } + } + } + if changed == 0 { + return 0, nil + } + return changed, q.write(entries) +} + +// rewriteAnchor replaces the old qualified name in one anchor, matching only at +// a name boundary. +// +// The boundary is the whole difficulty. Anchors are dotted names, so a plain +// prefix replace rewrites everything whose name merely STARTS with the renamed +// one: renaming Sales.Order would turn @Sales.OrderLine into +// @Sales.PurchaseOrderLine, which names nothing — and which `brain check` then +// reports as a stale decision, so the repair invents the very problem it exists +// to prevent. A match must be followed by end-of-anchor or a dot. +func rewriteAnchor(anchor, old, new string) (string, bool) { + at, name := "", anchor + if strings.HasPrefix(anchor, "@") { + at, name = "@", anchor[1:] + } + if name == old { + return at + new, true + } + if rest, ok := strings.CutPrefix(name, old+"."); ok { + return at + new + "." + rest, true + } + return anchor, false +} + +// CountAnchorsNaming reports how many anchors a rename would rewrite, without +// touching anything. It backs `mxcli rename --dry-run`, which must preview the +// brain's share of the change as well as the model's — a preview that silently +// omitted it would understate what the real run does. +func CountAnchorsNaming(s *Store, old string) (int, error) { + shards, err := s.ListShards() + if err != nil { + return 0, err + } + var n int + for _, shard := range shards { + entries, _, err := s.LoadShard(shard) + if err != nil { + return 0, err + } + for _, e := range entries { + for _, a := range e.Anchors { + if _, ok := rewriteAnchor(a, old, old); ok { + n++ + } + } + } + } + return n, nil +} diff --git a/cmd/mxcli/brain/rename_test.go b/cmd/mxcli/brain/rename_test.go new file mode 100644 index 0000000000..639e3ecdb9 --- /dev/null +++ b/cmd/mxcli/brain/rename_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "os" + "strings" + "testing" +) + +// `mxcli rename` "renames an element and automatically updates all +// cross-references" — within the model. The brain's anchors are references to +// the same elements and were not updated, so a refactor silently invalidated +// them. +// +// `brain check` catches only half of that, and the half it misses is inherent +// to the design rather than a bug in the check: a decision's anchor points +// backward, so one that stops resolving is reported NOT FOUND; a requirement's +// points forward, so one that stops resolving just counts as PLANNED — which is +// what a forward anchor failing is SUPPOSED to mean. The observed symptom on a +// real project was the plan's progress moving from 65/65 to 63/65 with nothing +// else to see. That ambiguity is the argument for fixing it at the rename. +func TestRenameRewritesAnchorsAcrossTheStore(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "orders are committed by Finance", "@Sales.Order")) + promote(t, s, mustEntry(t, "the status attribute drives the grid", "@Sales.Order.Status")) + promote(t, s, mustRequirement(t, "approvals read the order", "02-approvals", "@Sales.Order", "@Sales.ACT_Approve")) + + n, err := s.RenameAnchors("Sales.Order", "Sales.PurchaseOrder") + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Errorf("rewrote %d anchors, want 3", n) + } + + for _, shard := range []string{"Sales", PlanShard("02-approvals")} { + entries, _, err := s.LoadShard(shard) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + for _, a := range e.Anchors { + if strings.HasPrefix(a, "@Sales.Order") { + t.Errorf("%s: %q still names the old element", shard, a) + } + } + } + } + + // A member anchor follows its element: @Sales.Order.Status has to become + // @Sales.PurchaseOrder.Status, not be left behind or truncated. + if got := anchorsIn(t, s, "Sales"); !containsAnchor(got, "@Sales.PurchaseOrder.Status") { + t.Errorf("the member anchor did not follow the rename: %v", got) + } +} + +// The sharp edge. Anchors are dotted names, so a naive prefix replace rewrites +// every element whose name merely STARTS with the renamed one — @Sales.OrderLine +// becoming @Sales.PurchaseOrderLine, which names nothing and which `brain check` +// then reports as a stale decision. The match has to end at a boundary. +func TestRenameDoesNotTouchNamesThatMerelyStartWithTheOldOne(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "orders are committed by Finance", "@Sales.Order")) + promote(t, s, mustEntry(t, "lines are never edited after posting", "@Sales.OrderLine")) + promote(t, s, mustEntry(t, "the archive is a separate module", "@SalesArchive.Order")) + + n, err := s.RenameAnchors("Sales.Order", "Sales.PurchaseOrder") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("rewrote %d anchors, want exactly 1", n) + } + + got := anchorsIn(t, s, "Sales") + if !containsAnchor(got, "@Sales.OrderLine") { + t.Errorf("@Sales.OrderLine was rewritten by a rename of @Sales.Order: %v", got) + } + if a := anchorsIn(t, s, "SalesArchive"); !containsAnchor(a, "@SalesArchive.Order") { + t.Errorf("@SalesArchive.Order was rewritten by a rename in a different module: %v", a) + } +} + +// A module rename moves every anchor in the store AND the shard file itself. +// Without the move, every entry in modules/Old.md now anchors into New and +// `brain check` reports the lot as misfiled — trading one false signal for +// another. +func TestRenamingAModuleMovesItsShard(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "orders are committed by Finance", "@Sales.Order")) + promote(t, s, mustEntry(t, "sales is the only writer", "@Sales")) + promote(t, s, mustRequirement(t, "approvals read the order", "02-approvals", "@Sales.Order")) + + if _, err := s.RenameAnchors("Sales", "Commerce"); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(s.ShardPath("Sales")); !os.IsNotExist(err) { + t.Error("modules/Sales.md survived the module rename; its entries now anchor into Commerce " + + "and read as misfiled") + } + got := anchorsIn(t, s, "Commerce") + if !containsAnchor(got, "@Commerce.Order") || !containsAnchor(got, "@Commerce") { + t.Errorf("modules/Commerce.md holds %v", got) + } + // The plan shard's anchor moved too, but the plan shard itself does not: + // slices span modules by design. + if a := anchorsIn(t, s, PlanShard("02-approvals")); !containsAnchor(a, "@Commerce.Order") { + t.Errorf("the plan shard was not rewritten: %v", a) + } +} + +// An entry's id is content-derived, and its content includes its anchors — so +// re-deriving it here would be the obvious thing and is wrong. The id is a +// handle: `brain promote `, `brain resolve `, and prose that cites one. +// A rename must not invalidate every reference to the entry in order to fix the +// entry's references to the model. +func TestRenameKeepsEntryIDsStable(t *testing.T) { + s := newTestStore(t) + e := mustEntry(t, "orders are committed by Finance", "@Sales.Order") + promote(t, s, e) + + if _, err := s.RenameAnchors("Sales.Order", "Sales.PurchaseOrder"); err != nil { + t.Fatal(err) + } + + entries, _, err := s.LoadShard("Sales") + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].ID != e.ID { + t.Fatalf("the entry's id changed from %s to %v; every reference to it now dangles", + e.ID, idsOf(entries)) + } +} + +// A rename that matches nothing must say so rather than reporting success, so +// a caller can tell "no anchors needed updating" from "the anchors were missed". +func TestRenameReportsZeroWhenNothingMatches(t *testing.T) { + s := newTestStore(t) + promote(t, s, mustEntry(t, "orders are committed by Finance", "@Sales.Order")) + + n, err := s.RenameAnchors("Billing.Invoice", "Billing.Bill") + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("rewrote %d anchors in a store that references neither name", n) + } +} + +// The staged queue holds anchors too, and it is where an agent's captures live +// before anyone has looked at them — the ones most likely to name something +// just renamed. +func TestRenameRewritesTheStagedQueue(t *testing.T) { + s := newTestStore(t) + dir := strings.TrimSuffix(s.Root, "/"+StoreDir) + q := NewQueue(dir) + e := mustEntry(t, "orders are committed by Finance", "@Sales.Order") + if _, err := q.Append(e); err != nil { + t.Fatal(err) + } + + if _, err := RenameQueueAnchors(q, "Sales.Order", "Sales.PurchaseOrder"); err != nil { + t.Fatal(err) + } + + got, err := q.Load() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Anchors[0] != "@Sales.PurchaseOrder" { + t.Fatalf("queue anchors were not rewritten: %v", got) + } + if got[0].ID != e.ID { + t.Errorf("the queued entry's id changed; `brain promote %s` no longer finds it", e.ID) + } +} + +func anchorsIn(t *testing.T, s *Store, shard string) []string { + t.Helper() + entries, _, err := s.LoadShard(shard) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Anchors...) + } + return out +} + +func containsAnchor(anchors []string, want string) bool { + for _, a := range anchors { + if a == want { + return true + } + } + return false +} + +func idsOf(entries []Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.ID) + } + return out +} diff --git a/cmd/mxcli/brain/shard.go b/cmd/mxcli/brain/shard.go index 6c870776dd..e76364cc48 100644 --- a/cmd/mxcli/brain/shard.go +++ b/cmd/mxcli/brain/shard.go @@ -185,3 +185,55 @@ func CountLines(content string) int { } return strings.Count(content, "\n") + 1 } + +// extractFrontmatter returns the YAML frontmatter block at the top of a shard, +// fences included, or "" when there is none. +// +// A shard is re-rendered from the entries parsed out of it, so everything else +// in the file is discarded on the next write. That is deliberate for the parts +// mxcli owns — the title and the preamble are regenerated so they cannot drift +// from the shard's identity — but it also ate frontmatter, which mxcli does not +// own and which is where every markdown tool in the ecosystem keeps its +// per-file metadata: Foam and Obsidian tags, a docs site's nav weight, a +// linter's per-file config. +// +// The recognition is deliberately strict, because the failure of a loose rule +// is not a missed block but a swallowed document. Only an opening fence on the +// very first line, closed by a later fence, counts. An unterminated `---` is +// left alone: treating it as frontmatter would carry the entire file forward as +// opaque text and then write the entries out again beneath it. +func extractFrontmatter(content string) string { + if !strings.HasPrefix(content, "---\n") { + return "" + } + rest := content[len("---\n"):] + // The closing fence is a line of exactly "---". Scanning line by line + // rather than with an index search so a "---" inside a YAML value cannot + // close the block early. + offset := len("---\n") + for len(rest) > 0 { + line, tail, found := strings.Cut(rest, "\n") + if !found { + return "" // ran off the end: no closing fence, so not frontmatter + } + offset += len(line) + 1 + if strings.TrimRight(line, " \t") == "---" { + return content[:offset] + } + rest = tail + } + return "" +} + +// RenderShardWithFrontmatter renders a shard beneath a preserved frontmatter +// block. An empty block renders exactly as before, so a shard nobody has +// annotated is byte-identical and existing projects see no diff. +func RenderShardWithFrontmatter(shard string, entries []Entry, frontmatter string) string { + if frontmatter == "" { + return RenderShard(shard, entries) + } + if !strings.HasSuffix(frontmatter, "\n") { + frontmatter += "\n" + } + return frontmatter + "\n" + RenderShard(shard, entries) +} diff --git a/cmd/mxcli/brain/staged.go b/cmd/mxcli/brain/staged.go index 9a47c73236..3517d3eb33 100644 --- a/cmd/mxcli/brain/staged.go +++ b/cmd/mxcli/brain/staged.go @@ -65,6 +65,15 @@ func (q *Queue) Load() ([]Entry, error) { // motivated it in mxcli's own findings store was a many-parallel-writers // problem, and one developer on one project has little exposure to it (A5). func (q *Queue) Append(e Entry) (added bool, err error) { + // The write itself is a short O_APPEND and needs no protection. The lock is + // held for the duplicate check, and — more importantly — so that a promote + // cannot be rebuilding the file from a snapshot taken before this line. + release, err := acquireQueueLock(q.Path) + if err != nil { + return false, err + } + defer release() + entries, err := q.Load() if err != nil { return false, err @@ -93,7 +102,17 @@ func (q *Queue) Append(e Entry) (added bool, err error) { } // Drop removes an entry from the queue by id. +// +// This is the read-modify-write half of a promote, so the lock spans the load +// and the rewrite: without it a capture landing between the two is overwritten +// by a queue rebuilt from before it existed. func (q *Queue) Drop(id string) (bool, error) { + release, err := acquireQueueLock(q.Path) + if err != nil { + return false, err + } + defer release() + entries, err := q.Load() if err != nil { return false, err @@ -130,10 +149,7 @@ func (q *Queue) write(entries []Entry) error { b.Write(line) b.WriteByte('\n') } - if err := os.MkdirAll(filepath.Dir(q.Path), 0755); err != nil { - return err - } - return os.WriteFile(q.Path, []byte(b.String()), 0644) + return writeFileAtomic(q.Path, []byte(b.String())) } // Get returns the queued entry with the given id. @@ -149,3 +165,61 @@ func (q *Queue) Get(id string) (Entry, bool, error) { } return Entry{}, false, nil } + +// StagedFilter narrows the queue to what a caller actually wants to see. +// +// The queue is a flat, append-only list of everything ever staged, which is the +// right shape for a person reviewing before a promote and the wrong one for a +// dispatcher asking what a single slice recorded. +type StagedFilter struct { + // SinceID is the id of the last entry that was already in the queue. + // Everything after it — exclusively — is what has been staged since. + // + // This is the honest slice boundary. The queue is append-only, so its own + // order IS the timeline; Entry.Date is a day, so every capture in a session + // shares one value and cannot separate anything. + SinceID string + // Slice matches requirements of one slice. Note this is NOT "what slice 07 + // staged": `capture --slice` is what makes an entry a requirement, so a + // decision found while building a slice carries no slice at all. Use + // SinceID for that question and this one for queued scope. + Slice string +} + +// Empty reports whether the filter would return the queue unchanged. +func (f StagedFilter) Empty() bool { return f.SinceID == "" && f.Slice == "" } + +// FilterStaged applies f to entries, preserving queue order. +// +// An unknown SinceID is an error rather than an empty result. Empty is a +// meaningful answer here — "this slice recorded nothing", which a dispatcher +// acts on — so producing it from a typo or an id that has since been promoted +// would make the caller abort a slice that had in fact done its job. +func FilterStaged(entries []Entry, f StagedFilter) ([]Entry, error) { + out := entries + if f.SinceID != "" { + at := -1 + for i, e := range out { + if e.ID == f.SinceID { + at = i + break + } + } + if at < 0 { + return nil, fmt.Errorf("no staged entry with id %s; it may have been promoted or dropped "+ + "since it was noted (an empty result means the slice staged nothing, so this cannot "+ + "be reported as one)", f.SinceID) + } + out = out[at+1:] + } + if f.Slice != "" { + kept := make([]Entry, 0, len(out)) + for _, e := range out { + if e.Slice == f.Slice { + kept = append(kept, e) + } + } + out = kept + } + return out, nil +} diff --git a/cmd/mxcli/brain/staged_filter_test.go b/cmd/mxcli/brain/staged_filter_test.go new file mode 100644 index 0000000000..5dec0a24d8 --- /dev/null +++ b/cmd/mxcli/brain/staged_filter_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package brain + +import ( + "testing" +) + +// A dispatcher running one sub-agent per slice needs to ask what THIS slice +// staged, so it can refuse to advance on a slice that recorded nothing. The +// queue answers "everything ever staged" and nothing else. +// +// Which filter does that has to come from what an entry actually carries, and +// the obvious one does not work. `--slice` can only match Entry.Slice, which +// is set by `capture --slice` — and that flag makes the entry a REQUIREMENT. +// A decision captured while working slice 07 carries no slice at all, so a +// slice filter silently answers a narrower question than the one asked: it +// reports the slice's planned scope, not its findings. The queue's own order +// is the honest boundary — it is append-only, so "everything after the id that +// was last there" is exactly this slice's captures, decisions included. +// +// Date cannot substitute: Entry.Date is a day, so every capture in a session +// shares one value and a boundary inside a day does not exist. +func TestSinceIDReturnsEverythingCapturedAfterTheBoundary(t *testing.T) { + before := []Entry{mustEntry(t, "decided before the slice began"), mustEntry(t, "also before")} + during := []Entry{ + mustEntry(t, "a decision found while building the slice"), + mustRequirement(t, "a requirement of the slice", "07-planning"), + mustEntry(t, "another decision, no slice on it at all"), + } + all := append(append([]Entry{}, before...), during...) + + got, err := FilterStaged(all, StagedFilter{SinceID: before[len(before)-1].ID}) + if err != nil { + t.Fatal(err) + } + if len(got) != len(during) { + t.Fatalf("got %d entries after the boundary, want %d: %v", len(got), len(during), titles(got)) + } + for i := range during { + if got[i].ID != during[i].ID { + t.Errorf("position %d: got %q, want %q", i, got[i].Title, during[i].Title) + } + } + + // The decisions are the point. A slice filter would return one of these + // three; the whole reason --since exists is that it returns all three. + var decisions int + for _, e := range got { + if e.EntryKind() == KindDecision { + decisions++ + } + } + if decisions != 2 { + t.Errorf("got %d decisions after the boundary, want 2 — a slice's findings are mostly "+ + "decisions, and they carry no slice", decisions) + } +} + +// The boundary is exclusive, and an empty result is the answer a dispatcher +// most needs: the slice staged nothing. +func TestSinceIDIsExclusiveAndCanBeEmpty(t *testing.T) { + all := []Entry{mustEntry(t, "first thing"), mustEntry(t, "second thing")} + + got, err := FilterStaged(all, StagedFilter{SinceID: all[len(all)-1].ID}) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Errorf("the boundary entry itself came back: %v", titles(got)) + } +} + +// An id that is not in the queue must be an error, not an empty result. Empty +// is the signal a dispatcher acts on ("this slice recorded nothing"), so a +// typo'd or promoted id quietly producing it would make the dispatcher abort a +// slice that had in fact done its job. +func TestUnknownSinceIDIsAnErrorNotAnEmptyResult(t *testing.T) { + all := []Entry{mustEntry(t, "first thing")} + + if _, err := FilterStaged(all, StagedFilter{SinceID: "nope12"}); err == nil { + t.Error("an unknown --since id returned a result instead of an error; empty is a " + + "meaningful answer here and must not be produced by a typo") + } +} + +// --slice still earns its place for the plan-shaped question ("what scope is +// queued for this slice"), and must not quietly include decisions. +func TestSliceFilterMatchesRequirementsOfThatSliceOnly(t *testing.T) { + all := []Entry{ + mustEntry(t, "a decision with no slice"), + mustRequirement(t, "scope for accounts", "01-accounts"), + mustRequirement(t, "scope for approvals", "02-approvals"), + mustRequirement(t, "more scope for accounts", "01-accounts"), + } + + got, err := FilterStaged(all, StagedFilter{Slice: "01-accounts"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d, want 2: %v", len(got), titles(got)) + } + for _, e := range got { + if e.Slice != "01-accounts" { + t.Errorf("%q is in slice %q", e.Title, e.Slice) + } + } +} + +// Both filters together narrow rather than widen — a dispatcher asking "what +// scope did this slice add" wants the intersection. +func TestFiltersCombineAsAnIntersection(t *testing.T) { + old := mustRequirement(t, "scope queued before the slice ran", "01-accounts") + all := []Entry{ + old, + mustEntry(t, "a decision during the slice"), + mustRequirement(t, "scope added during the slice", "01-accounts"), + mustRequirement(t, "scope for another slice", "02-approvals"), + } + + got, err := FilterStaged(all, StagedFilter{SinceID: old.ID, Slice: "01-accounts"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Title != "scope added during the slice" { + t.Fatalf("got %v, want just the one entry matching both", titles(got)) + } +} + +func titles(entries []Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Title) + } + return out +} diff --git a/cmd/mxcli/brain/store.go b/cmd/mxcli/brain/store.go index cd584ed502..558674f36f 100644 --- a/cmd/mxcli/brain/store.go +++ b/cmd/mxcli/brain/store.go @@ -100,9 +100,27 @@ func (s *Store) LoadShard(shard string) ([]Entry, []string, error) { // SaveShard writes a shard, deleting it when it has no entries left. Leaving an // empty file behind would make the directory accumulate husks that read as // "this module has decisions" when it has none. +// +// Any YAML frontmatter already on the file is carried across. A shard is +// re-rendered from its parsed entries, which is right for the parts mxcli owns +// — title and preamble are regenerated so they cannot drift — but frontmatter +// is not one of them, and discarding it silently broke every markdown tool that +// keeps per-file metadata there. This is the single write choke point, so +// preserving here covers promote, replace, drop and rename at once +// (guard-don't-drop, ADR-0005). func (s *Store) SaveShard(shard string, entries []Entry) error { + return s.saveShardWith(shard, entries, s.Frontmatter(shard)) +} + +// saveShardWith writes a shard beneath an explicitly supplied frontmatter +// block. Only the module-rename move needs it: that writes to a path with no +// existing file, so there is nothing there to read the block back off. +func (s *Store) saveShardWith(shard string, entries []Entry, frontmatter string) error { path := s.ShardPath(shard) if len(entries) == 0 && shard != ProjectShard { + // The file goes, frontmatter included. A shard with no decisions left + // is not a shard, and keeping a husk alive for its metadata is the + // accumulation this branch exists to prevent. err := os.Remove(path) if os.IsNotExist(err) { return nil @@ -112,7 +130,20 @@ func (s *Store) SaveShard(shard string, entries []Entry) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - return os.WriteFile(path, []byte(RenderShard(shard, entries)), 0644) + return os.WriteFile(path, []byte(RenderShardWithFrontmatter(shard, entries, frontmatter)), 0644) +} + +// Frontmatter returns the YAML block at the top of a shard, or "" if the shard +// has none or does not exist yet. +func (s *Store) Frontmatter(shard string) string { + b, err := os.ReadFile(s.ShardPath(shard)) + if err != nil { + // An unreadable shard is not this function's problem to report: the + // caller is about to read or write it and will surface the error there. + // Returning "" only means "no frontmatter to carry". + return "" + } + return extractFrontmatter(string(b)) } // ListShards returns every shard that exists, project first and modules sorted. @@ -183,7 +214,11 @@ func (s *Store) Promote(e Entry, shard string) error { } } next := append(entries, e) - if lines, limit := CountLines(RenderShard(shard, next)), CapFor(shard); lines > limit { + // Counted WITH frontmatter: the cap is what a session pays to load the + // shard, `brain show` measures the file on disk, and the two disagreeing + // would show up exactly when a shard is near its limit. + rendered := RenderShardWithFrontmatter(shard, next, s.Frontmatter(shard)) + if lines, limit := CountLines(rendered), CapFor(shard); lines > limit { return &ErrCapExceeded{Shard: shard, Would: lines, Cap: limit} } return s.SaveShard(shard, next) diff --git a/cmd/mxcli/cmd_brain.go b/cmd/mxcli/cmd_brain.go index 7decb0a692..6177fce096 100644 --- a/cmd/mxcli/cmd_brain.go +++ b/cmd/mxcli/cmd_brain.go @@ -4,10 +4,12 @@ package main import ( + "encoding/json" "fmt" "os" osexec "os/exec" "path/filepath" + "slices" "sort" "strings" "time" @@ -53,6 +55,7 @@ so nothing reaches a pull request until someone has looked at it.`, mxcli brain staged -p app.mpr mxcli brain promote a1b2c3 -p app.mpr mxcli brain capture "Orders must be approvable by a manager" --slice 02-approvals -a @Sales.ACT_Order_Approve -p app.mpr + mxcli brain brief --slice 02-approvals -p app.mpr mxcli brain plan -p app.mpr mxcli brain check -p app.mpr mxcli brain show -p app.mpr`, @@ -125,13 +128,53 @@ var brainCaptureCmd = &cobra.Command{ var brainStagedCmd = &cobra.Command{ Use: "staged", Short: "List the queue, with the shard each entry would land in", + Long: `List what has been captured and not yet promoted. + +With no flags this is the review list a person reads before promoting. + +--since narrows it to what has been staged since that entry, which is how +a dispatcher asks what one slice recorded: note the last id in the queue before +dispatching, pass it afterwards, and refuse to advance on an empty answer +(--fail-if-empty exits 1 so a script does not have to parse for that). + +--since is the boundary rather than a date or a slice because neither of those +can express it. Entry.Date is a day, so every capture in a session shares one +value. And --slice matches only requirements — 'capture --slice' is what MAKES +an entry a requirement, so a decision found while building a slice carries no +slice at all, and a slice's findings are mostly decisions. The queue is +append-only, so its own order is the honest timeline.`, Run: func(cmd *cobra.Command, args []string) { - entries, err := brain.NewQueue(brainProjectDir(cmd)).Load() + all, err := brain.NewQueue(brainProjectDir(cmd)).Load() if err != nil { brainFatal(err) } + entries := all + filter := brain.StagedFilter{} + filter.SinceID, _ = cmd.Flags().GetString("since") + filter.Slice, _ = cmd.Flags().GetString("slice") + entries, err = brain.FilterStaged(entries, filter) + if err != nil { + brainFatal(err) + } + failIfEmpty, _ := cmd.Flags().GetBool("fail-if-empty") + defer func() { + if failIfEmpty && len(entries) == 0 { + os.Exit(1) + } + }() + if globalJSONFlag { + brainJSON(stagedReport(all, entries, filter)) + return + } if len(entries) == 0 { - fmt.Println("Nothing staged.") + if filter.Empty() { + fmt.Println("Nothing staged.") + } else { + // Distinguished on purpose: "nothing matched" is the answer a + // dispatcher acts on, and reading it as "the queue is empty" + // would hide entries a person still has to promote. + fmt.Println("Nothing staged matching that filter.") + } return } for _, e := range entries { @@ -217,6 +260,13 @@ var brainShowCmd = &cobra.Command{ } // Width is computed from the names actually present: a module shard is // named after its module, and those run long. + if len(args) == 1 { + usage = slices.DeleteFunc(usage, func(u brain.Usage) bool { return u.Shard != args[0] }) + } + if globalJSONFlag { + brainJSON(map[string]any{"shards": usage}) + return + } width := len("SHARD") for _, u := range usage { if n := len(shardLabel(u.Shard)); n > width { @@ -225,9 +275,6 @@ var brainShowCmd = &cobra.Command{ } fmt.Printf("%-*s %8s %8s %12s\n", width, "SHARD", "ENTRIES", "LINES", "HEADROOM") for _, u := range usage { - if len(args) == 1 && u.Shard != args[0] { - continue - } note := "" if u.Over() { note = " OVER CAP" @@ -285,11 +332,18 @@ mxcli maintains.`, Run: func(cmd *cobra.Command, args []string) { projectPath := brainProjectPath(cmd) store := brain.NewStore(filepath.Dir(projectPath)) - slices, err := store.ListSlices() + sliceShards, err := store.ListSlices() if err != nil { brainFatal(err) } - if len(slices) == 0 { + if only, _ := cmd.Flags().GetString("slice"); only != "" { + want := brain.PlanShard(only) + if !slices.Contains(sliceShards, want) { + brainFatal(fmt.Errorf("no slice %q; 'mxcli brain plan' lists them", only)) + } + sliceShards = []string{want} + } + if len(sliceShards) == 0 { fmt.Println("No slices yet. Record one with:") fmt.Println(" mxcli brain capture \"\" --slice 01- -a @Module.Element") return @@ -300,10 +354,14 @@ mxcli maintains.`, } defer closeFn() - rep, err := brain.Check(store, resolver, slices) + rep, err := brain.Check(store, resolver, sliceShards) if err != nil { brainFatal(err) } + if globalJSONFlag { + brainJSON(planReport(rep.Slices)) + return + } printBrainPlan(rep.Slices) }, } @@ -329,6 +387,70 @@ func printBrainPlan(slices []brain.SliceProgress) { fmt.Printf("\n%d of %d requirements built, across %d slice(s).\n", built, total, len(slices)) } +var brainBriefCmd = &cobra.Command{ + Use: "brief", + Short: "The reading pack for a slice: project + its modules + its plan", + Long: `Emit exactly the shards a session needs, as one bounded read. + +The store is sharded so a session can load project.md plus the modules it is +touching instead of the whole thing. Nothing produced that pack, though — +docs/brain/ is a directory, so a session either read all of it or guessed. + + mxcli brain brief --slice 07-planning project + the modules that slice's + requirements anchor into + its plan + mxcli brain brief --module Sales --module Finance + project + those modules, no plan + (maintenance rather than roadmap) + +Which modules a slice needs is DERIVED from its requirements' anchors, not +configured: asking the caller which modules its slice touches would be asking +it the thing it opened the brief to find out. + +The pack goes to stdout and the size line to stderr, so it can be piped +straight into a prompt. --json gives the shards separately with their paths.`, + Example: ` mxcli brain brief --slice 07-planning -p app.mpr + mxcli brain brief --module Sales -p app.mpr --json`, + Run: func(cmd *cobra.Command, args []string) { + store := brain.NewStore(brainProjectDir(cmd)) + if !store.Exists() { + fmt.Println("No store yet. Create one with 'mxcli brain init'.") + return + } + slice, _ := cmd.Flags().GetString("slice") + modules, _ := cmd.Flags().GetStringSlice("module") + if slice == "" && len(modules) == 0 { + brainFatal(fmt.Errorf("say what the session is working on: --slice or --module ")) + } + if slice != "" && len(modules) > 0 { + // Refused rather than merged: a brief's value is what it leaves + // out, and silently widening the pack past what was asked for is + // the whole-store read it exists to replace. + brainFatal(fmt.Errorf("--slice and --module are different questions; pass one")) + } + + var ( + b brain.Brief + err error + ) + if slice != "" { + b, err = store.Brief(slice) + } else { + b, err = store.BriefForModules(modules) + } + if err != nil { + brainFatal(err) + } + + if globalJSONFlag { + brainJSON(b) + return + } + fmt.Print(b.Text()) + // stderr, so `brain brief | ...` pipes the pack and not the commentary. + fmt.Fprintln(os.Stderr, b.Summary()) + }, +} + var brainCheckCmd = &cobra.Command{ Use: "check", Short: "Do the anchors still resolve, and is every entry in the right shard?", @@ -376,9 +498,14 @@ anchors into other modules are fine, because a fact can genuinely span two.`, if err != nil { brainFatal(err) } - if ci, _ := cmd.Flags().GetBool("ci"); ci { + switch ci, _ := cmd.Flags().GetBool("ci"); { + case globalJSONFlag: + // --json wins over --ci: both exist for a machine, and one of them + // carries the states and counts rather than only the problems. + brainJSON(rep) + case ci: printBrainReportCI(rep) - } else { + default: printBrainReport(rep) } if rep.Failed() { @@ -639,12 +766,100 @@ func init() { "Record this as a requirement of the named slice (plan/.md) instead of a decision") brainPromoteCmd.Flags().String("to", "", "Override the derived shard (use 'project' for a cross-cutting fact)") + brainStagedCmd.Flags().String("since", "", + "Only entries staged after this entry id — the slice boundary (see 'mxcli brain staged --help')") + brainStagedCmd.Flags().String("slice", "", + "Only requirements of this slice (decisions carry no slice; use --since for a slice's findings)") + brainStagedCmd.Flags().Bool("fail-if-empty", false, + "Exit 1 when nothing matches, so a dispatcher can refuse to advance on a slice that recorded nothing") brainCheckCmd.Flags().Bool("changed", false, "Only check shards touched by the working tree") brainCheckCmd.Flags().Bool("ci", false, "Machine-friendly output for CI") brainPlanCmd.Flags().StringP("project", "p", "", "Path to the .mpr file") + brainPlanCmd.Flags().String("slice", "", + "Report only this slice, instead of every slice in the plan") + brainBriefCmd.Flags().StringP("project", "p", "", "Path to the .mpr file") + brainBriefCmd.Flags().String("slice", "", + "The slice being worked; its modules are derived from its requirements' anchors") + brainBriefCmd.Flags().StringSlice("module", nil, + "Modules being worked, for a session with no slice; repeatable") brainResolveCmd.Flags().StringP("project", "p", "", "Path to the .mpr file") brainCmd.AddCommand(brainInitCmd, brainCaptureCmd, brainStagedCmd, - brainPromoteCmd, brainDropCmd, brainShowCmd, brainCheckCmd, brainPlanCmd, brainResolveCmd) + brainPromoteCmd, brainDropCmd, brainShowCmd, brainCheckCmd, brainPlanCmd, + brainResolveCmd, brainBriefCmd) rootCmd.AddCommand(brainCmd) } + +// brainJSON writes v to stdout as indented JSON. Every brain command printed +// for a human only, which is what made the store unusable as the channel +// between sub-agents: an orchestrator dispatching one agent per slice has to +// DECIDE on `staged` and `check`, not read them. +// +// Indented on purpose. These outputs are small (a queue, a plan, a report), and +// the consumer is as often a person eyeballing what the machine will see as a +// parser. +func brainJSON(v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + brainFatal(err) + } + fmt.Println(string(b)) +} + +// stagedEntry is one queued entry as a machine sees it. The shard is included +// because it is derived (an entry's first anchor names its file), so a caller +// would otherwise have to reimplement the routing rule to know where a promote +// would put it. +type stagedEntry struct { + brain.Entry + Shard string `json:"shard"` +} + +// stagedReport renders the queue for a machine. `all` is the unfiltered queue +// and `shown` what survived the filter — both are needed, because the two +// figures a dispatcher wants come from different sides of it. +func stagedReport(all, shown []brain.Entry, filter brain.StagedFilter) map[string]any { + out := make([]stagedEntry, 0, len(shown)) + for _, e := range shown { + out = append(out, stagedEntry{Entry: e, Shard: e.Shard()}) + } + rep := map[string]any{"staged": out, "count": len(out)} + + // The id to pass as --since next time. It comes from the UNFILTERED queue + // and is reported even when nothing matched, which is exactly the case a + // dispatcher needs it in: a slice that staged nothing must still hand the + // next slice a boundary, or the next one re-reports this one's captures. + if len(all) > 0 { + rep["last_id"] = all[len(all)-1].ID + } + rep["queue_size"] = len(all) + + // A count of 0 means two different things and the number cannot say which: + // the queue is empty, or the filter matched nothing. + if !filter.Empty() { + rep["filtered"] = true + if filter.SinceID != "" { + rep["since"] = filter.SinceID + } + if filter.Slice != "" { + rep["slice"] = filter.Slice + } + } + return rep +} + +// planReport carries the totals as well as the slices. A dispatcher's question +// is usually "is this slice done", and the answer is a comparison it should not +// have to assemble from four counters. +func planReport(slices []brain.SliceProgress) map[string]any { + var built, total int + for _, sl := range slices { + built += sl.Built + total += sl.Total() + } + return map[string]any{ + "slices": slices, + "built": built, + "total": total, + } +} diff --git a/cmd/mxcli/cmd_brain_json_test.go b/cmd/mxcli/cmd_brain_json_test.go new file mode 100644 index 0000000000..0109788457 --- /dev/null +++ b/cmd/mxcli/cmd_brain_json_test.go @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mendixlabs/mxcli/cmd/mxcli/brain" +) + +// The types marshalling correctly is only half of it. The other half is that +// nothing else reaches stdout — one stray Println (a "Nothing staged.", a +// progress line, the PoC banner) and the output stops being JSON while still +// looking fine to a person. So this runs the commands as a caller would and +// parses what comes back. +func TestBrainCommandsEmitParseableJSONOnStdout(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, nil, 0o644); err != nil { + t.Fatal(err) + } + + store := brain.NewStore(dir) + if _, err := store.Init(); err != nil { + t.Fatal(err) + } + queue := brain.NewQueue(dir) + decision, err := brain.NewEntry("Orders are committed by Finance, not Sales", []string{"@Sales.Order"}, brainTestDay()) + if err != nil { + t.Fatal(err) + } + if _, err := queue.Append(decision); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + args []string + want []string // top-level keys the contract promises + }{ + {"staged", []string{"brain", "staged", "-p", mpr, "--json"}, []string{"staged", "count"}}, + {"show", []string{"brain", "show", "-p", mpr, "--json"}, []string{"shards"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := runBrainForTest(t, tc.args) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("`mxcli %v` did not emit JSON on stdout: %v\ngot:\n%s", tc.args, err, out) + } + for _, k := range tc.want { + if _, ok := got[k]; !ok { + t.Errorf("no %q key in the output of `mxcli %v`: %s", k, tc.args, out) + } + } + }) + } + + // Control: without --json the same commands print for a person, and that + // output is deliberately NOT JSON. Without this, a change that made every + // command emit JSON unconditionally would pass the assertions above. + out := runBrainForTest(t, []string{"brain", "staged", "-p", mpr}) + var any0 map[string]any + if json.Unmarshal([]byte(out), &any0) == nil { + t.Errorf("`brain staged` without --json emitted JSON; the human output was replaced rather than added to:\n%s", out) + } +} + +// An empty queue must still be an object with a count, not the words "Nothing +// staged." A dispatcher's whole question is whether a slice staged anything, +// and it has to be able to ask that when the answer is no — which is the case +// where a human-shaped message would break the parse. +func TestBrainStagedJSONIsStillJSONWhenEmpty(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, nil, 0o644); err != nil { + t.Fatal(err) + } + + out := runBrainForTest(t, []string{"brain", "staged", "-p", mpr, "--json"}) + + var got struct { + Count int `json:"count"` + Staged []any `json:"staged"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("an empty queue did not emit JSON: %v\ngot:\n%s", err, out) + } + if got.Count != 0 || len(got.Staged) != 0 { + t.Errorf("empty queue reported %d staged: %s", got.Count, out) + } +} + +// runBrainForTest executes a command through the real root, so the persistent +// --json flag and PersistentPreRun are exercised exactly as a caller gets them. +// +// rootCmd is a package global and cobra keeps flag values between runs, so +// --json is reset first: without that, every run after a --json one inherits it +// and the control below (human output is NOT JSON) passes for the wrong reason. +func runBrainForTest(t *testing.T, args []string) string { + t.Helper() + if err := rootCmd.PersistentFlags().Set("json", "false"); err != nil { + t.Fatal(err) + } + globalJSONFlag = false + + out, err := captureStdout(t, func() error { + rootCmd.SetArgs(args) + return rootCmd.Execute() + }) + if err != nil { + t.Fatalf("`mxcli %v` failed: %v\n%s", args, err, out) + } + return out +} + +func brainTestDay() time.Time { return time.Date(2026, 9, 3, 0, 0, 0, 0, time.UTC) } + +// The dispatcher's loop, end to end: note the queue's last id, run the slice, +// ask what it staged. The two things that make it usable are asserted here +// because neither is obvious from the filter alone. +// +// 1. last_id comes from the UNFILTERED queue and is reported even when nothing +// matched. A slice that staged nothing must still hand the next slice a +// boundary, or the next one re-reports this one's captures. +// 2. count 0 is qualified by "filtered", because the number alone cannot say +// whether the queue is empty or the filter matched nothing — and only one +// of those means the slice did not do its job. +func TestStagedSinceGivesADispatcherItsSliceBoundary(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, nil, 0o644); err != nil { + t.Fatal(err) + } + queue := brain.NewQueue(dir) + stage := func(text string) brain.Entry { + t.Helper() + e, err := brain.NewEntry(text, nil, brainTestDay()) + if err != nil { + t.Fatal(err) + } + if _, err := queue.Append(e); err != nil { + t.Fatal(err) + } + return e + } + + boundary := stage("a decision from the slice before this one") + + type staged struct { + Count int `json:"count"` + LastID string `json:"last_id"` + Filtered bool `json:"filtered"` + } + decode := func(out string) staged { + t.Helper() + var s staged + if err := json.Unmarshal([]byte(out), &s); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + return s + } + + // The slice staged nothing. + got := decode(runBrainForTest(t, []string{"brain", "staged", "-p", mpr, "--since", boundary.ID, "--json"})) + if got.Count != 0 { + t.Errorf("count %d, want 0", got.Count) + } + if !got.Filtered { + t.Error("a zero count was not marked as filtered; a caller cannot tell it from an empty queue") + } + if got.LastID != boundary.ID { + t.Errorf("last_id is %q, want %q — a slice that staged nothing must still pass the boundary on", + got.LastID, boundary.ID) + } + + // Now it stages something, including a decision, which carries no slice. + found := stage("a decision found while building the slice") + got = decode(runBrainForTest(t, []string{"brain", "staged", "-p", mpr, "--since", boundary.ID, "--json"})) + if got.Count != 1 { + t.Errorf("count %d, want 1", got.Count) + } + if got.LastID != found.ID { + t.Errorf("last_id is %q, want the newest entry %q", got.LastID, found.ID) + } +} + +// The brief has to be pipeable: the pack on stdout, the commentary on stderr. +// If the size line landed on stdout, every use of `brain brief | ...` would +// feed a session a line about token counts as though it were a decision. +func TestBriefPutsThePackOnStdoutAndTheSizeOnStderr(t *testing.T) { + dir := t.TempDir() + mpr := filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, nil, 0o644); err != nil { + t.Fatal(err) + } + store := brain.NewStore(dir) + if _, err := store.Init(); err != nil { + t.Fatal(err) + } + for _, e := range []brain.Entry{ + mustTestEntry(t, "the whole app is single-tenant"), + mustTestEntry(t, "planning uses a snapshot", "@Planning.Snapshot"), + mustTestEntry(t, "billing runs nightly", "@Billing.ACT_Post"), + mustTestRequirement(t, "roll up by cost centre", "07-planning", "@Planning.ACT_Rollup"), + } { + if err := store.Promote(e, e.Shard()); err != nil { + t.Fatal(err) + } + } + + out := runBrainForTest(t, []string{"brain", "brief", "--slice", "07-planning", "-p", mpr}) + + for _, want := range []string{"single-tenant", "snapshot", "roll up by cost centre"} { + if !strings.Contains(out, want) { + t.Errorf("stdout does not contain %q", want) + } + } + if strings.Contains(out, "billing runs nightly") { + t.Error("a module the slice does not touch is in the pack") + } + if strings.Contains(out, "whole store is") { + t.Error("the size line is on stdout; `brain brief | ...` would feed it to the session as content") + } +} + +func mustTestEntry(t *testing.T, text string, anchors ...string) brain.Entry { + t.Helper() + e, err := brain.NewEntry(text, anchors, brainTestDay()) + if err != nil { + t.Fatal(err) + } + return e +} + +func mustTestRequirement(t *testing.T, text, slice string, anchors ...string) brain.Entry { + t.Helper() + e, err := brain.NewRequirement(text, anchors, slice, brainTestDay()) + if err != nil { + t.Fatal(err) + } + return e +} diff --git a/cmd/mxcli/cmd_lint.go b/cmd/mxcli/cmd_lint.go index 404e4de13e..f3d3d3cb4c 100644 --- a/cmd/mxcli/cmd_lint.go +++ b/cmd/mxcli/cmd_lint.go @@ -125,27 +125,7 @@ Examples: // so we can compute the catalog depth they need BEFORE building it. A rule // that needs the refs (full) or graph_* (communities) tables then gets them // automatically instead of silently returning empty results (issue #721). - lintRules := []linter.Rule{ - rules.NewNamingConventionRule(), - rules.NewEmptyMicroflowRule(), - rules.NewDomainModelSizeRule(), - rules.NewValidationFeedbackRule(), - rules.NewImageSourceRule(), - rules.NewEmptyContainerRule(), - rules.NewGallerySelectionListenerRule(), - rules.NewDataViewLayoutGridRule(), - rules.NewPageNavigationSecurityRule(), - rules.NewNoEntityAccessRulesRule(), - rules.NewWeakPasswordPolicyRule(), - rules.NewDemoUsersActiveRule(), - rules.NewOverlappingActivitiesRule(), // MPR008 - requires BSON inspection - rules.NewLoopChildContainmentRule(), // MPR011 - requires BSON inspection - rules.NewNoCommitInLoopRule(), // CONV011-CONV014 - require BSON inspection - rules.NewExclusiveSplitCaptionRule(), - rules.NewErrorHandlingOnCallsRule(), - rules.NewNoContinueErrorHandlingRule(), - rules.NewIrreducibleFlowGraphRule(), // MDL-FLOW01 - graph structure vs MDL nesting - } + lintRules := builtinLintRules() // Search upward from the project for .claude/lint-rules/, so one // directory at the repo root serves an app in a subfolder (#904). lintRulesDir := linter.FindLintRulesDir(projectDir) @@ -409,3 +389,32 @@ func plural(n int, one, many string) string { } return many } + +// builtinLintRules is the built-in rule set, in one place because more than one +// caller needs to know what it contains. `--list-rules` prints it, and the +// generated CLAUDE.md is asserted NOT to restate it — a check that needs the +// real list rather than a second copy of it, since a second copy is the drift +// it exists to prevent. +func builtinLintRules() []linter.Rule { + return []linter.Rule{ + rules.NewNamingConventionRule(), + rules.NewEmptyMicroflowRule(), + rules.NewDomainModelSizeRule(), + rules.NewValidationFeedbackRule(), + rules.NewImageSourceRule(), + rules.NewEmptyContainerRule(), + rules.NewGallerySelectionListenerRule(), + rules.NewDataViewLayoutGridRule(), + rules.NewPageNavigationSecurityRule(), + rules.NewNoEntityAccessRulesRule(), + rules.NewWeakPasswordPolicyRule(), + rules.NewDemoUsersActiveRule(), + rules.NewOverlappingActivitiesRule(), // MPR008 - requires BSON inspection + rules.NewLoopChildContainmentRule(), // MPR011 - requires BSON inspection + rules.NewNoCommitInLoopRule(), // CONV011-CONV014 - require BSON inspection + rules.NewExclusiveSplitCaptionRule(), + rules.NewErrorHandlingOnCallsRule(), + rules.NewNoContinueErrorHandlingRule(), + rules.NewIrreducibleFlowGraphRule(), // MDL-FLOW01 - graph structure vs MDL nesting + } +} diff --git a/cmd/mxcli/cmd_rename.go b/cmd/mxcli/cmd_rename.go index a408cd43bd..9eb0770823 100644 --- a/cmd/mxcli/cmd_rename.go +++ b/cmd/mxcli/cmd_rename.go @@ -5,8 +5,10 @@ package main import ( "fmt" "os" + "path/filepath" "strings" + "github.com/mendixlabs/mxcli/cmd/mxcli/brain" "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/spf13/cobra" ) @@ -28,6 +30,11 @@ Types: Use --dry-run to preview changes without modifying. +Anchors in docs/brain/ are updated too, if the project has a brain. They are +references to the same elements, and "mxcli brain check" cannot reliably report +a stale one after the fact: a requirement's anchor points forward, so one that +stops resolving is indistinguishable from something not built yet. + Example: mxcli rename -p app.mpr entity MyModule.Customer Client mxcli rename -p app.mpr microflow MyModule.ACT_Old ACT_New @@ -106,9 +113,91 @@ Example: os.Exit(1) } } + + renameBrainAnchors(projectPath, objectType, qualifiedName, newName, dryRun) }, } +// renameBrainAnchors keeps docs/brain/ pointing at the thing that was renamed. +// +// The model's own cross-references are updated by the RENAME statement above. +// The brain's anchors are references to the same elements and were not, so a +// refactor invalidated them silently — and `brain check` can only report half of +// it, because a requirement's forward anchor failing is indistinguishable from +// "not built yet". Both names are known here and nowhere later, which is why +// this belongs at the rename. +// +// It never fails the command. The rename itself has already been applied and is +// the thing the user asked for; a store that could not be updated is a warning +// to act on, not a reason to leave the project half-renamed. +func renameBrainAnchors(projectPath, objectType, qualifiedName, newName string, dryRun bool) { + projectDir := filepath.Dir(projectPath) + store := brain.NewStore(projectDir) + if !store.Exists() { + return + } + + oldName, ok := brainRenameTarget(objectType, qualifiedName) + if !ok { + return + } + // An element rename gives a bare new name; the module part is unchanged. + // A module rename gives the whole thing. + target := newName + if i := strings.LastIndex(oldName, "."); i >= 0 { + target = oldName[:i+1] + newName + } + + if dryRun { + // Report without writing, matching the statement's own DRY RUN. + n, err := brain.CountAnchorsNaming(store, oldName) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not read the project brain: %v\n", err) + return + } + if n > 0 { + fmt.Printf("Would update %d brain anchor(s): @%s -> @%s\n", n, oldName, target) + } + return + } + + shardN, err := store.RenameAnchors(oldName, target) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: the rename succeeded but docs/brain/ was not updated: %v\n", err) + fmt.Fprintf(os.Stderr, " Run 'mxcli brain check' — anchors naming %s are now stale.\n", oldName) + return + } + queueN, err := brain.RenameQueueAnchors(brain.NewQueue(projectDir), oldName, target) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: the staged brain queue was not updated: %v\n", err) + } + if n := shardN + queueN; n > 0 { + fmt.Printf("Updated %d brain anchor(s): @%s -> @%s\n", n, oldName, target) + } +} + +// brainRenameTarget maps a rename to the qualified name an anchor would use. +// +// Only the types an anchor can actually name are handled. An anchor is +// @Module[.Element[.Member]], so a constant or an association renames like any +// other element, while a type the anchor grammar cannot express is skipped +// rather than guessed at — writing a name no anchor could have held would +// corrupt entries instead of repairing them. +func brainRenameTarget(objectType, qualifiedName string) (string, bool) { + switch objectType { + case "ENTITY", "MICROFLOW", "NANOFLOW", "PAGE", "ENUMERATION", "ASSOCIATION", "CONSTANT": + // These are all Module.Element, which is what an anchor names. + if !strings.Contains(qualifiedName, ".") { + return "", false + } + return qualifiedName, true + case "MODULE": + return qualifiedName, true + default: + return "", false + } +} + func init() { renameCmd.Flags().Bool("dry-run", false, "Preview changes without modifying") rootCmd.AddCommand(renameCmd) diff --git a/cmd/mxcli/cmd_rename_brain_test.go b/cmd/mxcli/cmd_rename_brain_test.go new file mode 100644 index 0000000000..75c9f0462c --- /dev/null +++ b/cmd/mxcli/cmd_rename_brain_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "testing" + +// The mapping from a rename to the qualified name an anchor holds. An anchor is +// @Module[.Element[.Member]], so anything the anchor grammar cannot express is +// skipped rather than guessed at — writing a name no anchor could have held +// would corrupt entries instead of repairing them, and there is no signal when +// it happens. +func TestBrainRenameTargetOnlyClaimsWhatAnAnchorCanName(t *testing.T) { + for _, tc := range []struct { + objectType, qualified string + want string + ok bool + }{ + {"ENTITY", "Sales.Order", "Sales.Order", true}, + {"MICROFLOW", "Sales.ACT_Post", "Sales.ACT_Post", true}, + {"NANOFLOW", "Sales.NF_Refresh", "Sales.NF_Refresh", true}, + {"PAGE", "Sales.Order_Overview", "Sales.Order_Overview", true}, + {"ENUMERATION", "Sales.ENUM_Status", "Sales.ENUM_Status", true}, + {"ASSOCIATION", "Sales.Order_Customer", "Sales.Order_Customer", true}, + {"CONSTANT", "Sales.ApiRoot", "Sales.ApiRoot", true}, + {"MODULE", "Sales", "Sales", true}, + + // An element rename with no module cannot be turned into an anchor: + // there is nothing to qualify it with, and rewriting on the bare name + // would match every module's element of that name. + {"ENTITY", "Order", "", false}, + // A type no anchor names. Skipped, not guessed. + {"FOLDER", "Sales.Things", "", false}, + } { + got, ok := brainRenameTarget(tc.objectType, tc.qualified) + if ok != tc.ok || got != tc.want { + t.Errorf("brainRenameTarget(%q, %q) = (%q, %v), want (%q, %v)", + tc.objectType, tc.qualified, got, ok, tc.want, tc.ok) + } + } +} diff --git a/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index 5acd0fe10e..5ab57e91d1 100644 --- a/cmd/mxcli/init_claudemd.go +++ b/cmd/mxcli/init_claudemd.go @@ -55,17 +55,26 @@ func generateClaudeMD(projectName, mprFile string) string { var sb strings.Builder w := func(s string) { sb.WriteString(s) } - // ── Header ────────────────────────────────────────────────────── + // This file is re-read into EVERY context started in this project, so its + // size is a per-session tax, not a one-off. It used to carry the MDL + // command reference, the lint rule table and the skill table — ~4,800 of + // its ~6,900 tokens — all of which mxcli answers itself, and all of which + // had drifted (10 of 14 built-in rules listed; "27" Starlark rules against + // 31 shipped; no layouts, rules, queues or scheduled events at all). #906 + // was the same failure in the skill table, which reached 12 of 68 before + // anyone noticed. + // + // So the rule for anything added here is the brain's own: if a command can + // answer it, name the command instead. What stays is what no command can + // say — where things are, which gates to run, and how to behave. + w("# Mendix Project: " + projectName + "\n\n") - w("This is a Mendix project configured for AI-assisted development using mxcli and MDL (Mendix Definition Language).\n\n") + w("Built with mxcli and MDL (Mendix Definition Language).\n\n") - // ── Communication Style ──────────────────────────────────────── - // The brain's project.md is described as "loaded every session", and this - // is the only thing that makes that true. Routing to it through the skill - // alone does not: a skill is triggered by symptom, so a session that never - // hits the symptom never learns the project's own decisions — and the - // tightest cap in the store was justified by an unconditional load that - // nothing actually performed. + // ── Project Brain ─────────────────────────────────────────────── + // The brain's project.md is documented as loaded every session, and this + // is the only thing that makes that true: routing to it through the skill + // alone does not, because a skill is triggered by symptom. w("## Project Brain — read this first\n\n") w("If " + bt + "docs/brain/" + bt + " exists, read " + bt + "docs/brain/project.md" + bt + " before doing anything\n") w("else. It holds the decisions this project has already made — things no command can\n") @@ -73,603 +82,78 @@ func generateClaudeMD(projectName, mprFile string) string { w("Then, depending on what you are doing:\n\n") w("- **Building in a module** — also read " + bt + "docs/brain/modules/.md" + bt + " for the\n") w(" modules you are about to touch. Not the whole directory; only those.\n") + w("- **Working a slice** — " + bt + "./mxcli brain brief --slice -p " + mprPath + bt + " emits\n") + w(" exactly that pack (project + the slice's modules + its plan) as one read.\n") w("- **Planning, or picking work up** — run " + bt + "./mxcli brain plan -p " + mprPath + bt + ".\n") w(" It reports what is built from the model itself, so it cannot be out of date.\n\n") w("Record what you learn with " + bt + "./mxcli brain capture" + bt + ". Read\n") w(bt + ".ai-context/skills/project-brain/SKILL.md" + bt + " for what belongs there and what does not —\n") w("the short version is that anything mxcli can answer must never be written down.\n\n") + // ── Communication Style ───────────────────────────────────────── w("## Communication Style\n\n") - w("When discussing changes with the user:\n\n") - w("- **Never show raw MDL scripts in chat.** Instead, describe changes in plain language as a numbered list.\n") + w("- **Never show raw MDL in chat.** Describe changes in plain language as a numbered list.\n") w("- After the user approves, write the MDL to a script file, validate it, and execute it silently.\n") - w("- Only show MDL code if the user explicitly asks to see the script.\n") - w("- When reporting results, summarize what was created/modified in plain language.\n") - w("- **Always quote identifiers** in MDL scripts with double quotes (" + bt + "\"Name\"" + bt + "). This prevents conflicts with MDL reserved keywords and is always safe — quotes are stripped automatically. Quote entity names, attribute names, parameter names, variable names, and association names.\n\n") - w("**Example — instead of showing MDL code, write this:**\n\n") - w("> Here's what I'll do:\n") - w("> 1. Create a new **Customer** entity in MyModule with:\n") - w("> - **Name** (text, up to 100 characters)\n") - w("> - **Email** (text, up to 200 characters)\n") - w("> - **Age** (whole number)\n") - w(">\n") - w("> Shall I go ahead?\n\n") - - // ── mxcli Location ───────────────────────────────────────────── - w("## Important: mxcli Location\n\n") - w("The " + bt + "mxcli" + bt + " tool is located in the **root folder of this project**, not in the system PATH. Always use the local path:\n\n") - w(bt3 + "bash\n./mxcli -p " + mprPath + " # Correct - uses local binary\n" + bt3 + "\n\n") - w("**Do NOT use** " + bt + "mxcli" + bt + " directly - it will fail with \"command not found\". Always prefix with " + bt + "./" + bt + " to run the local binary.\n\n") - - // ── Mendix Validation Tool ───────────────────────────────────── - w("## Mendix Validation Tool (mx)\n\n") - w("The " + bt + "mx" + bt + " command validates Mendix projects (same checks as Studio Pro). To set it up:\n\n") - w(bt3 + "bash\n") - w("./mxcli setup mxbuild -p " + mprPath + " # Auto-download for project's Mendix version\n") - w(bt3 + "\n\n") - // The glob form used to be documented first. It breaks the moment a second - // mxbuild is cached — the shell expands it to two paths and mx reads the - // second as an argument ("Verb '...' is not recognized"), which reads like a - // broken install rather than a bad command line (ako/mxcli-maintenance-2). - // The version-resolving command goes first, and the direct form names a - // version instead of globbing. - w("After setup, " + bt + "mx" + bt + " is at " + bt + "~/.mxcli/mxbuild/{version}/modeler/mx" + bt + ". Usage:\n\n") - w(bt3 + "bash\n") - w("./mxcli docker check -p " + mprPath + " # Validate project (resolves the project's Mendix version)\n") - w(bt3 + "\n\n") - w("To call " + bt + "mx" + bt + " directly, name the version — a " + bt + "*" + bt + " glob breaks once two are cached:\n\n") - w(bt3 + "bash\n") - w("~/.mxcli/mxbuild//modeler/mx check " + mprPath + "\n") - w(bt3 + "\n\n") + w("- Show MDL only if the user asks to see the script.\n") + w("- Report results as plain language, not as a diff.\n\n") - // ── Quick Start ───────────────────────────────────────────────── - w("## Quick Start\n\n") - w("### Execute a Single Command\n\n") - w("Use the " + bt + "-c" + bt + " flag to run a single MDL command:\n\n") + // ── Running mxcli ─────────────────────────────────────────────── + w("## Running mxcli\n\n") + w("The binary is in the **root of this project**, not on " + bt + "PATH" + bt + " — always " + bt + "./mxcli" + bt + ".\n\n") w(bt3 + "bash\n") - w("./mxcli -p " + mprPath + " -c \"SHOW MODULES\" # List all modules\n") - w("./mxcli -p " + mprPath + " -c \"SHOW STRUCTURE\" # Project overview\n") - w("./mxcli -p " + mprPath + " -c \"SHOW ENTITIES IN MyModule\" # Entities in a module\n") - w("./mxcli -p " + mprPath + " -c \"DESCRIBE ENTITY MyModule.Customer\" # Entity details\n") + w("./mxcli -p " + mprPath + " -c \"SHOW STRUCTURE\" # one command\n") + w("./mxcli exec script.mdl -p " + mprPath + " # a script\n") + w("./mxcli # REPL\n") w(bt3 + "\n\n") - w("### Execute an MDL Script File\n\n") - w(bt3 + "bash\n./mxcli exec script.mdl -p " + mprPath + "\n" + bt3 + "\n\n") - w("### Start Interactive REPL\n\n") - w(bt3 + "bash\n./mxcli\n# Then: CONNECT LOCAL '" + mprPath + "';\n" + bt3 + "\n\n") - - // ── IMPORTANT: Before Writing MDL ─────────────────────────────── - w("## IMPORTANT: Before Writing MDL Scripts or Working with Data\n\n") - w("**Read the relevant skill files FIRST before writing any MDL, seeding data, or doing database/import work:**\n\n") - w("Every skill is a " + bt + "/SKILL.md" + bt + " directory with a " + bt + "description" + bt + " in its frontmatter (the\n") - w("[Agent Skills](https://agentskills.io) standard), so a tool that reads them announces the whole set on\n") - w("its own. The table below is a shortcut to the ones worth reading before you start, **not** the index —\n") - w("list " + bt + ".ai-context/skills/" + bt + " for everything available.\n\n") - w("| Skill File | When to Read |\n") - w("|------------|-------------|\n") - w("| " + bt + ".ai-context/skills/write-microflows/SKILL.md" + bt + " | **Before writing any microflow** - syntax, common mistakes, validation checklist |\n") - w("| " + bt + ".ai-context/skills/create-page/SKILL.md" + bt + " | **Before creating any page** - widget syntax reference |\n") - w("| " + bt + ".ai-context/skills/alter-page/SKILL.md" + bt + " | **Before modifying pages** - ALTER PAGE/SNIPPET SET, INSERT, DROP, REPLACE |\n") - w("| " + bt + ".ai-context/skills/overview-pages/SKILL.md" + bt + " | CRUD page patterns (overview + edit) |\n") - w("| " + bt + ".ai-context/skills/master-detail-pages/SKILL.md" + bt + " | Master-detail page patterns |\n") - w("| " + bt + ".ai-context/skills/generate-domain-model/SKILL.md" + bt + " | Entity, association, enumeration syntax |\n") - w("| " + bt + ".ai-context/skills/organize-project/SKILL.md" + bt + " | Folders, MOVE command, project structure |\n") - w("| " + bt + ".ai-context/skills/manage-security/SKILL.md" + bt + " | Security roles, GRANT/REVOKE, access control |\n") - w("| " + bt + ".ai-context/skills/manage-navigation/SKILL.md" + bt + " | Navigation profiles, menus, home/login pages |\n") - w("| " + bt + ".ai-context/skills/check-syntax/SKILL.md" + bt + " | **Pre-flight** validation checklist |\n") - w("| " + bt + ".ai-context/skills/demo-data/SKILL.md" + bt + " | **READ for any database/import work** - Mendix ID system, demo data |\n") - w("| " + bt + ".ai-context/skills/test-microflows/SKILL.md" + bt + " | **READ for testing** - test annotations, file formats, Docker setup |\n") - w("| " + bt + ".ai-context/skills/project-brain/SKILL.md" + bt + " | **Why was it done this way here?** - the project's recorded decisions in " + bt + "docs/brain/" + bt + " |\n") - w("\n") - w("**Always validate before presenting to user:**\n\n") - w(bt3 + "bash\n") - w("./mxcli check script.mdl # Syntax check\n") - w("./mxcli check script.mdl -p " + mprPath + " --references # With reference validation\n") - w(bt3 + "\n\n") - - // ── MDL Commands by Domain ────────────────────────────────────── - w("## MDL Commands by Domain\n\n") - - // Exploration & Structure - w("### Exploration & Structure\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW MODULES" + bt + " | List all modules |\n") - w("| " + bt + "SHOW STRUCTURE [DEPTH 1|2|3] [IN Module] [ALL]" + bt + " | Compact project overview at different detail levels |\n") - w("| " + bt + "SHOW CALLERS OF Module.Microflow" + bt + " | Find what calls a microflow |\n") - w("| " + bt + "SHOW CALLEES OF Module.Microflow" + bt + " | Find what a microflow calls |\n") - w("| " + bt + "SHOW REFERENCES OF Module.Entity" + bt + " | Find all references to an element |\n") - w("| " + bt + "SHOW IMPACT OF Module.Entity" + bt + " | Impact analysis for changes |\n") - w("| " + bt + "SHOW CONTEXT OF Module.Microflow" + bt + " | Show callers + callees + references |\n") - w("| " + bt + "SEARCH 'keyword'" + bt + " | Full-text search across all strings and source |\n") - w("| " + bt + "HELP [topic]" + bt + " | Show all commands or help on a topic |\n") - w("\n") - - // Domain Model - w("### Domain Model\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW ENTITIES [IN Module]" + bt + " | List entities |\n") - w("| " + bt + "SHOW ASSOCIATIONS [IN Module]" + bt + " | List associations |\n") - w("| " + bt + "SHOW ENUMERATIONS [IN Module]" + bt + " | List enumerations |\n") - w("| " + bt + "SHOW CONSTANTS [IN Module]" + bt + " | List constants |\n") - w("| " + bt + "DESCRIBE ENTITY Module.Entity" + bt + " | Show entity definition in MDL |\n") - w("| " + bt + "DESCRIBE ASSOCIATION Module.Assoc" + bt + " | Show association definition |\n") - w("| " + bt + "DESCRIBE ENUMERATION Module.Enum" + bt + " | Show enumeration definition |\n") - w("| " + bt + "CREATE MODULE ModuleName" + bt + " | Create a new module |\n") - w("| " + bt + "CREATE PERSISTENT ENTITY ..." + bt + " | Create a persistent entity with attributes |\n") - w("| " + bt + "CREATE NON-PERSISTENT ENTITY ..." + bt + " | Create a non-persistent (transient) entity |\n") - w("| " + bt + "CREATE ASSOCIATION ..." + bt + " | Create an association between entities |\n") - w("| " + bt + "CREATE ENUMERATION ..." + bt + " | Create an enumeration |\n") - w("| " + bt + "ALTER ENTITY Module.Entity ADD ..." + bt + " | Add/rename/modify/drop attributes, indexes, docs |\n") - w("| " + bt + "DROP ENTITY Module.Entity" + bt + " | Delete an entity |\n") - w("| " + bt + "DROP ASSOCIATION Module.Assoc" + bt + " | Delete an association |\n") - w("| " + bt + "DROP ENUMERATION Module.Enum" + bt + " | Delete an enumeration |\n") - w("\n") - - // Microflows & Nanoflows - w("### Microflows & Nanoflows\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW MICROFLOWS [IN Module]" + bt + " | List microflows |\n") - w("| " + bt + "SHOW NANOFLOWS [IN Module]" + bt + " | List nanoflows |\n") - w("| " + bt + "DESCRIBE MICROFLOW Module.Flow" + bt + " | Show microflow definition in MDL |\n") - w("| " + bt + "DESCRIBE NANOFLOW Module.Flow" + bt + " | Show nanoflow definition in MDL |\n") - w("| " + bt + "CREATE MICROFLOW ... BEGIN ... END;" + bt + " | Create a microflow with activities |\n") - w("| " + bt + "CREATE NANOFLOW ... BEGIN ... END;" + bt + " | Create a nanoflow with activities |\n") - w("| " + bt + "DROP MICROFLOW Module.Flow" + bt + " | Delete a microflow |\n") - w("| " + bt + "DROP NANOFLOW Module.Flow" + bt + " | Delete a nanoflow |\n") - w("\n") - - // Pages & Snippets - w("### Pages & Snippets\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW PAGES [IN Module]" + bt + " | List pages |\n") - w("| " + bt + "SHOW SNIPPETS [IN Module]" + bt + " | List snippets |\n") - w("| " + bt + "DESCRIBE PAGE Module.Page" + bt + " | Show page definition in MDL |\n") - w("| " + bt + "DESCRIBE SNIPPET Module.Snippet" + bt + " | Show snippet definition |\n") - w("| " + bt + "CREATE PAGE ... { widgets }" + bt + " | Create a page with widget syntax |\n") - w("| " + bt + "CREATE SNIPPET ... { widgets }" + bt + " | Create a reusable snippet |\n") - w("| " + bt + "ALTER PAGE Module.Page { ops }" + bt + " | Modify page in-place (SET, INSERT, DROP, REPLACE) |\n") - w("| " + bt + "ALTER SNIPPET Module.Snippet { ops }" + bt + " | Modify snippet in-place |\n") - w("| " + bt + "DROP PAGE Module.Page" + bt + " | Delete a page |\n") - w("| " + bt + "DROP SNIPPET Module.Snippet" + bt + " | Delete a snippet |\n") - w("\n") - - // Security - w("### Security\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW PROJECT SECURITY" + bt + " | Security level, admin, demo users overview |\n") - w("| " + bt + "SHOW MODULE ROLES [IN Module]" + bt + " | Module-level roles |\n") - w("| " + bt + "SHOW USER ROLES" + bt + " | Project-level user roles |\n") - w("| " + bt + "SHOW DEMO USERS" + bt + " | Configured demo users |\n") - w("| " + bt + "SHOW ACCESS ON MICROFLOW|PAGE|ENTITY Mod.Name" + bt + " | Role access on element |\n") - w("| " + bt + "SHOW SECURITY MATRIX [IN Module]" + bt + " | Full access overview |\n") - w("| " + bt + "CREATE MODULE ROLE Mod.Role" + bt + " | Create a module role |\n") - w("| " + bt + "CREATE USER ROLE Name (Mod.Role, ...)" + bt + " | Create a user role aggregating module roles |\n") - w("| " + bt + "ALTER USER ROLE Name ADD|REMOVE MODULE ROLES (...)" + bt + " | Modify user role |\n") - w("| " + bt + "GRANT EXECUTE ON MICROFLOW Mod.MF TO Mod.Role" + bt + " | Grant microflow access |\n") - w("| " + bt + "GRANT VIEW ON PAGE Mod.Page TO Mod.Role" + bt + " | Grant page access |\n") - w("| " + bt + "GRANT Mod.Role ON Mod.Entity (CREATE, DELETE, READ *, WRITE *)" + bt + " | Grant entity access |\n") - w("| " + bt + "REVOKE EXECUTE|VIEW|role ON element FROM role" + bt + " | Revoke access |\n") - w("| " + bt + "ALTER PROJECT SECURITY LEVEL OFF|PROTOTYPE|PRODUCTION" + bt + " | Set security level |\n") - w("| " + bt + "ALTER PROJECT SECURITY DEMO USERS ON|OFF" + bt + " | Toggle demo users |\n") - w("| " + bt + "CREATE DEMO USER 'name' PASSWORD 'pass' (UserRole, ...)" + bt + " | Create demo user |\n") - w("| " + bt + "DROP MODULE ROLE|USER ROLE|DEMO USER ..." + bt + " | Delete roles/users |\n") - w("\n") - - // Navigation - w("### Navigation\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW NAVIGATION" + bt + " | Summary of all profiles |\n") - w("| " + bt + "SHOW NAVIGATION MENU [Profile]" + bt + " | Menu tree for profile or all |\n") - w("| " + bt + "SHOW NAVIGATION HOMES" + bt + " | Home page assignments across profiles |\n") - w("| " + bt + "DESCRIBE NAVIGATION [Profile]" + bt + " | Full MDL output (round-trippable) |\n") - w("| " + bt + "CREATE OR REPLACE NAVIGATION Profile ..." + bt + " | Full replacement of a navigation profile |\n") - w("\n") - - // Project Settings - w("### Project Settings\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW SETTINGS" + bt + " | Overview of all settings |\n") - w("| " + bt + "DESCRIBE SETTINGS" + bt + " | Full MDL output (round-trippable) |\n") - w("| " + bt + "ALTER SETTINGS MODEL Key = Value" + bt + " | AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc. |\n") - w("| " + bt + "ALTER SETTINGS CONFIGURATION 'Name' Key = Value" + bt + " | DatabaseType, DatabaseUrl, HttpPortNumber, etc. |\n") - w("| " + bt + "ALTER SETTINGS CONSTANT 'Name' VALUE 'val' IN CONFIGURATION 'cfg'" + bt + " | Override constant per configuration |\n") - w("| " + bt + "ALTER SETTINGS LANGUAGE Key = Value" + bt + " | DefaultLanguageCode |\n") - w("| " + bt + "ALTER SETTINGS WORKFLOWS Key = Value" + bt + " | UserEntity, DefaultTaskParallelism |\n") - w("\n") - - // Business Events & Java Actions - w("### Business Events & Java Actions\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW DATABASE CONNECTIONS [IN Module]" + bt + " | List database connections |\n") - w("| " + bt + "DESCRIBE DATABASE CONNECTION Mod.Name" + bt + " | Show connection definition in MDL |\n") - w("| " + bt + "SHOW BUSINESS EVENTS [IN Module]" + bt + " | List business event services |\n") - w("| " + bt + "DESCRIBE BUSINESS EVENT SERVICE Mod.Name" + bt + " | Full MDL output |\n") - w("| " + bt + "CREATE BUSINESS EVENT SERVICE ..." + bt + " | Create a business event service |\n") - w("| " + bt + "DROP BUSINESS EVENT SERVICE Mod.Name" + bt + " | Delete a service |\n") - w("| " + bt + "SHOW JAVA ACTIONS [IN Module]" + bt + " | List Java actions |\n") - w("| " + bt + "DESCRIBE JAVA ACTION Mod.Name" + bt + " | Full MDL output with signature |\n") - w("| " + bt + "CREATE JAVA ACTION ... AS $$ ... $$" + bt + " | Create with inline Java code |\n") - w("| " + bt + "DROP JAVA ACTION Mod.Name" + bt + " | Delete a Java action |\n") - w("\n") - - // OData - w("### OData\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SHOW ODATA CLIENTS [IN Module]" + bt + " | List consumed OData services |\n") - w("| " + bt + "SHOW ODATA SERVICES [IN Module]" + bt + " | List published OData services |\n") - w("| " + bt + "DESCRIBE ODATA CLIENT Mod.Name" + bt + " | Full consumed OData MDL output |\n") - w("| " + bt + "DESCRIBE ODATA SERVICE Mod.Name" + bt + " | Full published OData MDL output |\n") - w("| " + bt + "CREATE ODATA CLIENT ..." + bt + " | Create a consumed OData service |\n") - w("| " + bt + "CREATE ODATA SERVICE ..." + bt + " | Create a published OData service |\n") - w("| " + bt + "ALTER ODATA CLIENT|SERVICE ..." + bt + " | Modify an OData service |\n") - w("| " + bt + "DROP ODATA CLIENT|SERVICE Mod.Name" + bt + " | Delete an OData service |\n") - w("\n") - - // External SQL - w("### External SQL\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "SQL CONNECT '' AS " + bt + " | Connect to external database (postgres) |\n") - w("| " + bt + "SQL DISCONNECT " + bt + " | Close connection |\n") - w("| " + bt + "SQL CONNECTIONS" + bt + " | List active connections (alias + driver only) |\n") - w("| " + bt + "SQL SHOW TABLES" + bt + " | List tables via information_schema |\n") - w("| " + bt + "SQL DESCRIBE " + bt + " | Show columns, types, nullability |\n") - w("| " + bt + "SQL " + bt + " | Raw SQL passthrough to external DB |\n") - w("\n") - - // Catalog Queries - w("### Catalog Queries\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "REFRESH CATALOG" + bt + " | Build catalog (metadata only) |\n") - w("| " + bt + "REFRESH CATALOG FULL" + bt + " | Full catalog with activities, widgets, cross-refs |\n") - w("| " + bt + "SHOW CATALOG TABLES" + bt + " | List available catalog tables |\n") - w("| " + bt + "SELECT ... FROM CATALOG.ENTITIES WHERE ..." + bt + " | SQL queries against project metadata |\n") - w("\n") - - w("Available catalog tables: " + bt + "CATALOG.MODULES" + bt + ", " + bt + "CATALOG.ENTITIES" + bt + ", " + bt + "CATALOG.MICROFLOWS" + bt + ", " + bt + "CATALOG.PAGES" + bt + ", " + bt + "CATALOG.WORKFLOWS" + bt + ", " + bt + "CATALOG.ENUMERATIONS" + bt + ", " + bt + "CATALOG.ASSOCIATIONS" + bt + ", " + bt + "CATALOG.SNIPPETS" + bt + ", " + bt + "CATALOG.REFS" + bt + " (requires FULL mode).\n\n") - - // Project Organization - w("### Project Organization\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "MOVE PAGE|MICROFLOW|SNIPPET|... Mod.Name TO FOLDER 'path'" + bt + " | Move element to folder |\n") - w("| " + bt + "MOVE PAGE Mod.Name TO Module" + bt + " | Move to module root |\n") - w("| " + bt + "MOVE ENTITY Old.Name TO NewModule" + bt + " | Move entity across modules |\n") - w("| " + bt + "SHOW WORKFLOWS [IN Module]" + bt + " | List workflows |\n") - w("| " + bt + "DESCRIBE WORKFLOW Module.Workflow" + bt + " | Show workflow definition |\n") - w("| " + bt + "SHOW WIDGETS [IN Module]" + bt + " | Widget discovery (experimental) |\n") - w("\n") - - // ── Script Validation ─────────────────────────────────────────── - w("## Script Validation (mxcli check)\n\n") - w("Before executing MDL scripts, validate them for syntax errors:\n\n") - w(bt3 + "bash\n./mxcli check script.mdl\n" + bt3 + "\n\n") - w("### Check with Reference Validation\n\n") - w("Validate that all referenced modules, entities, and associations exist:\n\n") - w(bt3 + "bash\n./mxcli check script.mdl -p " + mprPath + " --references\n" + bt3 + "\n\n") - w("The reference checker is smart - it automatically skips references to objects that are created within the same script.\n\n") - - // ── Linting ───────────────────────────────────────────────────── - w("## Linting\n\n") - w("Check your project for common issues:\n\n") - w(bt3 + "bash\n") - w("# Lint the project\n./mxcli lint -p " + mprPath + "\n\n") - w("# With colored output\n./mxcli lint -p " + mprPath + " --color\n\n") - w("# List available rules\n./mxcli lint -p " + mprPath + " --list-rules\n\n") - w("# Output as SARIF\n./mxcli lint -p " + mprPath + " --format sarif > results.sarif\n") - w(bt3 + "\n\n") - w("### Built-in Rules\n\n") - w("| Rule | Category | Description |\n") - w("|------|----------|-------------|\n") - w("| MPR001 | quality | PascalCase naming conventions (entities, microflows, pages, enumerations) |\n") - w("| MPR002 | quality | Empty microflows (no activities) |\n") - w("| MPR003 | design | Domain model size (>15 persistent entities per module) |\n") - w("| MPR004 | correctness | Empty validation feedback message (CE0091) |\n") - w("| MPR005 | correctness | Unconfigured image widget source |\n") - w("| MPR006 | correctness | Empty containers (runtime crash) |\n") - w("| MPR007 | security | Navigation page without allowed role (CE0557) |\n") - w("| SEC001 | security | Persistent entity without access rules |\n") - w("| SEC002 | security | Weak password policy (minimum length < 8) |\n") - w("| SEC003 | security | Demo users active at non-development security level |\n") - w("\n") - w("### Bundled Starlark Rules\n\n") - w("27 additional rules in " + bt + ".claude/lint-rules/*.star" + bt + ":\n\n") - w("| Rule | Category | Description |\n") - w("|------|----------|-------------|\n") - w("| SEC004 | security | Guest access enabled - review anonymous entity access |\n") - w("| SEC005 | security | Strict mode disabled - XPath constraint enforcement off |\n") - w("| SEC006 | security | PII attributes exposed without access rules |\n") - w("| SEC007 | security | Anonymous unconstrained READ (DIVD-2022-00019) |\n") - w("| SEC008 | security | PII entities readable without row scoping |\n") - w("| SEC009 | security | Large entities missing member-level access restrictions |\n") - w("| ARCH001 | architecture | Cross-module data access in pages |\n") - w("| ARCH002 | architecture | Data changes should go through microflows |\n") - w("| ARCH003 | architecture | Persistent entities need a unique business key |\n") - w("| QUAL001 | quality | McCabe cyclomatic complexity threshold |\n") - w("| QUAL002 | quality | Missing documentation on entities/microflows |\n") - w("| QUAL003 | quality | Long microflows (too many activities) |\n") - w("| QUAL004 | quality | Orphaned/unreferenced elements |\n") - w("| DESIGN001 | design | Entity with too many attributes |\n") - w("| CONV001 | naming | Boolean attributes must start with Is/Has/Can/Should/Was/Will |\n") - w("| CONV002 | quality | String/numeric attributes should not have default values |\n") - w("| CONV003 | naming | Pages should follow Entity_NewEdit/View/Overview naming |\n") - w("| CONV004 | naming | Enumerations should be prefixed with ENUM_ |\n") - w("| CONV005 | naming | Snippets should be prefixed with SNIPPET_ |\n") - w("| CONV006 | security | Entity access rules should not grant Create/Delete rights |\n") - w("| CONV007 | security | All persistent entity access rules need XPath constraints |\n") - w("| CONV008 | security | Each module role should map to exactly one user role |\n") - w("| CONV009 | quality | Microflows should have at most 15 objects |\n") - w("| CONV015 | quality | Entities should not have validation rules |\n") - w("| CONV016 | performance | Entities should not have event handlers |\n") - w("| CONV017 | performance | Attributes should not be calculated (virtual) |\n") - w("\n") - w("Custom Starlark rules in " + bt + ".claude/lint-rules/*.star" + bt + " are loaded automatically. See " + bt + "write-lint-rules" + bt + " skill for authoring guide.\n\n") + w("Scripts live in " + bt + "mdlsource/" + bt + ", one file per concern, re-runnable.\n\n") - // ── Report ────────────────────────────────────────────────────── - w("## Best Practices Report\n\n") - w("Generate a scored best practices report:\n\n") + // ── The gates ─────────────────────────────────────────────────── + // Ordered cheapest-first on purpose: each one is only worth paying for + // once the one above it is clean. + w("## The gates, in order\n\n") + w("Run them cheapest-first; each is only worth paying for once the one above is clean.\n\n") w(bt3 + "bash\n") - w("# Markdown report (default)\n./mxcli report -p " + mprPath + "\n\n") - w("# JSON report\n./mxcli report -p " + mprPath + " --format json\n\n") - w("# HTML report\n./mxcli report -p " + mprPath + " --format html\n") + w("./mxcli check script.mdl -p " + mprPath + " --references # syntax + references (~2s)\n") + w("./mxcli exec script.mdl -p " + mprPath + " # apply\n") + w("./mxcli lint -p " + mprPath + " # rules (~3s)\n") + w("./mxcli report -p " + mprPath + " # scored best practices\n") + w("./mxcli docker check -p " + mprPath + " # mxbuild, the slow one (~25s)\n") + w("./mxcli run --local --watch -p " + mprPath + " # the app, hot-reloading\n") w(bt3 + "\n\n") - w("The report scores 6 categories (Naming, Security, Quality, Architecture, Performance, Design) on a 0-100 scale. See " + bt + "assess-quality" + bt + " skill for the full assessment guide.\n\n") - - // ── Slash Commands ────────────────────────────────────────────── - w("## Slash Commands\n\n") - w("Use these commands to quickly perform common tasks:\n\n") - w("| Command | Description |\n") - w("|---------|-------------|\n") - w("| " + bt + "/create-entity" + bt + " | Create a new entity with attributes |\n") - w("| " + bt + "/create-crud" + bt + " | Generate entity + overview + edit pages |\n") - w("| " + bt + "/refresh-catalog" + bt + " | Rebuild catalog for queries |\n") - w("| " + bt + "/explore" + bt + " | Explore project structure |\n") - w("| " + bt + "/check-script" + bt + " | Validate MDL script syntax |\n") - w("| " + bt + "/validate-project" + bt + " | Run mx check to validate project |\n") - w("| " + bt + "/lint" + bt + " | Check project for common issues |\n") - w("| " + bt + "/test" + bt + " | Run Playwright tests against the running app |\n") - w("| " + bt + "/diff-local" + bt + " | Show git diff of local MPR v2 changes |\n") - w("| " + bt + "/diff-script" + bt + " | Compare MDL script against project state |\n") - w("\n") - - // ── Skills Reference ──────────────────────────────────────────── - w("## Skills Reference\n\n") - w("Skills are in " + bt + ".ai-context/skills//SKILL.md" + bt + " (and " + bt + ".claude/skills/" + bt + " for Claude\n") - w("Code, which discovers them from there). Each one's frontmatter says what it covers and when to reach\n") - w("for it. Read the relevant skill before starting work.\n\n") - - w("### Quick Reference\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| cheatsheet-variables | Variable declaration syntax quick lookup |\n") - w("| cheatsheet-errors | Common MDL errors and fixes |\n") - w("\n") - - w("### Syntax Reference\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| mdl-entities | Entity, attribute, association syntax |\n") - w("| write-microflows | **Read first** - Microflow syntax, common mistakes |\n") - w("| write-oql-queries | OQL query syntax for VIEW entities |\n") - w("| create-page | Page and widget syntax |\n") - w("| fragments | Reusable widget group syntax |\n") - w("\n") - - w("### Patterns\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| patterns-crud | Create/Read/Update/Delete patterns |\n") - w("| patterns-data-processing | Loops, aggregates, batch processing |\n") - w("| validation-microflows | Validation feedback patterns |\n") - w("\n") - - w("### Pages\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| overview-pages | List/grid overview page patterns |\n") - w("| master-detail-pages | Master-detail layout patterns |\n") - w("| alter-page | ALTER PAGE/SNIPPET in-place modifications |\n") - w("| bulk-widget-updates | Bulk widget property updates across pages |\n") - w("\n") - - w("### Integration\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| database-connections | External database connections (PostgreSQL, Oracle) |\n") - w("| rest-client | REST API consumption |\n") - w("| mock-rest-apis | Mocking a REST dependency (Prism, forward proxy, constant swap) |\n") - w("| java-actions | Custom Java actions |\n") - w("| odata-data-sharing | OData services and external entities |\n") - w("\n") - - w("### Operations\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| manage-security | Security roles, GRANT/REVOKE, access control |\n") - w("| manage-navigation | Navigation profiles, menus, home/login pages |\n") - w("| organize-project | Folders, MOVE command, project structure |\n") - w("| project-settings | Project configuration (model, runtime, language) |\n") - w("| business-events | Business event services |\n") - w("\n") - - w("### Infrastructure\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| docker-workflow | Docker build and deployment |\n") - w("| run-app | Running the Mendix app locally |\n") - w("| runtime-admin-api | M2EE admin API |\n") - w("| system-module | System module entities reference |\n") - w("| verify-with-oql | OQL verification queries |\n") - w("| demo-data | **Read first for data work** - Demo data insertion |\n") - w("\n") - - w("### Testing & Quality\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| test-app | Playwright UI tests + DB assertions |\n") - w("| record-narrated-demo | Narrated walkthrough video, after the journey passes |\n") - w("| test-microflows | Microflow unit testing (.test.mdl files) |\n") - w("| write-lint-rules | Custom Starlark lint rule authoring |\n") - w("| assess-quality | **Full project quality assessment** against best practices |\n") - w("\n") - - w("### Domain Model\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| generate-domain-model | Full domain model generation |\n") - w("\n") - - w("### Migration\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| assess-migration | Migration assessment and planning |\n") - w("| migrate-k2-nintex | K2/Nintex workflow migration |\n") - w("| migrate-outsystems | OutSystems migration |\n") - w("| migrate-oracle-forms | Oracle Forms migration |\n") - w("| graph-studio-app | Reverse-engineer/graph Studio Pro app |\n") - w("\n") - - w("### Debugging & Preflight\n\n") - w("| Skill | Purpose |\n") - w("|-------|--------|\n") - w("| debug-bson | BSON serialization debugging |\n") - w("| check-syntax | Pre-flight validation checklist |\n") - w("\n") - - // ── MDL Syntax Quick Reference ────────────────────────────────── - w("## MDL Syntax Quick Reference\n\n") - - w("### Entity Generalization (EXTENDS)\n\n") - w("**CRITICAL: EXTENDS goes BEFORE the opening parenthesis, not after!**\n\n") - w(bt3 + "sql\nCREATE PERSISTENT ENTITY Module.ProductPhoto EXTENDS System.Image (\n PhotoCaption: String(200)\n);\n" + bt3 + "\n\n") - - w("### Microflows - Supported Statements\n\n") - w("| Statement | Syntax |\n") - w("|-----------|--------|\n") - w("| Variable declaration (primitive only) | " + bt + "DECLARE $Var Type = value;" + bt + " |\n") - w("| Assignment | " + bt + "SET $Var = expression;" + bt + " |\n") - w("| Enum split (CASE) | " + bt + "CASE $Var/Attr WHEN Value[, Value] THEN ... WHEN (empty) THEN ... END CASE;" + bt + " (bare enum values, no `ELSE`, no alias) |\n") - w("| Create object | " + bt + "$Var = CREATE Module.Entity (Attr = value);" + bt + " |\n") - w("| Change object | " + bt + "CHANGE $Entity (Attr = value);" + bt + " |\n") - w("| Commit | " + bt + "COMMIT $Entity [WITH EVENTS] [REFRESH];" + bt + " |\n") - w("| Delete | " + bt + "DELETE $Entity;" + bt + " |\n") - w("| Rollback | " + bt + "ROLLBACK $Entity [REFRESH];" + bt + " |\n") - w("| Retrieve | " + bt + "RETRIEVE $Var FROM Module.Entity [WHERE condition];" + bt + " |\n") - w("| Call microflow | " + bt + "$Result = CALL MICROFLOW Module.Name (Param = $value);" + bt + " |\n") - w("| Call nanoflow | " + bt + "$Result = CALL NANOFLOW Module.Name (Param = $value);" + bt + " |\n") - w("| Call Java action | " + bt + "$Result = CALL JAVA ACTION Module.Name (Param = value);" + bt + " |\n") - w("| Show page | " + bt + "SHOW PAGE Module.PageName ($Param = $value);" + bt + " |\n") - w("| Close page | " + bt + "CLOSE PAGE;" + bt + " |\n") - w("| Validation | " + bt + "VALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';" + bt + " |\n") - w("| Log | " + bt + "LOG INFO|WARNING|ERROR [NODE 'name'] 'message';" + bt + " |\n") - w("| Annotation | " + bt + "@annotation 'text'" + bt + " (before activity) |\n") - w("| Position | " + bt + "@position(x, y)" + bt + " (before activity) |\n") - w("| Error handling | " + bt + "... ON ERROR CONTINUE|ROLLBACK|{ handler };" + bt + " |\n") - w("| IF | " + bt + "IF condition THEN ... [ELSE ...] END IF;" + bt + " |\n") - w("| LOOP | " + bt + "LOOP $Item IN $List BEGIN ... END LOOP;" + bt + " |\n") - w("| WHILE | " + bt + "WHILE condition BEGIN ... END WHILE;" + bt + " |\n") - w("| Return | " + bt + "RETURN $value;" + bt + " |\n") - w("\n") - - w("### Microflows - NOT Supported (Will Cause Parse Errors)\n\n") - w("| Unsupported | Use Instead |\n") - w("|-------------|-------------|\n") - w("| " + bt + "DECLARE $Entity Module.Entity;" + bt + " (MDL043/CE0053) | Get the object from a parameter, a " + bt + "RETRIEVE" + bt + ", or " + bt + "$E = CREATE Module.Entity(...)" + bt + " |\n") - w("| " + bt + "DECLARE $List List of Module.Entity = empty;" + bt + " (MDL040) | Accept the list as a parameter, or " + bt + "RETRIEVE" + bt + " / " + bt + "$L = CREATE LIST OF Module.Entity" + bt + " |\n") - w("| " + bt + "TRY ... CATCH" + bt + " | " + bt + "ON ERROR { ... }" + bt + " blocks |\n") - w("\n") - w("**Notes:**\n") - w("- " + bt + "RETRIEVE ... LIMIT n" + bt + " IS supported. " + bt + "LIMIT 1" + bt + " returns a single entity.\n") - w("- " + bt + "ROLLBACK $Entity [REFRESH];" + bt + " IS supported. Rolls back uncommitted changes.\n\n") - - w("### Pages Syntax Summary\n\n") - w("| Element | Syntax | Example |\n") - w("|---------|--------|--------|\n") - w("| Page properties | " + bt + "(Key: value, ...)" + bt + " | " + bt + "(Title: 'Edit', Layout: Atlas_Core.Atlas_Default)" + bt + " |\n") - w("| Widget name | Required after type | " + bt + "TEXTBOX txtName (...)" + bt + " |\n") - w("| Attribute binding | " + bt + "Attribute: AttrName" + bt + " | " + bt + "TEXTBOX txt (Label: 'Name', Attribute: Name)" + bt + " |\n") - w("| Microflow action | " + bt + "Action: MICROFLOW Name(Param: val)" + bt + " | " + bt + "Action: MICROFLOW Mod.ACT_Process(Order: $Order)" + bt + " |\n") - w("| Database source | " + bt + "DataSource: DATABASE Entity" + bt + " | " + bt + "DATAGRID dg (DataSource: DATABASE Mod.Entity)" + bt + " |\n") - w("| Selection source | " + bt + "DataSource: SELECTION widget" + bt + " | " + bt + "DATAVIEW dv (DataSource: SELECTION galleryList)" + bt + " |\n") - w("\n") - - w("**Supported Widgets:** LAYOUTGRID, ROW, COLUMN, CONTAINER, TEXTBOX, TEXTAREA, CHECKBOX, RADIOBUTTONS, DATEPICKER, COMBOBOX, DYNAMICTEXT, DATAGRID, GALLERY, LISTVIEW, IMAGE, STATICIMAGE, DYNAMICIMAGE, ACTIONBUTTON, LINKBUTTON, DATAVIEW, HEADER, FOOTER, CONTROLBAR, SNIPPETCALL, NAVIGATIONLIST, CUSTOMCONTAINER.\n\n") - - // ALTER PAGE summary - w("### ALTER PAGE / ALTER SNIPPET\n\n") - w("Modify existing pages in-place without full " + bt + "CREATE OR REPLACE" + bt + ":\n\n") - w("| Operation | Syntax |\n") - w("|-----------|--------|\n") - w("| Set property | " + bt + "SET Caption = 'New' ON widgetName" + bt + " |\n") - w("| Set multiple | " + bt + "SET (Caption = 'Save', ButtonStyle = Success) ON btn" + bt + " |\n") - w("| Page-level set | " + bt + "SET Title = 'New Title'" + bt + " (no ON clause) |\n") - w("| Insert after | " + bt + "INSERT AFTER widgetName { widgets }" + bt + " |\n") - w("| Insert before | " + bt + "INSERT BEFORE widgetName { widgets }" + bt + " |\n") - w("| Drop widgets | " + bt + "DROP WIDGET name1, name2" + bt + " |\n") - w("| Replace widget | " + bt + "REPLACE widgetName WITH { widgets }" + bt + " |\n") - w("\n") - - // Reserved words - w("### Quoted Identifiers\n\n") - w("**Always quote all identifiers** (entity names, attribute names, parameter names) with double quotes. This eliminates all reserved keyword conflicts and is always safe — quotes are stripped automatically.\n\n") - w(bt3 + "sql\nCREATE PERSISTENT ENTITY Module.\"Customer\" (\n \"Name\": String(200),\n \"Status\": String(50),\n \"Create\": DateTime\n);\n" + bt3 + "\n\n") - - // ── MDL Script Files ──────────────────────────────────────────── - w("## MDL Script Files\n\n") - w("Store MDL scripts in the " + bt + "mdlsource/" + bt + " directory:\n\n") - w(bt3 + "\nmdlsource/\n") - w("\u251c\u2500\u2500 domain-model.mdl # Entity definitions\n") - w("\u251c\u2500\u2500 microflows.mdl # Business logic\n") - w("\u2514\u2500\u2500 setup.mdl # Initial setup script\n") - w(bt3 + "\n\n") - w("Execute a script:\n\n") - w(bt3 + "sql\nEXECUTE SCRIPT 'mdlsource/domain-model.mdl';\n" + bt3 + "\n\n") - - // ── Example: Entity ───────────────────────────────────────────── - w("## Example: Create an Entity\n\n") - w(bt3 + "sql\n") - w("/**\n * Customer entity\n *\n * Stores customer information.\n */\n") - w("@Position(100, 100)\n") - w("CREATE PERSISTENT ENTITY Sales.Customer (\n") - w(" /** Customer name */\n") - w(" Name: String(200) NOT NULL ERROR 'Name is required',\n") - w(" /** Email address */\n") - w(" Email: String(200) UNIQUE ERROR 'Email must be unique',\n") - w(" /** Phone number */\n") - w(" Phone: String(50),\n") - w(" /** Active status */\n") - w(" IsActive: Boolean DEFAULT true\n") - w(");\n") - w(bt3 + "\n\n") - - // ── Example: Microflow ────────────────────────────────────────── - w("## Example: Create a Microflow\n\n") - w(bt3 + "sql\n") - w("/**\n * Validates a customer before saving\n *\n * @param $Customer The customer to validate\n * @returns Boolean indicating validity\n */\n") - w("CREATE MICROFLOW Sales.VAL_Customer (\n") - w(" $Customer: Sales.Customer\n)\n") - w("RETURNS Boolean AS $IsValid\n") - w("BEGIN\n") - w(" DECLARE $IsValid Boolean = true;\n\n") - w(" IF trim($Customer/Name) = '' THEN\n") - w(" SET $IsValid = false;\n") - w(" VALIDATION FEEDBACK $Customer/Name MESSAGE 'Name is required';\n") - w(" END IF;\n\n") - w(" RETURN $IsValid;\n") - w("END;\n/\n") - w(bt3 + "\n\n") - - w("## MDL Reference\n\n") - w("For detailed MDL syntax, see the skill files in " + bt + ".ai-context/skills//SKILL.md" + bt + ".\n") + w("**" + bt + "lint" + bt + " printing no errors is not a pass** — read the warning count, and read\n") + w(bt + "report" + bt + "'s score. A green " + bt + "check" + bt + " proves nothing about how a page renders:\n") + w("anything visual or stateful needs the app actually running.\n\n") + w("Set " + bt + "mx" + bt + " up once with " + bt + "./mxcli setup mxbuild -p " + mprPath + bt + ". To call it directly,\n") + w("name the version — " + bt + "~/.mxcli/mxbuild//modeler/mx" + bt + " — because a " + bt + "*" + bt + " glob\n") + w("breaks the moment two are cached.\n\n") + + // ── The reference is the tool ─────────────────────────────────── + w("## Syntax, rules and skills — ask the tool, not this file\n\n") + w("These change every release. Nothing here restates them, because a copy that\n") + w("disagrees with the tool is worse than no copy.\n\n") + w("| To find out | Run |\n") + w("|---|---|\n") + w("| What MDL can say, and how | " + bt + "./mxcli syntax" + bt + " → " + bt + "./mxcli syntax [sub]" + bt + " (" + bt + "--json" + bt + " for bulk) |\n") + w("| Which lint rules exist | " + bt + "./mxcli lint -p " + mprPath + " --list-rules" + bt + " |\n") + w("| What a command takes | " + bt + "./mxcli help " + bt + " |\n") + w("| What this project contains | " + bt + "./mxcli -p " + mprPath + " -c \"SHOW STRUCTURE\"" + bt + " |\n") + w("| Why it was built this way | " + bt + "docs/brain/" + bt + " (above) |\n") + w("\n") + w("**Skills** are in " + bt + ".ai-context/skills//SKILL.md" + bt + " (and " + bt + ".claude/skills/" + bt + ", which is\n") + w("the path Claude Code scans). Each one's frontmatter " + bt + "description" + bt + " says when to reach for\n") + w("it — that IS the index, so list the directory rather than looking for a table. Read the\n") + w("matching skill **before** writing microflows, pages, security, or anything touching data.\n\n") + + // ── The non-derivable conventions ─────────────────────────────── + // Short, and each one is here precisely because no command reports it. + w("## Conventions no command will tell you\n\n") + w("- **Quote every identifier** in MDL — " + bt + "Module.\"Customer\"" + bt + ", " + bt + "\"Status\": String(50)" + bt + ".\n") + w(" Quotes are stripped, so it is always safe, and it sidesteps every parser keyword.\n") + w(" It does **not** exempt names Mendix itself reserves (" + bt + "Type" + bt + ", " + bt + "ID" + bt + ", " + bt + "CreatedDate" + bt + ") —\n") + w(" those are rejected quoted or not.\n") + w("- **A " + bt + "/** ... */" + bt + " comment before a statement sets that element's documentation.**\n") + w("- **" + bt + "@Position(x, y)" + bt + " is optional** — mxcli places microflow activities, and\n") + w(" " + bt + "./mxcli layout" + bt + " arranges the domain model.\n\n") return sb.String() } diff --git a/cmd/mxcli/init_claudemd_budget_test.go b/cmd/mxcli/init_claudemd_budget_test.go new file mode 100644 index 0000000000..895852416a --- /dev/null +++ b/cmd/mxcli/init_claudemd_budget_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "regexp" + "strings" + "testing" +) + +// The generated CLAUDE.md is re-read into EVERY context this project starts, +// so its size is a per-session tax rather than a one-off. Measured on a real +// build (ako/CapTrackV4, 15 slices): 6,918 tokens × 15 sub-agent contexts = +// ~104k tokens of re-read reference. +// +// The budget is expressed in bytes because that is what the generator produces +// and what a test can measure without a tokenizer; ~4 bytes/token is the usual +// ratio for English prose with code fences, so 6,000 bytes is roughly the +// 1,500-token target. +const claudeMDBudgetBytes = 6000 + +func TestGeneratedClaudeMDStaysWithinItsContextBudget(t *testing.T) { + md := generateClaudeMD("Demo", "Demo.mpr") + if len(md) > claudeMDBudgetBytes { + t.Errorf("generated CLAUDE.md is %d bytes (~%d tokens), over the %d-byte budget.\n"+ + "It is re-read into every context this project starts, so anything mxcli can\n"+ + "answer on demand belongs behind a command, not in here.", + len(md), len(md)/4, claudeMDBudgetBytes) + } +} + +// A table of rule IDs, MDL commands or skill names in the generated file is a +// transcription of something mxcli answers authoritatively, and it drifts +// silently: nothing fails when a rule is added, so the copy in every project's +// CLAUDE.md quietly stops matching the tool. +// +// This is not hypothetical here. #906 was the same failure in the skill table, +// which had drifted to 12 of 68 before anyone noticed. At the commit this test +// was written the lint tables were wrong on both axes at once: 10 of the 14 +// registered built-in rules were listed (MPR008-011 missing, MPR008 being +// precisely the rule whose guidance projects argue with), and the Starlark +// count read "27" against 31 shipped files. +func TestGeneratedClaudeMDDoesNotTranscribeWhatMxcliAnswers(t *testing.T) { + md := generateClaudeMD("Demo", "Demo.mpr") + + // Every built-in rule ID the linter registers. If the doc names one, it is + // keeping a list it does not own. + for _, r := range builtinLintRules() { + if strings.Contains(md, r.ID()) { + t.Errorf("generated CLAUDE.md names lint rule %s. The rule list belongs to "+ + "`mxcli lint --list-rules`; a copy here goes stale the next time a rule is added.", r.ID()) + } + } + + // A markdown table row whose first cell is an MDL statement is the command + // reference being restated. `mxcli syntax [topic]` owns that, and has a + // --json mode built for exactly this consumer. + cmdRow := regexp.MustCompile("(?m)^\\| `(SHOW|DESCRIBE|CREATE|ALTER|DROP|GRANT|REVOKE|MOVE|REFRESH|SQL) ") + if m := cmdRow.FindAllString(md, -1); len(m) > 0 { + t.Errorf("generated CLAUDE.md restates %d MDL command table rows (e.g. %q). "+ + "Point at `mxcli syntax ` instead.", len(m), strings.TrimSpace(m[0])) + } +} + +// Deleting the tables is only safe if what replaces them actually resolves. +// These are the commands the trimmed file sends a reader to, so a rename or +// removal of one has to break this test rather than a project's onboarding. +func TestGeneratedClaudeMDPointsAtCommandsThatExist(t *testing.T) { + md := generateClaudeMD("Demo", "Demo.mpr") + for _, want := range []string{ + "mxcli syntax", // the MDL command + syntax reference + "--list-rules", // the lint rule list + ".ai-context/skills/", // the skill index, routed by frontmatter + } { + if !strings.Contains(md, want) { + t.Errorf("generated CLAUDE.md does not mention %q; the content it replaced is then simply gone", want) + } + } + for _, name := range []string{"syntax", "lint", "check", "exec"} { + if _, _, err := rootCmd.Find([]string{name}); err != nil { + t.Errorf("CLAUDE.md sends readers to `mxcli %s`, which is not a registered command: %v", name, err) + } + } +} diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index fc7bbb899c..227fd6752b 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -278,6 +278,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "FILTERTYPE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "IMAGE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "GLYPH", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "GLYPHS", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "QUEUE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "QUEUES", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SCHEDULED", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, @@ -473,6 +474,10 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "PARAMETERS", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "HEADERS", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "NAVIGATION", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "SYNC", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "ONLINE", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "NEVER", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, + {Label: "PRESERVE", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "MENU", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "HOMES", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, {Label: "HOME", Kind: protocol.CompletionItemKindKeyword, Detail: "REST keyword"}, diff --git a/cmd/mxcli/syntax/features_integration.go b/cmd/mxcli/syntax/features_integration.go index 62f41c06ce..1e4976f2d1 100644 --- a/cmd/mxcli/syntax/features_integration.go +++ b/cmd/mxcli/syntax/features_integration.go @@ -616,7 +616,31 @@ DESCRIBE DATABASE CONNECTION Ops.Erp;`, }, Syntax: "SHOW ICON COLLECTIONS [IN Module];\nDESCRIBE ICON COLLECTION Module.Name; -- lists every icon + its reference form", Example: "-- Icon collections ship with the theme/Atlas (read-only). Their icons are\n-- referenced from a widget as Module.Collection.IconName (e.g. a button's icon:).\nSHOW ICON COLLECTIONS;\nDESCRIBE ICON COLLECTION Atlas_Core.Atlas_Filled;\n-- → then: ACTIONBUTTON b (Caption: 'Edit', Action: ..., Icon: 'Atlas_Core.Atlas_Filled.pencil')", - SeeAlso: []string{"image-collection", "page.action"}, + SeeAlso: []string{"image-collection", "glyph", "page.action"}, + }) + + Register(SyntaxFeature{ + Path: "glyph", + Summary: "Glyph icons — the numeric codes `icon glyph ` accepts, and their names", + Keywords: []string{ + "glyph", "glyphs", "show glyphs", "describe glyph", "icon glyph", + "glyphicon", "glyph code", "menu icon", "navigation icon", + }, + Syntax: "SHOW GLYPHS [LIKE 'text']; -- LIKE matches the icon NAME\n" + + "DESCRIBE GLYPH 57350; -- by character code\n" + + "DESCRIBE GLYPH 'star'; -- or by name", + Example: "-- A glyph is a character code in the Mendix glyph font, not a document in the\n" + + "-- project, so there is no module to scope and no connection needed.\n" + + "SHOW GLYPHS LIKE 'star';\n" + + "-- 57350 star icon glyph 57350\n" + + "-- 57351 star-empty icon glyph 57351\n" + + "DESCRIBE GLYPH 'star';\n" + + "-- → then: MENU ITEM 'Favourites' PAGE M.Favourites ICON GLYPH 57350;\n" + + "\n" + + "-- Prefer an icon COLLECTION reference where the icon exists there: it is a\n" + + "-- model reference that `check --references` resolves, while a glyph code is\n" + + "-- an unchecked integer until MDL078 sees it.", + SeeAlso: []string{"icon-collection", "navigation.create"}, }) // ── Import / Export Mappings ────────────────────────────────────── diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index f89423b980..b1cc2d3efe 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -152,7 +152,8 @@ DISCONNECT;`, "create navigation", "replace navigation", "home page", "login page", "not found page", "menu item", "menu icon", "navigation profile", "phone profile", "tablet profile", - "offline profile", "offline navigation", + "offline profile", "offline navigation", "sync", "synchronization", + "offline sync", "offline entity", "pwa", "download mode", }, Syntax: `CREATE OR REPLACE NAVIGATION HOME PAGE Module.Page @@ -162,6 +163,14 @@ DISCONNECT;`, [MENU ( MENU ITEM 'Label' PAGE Module.Page [ICON Module.IconCollection.Name]; MENU 'Group' [ICON Module.IconCollection.Name] ( ... ); + )] + [SYNC ( + SYNC Module.Entity ONLINE; + SYNC Module.Entity ALL; + SYNC Module.Entity WHERE [Amount > 0]; + SYNC Module.Entity NEVER; + SYNC Module.Entity NONE; + SYNC Module.Entity NONE PRESERVE DATA; )]; -- FOR takes a USER role, written BARE (FOR Administrator). User roles are @@ -183,6 +192,31 @@ DISCONNECT;`, -- the project does not have it yet: -- Responsive Phone Tablet online -- ResponsiveOffline PhoneOffline TabletOffline offline +-- +-- SYNC configures offline synchronization, and an offline profile downloads +-- NOTHING until its entities have one -- a profile with no SYNC block builds, +-- routes and installs as a PWA, and shows an empty app. +-- +-- The six modes are the members Mendix stores, NOT the captions Studio Pro +-- shows: its "All Objects" is ALL and its "By XPath" is WHERE. WHERE implies +-- the constrained mode rather than naming it, so a constraint without a mode +-- and a mode without a constraint are both unspellable. +-- +-- ONLINE fetched from the server, never held on the device +-- ALL every object downloaded +-- WHERE [] only the objects the XPath selects +-- NEVER not synchronized +-- NONE not downloaded; anything already on the device is dropped +-- NONE PRESERVE DATA not downloaded; what is on the device stays +-- +-- WHERE takes the XPath in BRACKETS, verbatim -- nothing inside is escaped. +-- A quoted WHERE '' still parses, but every quote inside it doubles, +-- and a stored constraint already carries Mendix's own escaping, so the two +-- compose into runs of six quotes. DESCRIBE emits the bracket form. +-- +-- The block REPLACES the stored list, the way MENU replaces the menu. An +-- entity's compatibility-mode flag has no syntax and is preserved across the +-- rewrite untouched; DESCRIBE NAVIGATION flags it rather than dropping it. -- An invented name ("Mobile") is an error: the runtime routes on User-Agent to -- Mendix's own kinds, so a profile the platform does not define can never route. -- diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 7ab1fdbebd..a588e89264 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -242,7 +242,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { "popup width", "popup height", "popup resizable", "drop template", "insert template", "list view template", }, - Syntax: "ALTER PAGE Module.Name {\n SET property = value ON widgetName;\n SET Action = MICROFLOW Module.MF ON btnSave; -- any CREATE PAGE action form\n SET DataSource = $Param ON dvOrder;\n SET (prop1 = val1, prop2 = val2) ON widgetName;\n SET Title = 'New Title'; -- page-level (case-sensitive)\n SET Class = 'css-class'; -- page-level CSS class / style\n SET Style = 'css: rule';\n SET PopupWidth = 800; -- page-level pop-up dimensions\n SET PopupHeight = 480;\n SET PopupResizable = true;\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP WIDGET name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n};", + Syntax: "ALTER PAGE Module.Name {\n SET property = value ON widgetName; -- widget property names: any casing\n SET Action = MICROFLOW Module.MF ON btnSave; -- any CREATE PAGE action form\n SET DataSource = $Param ON dvOrder;\n SET (prop1 = val1, prop2 = val2) ON widgetName;\n SET Title = 'New Title'; -- page-level (case-sensitive)\n SET Class = 'css-class'; -- page-level CSS class / style\n SET Style = 'css: rule';\n SET PopupWidth = 800; -- page-level pop-up dimensions\n SET PopupHeight = 480;\n SET PopupResizable = true;\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP WIDGET name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n};", Example: "ALTER PAGE Module.EditPage {\n SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Attribute: MiddleName)\n };\n DROP WIDGET txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) diff --git a/cmd/mxcli/syntax/features_security.go b/cmd/mxcli/syntax/features_security.go index 3a78c72b2e..dbda4e3e98 100644 --- a/cmd/mxcli/syntax/features_security.go +++ b/cmd/mxcli/syntax/features_security.go @@ -84,7 +84,7 @@ func init() { "user role", "application role", "manage roles", "add module roles", "remove module roles", }, - Syntax: "CREATE USER ROLE ( [, ...]) [MANAGE ALL ROLES];\nALTER USER ROLE ADD MODULE ROLES ( [, ...]);\nALTER USER ROLE REMOVE MODULE ROLES ( [, ...]);\nDROP USER ROLE ;", + Syntax: "CREATE USER ROLE ( [, ...]) [MANAGE ALL ROLES];\nALTER USER ROLE ADD MODULE ROLES ( [, ...]);\nALTER USER ROLE REMOVE MODULE ROLES ( [, ...]);\nDROP USER ROLE [IF EXISTS] ;", Example: "CREATE USER ROLE AppAdmin (Shop.Admin, HR.Admin) MANAGE ALL ROLES;\nALTER USER ROLE AppAdmin ADD MODULE ROLES (Reporting.Viewer);", SeeAlso: []string{"security.module-role", "security.demo-user"}, }) @@ -130,7 +130,7 @@ func init() { "demo user", "test user", "demo account", "password", "login", }, - Syntax: "CREATE DEMO USER '' PASSWORD '' [ENTITY Module.Entity] ( [, ...]);\nDROP DEMO USER '';", + Syntax: "CREATE DEMO USER '' PASSWORD '' [ENTITY Module.Entity] ( [, ...]);\nDROP DEMO USER [IF EXISTS] '';", Example: "CREATE DEMO USER 'admin' PASSWORD 'Admin1!' (AppAdmin);\nCREATE DEMO USER 'user' PASSWORD 'User1!' (AppUser);", SeeAlso: []string{"security.user-role", "security.project-security"}, }) diff --git a/cmd/mxcli/syntax/features_workflow.go b/cmd/mxcli/syntax/features_workflow.go index fc511ae873..3cb3465de9 100644 --- a/cmd/mxcli/syntax/features_workflow.go +++ b/cmd/mxcli/syntax/features_workflow.go @@ -74,8 +74,19 @@ func init() { // outcome does not ('OK' { }). The two read alike but are separate // grammar rules, so the arrow is easy to drop — this entry did, and // taught the broken form until TestExamplesParse started checking it. - Syntax: "DECISION [] ['
'] [COMMENT '']\n OUTCOMES '' -> { } ...;", - Example: "DECISION 'Check amount'\n OUTCOMES\n 'Under 1000' -> { }\n 'Over 1000' -> {\n USER TASK ManagerApproval 'Manager must approve'\n OUTCOMES 'OK' { };\n };", + // + // The outcome VALUE is not free text either: Mendix stores it as an + // EnumerationValueIdentifier and parses it when the project is loaded, + // so anything but Module.Enumeration.Value leaves a project Studio Pro + // cannot open. This entry taught 'Under 1000' / 'Over 1000' — both of + // which corrupt the model (ako/mxcli#1031, ako/mxcli#1065). + Syntax: "-- Boolean decision:\n" + + "DECISION [] '' [COMMENT '']\n OUTCOMES TRUE -> { } FALSE -> { };\n\n" + + "-- Enumeration decision — each outcome is a QUALIFIED enum value,\n" + + "-- plus one '' outcome for 'none of the above' (without it: CE6686):\n" + + "DECISION [] '' [COMMENT '']\n OUTCOMES 'Module.Enumeration.Value' -> { } ... '' -> { };", + Example: "-- Boolean\nDECISION decision1 '$WorkflowContext/Amount > 1000'\n OUTCOMES\n TRUE -> {\n USER TASK ManagerApproval 'Manager must approve'\n OUTCOMES 'OK' { };\n }\n FALSE -> { };\n\n" + + "-- Enumeration: the value must be Module.Enumeration.Value.\n-- A bare 'Approved' makes the project UNLOADABLE, not merely invalid.\nDECISION decision2 '$WorkflowContext/Status'\n OUTCOMES\n 'Sales.ENUM_Status.Approved' -> { }\n 'Sales.ENUM_Status.Rejected' -> { }\n '' -> { };", SeeAlso: []string{"workflow.create", "workflow.parallel-split"}, }) @@ -98,8 +109,11 @@ func init() { "call microflow", "microflow task", "automated step", "system task", }, - Syntax: "CALL MICROFLOW Module.MF [AS ] [COMMENT '']\n [OUTCOMES '' { } ...];", - Example: "CALL MICROFLOW HR.SendNotification\n COMMENT 'Notify manager';", + // The WITH values are QUOTED — the grammar takes a string literal there, + // not a bare variable. Omitting the clause from this entry is how an + // author ends up writing the unquoted form (ako/mxcli#1023). + Syntax: "CALL MICROFLOW Module.MF [AS ] [COMMENT '']\n [WITH ( = '', ...)]\n [OUTCOMES '' -> { } ...];", + Example: "CALL MICROFLOW HR.SendNotification\n COMMENT 'Notify manager';\n\n-- Parameter values are quoted, and named by their BARE parameter name:\nCALL MICROFLOW HR.Escalate AS callMicroflow1\n WITH (Request = '$WorkflowContext');", SeeAlso: []string{"workflow.create", "workflow.call-workflow"}, }) @@ -109,8 +123,8 @@ func init() { Keywords: []string{ "call workflow", "sub-workflow", "nested workflow", }, - Syntax: "CALL WORKFLOW Module.WF [AS ] [COMMENT ''];", - Example: "CALL WORKFLOW HR.SubApproval COMMENT 'Delegate to sub-process';", + Syntax: "CALL WORKFLOW Module.WF [AS ] [COMMENT '']\n [WITH ( = '', ...)];", + Example: "CALL WORKFLOW HR.SubApproval COMMENT 'Delegate to sub-process';\n\n-- Parameter values are quoted:\nCALL WORKFLOW HR.SubApproval AS callWf1\n WITH (Request = '$WorkflowContext');", SeeAlso: []string{"workflow.create", "workflow.call-microflow"}, }) diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index 5c8b1bcbab..bd8c9c7129 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -214,7 +214,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Drop module role | `DROP MODULE ROLE Mod.Role;` | | | Create user role | `CREATE USER ROLE Name (Mod.Role, ...) [MANAGE ALL ROLES];` | Aggregates module roles | | Alter user role | `ALTER USER ROLE Name ADD\|REMOVE MODULE ROLES (Mod.Role, ...);` | | -| Drop user role | `DROP USER ROLE Name;` | | +| Drop user role | `DROP USER ROLE [IF EXISTS] Name;` | `IF EXISTS` makes a cleanup script re-runnable | | Grant microflow access | `GRANT EXECUTE ON MICROFLOW Mod.MF TO Mod.Role, ...;` | | | Revoke microflow access | `REVOKE EXECUTE ON MICROFLOW Mod.MF FROM Mod.Role, ...;` | | | Grant page access | `GRANT VIEW ON PAGE Mod.Page TO Mod.Role, ...;` | | @@ -225,7 +225,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Toggle demo users | `ALTER PROJECT SECURITY DEMO USERS ON\|OFF;` | | | Toggle guest access | `ALTER PROJECT SECURITY GUEST ACCESS ON ROLE UserRole\|OFF;` | Anonymous users; role required (CE0133) | | Create demo user | `CREATE DEMO USER 'name' PASSWORD 'pass' [ENTITY Module.Entity] (UserRole, ...);` | | -| Drop demo user | `DROP DEMO USER 'name';` | | +| Drop demo user | `DROP DEMO USER [IF EXISTS] 'name';` | `IF EXISTS` makes a cleanup script re-runnable | ## Workflows diff --git a/docs-site/src/language/navigation-profiles.md b/docs-site/src/language/navigation-profiles.md index 078614c320..51779b8580 100644 --- a/docs-site/src/language/navigation-profiles.md +++ b/docs-site/src/language/navigation-profiles.md @@ -9,8 +9,14 @@ A navigation profile defines the navigation structure for a specific device type | Responsive web | `Responsive` | Default browser navigation | | Tablet web | `Tablet` | Tablet-optimized browser navigation | | Phone web | `Phone` | Phone-optimized browser navigation | +| Responsive web offline | `ResponsiveOffline` | Offline-capable (PWA) | +| Tablet web offline | `TabletOffline` | Offline-capable (PWA) | +| Phone web offline | `PhoneOffline` | Offline-capable (PWA) | | Native mobile | `NativePhone` | React Native mobile navigation | +The web kinds are a closed set and the profile is **created** if the project +does not have it yet. The offline kinds are the online names plus `Offline`. + ## CREATE OR REPLACE NAVIGATION Replaces an entire navigation profile: @@ -53,6 +59,42 @@ CREATE OR REPLACE NAVIGATION Phone HOME PAGE MyModule.Home_Phone; ``` +## Offline Synchronization + +An offline profile downloads **nothing** until its entities have a sync mode. +Without a `sync` block the app builds, routes and installs as a PWA — and shows +an empty screen. This is the most common way an offline profile looks broken +while every check passes. + +```sql +create or replace navigation PhoneOffline + home page MyModule.Mobile_Dashboard + sync ( + sync MyModule.Setting online; + sync MyModule.Order all; + sync MyModule.Trip where [Distance > 0]; + sync MyModule.Audit never; + sync MyModule.Lookup none; + sync MyModule.Draft none preserve data; + ); +``` + +| Mode | Meaning | +|------|---------| +| `online` | fetched from the server, never held on the device | +| `all` | every object downloaded | +| `where [xpath]` | only the objects the XPath selects | +| `never` | not synchronized | +| `none` | not downloaded; anything already on the device is dropped | +| `none preserve data` | not downloaded; what is on the device stays | + +The words are the values Mendix stores, not Studio Pro's captions — its +"All Objects" is `all` and its "By XPath" is `where`. Copying a caption gives a +parse error rather than a broken document. + +See [ALTER NAVIGATION](../reference/navigation/alter-navigation.md#offline-synchronization) +for the full reference. + ## DESCRIBE NAVIGATION View the current navigation profile in MDL syntax: diff --git a/docs-site/src/reference/navigation/alter-navigation.md b/docs-site/src/reference/navigation/alter-navigation.md index 0188ca3779..4f84cd32de 100644 --- a/docs-site/src/reference/navigation/alter-navigation.md +++ b/docs-site/src/reference/navigation/alter-navigation.md @@ -11,6 +11,9 @@ CREATE OR REPLACE NAVIGATION profile [ MENU ( menu_items ) ] + [ SYNC ( + sync_rules + ) ] ``` ## Description @@ -28,8 +31,16 @@ Mendix supports the following navigation profile types: | `Responsive` | Web browser (desktop and mobile responsive) | | `Tablet` | Tablet-optimized web | | `Phone` | Phone-optimized web | +| `ResponsiveOffline` | Responsive web, offline-capable (PWA) | +| `TabletOffline` | Tablet web, offline-capable (PWA) | +| `PhoneOffline` | Phone web, offline-capable (PWA) | | `NativePhone` | Native mobile application | +The web kinds are a closed set, and the profile is **created** if the project +does not have it yet. The three offline kinds are the online names plus +`Offline`, and each one needs a [`SYNC` block](#offline-synchronization) to +download anything. + ### Menu Items Menu items form a hierarchy. Top-level items appear in the main navigation bar. Nested submenus are created with the `MENU 'label' ( ... )` syntax. @@ -39,7 +50,9 @@ Each `MENU ITEM` specifies a label and a target page. Menu items are terminated ## Parameters `profile` -: The navigation profile type: `Responsive`, `Tablet`, `Phone`, or `NativePhone`. +: The navigation profile type — see [Profile Types](#profile-types). One of + `Responsive`, `Tablet`, `Phone`, `ResponsiveOffline`, `TabletOffline`, + `PhoneOffline` or `NativePhone`. `HOME PAGE module.PageName` : The default home page for the profile. Required. The page must already exist. @@ -64,6 +77,48 @@ Each `MENU ITEM` specifies a label and a target page. Menu items are terminated `MENU 'label' ( ... )` : A submenu containing nested menu items and/or further submenus. +### Offline Synchronization + +`SYNC ( ... )` configures which entities an offline profile downloads. **An +offline profile downloads nothing without it** — the app builds, routes and +installs as a PWA, and shows an empty screen. + +```sql +SYNC ( + SYNC Sales.Setting ONLINE; + SYNC Sales.Order ALL; + SYNC Sales.Trip WHERE [Distance > 0]; + SYNC Sales.Audit NEVER; + SYNC Sales.Lookup NONE; + SYNC Sales.Draft NONE PRESERVE DATA; +) +``` + +| Mode | Meaning | +|------|---------| +| `ONLINE` | Fetched from the server; never held on the device | +| `ALL` | Every object downloaded | +| `WHERE [ xpath ]` | Only the objects the XPath selects | +| `NEVER` | Not synchronized | +| `NONE` | Not downloaded; anything already on the device is dropped | +| `NONE PRESERVE DATA` | Not downloaded; what is on the device stays | + +These are the values Mendix stores, **not** the captions Studio Pro shows in +its *Customize offline synchronization* dialog: its "All Objects" is `ALL` and +its "By XPath" is `WHERE`. A caption is refused rather than written. + +`WHERE` implies the constrained mode rather than naming it, so a constraint +without a mode and a mode without a constraint are both unspellable. The XPath +goes in **brackets** and is taken verbatim — nothing inside is escaped. A +quoted `WHERE 'xpath'` still parses, but every quote inside it must be doubled. + +The block replaces the stored list, the way `MENU` replaces the menu. Omitting +it leaves the stored configuration alone. + +An entity's *compatibility mode* flag has no MDL syntax. It is read, preserved +across a rewrite, and reported by `DESCRIBE NAVIGATION` — never silently +dropped. + ## Examples Minimal navigation with just a home page: diff --git a/docs-site/src/reference/security/README.md b/docs-site/src/reference/security/README.md index 0ebb58bfaa..283436de9d 100644 --- a/docs-site/src/reference/security/README.md +++ b/docs-site/src/reference/security/README.md @@ -28,6 +28,6 @@ Mendix security operates at two levels. **Module roles** define permissions with | Toggle demo users | `ALTER PROJECT SECURITY DEMO USERS ON\|OFF` | | Toggle guest access | `ALTER PROJECT SECURITY GUEST ACCESS ON [ROLE UserRole]\|OFF` | | Drop module role | `DROP MODULE ROLE module.Role` | -| Drop user role | `DROP USER ROLE Name` | -| Drop demo user | `DROP DEMO USER 'username'` | +| Drop user role | `DROP USER ROLE [IF EXISTS] Name` | +| Drop demo user | `DROP DEMO USER [IF EXISTS] 'username'` | | Alter user role | `ALTER USER ROLE Name ADD\|REMOVE MODULE ROLES (module.Role, ...)` | diff --git a/docs-site/src/tools/project-brain.md b/docs-site/src/tools/project-brain.md index b3c3f603d7..08ce585713 100644 --- a/docs-site/src/tools/project-brain.md +++ b/docs-site/src/tools/project-brain.md @@ -41,11 +41,37 @@ docs/brain/ Committed, and reviewed in a pull request like any other change. +A shard is re-rendered from its entries on every write, so the title and the +preamble are regenerated and hand-edits to them do not survive. **YAML +frontmatter is preserved**, because mxcli does not own it — it is where markdown +tooling keeps per-file metadata (Foam and Obsidian tags, a docs site's nav +weight), and discarding it would quietly break any of those. It counts toward +the shard's cap, since a session loading the shard loads it too. A shard with no +frontmatter never grows an empty block. + The split is not cosmetic. A single file would make the size cap a project-wide budget — recording a `Sales` decision would compete with a `Finance` one — and every session would load every module's decisions. With one file per module, a session loads `project.md` plus the shards for the modules it is touching. +`mxcli brain brief` assembles that set, so the saving does not depend on anyone +judging it correctly: + +```bash +mxcli brain brief --slice 07-planning -p app.mpr # project + that slice's + # modules + its plan +mxcli brain brief --module Sales -p app.mpr # project + Sales, no plan +``` + +Which modules a slice needs is derived from its requirements' anchors — you do +not name them, because that is the thing the brief is being read to find out. +The pack goes to stdout and its size to stderr, so it pipes straight into a +prompt; `--json` returns the shards separately with their paths. + +This is worth little in one long session, where the store is read once and then +cached. It is worth a large fraction of the context when each slice runs in its +own session or sub-agent and the pack is re-read from a cold start every time. + ## Anchors An entry's anchors are what make it routable and checkable. @@ -246,21 +272,54 @@ Sizes are computed on every run and are deliberately not written into any committed file, including the store's own `README.md` — a figure in prose is stale the next time anyone promotes. +## Renaming + +`mxcli rename` rewrites matching anchors in `docs/brain/` and in the staged +queue, and reports the count: + +``` +Renamed entity: Sales.Order → Sales.PurchaseOrder +Updated 2 brain anchor(s): @Sales.Order -> @Sales.PurchaseOrder +``` + +Renaming a module also moves `modules/.md` to `modules/.md`, so its +entries do not immediately read as misfiled. Entry ids are not re-derived: an id +is a handle (`brain promote `, prose that cites one), and invalidating every +reference *to* an entry in order to fix that entry's references to the model +would trade one dangling pointer for several. + +It happens at the rename because it cannot be done afterwards. A decision's +anchor points backward, so a stale one shows up as `NOT FOUND`; a requirement's +points forward, so a stale one merely counts as `PLANNED` — indistinguishable +from not built yet. Measured on a real project, a refactor moved the reported +progress from 65/65 to 63/65 and the number was the only symptom. + ## Commands | Command | Does | |---|---| | `brain init` | Creates `docs/brain/`. Refuses a `docs/brain/` it did not write | | `brain capture "" [-a @Anchor]…` | Queues an entry. Never commits | -| `brain staged` | Lists the queue with the shard each entry would land in | +| `brain staged [--since ] [--slice ] [--fail-if-empty]` | Lists the queue with the shard each entry would land in | | `brain promote [--to ]` | Writes it into its shard | | `brain drop ` | Removes it from the queue or from its shard | | `brain capture "" --slice [-a @Anchor]…` | Queues a **requirement** of that slice | | `brain capture "" --open [-a @Anchor]…` | Queues an **open question**; anchors not checked | | `brain resolve ""` | Answers it, turning it into a decision in place | -| `brain plan` | Each slice's requirements counted against the model | +| `brain plan [--slice ]` | Each slice's requirements counted against the model | +| `brain brief --slice \| --module ` | The reading pack: exactly the shards that work needs | | `brain check [--changed]` | Anchors resolve, entries filed correctly, plus slice progress | | `brain show []` | Entries, lines and headroom per shard | Dropping the last entry from a module shard deletes the file, so the directory does not accumulate husks that read as "this module has decisions". + +Every command takes `--json`, so a dispatcher running one agent per slice can +act on the answers rather than read them. `brain staged --since ` is the +slice boundary: note `last_id` before handing a slice off, pass it back +afterwards, and `--fail-if-empty` exits 1 on a slice that recorded nothing. + +`--since` rather than `--slice`, because `capture --slice` is what makes an entry +a *requirement* — a decision found while building a slice carries no slice at +all, and a slice's findings are mostly decisions. The queue is append-only, so +its own order is the honest boundary. diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index c72a818c9b..3003709d22 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -43,9 +43,36 @@ This catches everything Level 1 catches, plus: - Microflow calls to non-existent microflows - Page layouts that don't exist in the project - Association endpoints pointing to missing entities +- A plain `create` whose name already exists in the project This is the check you should run before executing a script. It's fast (reads the project but doesn't modify it) and catches most mistakes. +### Name conflicts with the project + +A plain `create` of something the project already has is reported here rather +than left to `exec`: + +``` +statement 4: association already exists in project: Sales.Order_Customer — use CREATE OR MODIFY to update it +``` + +This matters because `exec` stops at the *first* such statement, having already +written the ones before it — so a script that only fails at execution time +leaves the project half-modified. `--references` reports every conflict in the +script up front, before anything is written. + +Three spellings say "it is fine if this already exists", and none of them is +reported as a conflict: + +| Form | What exec does | +|------|----------------| +| `create or modify X` | rewrites the element | +| `create or replace X` | rewrites the element | +| `create X if not exists` | leaves the stored element untouched | + +`create module M;` is also never reported: it is a no-op when the module exists, +which is why it can safely open every script. + ## Level 3: Full project validation with mx check After executing your script, validate the entire project using the Mendix toolchain: diff --git a/docs-wiki/bug-patterns/duplicate-resolver-drift.md b/docs-wiki/bug-patterns/duplicate-resolver-drift.md index 01cb279764..ca5fb1856e 100644 --- a/docs-wiki/bug-patterns/duplicate-resolver-drift.md +++ b/docs-wiki/bug-patterns/duplicate-resolver-drift.md @@ -28,7 +28,7 @@ defect wearing different clothes. ## How it fits -**Four places the duplication keeps appearing.** +**Five places the duplication keeps appearing.** *`check` versus `exec`.* The two passes historically ran different validator sets over the same script, so `check` could reject what `exec` wrote and `exec` could @@ -55,6 +55,17 @@ construction. The folder clause is the clean example: every doctype's `FOLDER` handling had the same bug, and the report that named one of them read as a doctype-specific defect. +*Two walks over one tree.* The duplication does not need two *resolvers* — two +traversals of the same structure drift the same way, and faster, because each +hand-enumerates the branches it descends into. Both walks over a workflow's +activity tree carried their own switch over `ConditionOutcome`; each was missing +a different subset (one skipped enum outcomes and every boundary-event body, the +other only the boundary events), so a `call microflow` in a decision's enum +branch went unwired while the same statement in the main flow was fine. The +interface already exposed the accessor the switches were standing in for +(`GetFlow`), which is the tell for this variant: a hand-written switch over an +interface's implementations answers a question the interface answers already. + **The tell is that the fix for the reported instance is obviously incomplete.** When a symptom's cause is "this switch was missing a case", the next question is how many other switches answer the same question — the answer has repeatedly been diff --git a/docs-wiki/bug-patterns/mutator-addressing.md b/docs-wiki/bug-patterns/mutator-addressing.md index be6e765177..33ff61b9a3 100644 --- a/docs-wiki/bug-patterns/mutator-addressing.md +++ b/docs-wiki/bug-patterns/mutator-addressing.md @@ -48,11 +48,24 @@ BEFORE and AFTER position a widget among siblings, and treating them as INTO would silently put widgets somewhere the script did not ask for. **Property lookup is per-shape, and the shapes differ.** A button's text is a -`CaptionTemplate`, not a `Caption`. Page-level property names are case-sensitive -while widget ones are matched lowercase. A column's value kind comes from the +`CaptionTemplate`, not a `Caption`. A column's value kind comes from the schema — expression, primitive or text template — and writing a string where a reference belongs is accepted by everything and visible to nothing. +**A key stored case-sensitively still has to be matched case-insensitively.** +CREATE has always resolved the author's spelling case-insensitively, so any +resolver on the ALTER side that does not is a verb the tool accepts on the way in +and rejects on the way back out — and DESCRIBE, which prints canonical capitalised +names, hands the author the spelling that fails. This has now been the cause +twice: first for first-class widget properties (`set class`), then for pluggable +ones (`set PageSize` on a grid CREATE had just written with `PageSize: 20`), where +the second fix was blocked for a month by the first one's comment asserting that +template keys must match exactly. The way to settle it is to **measure the +ambiguity rather than assume it**: relaxing the match is safe exactly when no +single lookup scope holds two keys differing only in case, which across every +shipped widget template is 0 of 1208 keys — and a test pins that as templates +are added. + **Hand-built BSON drifts from codec-built BSON.** The mutator constructs documents directly while CREATE goes through the codec, so the two encodings of "the same" widget diverge — an empty-string value where the codec writes an diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 500023d960..00720ab3e0 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -618,7 +618,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Drop module role | `drop module role Mod.Role;` | | | Create user role | `create user role Name (Mod.Role, ...) [manage all roles];` | Aggregates module roles | | Alter user role | `alter user role Name add\|remove module roles (Mod.Role, ...);` | | -| Drop user role | `drop user role Name;` | | +| Drop user role | `drop user role [if exists] Name;` | `if exists` makes a cleanup script re-runnable | | Grant microflow access | `grant execute on microflow Mod.MF to Mod.Role, ...;` | | | Revoke microflow access | `revoke execute on microflow Mod.MF from Mod.Role, ...;` | | | Grant nanoflow access | `grant execute on nanoflow Mod.NF to Mod.Role, ...;` | | @@ -633,7 +633,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Enable guest access | `alter project security guest access on role UserRole;` | Anonymous users. The role is what visitors get — its entity access is the public surface. Mendix fails the build without one (CE0133), so `on` is refused unless a role is given or already stored. mxcli validates the role exists; Mendix does not | | Disable guest access | `alter project security guest access off;` | Keeps the stored role, so re-enabling needs no `role` clause | | Create demo user | `create demo user 'name' password 'pass' [entity Module.Entity] (UserRole, ...);` | | -| Drop demo user | `drop demo user 'name';` | | +| Drop demo user | `drop demo user [if exists] 'name';` | `if exists` makes a cleanup script re-runnable | ## Workflows @@ -646,9 +646,9 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a **Workflow Activity Types:** - `user task '' [page Mod.Page] [targeting [users|groups] microflow Mod.MF] [targeting [users|groups] xpath ''] [outcomes '' { } ...];` -- `call microflow Mod.MF [as ] [comment ''] [outcomes '' { } ...];` -- `call workflow Mod.WF [as ] [comment ''];` -- `decision [] [''] outcomes '' { } ...;` +- `call microflow Mod.MF [as ] [comment ''] [with ( = '', ...)] [outcomes '' -> { } ...];` +- `call workflow Mod.WF [as ] [comment ''] [with ( = '', ...)];` +- `decision [] [''] outcomes -> { } ...;` - `parallel split [] path 1 { } path 2 { };` - `jump to ;` - `wait for timer [] [''];` @@ -664,9 +664,17 @@ and when reproducing a workflow Studio Pro authored: Studio Pro names activities by type and ordinal (`decision1`, `split1`, `callMicroflow1`) regardless of caption, so `describe workflow` emits the name whenever it is not derivable. -**Decision outcomes** are enumeration value identifiers, bare (`Approved`) or -qualified (`Module.Enum.Approved` — the form Studio Pro stores). Free text with -spaces is rejected (`MDL-WF03`). +**Decision outcomes** are `true` / `false` for a boolean decision, and a **fully +qualified** enumeration value identifier — `Module.Enumeration.Value` — for an +enum decision, plus one `'' -> { }` outcome for "none of the above" (without it +the build fails `CE6686`). Anything shorter is refused as `MDL-WF03`, and by +`exec`: Mendix parses the value when the project is **loaded**, so a bare +`'Approved'` — or `'Status.Approved'`, even when the enumeration is in the same +module — is not a build error but a `StorageLoadException` that leaves the +project unopenable in Studio Pro and mxbuild. + +**Parameter values in `with (...)` are quoted strings**, not bare variables: +`call microflow Mod.MF with (Request = '$WorkflowContext')`. **Example:** ```sql @@ -770,6 +778,7 @@ alter workflow Module.OrderApproval | Show home pages | `show navigation homes;` | Home page assignments across profiles | | Describe navigation | `describe navigation [Profile];` | Full MDL output (round-trippable) | | Create/replace navigation | `create or replace navigation Profile ...;` | Full replacement — and **creates** the profile if the project does not have it | +| Offline sync | `sync ( sync Mod.Entity all; ... )` | A clause of CREATE NAVIGATION. Modes: `online`, `all`, `where ''`, `never`, `none`, `none preserve data`. **Not** Studio Pro's captions — its "All Objects" is `all`, its "By XPath" is `where`. An offline profile downloads nothing without this | | Profile kinds | `Responsive` · `Phone` · `Tablet` · `ResponsiveOffline` · `PhoneOffline` · `TabletOffline` | A closed set. An invented name (`Mobile`) is an error, not a new profile: the runtime routes on User-Agent to Mendix's own kinds. Native profiles are a different document type and are not creatable | | Offline profiles | `create or replace navigation TabletOffline ...;` | Same properties as the online twin, but every page the profile can reach may bind an attribute across **at most one** association hop (**CE6206**). Creating one reports the documents that already exceed that | @@ -815,6 +824,27 @@ name at all: | `icon glyph 57377` | `Forms$GlyphIcon` | a numeric character code | | `icon image MyModule.Images.logo` | `Forms$ImageIcon` | a name in an image collection | +**Browse the glyph codes with `show glyphs`.** A glyph is a character code in a +font, not a document in the project, so there is nothing to scope with `IN` and +no connection is needed: + +```sql +show glyphs; -- all 247, with names +show glyphs like 'star'; -- 57350 star, 57351 star-empty +describe glyph 57350; -- by code +describe glyph 'star'; -- or by name +``` + +**A glyph code the font does not define is reported (MDL078, a warning).** A +glyph code is a bare integer, so nothing resolves it: `mxcli check` and `mx check` +both pass at 0 errors and the failure lands at `mxbuild --target=deploy`, as +*"An exception occurred while exporting layout ''"* — naming a +document that is not the cause. Measured on 11.14.0: mxbuild resolves the code +through a LINQ `.First(...)` in `GlyphFont.GetClass`, which throws on an absent +one. The rule checks the 247 codes the shipped font actually defines. Prefer an +icon collection reference, which `check --references` resolves before anything is +written. + The bare form is the icon-collection icon, so every existing script keeps its meaning. The keyword forms exist because writing a bare name for an image icon would rebuild it as a collection icon — a silent variant swap. diff --git a/docs/11-proposals/PROPOSAL_first_class_expressions.md b/docs/11-proposals/PROPOSAL_first_class_expressions.md new file mode 100644 index 0000000000..066de42c44 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_first_class_expressions.md @@ -0,0 +1,171 @@ +--- +title: First-class expressions for expression-typed MDL properties +status: draft +date: 2026-09-08 +related: + - https://github.com/mendixlabs/mxcli/issues/750 + - PROPOSAL_expression_type_checking.md + - docs/13-decisions/0003-mdl-is-sql-shaped.md +--- + +# First-class expressions for expression-typed MDL properties + +> Written for `mendixlabs/mxcli#750`, which lists four design questions and +> defers them to a proposal. This answers them, and corrects the framing the +> problem is usually reported with. + +## 1. The problem, and what it is *not* + +A property that holds a Mendix expression but is declared as a quoted string +forces every quote inside it to be doubled: + +```mdl +dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''' +``` + +That `'''''` is `else ''` — an empty string — inside a doubled-quote string. +Counted across the shipped skills and examples: **23 runs of four or more +consecutive quotes**, twelve of them five-long. + +The worst case is not additive but *multiplicative*, and it turns up wherever a +stored value already carries Mendix's own escaping. An offline sync constraint +stored as `contains(ActionValue, '''abc''')` re-emitted into a quoted MDL +string became: + +```mdl +where '[ ( contains(ActionValue, ''''''abc'''''') ) ]' +``` + +Six. Correct, verified by round trip, and unreadable. + +**It is not caused by expressions being stored as strings.** That framing is +natural and wrong, and `PROPOSAL_expression_type_checking.md` already corrected +it once, in its 2026-06-19 revision: + +> It assumed our microflow expressions are stored as **raw strings**… In fact +> our visitor **already parses expressions into typed `mdl/ast` nodes**. + +XPath is parsed too — `XPathPathExpr`, `XPathStep`, `AttributePathExpr`. And a +retrieve's constraint already round-trips first-class: + +``` +where Distance > 0 and contains($L/Name, 'abc'); +``` + +So the machinery exists and is used. The defect is narrower and more tractable: +**specific properties are declared as generic quoted-string slots** while the +expression grammar sits unused beside them. `dynamicclasses` is handled by name +in `mdl/backend/pagemutator/mutator.go:2526` as an ordinary string property; it +never meets an expression rule at all. + +## 2. There are two expression families, not one + +This is the question #750 flags and the reason it has not moved: + +> Note `[ … ]` today parses `xpathExpr` (XPath-flavored), whereas +> `dynamicclasses` is a full **Mendix microflow expression**. + +The families are genuinely different languages: + +| | Grammar | Shape | Used by | +|---|---|---|---| +| **XPath constraint** | `xpathConstraint: LBRACKET xpathExpr RBRACKET` | `[Amount > 0 and contains(Name, 'x')]` | `visible:`, `editable:`, `retrieve … where`, offline `sync … where` | +| **Mendix expression** | microflow `expression` | `if $x/F then 'a' else ''` | `dynamicclasses`, `DynamicCellClass`, page-variable defaults, calculated attributes | + +**Recommendation: do not unify them.** One delimiter over two grammars means +either a parser that guesses which language it is reading, or an XPath rule +quietly extended until it accepts `if … then … else` — and a value that parses +under the wrong grammar produces a document that stores cleanly and fails at +build. The delimiter should follow the family. + +A worked precedent for the XPath half already exists, added while this proposal +was being written: `sync … where` takes `[…]`, `DESCRIBE` emits it, and the +quoted form still parses. It took a grammar alternative and eight lines of +visitor. That is the shape of every XPath-family slot. + +## 3. Answers to #750's four questions + +### 3.1 Delimiter + +- **XPath family: reuse `[ … ]`.** It already means "XPath constraint" in three + places; a fourth is free, and readers already know it. +- **Expression family: reuse the microflow `expression` rule with no delimiter + at all**, the way `if`/`while`/`return` already do — the property's `:` is the + delimiter: + + ```mdl + dynamicclasses: if $currentObject/Featured then 'is-featured' else '' + ``` + + Brackets here would be actively misleading: `[…]` reads as XPath everywhere + else in MDL, and this is not XPath. + + The cost is real and worth stating: an undelimited expression has to end + somewhere, and inside a `(key: value, …)` property list that means the + expression grammar must not consume the `,` or `)`. This is the one place the + work is more than additive, and it is why the XPath half should ship first. + +### 3.2 Backward compatibility + +Keep the quoted form parsing everywhere it parses today, permanently. It is not +deprecated: a quoted `'…'` remains the way to write a value that really is a +plain string, and the distinction is what lets a reader tell them apart. + +Two properties make this safe rather than merely polite: the two forms must +produce an **identical** stored value, and there must be a test that says so. +The `sync … where` implementation has one; every slot converted needs its own. + +### 3.3 DESCRIBE + +Emit the first-class form. That is what makes the change worth doing — it is not +cosmetic, because a describer emitting the quoted form has to escape, and +escaping is where these defects live: + +- `#1006` — `DESCRIBE WORKFLOW` emitted `TARGETING USERS XPATH` without doubling + inner quotes, so its own output failed `mxcli check`. +- `#394` — `DESCRIBE ENUMERATION` emits unescaped single quotes in captions. +- `#642` — the quoted `where ''` form mis-stored *every* constraint + (CE0161) while the bracket form was correct. + +Three bugs, one root: a describer that has to escape eventually will not. +Emitting a form that needs no escaping removes the class. + +**One caveat measured on the offline-sync change.** A stored constraint carries +Studio Pro's own whitespace, and the first rewrite normalises it — describe → +exec → describe differs once, then is stable. That is `normalizeXPathTokens`, +which every retrieve constraint already goes through, so it is existing +behaviour rather than something the first-class form introduces. Worth stating +in any slice's acceptance criteria so it is not mistaken for a round-trip bug. + +### 3.4 Scope + +Ship in family order, XPath first because it is additive: + +1. **XPath family** — audit for slots still taking a quoted constraint. `sync … + where` is done; `targeting users xpath` (#1006's slot) is the obvious next. +2. **Expression family, single-value slots** — `dynamicclasses`, + `DynamicCellClass`. Highest-value by the count in §1, and the ones #750 names. +3. **Expression family, inside property lists** — page-variable defaults, + calculated attributes. Needs §3.1's termination question settled first. + +## 4. What this unlocks + +`PROPOSAL_expression_type_checking.md` needs its checker fed from parsed +expressions. A slot that stores an opaque string has nothing to feed it: today +`dynamicclasses` cannot be type-checked at all, because by the time it reaches +the executor it is a string that was never parsed. Converting a slot to the +first-class form is therefore the precondition for checking it, and the two +proposals compose rather than compete. + +## 5. Open question + +**Whitespace inside string literals is not safe to normalise, and at least one +code path did.** Folding a multi-line constraint with `strings.Fields` collapses +runs of whitespace *inside* quoted literals too, so `'two spaces'` silently +becomes `'two spaces'` — a change to the value being matched on, in a place +nobody would look. Found and fixed in the offline-sync describer, where the fold +is now quote-aware. + +Whether any other expression or XPath path folds or normalises whitespace +without tracking quote state is **unaudited**, and it is the kind of defect that +leaves no trace: the document stays valid and the build stays green. diff --git a/docs/11-proposals/PROPOSAL_offline_sync_configuration.md b/docs/11-proposals/PROPOSAL_offline_sync_configuration.md index 217c744791..be765dbae9 100644 --- a/docs/11-proposals/PROPOSAL_offline_sync_configuration.md +++ b/docs/11-proposals/PROPOSAL_offline_sync_configuration.md @@ -4,6 +4,7 @@ status: draft date: 2026-09-07 related: - navigation-support.md + - https://github.com/ako/TestApp - docs/13-decisions/0003-mdl-is-sql-shaped.md - docs/13-decisions/0005-semantic-model-interface-currency.md --- @@ -11,9 +12,12 @@ related: # Offline synchronization configuration > Prompted by a CapTrack screenshot of Studio Pro's **Customize offline -> synchronization** dialog: three entities, three different sync modes, one XPath -> constraint. mxcli can create the profile that dialog belongs to, and can read -> every row in it, and cannot write a single one. +> synchronization** dialog. mxcli can create the profile that dialog belongs to, +> and can read every row in it, and cannot write a single one. +> +> Pinned against `ako/TestApp`, whose `TabletOffline` profile configures seven +> entities across all six sync modes — so §2.1 onward is measured against a +> stored document rather than inferred from the UI. ## 1. Problem @@ -59,45 +63,65 @@ A hand-configured sync setup therefore survives `CREATE OR REPLACE NAVIGATION` today. This is a clean gap, not a data-loss defect — the opposite of what `create or modify entity` was doing to access rules. -### 2.1 The read is lossy, and that is the hazard +### 2.1 Measured against a real document -`modelsdk/gen` declares **six** properties on `Navigation$OfflineEntityConfig`: +`ako/TestApp` carries a `TabletOffline` profile with **seven** configured +entities, covering **all six** members of the sync enum — so the shape below is +observed, not derived: + +| Entity | SyncMode | Constraint | CompatibilityMode | +|---|---|---|---| +| `Mappings.Customer` | `Never` | — | false | +| `Pages.Bus` | `All` | — | false | +| `Rules.BusinessRule` | `Online` | — | false | +| `Rules.RuleAction` | `Constrained` | `[\n (\n contains(ActionValue, '''abc''')\n )\n]` | false | +| `Rules.RuleCategory` | `None` | — | false | +| `Rules.RuleExecutionLog` | `NoneAndPreserveData` | — | false | +| `System.Language` | `All` | — | false | + +**Studio Pro writes exactly four properties**, not the six `modelsdk/gen` +declares: ``` -Entity DownloadMode ShouldDownload SyncMode Constraint CompatibilityMode +CompatibilityMode Constraint Entity SyncMode ``` -`mdl/types.NavOfflineEntity` keeps **three** — `Entity`, `SyncMode`, -`Constraint`. `DownloadMode`, `ShouldDownload` and `CompatibilityMode` are read -and discarded. `CompatibilityMode` is the column carrying warning triangles in -the screenshot, so it is not hypothetical. - -That matters the moment authoring exists. A writer that builds a config element -from the three fields the semantic model carries writes a document missing the -other three — which is precisely the class of defect that had -`create or modify entity` deleting access rules, and `create or modify entity` -is the more instructive precedent: the fix there was not to extend the carry -list but to **invert the direction**, starting from what is stored and -overwriting only what the statement declares. - -So this proposal's first rule: **the read must carry all six properties before -the write carries any.** MDL will be able to spell three of them; the other -three must survive a rewrite untouched. The precedent is `ruleInfoFromGen` for -validation rules, where the payload is carried on READ specifically so a -rewrite can be refused or preserved rather than silently downgraded. - -### 2.2 `generated/metamodel` and `gen` disagree, and the snapshot is why - -`generated/metamodel.NavigationOfflineEntityConfig` declares **three** -properties — `Constraint`, `Entity`, `SyncMode`. CLAUDE.md makes -`generated/metamodel` the arbiter when the two disagree, with one caveat that -applies exactly here: it is a **snapshot of 11.6.0**, so it is sound for what it -contains and says nothing about properties introduced later. - -`CompatibilityMode` appears in the Studio Pro UI of the version in the -screenshot. The likeliest reading is that it postdates the snapshot rather than -that `gen` invented it — but *likeliest* is not measured, and the rule for that -is to get a real document. +`DownloadMode` and `ShouldDownload` occur **zero times** in the document. gen +declares them, and nothing on a web profile writes them — presumably native-only. +That shrinks the carry problem considerably: the semantic model drops **one** of +the four written properties, `CompatibilityMode`, not three of six. + +Three further observations that change the design: + +- **`System.Language` is configured.** A validation rule that refuses System or + Marketplace entities here would reject a real document. +- **The constraint is stored multi-line**, with embedded newlines and Mendix's + doubled-quote escaping (`'''abc'''` — a quoted literal inside a quoted XPath). + MDL's own string escaping has to survive a round trip of that, which is the + one part of the syntax with a non-obvious test. +- **The typed-array marker is `3`**, and `OfflineEntityConfigs` is present on + every web profile including the online `Responsive` one, where it is the empty + `[3]`. + +### 2.2 Two properties the generated sources do not have + +**`CompatibilityMode` is real.** It is written on all seven configs. +`generated/metamodel` declares three properties and is missing it; `gen` has it. +This is exactly the documented caveat on the arbiter rule — `generated/metamodel` +is a **snapshot of 11.6.0**, sound for what it contains and silent about +anything added later. Settled in gen's favour, by a document. + +**`ThrowPartialSyncError` is in neither.** The screenshot's "Throw error when +server rejects objects during synchronization" is stored as a profile-level bool +of that name, and it occurs **zero times** in `modelsdk/gen` *and* zero times in +`generated/metamodel`. It is also on the online `Responsive` profile, so it +belongs to every web profile rather than to offline ones. + +A property neither generated source knows about cannot be written through the +codec's typed accessors at all. It has to be an overlay onto the stored profile +document — which is what `mdl/settingsoverlay` already does, and which brings its +rules with it: write only a key the document already carries, and never invent +one. That is a constraint on the design, not a detail. ## 3. The trap this feature is walking into @@ -107,8 +131,14 @@ The sync mode is an enumeration, and `generated/metamodel` declares six members: All Constrained Never None NoneAndPreserveData Online ``` -Studio Pro's dropdown shows **captions**: "Online", "All Objects", "By XPath". -Neither "All Objects" nor "By XPath" is a member of the enumeration. +Studio Pro's dropdown shows **captions** — the screenshot's three visible rows +read "Online", "All Objects" and "By XPath". Neither "All Objects" nor "By XPath" +is a member of the enumeration. + +The reference document settles the mapping rather than leaving it to be guessed: +the entity whose row shows "By XPath" is stored as `Constrained`, and the one +showing "All Objects" as `All`. All six members occur in that one profile, so +the dialog exposes more captions than the screenshot happened to show. This is the same defect that shipped as `mendixlabs/mxcli#1035` two days ago — `gallery.def.json` stored `pagingPosition: "below"` because "Below grid" was the @@ -159,10 +189,21 @@ The cost is that `Constrained` has no bare word, which is correct: there is nothing to say. **Every mode word maps to one enum member, and the mapping is a table with a -test.** `ONLINE`→`Online`, `ALL`→`All`, `NEVER`→`Never`, `WHERE`→`Constrained`. -`None` and `NoneAndPreserveData` need words too — and need a reference document -before they get them, because the difference between them is data retention on -the device and the dialog in the screenshot does not obviously expose either. +test.** All six members occur in the reference document, so all six need words +and none is speculative: + +| MDL | Stored | +|---|---| +| `ONLINE` | `Online` | +| `ALL` | `All` | +| `NEVER` | `Never` | +| `WHERE ''` | `Constrained` | +| `NONE` | `None` | +| `NONE PRESERVE DATA` | `NoneAndPreserveData` | + +`None` and `NoneAndPreserveData` differ in whether data already on the device +survives, which is why the second is a modifier on the first rather than an +unrelated word. ### 4.2 Writing @@ -172,11 +213,19 @@ deliberately not proposed: the list is small, wholly visible in one describe, and diff-friendly as a block. The write is an **overlay on the stored element**, not a rebuild — §2.1. For an -entity already configured, `DownloadMode`, `ShouldDownload` and -`CompatibilityMode` are read from the stored config and written back unchanged. -For a newly added entity they take the codec's declared defaults, which is the -one case where a reference document is load-bearing: a default guessed wrong is -invisible until a device syncs. +entity already configured, `CompatibilityMode` is read from the stored config and +written back unchanged; it is the only written property MDL will not be able to +spell. A newly added entity gets `false`, which is what all seven reference +configs carry. + +`DownloadMode` and `ShouldDownload` are **not written**, and the writer must not +start writing them. A property absent from every real document is one Studio Pro +fills in on load; emitting it is how a document mxbuild accepts becomes one +Studio Pro cannot open. + +`ThrowPartialSyncError` is profile-level and unknown to both generated sources +(§2.2), so it is a separate raw-BSON overlay under the guard-don't-drop rules — +written only onto a document that already carries the key. ### 4.3 Catalog and references @@ -186,30 +235,36 @@ This is the same argument that made a widget a reference target — "which profiles sync this entity?" is the question an offline change asks, and it is currently unanswerable. -## 5. What needs a reference document before implementation - -There is **no local project with a populated offline entity config.** Every -project on this machine carries the empty `OfflineEntityConfigs` key, which -every navigation profile has; none carries a `Navigation$OfflineEntityConfig` -element. An earlier scan of this reported five hits and was wrong — it matched -the key name, not the element. - -So the shape must come from a real document, and CapTrack in the screenshot is -one: three entities, three modes, one XPath constraint, and visible -compatibility-mode state. - -What a dump of that document settles, none of which should be guessed: - -1. **Which of the six properties Studio Pro actually writes**, and their - defaults — particularly whether `ShouldDownload` and `DownloadMode` are - written at all on a web profile or are native-only. -2. **Whether `CompatibilityMode` really exists on this version**, closing §2.2. -3. **What the caption-to-key mapping is**, confirming "All Objects"→`All` and - "By XPath"→`Constrained` rather than assuming it. -4. **Where "Throw error when server rejects objects during synchronization" - lives.** It is on neither `gen`'s `NavigationProfile` nor the metamodel's, so - it is either a later property or is not stored on the profile at all. It is - in the screenshot, so it is stored somewhere. +## 5. The reference document + +`ako/TestApp` is the reference, and its `TabletOffline` profile answers every +question this proposal originally listed as blocking. The four are closed: + +1. **Which properties Studio Pro writes** — four: `Entity`, `SyncMode`, + `Constraint`, `CompatibilityMode`. `DownloadMode` and `ShouldDownload` occur + zero times and must not be written (§2.1). +2. **Whether `CompatibilityMode` exists on this version** — yes, on all seven + configs. `gen` is right and `generated/metamodel` is a stale snapshot, exactly + as its documented caveat allows (§2.2). +3. **The caption-to-key mapping** — confirmed against the stored values, with all + six enum members present in one profile (§3). +4. **Where the throw-on-reject setting lives** — `ThrowPartialSyncError`, a + profile-level bool on every web profile, and **absent from both generated + sources** (§2.2). + +An earlier scan of this machine reported five projects with offline configs and +was wrong: it matched the `OfflineEntityConfigs` key that every profile carries, +not the element. No local project has one; `ako/TestApp` had to be fetched. + +What is still unmeasured, and does not block phase 1: + +- **Native profiles.** `OfflineEntityConfigs` is declared on + `NativeNavigationProfile` too, and `DownloadMode`/`ShouldDownload` are the + obvious candidates for being written there. Nothing in this proposal touches + native, and a native reference document is needed before it does. +- **`CompatibilityMode: true`.** All seven references are `false`, so the value + is carried but the true case has never been seen. Carrying it is safe; + authoring it is not proposed. ## 6. Phasing @@ -221,8 +276,11 @@ What a dump of that document settles, none of which should be guessed: closes the describe → exec round trip. 3. **Catalog rows and the reference edge.** -`None` / `NoneAndPreserveData` words, and the throw-on-reject setting, are held -back to whichever slice the reference document lands in. +`ThrowPartialSyncError` belongs to slice 2 but is a separate mechanism — a +raw-BSON overlay rather than a codec write (§2.2) — so it can land after the +`SYNC` block without holding it up. + +Native profiles are out of scope until a native reference document exists (§5). ## 7. Overlap diff --git a/mdl-examples/bug-tests/1071-foldered-enum-references.mdl b/mdl-examples/bug-tests/1071-foldered-enum-references.mdl new file mode 100644 index 0000000000..f17b45b012 --- /dev/null +++ b/mdl-examples/bug-tests/1071-foldered-enum-references.mdl @@ -0,0 +1,78 @@ +-- ============================================================================ +-- `check --references` reported every enumeration missing (mendixlabs/mxcli#1071) +-- ============================================================================ +-- +-- Symptom (before fix): +-- +-- $ mxcli check script.mdl -p project.mpr --references +-- Reference errors: +-- statement 4: attribute 'CriticalPathStation': enumeration not found: Approval.StationKey +-- +-- while DESCRIBE ENUMERATION returned its values, SHOW ENUMERATIONS listed +-- it, and mxbuild built the same declaration at 0 errors. A pure FALSE +-- NEGATIVE: `exec` writes the attribute and the project checks clean, so the +-- only thing broken was the checker. +-- +-- It read as "enumerations are never resolved", because the reporter's module +-- keeps its enumerations in folders — and that is the whole discriminator. +-- +-- Root cause: +-- `enumerationExists` matched containers directly, +-- +-- if enum.ContainerID == module.ID && enum.Name == enumName +-- +-- which only ever holds for an enumeration sitting directly in the module +-- ROOT. One inside a folder has the FOLDER as its container. Every other +-- command resolves through the container hierarchy, which walks folders up to +-- the module — so the reference checker was the only one that could not see +-- inside a folder. +-- +-- This is the same defect as upstream #976, which fixed DROP and did not +-- sweep for the other caller of that question. +-- +-- After fix: +-- `enumerationExists` defers to `findEnumeration`, deleting the duplicate +-- rather than patching one copy of it. Both call sites are covered: ALTER +-- ENTITY ADD ATTRIBUTE (reported) and CREATE ENTITY with an enumerated +-- attribute (not reported, measured before fixing). +-- +-- WHAT THIS FILE PROVES, AND WHAT IT DOES NOT +-- Executing it proves nothing about the bug: `exec` always worked. The defect +-- is only visible to `check --references` against a project that HAS the +-- foldered enumeration, so run the two commands at the bottom. The automated +-- coverage is in mdl/executor/validate_enum_folder_test.go, which exercises +-- both call sites and keeps the controls. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1071-foldered-enum-references.mdl -p app.mpr +-- -- then, against that same project: +-- mxcli check -p app.mpr --references +-- ============================================================================ + +create module Approval; +create or modify entity Approval.ApprovalRun ( Name: string(200) ); + +-- The control: an enumeration at the module root. This one resolved before the +-- fix too, which is what makes the failure below a container-resolution bug +-- rather than "references are broken". +create or modify enumeration Approval.RootEnum ( A 'Alpha', B 'Bravo' ); + +-- The reported shape: an enumeration filed in a folder. +create or modify enumeration Approval.StationKey ( S1 'Station one', S2 'Station two' ); +move enumeration Approval.StationKey to folder 'Enums'; + +-- Both of these must pass `check --references`. Before the fix the first was +-- reported as "enumeration not found" and the second passed. +alter entity Approval."ApprovalRun" + add attribute if not exists "CriticalPathStation": Enumeration(Approval.StationKey); +alter entity Approval."ApprovalRun" + add attribute if not exists "RootAttr": Enumeration(Approval.RootEnum); + +-- The other call site, which the report did not mention: CREATE ENTITY resolves +-- enumerated attributes through the same helper and failed the same way. +create or modify entity Approval.NewThing ( + Name: string(200), + Station: Enumeration(Approval.StationKey) +); + +show enumerations in Approval; diff --git a/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl b/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl index 466e215fd2..0faf6077cb 100644 --- a/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl +++ b/mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl @@ -23,8 +23,13 @@ -- After fix: -- setRawWidgetPropertyMut matches first-class properties via strings.ToLower, so -- `set class`/`set dynamicclasses`/`set caption` (any case) work on built-in --- widgets. The pluggable fallback keeps original casing (template keys are --- case-sensitive). +-- widgets. +-- +-- The pluggable fallback was left case-SENSITIVE here, on the belief that +-- template keys must be matched exactly. That was wrong the same way, and +-- surfaced as mendixlabs/mxcli#1069: `set PageSize` failed on a grid CREATE had +-- just written with `PageSize: 20`. It now resolves case-insensitively too — +-- see alter-page-pluggable-property-casing.mdl. -- -- Usage: -- mxcli exec mdl-examples/bug-tests/alter-page-lowercase-set-on-builtin.mdl -p app.mpr diff --git a/mdl-examples/bug-tests/alter-page-pluggable-property-casing.mdl b/mdl-examples/bug-tests/alter-page-pluggable-property-casing.mdl new file mode 100644 index 0000000000..9edb316b86 --- /dev/null +++ b/mdl-examples/bug-tests/alter-page-pluggable-property-casing.mdl @@ -0,0 +1,76 @@ +-- ============================================================================ +-- ALTER PAGE ... SET rejected a pluggable property CREATE had accepted +-- Upstream issue: mendixlabs/mxcli#1069 +-- ============================================================================ +-- +-- Symptom (before fix): +-- `create page` accepted `PageSize: 20` on a DataGrid 2 and the app paged at +-- 20 — but the same spelling in ALTER PAGE hard-errored: +-- Error: failed to set: failed to set PageSize on dgProducts: +-- pluggable property "PageSize" not found +-- `mxcli check --references` passed the script, so the failure only appeared +-- at exec, after earlier statements had already been written. The workaround +-- was to re-emit the whole page with CREATE OR MODIFY — a two-value edit +-- turned into a 190-line script. +-- +-- Aggravating it: DESCRIBE PAGE prints the property CAPITALISED +-- (`PageSize: 10`), so describe -> edit -> exec produced a script mxcli then +-- refused to run. +-- +-- Root cause: +-- A pluggable property is keyed in the widget template in lowerCamel +-- (`pageSize`). CREATE resolves the author's spelling case-insensitively +-- (the widget engine's lookupProperty, and WidgetV3.GetStringProp before it). +-- ALTER went through setPluggableWidgetPropertyMut, which compared the +-- template key BYTE-FOR-BYTE. Only the exact `pageSize` worked. +-- +-- This is the sequel to alter-page-lowercase-set-on-builtin.mdl, which fixed +-- the same class for FIRST-CLASS properties and left the pluggable fallback +-- case-sensitive on the belief that "template keys are case-sensitive". They +-- are stored case-sensitively; they do not need to be MATCHED that way. +-- Measured across every shipped widget template and definition: 96 property +-- scopes, 1208 keys, 0 pairs differing only in case — so the relaxed match is +-- unambiguous, not merely convenient +-- (TestPluggablePropertyKeysAreUniqueIgnoringCase pins it). +-- +-- After fix: +-- setPluggableWidgetPropertyMut resolves with strings.EqualFold, so every +-- spelling below works on both engines. A genuine typo still errors — that is +-- the author's only signal, since check --references does not resolve +-- pluggable property names. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/alter-page-pluggable-property-casing.mdl -p app.mpr +-- ============================================================================ + +create entity MyFirstModule.Product ( Title: String(200) ); + +create or replace page MyFirstModule.P_GridPaging +( + Title: 'Grid Paging', + Layout: Atlas_Core.Atlas_Default +) +{ + datagrid dgProducts (DataSource: database MyFirstModule.Product, PageSize: 20) { + column colTitle (Attribute: Title, Caption: 'Title') + } +} + +-- The statement from the issue. Before the fix: `pluggable property "PageSize" +-- not found`, and nothing after it in the script ran. +alter page MyFirstModule.P_GridPaging { + set PageSize = 10 on dgProducts; +} + +-- The template's own spelling — the only one that used to work. +alter page MyFirstModule.P_GridPaging { + set pageSize = 25 on dgProducts; +} + +-- Flat lowercase, as an author types it without thinking about the template. +alter page MyFirstModule.P_GridPaging { + set pagesize = 15 on dgProducts; +} + +-- Round-trip: DESCRIBE emits `PageSize: 15`, which is now executable MDL. +describe page MyFirstModule.P_GridPaging; diff --git a/mdl-examples/bug-tests/check-references-project-conflicts.mdl b/mdl-examples/bug-tests/check-references-project-conflicts.mdl new file mode 100644 index 0000000000..19d9e9c531 --- /dev/null +++ b/mdl-examples/bug-tests/check-references-project-conflicts.mdl @@ -0,0 +1,57 @@ +-- `mxcli check --references` under-reported project conflicts. +-- +-- stmtCreateInfo classified 24 document types; projectNameSets.setFor knew 20. +-- The four that fell through returned nil, which the caller reads as "no +-- conflicts for that type" — so a plain CREATE of an association, rule or +-- javascript action that the project already had passed the check and then +-- failed at exec, part-way through, with the earlier statements written. +-- +-- Run this twice against the same project: +-- +-- mxcli exec mdl-examples/bug-tests/check-references-project-conflicts.mdl -p app.mpr +-- mxcli check mdl-examples/bug-tests/check-references-project-conflicts.mdl -p app.mpr --references +-- +-- The second command must report a conflict for EACH of ConflictTest.Child_Parent, +-- ConflictTest.IsBig and ConflictTest.Ping — not only the entities. Before the +-- fix it reported the entities alone. + +create module ConflictTest; + +create entity ConflictTest.Parent ( Name : String(100) ); +create entity ConflictTest.Child ( Label : String(100) ); + +-- association: was not project-checked +create association ConflictTest.Child_Parent + from ConflictTest.Child + to ConflictTest.Parent; + +-- rule: was not project-checked +create rule ConflictTest.IsBig ( Amount : Decimal ) returns Boolean +begin + return $Amount > 10; +end; + +-- javascript action: was not project-checked +create javascript action ConflictTest.Ping() returns Boolean +as $$ + return true; +$$; + +-- --------------------------------------------------------------------------- +-- The other half: IF NOT EXISTS is the THIRD idempotency spelling (beside OR +-- MODIFY and OR REPLACE) and stmtCreateInfo did not recognise it. Exec skips +-- these with "already exists — skipped", but the check reported them as +-- conflicts — so a re-runnable domain script failed its own second run at +-- check time. On a second run these two statements must stay clean. +-- --------------------------------------------------------------------------- + +create entity if not exists ConflictTest.Parent ( Name : String(100) ); +create association if not exists ConflictTest.Child_Parent + from ConflictTest.Child + to ConflictTest.Parent; + +-- CREATE MODULE is deliberately NOT project-checked: it is idempotent at exec +-- (prints "already exists", exits 0), and `create module M;` opens nearly every +-- script — which is why statement 1 above must stay clean on the second run. +-- (Repeating it HERE would instead trip MDL-DUPDEF, the in-script duplicate +-- check, which is a different rule and correctly still fires.) diff --git a/mdl-examples/bug-tests/drop-security-if-exists.mdl b/mdl-examples/bug-tests/drop-security-if-exists.mdl new file mode 100644 index 0000000000..3c52ab5f95 --- /dev/null +++ b/mdl-examples/bug-tests/drop-security-if-exists.mdl @@ -0,0 +1,60 @@ +-- `drop demo user` and `drop user role` had no IF EXISTS form, so a one-time +-- cleanup either broke every later run of its slice script or had to be +-- commented out (ako/CapTrackV4 R5, and 024 for how it was found). +-- +-- The check that finds this class is one loop — the thing a fresh clone does: +-- +-- for f in $(ls mdlsource/*.mdl | sort); do ./mxcli exec "$f" -p app.mpr; done +-- +-- Five statement forms in that project succeeded once and failed after. Four had +-- an idempotent spelling already (`create or modify` for entities, associations, +-- enumerations and user roles); these two had none at all, which is why they are +-- the ones that got commented out. A script that only works once works for +-- whoever wrote it and fails for the next person. +-- +-- The spelling is the one ALTER ENTITY already uses (`drop attribute if +-- exists`), so this reuses the grammar's own ifExists rule rather than inventing +-- a second way to say it. +-- +-- Verify: +-- +-- mxcli exec drop-security-if-exists.mdl -p app.mpr +-- mxcli exec drop-security-if-exists.mdl -p app.mpr -- again, same output +-- +-- Then perturb: +-- +-- remove `if exists` from either drop below +-- -> `Error: user role not found: DropIf_Role` on the second run, and the +-- statements after it never execute. + +create or modify module role CapTrack.DropIf_Role; +/ + +create or modify user role DropIf_Role (CapTrack.DropIf_Role, System.User); +/ + +create demo user 'dropif_demo' password 'Password12345!' (DropIf_Role); +/ + +-- The cleanup. Both must be skippable, because on the second run the objects +-- above have already been removed by the first. +drop demo user if exists 'dropif_demo'; +/ + +drop user role if exists DropIf_Role; +/ + +-- ...and again, with nothing there at all. This is the run that used to fail. +drop demo user if exists 'dropif_demo'; +/ + +drop user role if exists DropIf_Role; +/ + +-- CONTROL: a drop of something that never existed is still skipped quietly, +-- so `if exists` is about presence and not about "we just deleted it". +drop user role if exists DropIf_NeverExisted; +/ + +drop demo user if exists 'dropif_never_existed'; +/ diff --git a/mdl-examples/bug-tests/navigation-glyph-code.mdl b/mdl-examples/bug-tests/navigation-glyph-code.mdl new file mode 100644 index 0000000000..c63b6d5cff --- /dev/null +++ b/mdl-examples/bug-tests/navigation-glyph-code.mdl @@ -0,0 +1,99 @@ +-- `icon glyph ` with a code the Mendix glyph font does not define broke the +-- build, blaming a document nobody had touched (ako/CapTrackV4 007, R2). +-- +-- menu item 'Overview' page M.P icon glyph 57562; +-- mxcli check -> passed +-- mx check -> 0 errors +-- mxbuild --target=deploy -> +-- ERROR: One or more errors occurred. +-- (An exception occurred while exporting layout 'CapTrack.App_Default'.) +-- +-- Neither the navigation nor the menu item is mentioned, and the layout named is +-- fine. Bisecting it cost three build cycles. +-- +-- THE MECHANISM, measured on 11.14.0 with two full builds of one project +-- differing only in the code: +-- +-- 57562 (0xEBDA) ERROR ... exporting layout 'CapTrack.App_Default' +-- -> System.InvalidOperationException: +-- Sequence contains no matching element +-- at ...Forms.Icons.GlyphFont.GetClass(Int32 code) +-- 57377 (0xE021) pages and layouts export cleanly +-- +-- GlyphFont.GetClass is a LINQ `.First(...)` over mxbuild's glyph table, so an +-- absent code throws instead of reporting. MDL078 checks the same set: the cmap +-- of the font Atlas_Core ships (glyphicons-halflings-regular.woff), 247 codes in +-- 35 runs, which agrees with mxbuild on both measured points. +-- +-- A WARNING, not an error. The table is a snapshot of a Mendix asset, and if +-- Mendix ever extends the font then refusing a newly-valid code would be worse +-- than the gap this closes. +-- +-- THE BETTER SPELLING is an icon collection reference, which is a model +-- reference and is resolved by `mxcli check --references` before anything is +-- written (finding 013): +-- +-- icon Atlas_Core.Atlas."user-neutral-group" +-- +-- Browse them with `describe icon collection Atlas_Core.Atlas`. +-- +-- AND THE GLYPHS THEMSELVES are now browsable, which they were not when MDL078 +-- landed — its advice was a list of numeric ranges with holes in it: +-- +-- show glyphs; -- all 247, with names +-- show glyphs like 'star'; -- 57350 star, 57351 star-empty +-- describe glyph 57350; -- by code +-- describe glyph 'star'; -- or by name +-- +-- Neither needs a project: a glyph is a character code in a font, not a document. +-- +-- Verify: +-- +-- mxcli check navigation-glyph-code.mdl +-- -- one MDL078 warning, for GlyphNav.PG_Bad's item only +-- +-- Then perturb: +-- +-- change 57377 to 57344 (0xE000, one below the font's first code) +-- -> a second MDL078. Before the rule: silence here, and a failed deploy +-- naming an unrelated layout. + +create module GlyphNav; +/ + +/** A page for the menu items to point at. */ +CREATE OR REPLACE PAGE GlyphNav.PG_Home ( Title: 'Home', Layout: Atlas_Core.Atlas_Default ) +{ + CONTAINER root { DYNAMICTEXT txt (Content: 'home') } +} +/ + +-- THE REPORTED CASE. 57562 is past the font's last icon (57952 / 0xE260) with +-- nothing in between, so mxbuild's lookup throws. +create or modify menu GlyphNav.MENU_Bad ( + menu item 'Overview' page GlyphNav.PG_Home icon glyph 57562; +) +/ + +-- CONTROL: the code the blank app itself ships. A rule that flagged glyphs in +-- general would reject this, and every working navigation with it. +create or modify menu GlyphNav.MENU_Good ( + menu item 'Overview' page GlyphNav.PG_Home icon glyph 57377; +) +/ + +-- CONTROL: the spelling that avoids the whole problem. An icon collection entry +-- is resolved by `check --references`, so a wrong NAME is caught immediately +-- where a wrong NUMBER never was. +create or modify menu GlyphNav.MENU_Collection ( + menu item 'Overview' page GlyphNav.PG_Home icon Atlas_Core.Atlas.dashboard; +) +/ + +-- The browsing commands, which is how the code above is meant to be found. +-- Read-only, and they work without `-p`. +show glyphs like 'star'; +/ + +describe glyph 57350; +/ diff --git a/mdl-examples/bug-tests/odata-1073-external-action-nullable-params.mdl b/mdl-examples/bug-tests/odata-1073-external-action-nullable-params.mdl new file mode 100644 index 0000000000..b950e0dac7 --- /dev/null +++ b/mdl-examples/bug-tests/odata-1073-external-action-nullable-params.mdl @@ -0,0 +1,114 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1073: a call on a NULLABLE action parameter was CE7252 +-- ============================================================================ +-- +-- Report: "no way to pass an empty/null binding for an optional parameter; +-- results in CE7252". Four MDL spellings were tried (`= null`, `= empty`, +-- a bare `= )`, and omitting the parameter) and the conclusion drawn was that +-- MDL had no syntax for the state. +-- +-- The premise was wrong and the bug was real, but they were not the same thing. +-- +-- WHAT STUDIO PRO ACTUALLY STORES. Measured on ako/TestApp (Mendix 11.14.0), +-- microflow Odata.UnboundedActionMF, against the contract below: +-- +-- ParameterName Nullable Argument CanBeEmpty +-- command false "empty" false +-- additional true "empty" true +-- +-- Two things follow, and both contradict the natural reading: +-- +-- 1. `Argument` is the EXPRESSION `empty`, not an empty string. An unfilled +-- argument in Studio Pro is the Mendix null literal. So there is no +-- "empty binding" to spell — `additional = empty` was always the right +-- MDL, and it already wrote a byte-identical Argument. `= empty` and +-- `= null` both parse and always did; the grammar rules involved are +-- byte-identical at v0.20.0, so this was never a parser gap. +-- +-- 2. `CanBeEmpty` mirrors the CONTRACT's Nullable, not anything the developer +-- typed. mxcli never set it, so it was Go's false on every mapping, and +-- Mendix reported the disagreement as +-- +-- CE7252 "The parameters for remote action '' have changed." +-- +-- the same code #1020 produced for a different missing field, which is +-- why upgrading past #1020 did not clear it. +-- +-- THE DEFAULT IS THE SUBTLE PART. CSDL makes Nullable optional on +-- and defaults it to TRUE — the opposite of Go's zero value. `note` below has +-- no Nullable attribute; defaulting it to false is CE7252, measured. +-- +-- Not fixed here, and deliberately: Studio Pro also writes empty marker arrays +-- `AdditionalAttributes` and `IncludedAssociations` (marker 2) on the call and +-- on every mapping. mxbuild 11.14 builds at 0 errors without them, and they +-- have not been verified against Studio Pro itself, so they are recorded rather +-- than guessed at. +-- +-- --------------------------------------------------------------------------- +-- The contract this script assumes (import as a consumed OData service named +-- Odata.Bug1073). The five actions are one per parameter shape that behaves +-- differently; ako/TestApp has it imported already. +-- --------------------------------------------------------------------------- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- +-- Every unbound action needs an in the EntityContainer or +-- Mendix does not consider it callable at all (CE7251). +-- +-- --------------------------------------------------------------------------- +-- WHAT THIS SCRIPT PROVES, AND WHAT IT DOES NOT +-- --------------------------------------------------------------------------- +-- It proves the WRITE path: run it against a project with the contract above +-- and `mxcli docker check --no-update-widgets` reports 0 errors, where before +-- the fix each call was one CE7252. +-- +-- It does NOT prove the read path, and cannot: CanBeEmpty is derived from the +-- contract on every write, so a re-run reproduces the correct value whether or +-- not the stored one was read back. The unit tests in +-- mdl/executor/external_action_can_be_empty_test.go carry the control. +-- ============================================================================ + +-- The reporter's shape: one required parameter, one optional. Before the fix +-- this single statement was CE7252. +create or modify microflow Odata.ACT_1073_RunCommand () +begin + call external action Odata.Bug1073.RunCommand(command = empty, additional = empty); + return; +end; + +-- Real arguments, not just `empty` — CanBeEmpty is about what the CONTRACT +-- permits, so it must be written the same way when the argument is supplied. +create or modify microflow Odata.ACT_1073_RunCommandBound () +begin + declare $Cmd string = 'restart'; + call external action Odata.Bug1073.RunCommand(command = $Cmd, additional = 'now'); + return; +end; + +-- The absent-Nullable case. Defaulting it to false instead of true reproduces +-- CE7252 on this statement alone. +create or modify microflow Odata.ACT_1073_Annotate () +begin + call external action Odata.Bug1073.Annotate(target = 'x', note = empty); + return; +end; + +-- Nullable parameters that are not strings, in case CanBeEmpty were ever +-- type-dependent. It is not. +create or modify microflow Odata.ACT_1073_Schedule () +begin + call external action Odata.Bug1073.Schedule(label = 'nightly', retries = empty, notBefore = empty); + return; +end; diff --git a/mdl-examples/bug-tests/wf-freetext-decision-outcome.fail.mdl b/mdl-examples/bug-tests/wf-freetext-decision-outcome.fail.mdl index b013de9244..c464f90aaa 100644 --- a/mdl-examples/bug-tests/wf-freetext-decision-outcome.fail.mdl +++ b/mdl-examples/bug-tests/wf-freetext-decision-outcome.fail.mdl @@ -9,9 +9,16 @@ -- outcome is not a valid EnumerationValueIdentifier (a decision branches on the -- enumeration returned by its expression). -- --- Fix: `mxcli check` now flags any decision/call-microflow outcome whose name --- is not a valid identifier as MDL-WF03 (error). 'Reopened' is a valid --- identifier and is NOT flagged; only 'Confirmed closed' is. +-- Fix: `mxcli check` flags any decision/call-microflow outcome that is not a +-- qualified enumeration value identifier as MDL-WF03 (error). +-- +-- BOTH outcomes here are now flagged. The original fix accepted 'Reopened' +-- because it is a well-formed identifier — but Mendix stores the value as an +-- EnumerationValueIdentifier and parses it when the project is LOADED, so a +-- short name is not a build error, it leaves a project Studio Pro and mxbuild +-- cannot open at all (ako/mxcli#1031, ako/mxcli#1065). Measured: one segment +-- and two segments both fail to load, three (Module.Enumeration.Value) checks +-- at 0 errors. -- -- Note: the deeper issue — MDL decision syntax cannot bind the enum / deciding -- microflow the outcomes belong to — is a separate language-design gap. diff --git a/mdl-examples/bug-tests/workflow-1031-enum-outcome-must-be-qualified.mdl b/mdl-examples/bug-tests/workflow-1031-enum-outcome-must-be-qualified.mdl new file mode 100644 index 0000000000..9108c84c43 --- /dev/null +++ b/mdl-examples/bug-tests/workflow-1031-enum-outcome-must-be-qualified.mdl @@ -0,0 +1,103 @@ +-- ako/mxcli#1031 + ako/mxcli#1065 — a decision outcome that is not a qualified +-- enumeration value identifier makes the project UNLOADABLE, and +-- ako/mxcli#1023 — an unquoted `with (...)` value crashed the binary. +-- +-- ============================================================================ +-- #1031 / #1065: EnumerationValueConditionOutcome.Value must be qualified +-- ============================================================================ +-- +-- Symptom: `decision ... outcomes 'OutcomeA' -> { }` passed `mxcli check`, +-- exec printed "Created workflow", and `describe workflow` round-tripped it — +-- but the project then failed to LOAD in Studio Pro and mxbuild: +-- +-- Mendix.Modeler.Storage.StorageLoadException: ... +-- - Enumeration value condition outcome in workflow 'WFP.Dec' has an invalid +-- value 'OutcomeA' for property Value. The text 'OutcomeA' is not a valid +-- EnumerationValueIdentifier. +-- +-- This is worse than a build error: it is thrown by the UnitLoader before any +-- consistency check runs, so there is no CE number and no `N errors.` line — +-- the project cannot be opened at all. +-- +-- What settled the threshold (11.10.0, one workflow per copy of the same app, +-- verdict = the literal `mx check` line): +-- +-- 'OutcomeA' (1 segment) -> StorageLoadException, unloadable +-- 'Status.OutcomeA' (2 segments) -> StorageLoadException, unloadable +-- 'WFP.Status.OutcomeA' (3 segments) -> The app contains: 0 errors. +-- +-- The two-segment row is the one worth keeping: "qualify it" is ambiguous +-- without it, and shortening an enumeration that lives in the same module is +-- exactly what an author would try. +-- +-- Fix: MDL-WF03 requires Module.Enumeration.Value on decision and +-- call-microflow outcomes, and on ALTER WORKFLOW ... INSERT CONDITION, which +-- writes the same field. Because MDL-WF03 is an error and `exec` refuses a +-- script with errors, the corrupting write can no longer happen. + +create enumeration WF1031.Status ( + OutcomeA 'Outcome A', + OutcomeB 'Outcome B' +); +/ + +create persistent entity WF1031.Ctx ( + Title: string(50), + Status: WF1031.Status +); +/ + +create microflow WF1031.ACT_Noop ($Ctx: WF1031.Ctx) +begin + @position(200,200) return; +end; +/ + +-- The qualified form: this is what Studio Pro stores, and the only form that +-- loads. The trailing '' outcome is the "none of the above" branch every +-- enumeration decision must carry, or the build fails CE6686. +-- +-- The nested calls spell out `with (...)` and `outcomes default -> { }`: mxcli +-- auto-wires those for a call activity in the workflow's MAIN flow but not for +-- one inside a decision branch, and an unwired call is CE6685 + CE6686. +create workflow WF1031.Dec + parameter $Ctx: WF1031.Ctx +begin + decision decision1 '$WorkflowContext/Status' + outcomes + 'WF1031.Status.OutcomeA' -> { + call microflow WF1031.ACT_Noop as callMicroflow1 + with (Ctx = '$WorkflowContext') outcomes default -> { }; + } + 'WF1031.Status.OutcomeB' -> { + call microflow WF1031.ACT_Noop as callMicroflow2 + with (Ctx = '$WorkflowContext') outcomes default -> { }; + } + '' -> { } + ; +end workflow; +/ + +-- ============================================================================ +-- #1023: an unquoted `with (...)` value crashed the process +-- ============================================================================ +-- +-- Symptom: `call microflow M.ACT with (Ctx = $WorkflowContext)` — the bare +-- variable spelling used everywhere else in MDL — took the binary down with a +-- SIGSEGV in buildWorkflowCallMicroflow, on `check`, on `check --references` +-- and on `exec` alike, with no diagnostic beyond the Go panic. +-- +-- Root cause: the grammar requires a STRING_LITERAL there, but the AST builder +-- walks the parse tree even when the parse failed (that is what lets `check` +-- report more than the first error), so the rule was visited with a nil child. +-- +-- Fix: the mapping builder nil-checks both children and skips what the parser +-- could not build, leaving the syntax error the listener already recorded as +-- the thing the author is told about. The value must still be quoted: + +create workflow WF1031.Call + parameter $Ctx: WF1031.Ctx +begin + call microflow WF1031.ACT_Noop as callMicroflow1 with (Ctx = '$WorkflowContext'); +end workflow; +/ diff --git a/mdl-examples/bug-tests/workflow-417-nested-call-microflow-autowire.mdl b/mdl-examples/bug-tests/workflow-417-nested-call-microflow-autowire.mdl new file mode 100644 index 0000000000..54ef493b6e --- /dev/null +++ b/mdl-examples/bug-tests/workflow-417-nested-call-microflow-autowire.mdl @@ -0,0 +1,56 @@ +-- ako/mxcli#417: a `call microflow` nested in a decision branch was not auto-wired +-- +-- Symptom: `mxcli check` passed and `exec` reported success, but mxbuild gave +-- 4 errors on the workflow below: +-- +-- [CE6685] The parameters of the selected microflow have changed, please +-- update them in the properties. (x2) +-- [CE6686] The current outcomes of the call microflow activity do not match +-- the configured microflow. Regenerate the outcomes. (x2) +-- +-- `describe workflow` showed the two nested activities written with no +-- `with (...)` and no `outcomes` clause, while the CONTROL — the identical +-- activity in the workflow's MAIN flow — was auto-wired to +-- `with (Ctx = '$WorkflowContext') outcomes DEFAULT -> { }` and checked clean. +-- +-- Cause: autoBindActivitiesInFlow enumerated the flows to recurse into with a +-- per-outcome type switch that handled BooleanConditionOutcome and +-- VoidConditionOutcome only. An EnumerationValueConditionOutcome's flow — and +-- every boundary-event body — was never visited, so nothing auto-generated the +-- ParameterMappings or the default outcome for the activities inside it. +-- +-- Fix: one `nestedFlows` helper enumerates every nested flow of an activity +-- (condition outcomes via the ConditionOutcome interface, user-task outcomes, +-- parallel split paths, boundary event bodies); both tree walks over a workflow +-- use it, so no outcome kind can be skipped. +-- +-- Expected: 0 errors from mxbuild. All three call-microflow activities carry +-- `with (Ctx = ...)` and an `outcomes DEFAULT -> { }` clause. + +create enumeration WF417.Status ( + OutcomeA 'Outcome A', + OutcomeB 'Outcome B' +); + +create persistent entity WF417.Ctx ( + Status: enum WF417.Status +); + +create microflow WF417.ACT_Noop ($Ctx: WF417.Ctx) +begin + @position(200,200) + return; +end; + +create workflow WF417.Dec parameter $Ctx: WF417.Ctx +begin + -- CONTROL: main-flow activity, auto-wired before this fix and after it. + call microflow WF417.ACT_Noop as callMicroflowControl; + + decision decision1 '$WorkflowContext/Status' + outcomes + 'WF417.Status.OutcomeA' -> { call microflow WF417.ACT_Noop as callMicroflow1; } + 'WF417.Status.OutcomeB' -> { call microflow WF417.ACT_Noop as callMicroflow2; } + '' -> { } + ; +end workflow; diff --git a/mdl-examples/doctype-tests/navigation-offline-sync.mdl b/mdl-examples/doctype-tests/navigation-offline-sync.mdl new file mode 100644 index 0000000000..9ced3e3ee1 --- /dev/null +++ b/mdl-examples/doctype-tests/navigation-offline-sync.mdl @@ -0,0 +1,78 @@ +-- Offline synchronization on a navigation profile. +-- +-- An offline profile downloads NOTHING until its entities are given a sync +-- mode: without a SYNC block the app builds, routes and installs as a PWA, and +-- shows an empty screen. +-- +-- The mode words are the members Mendix stores, not the captions Studio Pro +-- shows in its "Customize offline synchronization" dialog -- "All Objects" is +-- ALL and "By XPath" is WHERE. All six are exercised here because a real +-- project (ako/TestApp) stores all six. + +create module "OfflineSync"; + +create entity "OfflineSync"."Vehicle" ( + "Name": String(100), + "Mileage": Integer +); + +create entity "OfflineSync"."Trip" ( + "Distance": Integer +); + +create entity "OfflineSync"."Lookup" ( + "Code": String(20) +); + +create entity "OfflineSync"."AuditEntry" ( + "Message": String(200) +); + +create entity "OfflineSync"."Draft" ( + "Body": String(500) +); + +create entity "OfflineSync"."Setting" ( + "Key": String(50) +); + +create page "OfflineSync"."Mobile_Home" +( + title: 'Offline home', + layout: Atlas_Core.Atlas_Default, + url: 'offline_home' +) +{ + dynamictext home_text (content: 'Offline home', rendermode: H2) +} + +-- PhoneOffline is created if the project does not already have it: Mendix's +-- web profile kinds are a closed set, and the offline ones are the online +-- names plus "Offline". +create or replace navigation "PhoneOffline" + home page "OfflineSync"."Mobile_Home" + sync ( + -- Fetched from the server; never held on the device. + sync "OfflineSync"."Setting" online; + -- Every object downloaded. + sync "OfflineSync"."Vehicle" all; + -- Only what the XPath selects. WHERE implies the constrained mode, so a + -- constraint without a mode cannot be written. + -- + -- The bracket form takes the XPath verbatim: nothing inside it is escaped, + -- and quoted literals stay readable. A quoted 'string' still parses, but + -- every quote inside it has to be doubled -- and the stored value already + -- carries Mendix's own escaping, so the two compose into runs of six + -- quotes (mendixlabs/mxcli#750). DESCRIBE emits the bracket form. + sync "OfflineSync"."Trip" where [Distance > 0 and contains(Name, 'ab')]; + -- Not synchronized at all. + sync "OfflineSync"."AuditEntry" never; + -- Not downloaded, and anything already on the device is dropped. + sync "OfflineSync"."Lookup" none; + -- Not downloaded, but what is already on the device stays. + sync "OfflineSync"."Draft" none preserve data; + ); + +-- Re-running the block replaces the stored list, the way MENU replaces the +-- menu. Compatibility mode has no syntax and survives the rewrite untouched. +describe navigation "PhoneOffline"; diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index f2bc41c883..001388e9f1 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -13,11 +13,29 @@ type AlterNavigationStmt struct { NotFoundPage *QualifiedName // NOT FOUND PAGE ... MenuItems []NavMenuItemDef // MENU (...) block HasMenuBlock bool // true if MENU (...) was present (even if empty → clears menu) + SyncEntries []NavSyncDef // SYNC (...) block — offline synchronization + HasSyncBlock bool // true if SYNC (...) was present (even if empty → clears the list) CreateOrModify bool // true if CREATE OR REPLACE/MODIFY was used } func (s *AlterNavigationStmt) isStatement() {} +// NavSyncDef represents one `SYNC ` line inside a SYNC block. +// +// Mode carries the STORED enum member, not the word the user typed: the visitor +// maps ONLINE/ALL/NEVER/NONE/NONE PRESERVE DATA/WHERE onto Online/All/Never/ +// None/NoneAndPreserveData/Constrained, so nothing downstream has to know the +// spelling. Studio Pro's captions ("All Objects", "By XPath") are not members +// of the enumeration and never appear here. +type NavSyncDef struct { + Entity QualifiedName + Mode string + // Constraint is the XPath from a WHERE clause, and is set only when Mode is + // Constrained — the two are derived from the same alternative precisely so + // they cannot disagree. + Constraint string +} + // NavHomePageDef represents a HOME PAGE or HOME MICROFLOW clause. type NavHomePageDef struct { IsPage bool // true = PAGE, false = MICROFLOW diff --git a/mdl/ast/ast_query.go b/mdl/ast/ast_query.go index ee115cf89c..f9fb007194 100644 --- a/mdl/ast/ast_query.go +++ b/mdl/ast/ast_query.go @@ -11,6 +11,7 @@ type ShowStmt struct { ObjectType ShowObjectType InModule string // Optional module filter Name *QualifiedName // Optional specific object name + Like string // For SHOW GLYPHS LIKE 'pattern' — a name substring Transitive bool // For SHOW CALLERS/CALLEES TRANSITIVE Depth int // For SHOW CONTEXT/STRUCTURE DEPTH N (default 2) All bool // For SHOW STRUCTURE ALL (include system modules) @@ -85,6 +86,7 @@ const ( ShowDatabaseConnections // SHOW DATABASE CONNECTIONS [IN module] ShowImageCollections // SHOW IMAGE COLLECTIONS [IN module] ShowIconCollections // SHOW ICON COLLECTIONS [IN module] + ShowGlyphs // SHOW GLYPHS [LIKE 'pattern'] ShowRestClients // SHOW REST CLIENTS [IN module] ShowPublishedRestServices // SHOW PUBLISHED REST SERVICES [IN module] ShowDataTransformers // LIST DATA TRANSFORMERS [IN module] @@ -218,6 +220,8 @@ func (t ShowObjectType) String() string { return "IMAGE COLLECTIONS" case ShowIconCollections: return "ICON COLLECTIONS" + case ShowGlyphs: + return "GLYPHS" case ShowRestClients: return "REST CLIENTS" case ShowPublishedRestServices: @@ -293,7 +297,11 @@ type DescribeStmt struct { Name QualifiedName WithAll bool // For DESCRIBE MODULE ... WITH ALL Format string // For DESCRIBE CONTRACT ... FORMAT mdl - Qualifier string // For DESCRIBE JAR DEPENDENCY: the 'group:artifact' coordinate + // Qualifier carries the subject of a DESCRIBE whose subject is not a + // qualified name: the 'group:artifact' coordinate of a JAR DEPENDENCY, and + // the code or name of a GLYPH (a glyph has no qualified name — it is a + // character code in a font, not an element in the project). + Qualifier string } func (s *DescribeStmt) isStatement() {} @@ -327,6 +335,7 @@ const ( DescribeFragment // DESCRIBE FRAGMENT Name DescribeImageCollection // DESCRIBE IMAGE COLLECTION Module.Name DescribeIconCollection // DESCRIBE ICON COLLECTION Module.Name + DescribeGlyph // DESCRIBE GLYPH 57350 | DESCRIBE GLYPH 'star' DescribeRestClient // DESCRIBE REST CLIENT Module.Name DescribePublishedRestService // DESCRIBE PUBLISHED REST SERVICE Module.Name DescribeDataTransformer // DESCRIBE DATA TRANSFORMER Module.Name @@ -408,6 +417,8 @@ func (t DescribeObjectType) String() string { return "IMAGE COLLECTION" case DescribeIconCollection: return "ICON COLLECTION" + case DescribeGlyph: + return "GLYPH" case DescribeRestClient: return "REST CLIENT" case DescribePublishedRestService: diff --git a/mdl/ast/ast_security.go b/mdl/ast/ast_security.go index 35da7c09df..ab0bad5628 100644 --- a/mdl/ast/ast_security.go +++ b/mdl/ast/ast_security.go @@ -44,9 +44,12 @@ type AlterUserRoleStmt struct { func (s *AlterUserRoleStmt) isStatement() {} -// DropUserRoleStmt represents: DROP USER ROLE Name +// DropUserRoleStmt represents: DROP USER ROLE [IF EXISTS] Name type DropUserRoleStmt struct { - Name string + // IfExists downgrades "not found" to a no-op, so a one-time cleanup can sit + // in a slice script that is re-run. + IfExists bool + Name string } func (s *DropUserRoleStmt) isStatement() {} @@ -213,8 +216,10 @@ type CreateDemoUserStmt struct { func (s *CreateDemoUserStmt) isStatement() {} -// DropDemoUserStmt represents: DROP DEMO USER 'name' +// DropDemoUserStmt represents: DROP DEMO USER [IF EXISTS] 'name' type DropDemoUserStmt struct { + // IfExists downgrades "not found" to a no-op; see DropUserRoleStmt. + IfExists bool UserName string } diff --git a/mdl/backend/modelsdk/navigation_offline_write_test.go b/mdl/backend/modelsdk/navigation_offline_write_test.go new file mode 100644 index 0000000000..eb40bd5c13 --- /dev/null +++ b/mdl/backend/modelsdk/navigation_offline_write_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" + "go.mongodb.org/mongo-driver/bson" +) + +func offlineCfg(entity, mode, constraint string, compat bool) bson.D { + return bson.D{ + {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, + {Key: "CompatibilityMode", Value: compat}, + {Key: "Constraint", Value: constraint}, + {Key: "Entity", Value: entity}, + {Key: "SyncMode", Value: mode}, + } +} + +func cfgMap(t *testing.T, v any) map[string]any { + t.Helper() + d, ok := v.(bson.D) + if !ok { + t.Fatalf("not a document: %T", v) + } + return d.Map() +} + +// The property MDL cannot spell must survive a rewrite that never mentions it. +// +// Every config in the reference document (ako/TestApp) carries +// CompatibilityMode false, so the true case cannot be observed there — and a +// writer that always emitted false would look correct against all seven. This +// is the test that distinguishes them, and it is the guard-don't-drop rule: +// building the element from the spec alone clears the property silently, +// because the document stays valid and mx check reports 0 errors either way. +func TestOfflineWriteCarriesCompatibilityModeThroughARewrite(t *testing.T) { + stored := bson.A{ + navMarkerItems, + offlineCfg("Rules.RuleAction", "All", "", true), + offlineCfg("Pages.Bus", "All", "", false), + } + // The spec rewrites both entities and says nothing about compatibility mode. + specs := []types.NavOfflineEntitySpec{ + {Entity: "Rules.RuleAction", SyncMode: "Never"}, + {Entity: "Pages.Bus", SyncMode: "Online"}, + } + + out := navOfflineConfigs(stored, specs) + if len(out) != 3 { + t.Fatalf("expected marker + 2 configs, got %d entries", len(out)) + } + got := cfgMap(t, out[1]) + if got["Entity"] != "Rules.RuleAction" { + t.Fatalf("order not preserved: %v", got["Entity"]) + } + if got["CompatibilityMode"] != true { + t.Error("CompatibilityMode was dropped by a rewrite that never mentioned it") + } + if got["SyncMode"] != "Never" { + t.Errorf("SyncMode = %v, want the spec's Never", got["SyncMode"]) + } + // Control, same shape in the other direction: an entity stored as false + // stays false, so the carry is reading the stored value rather than + // defaulting to true. + if second := cfgMap(t, out[2]); second["CompatibilityMode"] != false { + t.Errorf("false was not carried either: %v", second["CompatibilityMode"]) + } +} + +// An entity the spec ADDS has no stored config to carry from, and takes the +// value every reference config holds. +func TestOfflineWriteDefaultsANewEntityToCompatibilityModeOff(t *testing.T) { + out := navOfflineConfigs(bson.A{navMarkerItems}, []types.NavOfflineEntitySpec{ + {Entity: "Mod.Fresh", SyncMode: "All"}, + }) + if got := cfgMap(t, out[1]); got["CompatibilityMode"] != false { + t.Errorf("a new entity must default to false, got %v", got["CompatibilityMode"]) + } +} + +// gen declares DownloadMode and ShouldDownload; ako/TestApp writes neither. +// Emitting a property Studio Pro fills in on load is how a document mxbuild +// accepts becomes one Studio Pro cannot open, so the writer must stay silent +// about them — and the typed-array marker must be 3, as the reference has. +func TestOfflineWriteEmitsExactlyThePropertiesStudioProWrites(t *testing.T) { + out := navOfflineConfigs(bson.A{navMarkerItems}, + []types.NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "Constrained", Constraint: "[X = 1]"}}) + + if out[0] != navMarkerItems { + t.Errorf("typed-array marker = %v, want %v", out[0], navMarkerItems) + } + got := cfgMap(t, out[1]) + for _, absent := range []string{"DownloadMode", "ShouldDownload"} { + if _, present := got[absent]; present { + t.Errorf("%s must not be written — it occurs zero times in every reference document", absent) + } + } + for _, required := range []string{"$Type", "CompatibilityMode", "Constraint", "Entity", "SyncMode"} { + if _, present := got[required]; !present { + t.Errorf("%s missing from the written config", required) + } + } + if len(got) != 6 { // the five above plus $ID + t.Errorf("wrote %d properties (%v); Studio Pro writes four plus $ID and $Type", len(got), got) + } +} diff --git a/mdl/backend/modelsdk/navigation_read.go b/mdl/backend/modelsdk/navigation_read.go index 9e4ee77662..5c3cd6ac5b 100644 --- a/mdl/backend/modelsdk/navigation_read.go +++ b/mdl/backend/modelsdk/navigation_read.go @@ -217,9 +217,10 @@ func appendOfflineEntities(profile *types.NavigationProfile, items []element.Ele continue } e := &types.NavOfflineEntity{ - Entity: oe.EntityQualifiedName(), - SyncMode: oe.SyncMode(), - Constraint: oe.Constraint(), + Entity: oe.EntityQualifiedName(), + SyncMode: oe.SyncMode(), + Constraint: oe.Constraint(), + CompatibilityMode: oe.CompatibilityMode(), } if e.Entity != "" { profile.OfflineEntities = append(profile.OfflineEntities, e) diff --git a/mdl/backend/modelsdk/navigation_write.go b/mdl/backend/modelsdk/navigation_write.go index 58ce1bbe21..6e0d3cccd3 100644 --- a/mdl/backend/modelsdk/navigation_write.go +++ b/mdl/backend/modelsdk/navigation_write.go @@ -197,6 +197,11 @@ func navPatchWebProfile(doc bson.D, spec types.NavigationProfileSpec) bson.D { {Key: "Items", Value: menuItems}, }) } + + if spec.HasSync { + doc = navSetField(doc, "OfflineEntityConfigs", + navOfflineConfigs(navGetArray(doc, "OfflineEntityConfigs"), spec.OfflineEntities)) + } return doc } @@ -379,3 +384,59 @@ func navMenuAction(mi types.NavMenuItemSpec) bson.D { {Key: "$Type", Value: "Forms$NoAction"}, } } + +// navOfflineConfigs rebuilds the OfflineEntityConfigs list from the spec while +// carrying forward the properties MDL cannot express. +// +// The carry is the whole point. Studio Pro writes four properties on a web +// profile's config and MDL can spell three; CompatibilityMode is read (phase 1) +// and put back here, keyed by entity. Building the element from the spec alone +// would clear it on every rewrite — silently, because the document stays valid +// and `mx check` reports 0 errors either way. That is the defect that had +// `create or modify entity` deleting access rules. +// +// DownloadMode and ShouldDownload are NOT written, though modelsdk/gen declares +// them: they occur zero times in ako/TestApp's configs. A property absent from +// every real document is one Studio Pro fills in on load, and emitting it is +// how a document mxbuild accepts becomes one Studio Pro cannot open. +func navOfflineConfigs(stored bson.A, specs []types.NavOfflineEntitySpec) bson.A { + // Index what is stored by entity so a rewrite of the same entity keeps its + // unauthorable properties. An entity the spec adds has no stored config and + // takes the default every reference config carries. + compat := map[string]bool{} + for _, item := range stored { + cfg, ok := item.(bson.D) + if !ok { + continue // the leading typed-array marker + } + if e := navGetString(cfg, "Entity"); e != "" { + compat[e] = navGetBool(cfg, "CompatibilityMode") + } + } + + out := bson.A{navMarkerItems} + for _, s := range specs { + out = append(out, bson.D{ + {Key: "$ID", Value: navID()}, + {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, + {Key: "CompatibilityMode", Value: compat[s.Entity]}, + {Key: "Constraint", Value: s.Constraint}, + {Key: "Entity", Value: s.Entity}, + {Key: "SyncMode", Value: s.SyncMode}, + }) + } + return out +} + +// navGetBool reads a bool field, defaulting to false for an absent or +// wrong-typed value — which is what every reference config carries. +func navGetBool(doc bson.D, key string) bool { + for _, e := range doc { + if e.Key == key { + if b, ok := e.Value.(bool); ok { + return b + } + } + } + return false +} diff --git a/mdl/backend/modelsdk/page.go b/mdl/backend/modelsdk/page.go index b0cd036167..e2e49a4613 100644 --- a/mdl/backend/modelsdk/page.go +++ b/mdl/backend/modelsdk/page.go @@ -85,7 +85,12 @@ func (b *Backend) ListSnippets() ([]*pages.Snippet, error) { } out := make([]*pages.Snippet, 0, len(units)) for _, u := range units { - s := &pages.Snippet{ContainerID: u.ContainerID, Name: u.Element.Name(), Excluded: u.Element.Excluded()} + s := &pages.Snippet{ + ContainerID: u.ContainerID, + Name: u.Element.Name(), + Documentation: u.Element.Documentation(), + Excluded: u.Element.Excluded(), + } s.ID = model.ID(u.Element.ID()) // Populate declared parameters — the page builder reads these to validate // and wire SNIPPETCALL argument mappings (without them every parameterised @@ -135,9 +140,17 @@ func pageFromGen(p *genPg.Page, containerID model.ID) *pages.Page { out := &pages.Page{ ContainerID: containerID, Name: p.Name(), - Excluded: p.Excluded(), - URL: p.Url(), - Title: textElementToModel(p.Title()), + // A page's documentation is written correctly and stored correctly; this + // engine simply did not read it back, so `mxcli lint` QUAL002 reported + // "Page 'X' has no documentation" against a page carrying a javadoc + // comment, and the catalog's Description column was blank for every page + // (ako/CapTrackV4 R12). The legacy parser has always carried it, as do the + // layout, building-block and page-template readers in this file — page and + // snippet were the two that did not. + Documentation: p.Documentation(), + Excluded: p.Excluded(), + URL: p.Url(), + Title: textElementToModel(p.Title()), } out.ID = model.ID(p.ID()) // AllowedRoles (BY_NAME module-role references, stored under the diff --git a/mdl/backend/modelsdk/page_documentation_test.go b/mdl/backend/modelsdk/page_documentation_test.go new file mode 100644 index 0000000000..359ce0bd65 --- /dev/null +++ b/mdl/backend/modelsdk/page_documentation_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + genPg "github.com/mendixlabs/mxcli/modelsdk/gen/pages" +) + +// `mxcli lint` QUAL002 reported "Page 'X' has no documentation" against a page +// carrying a javadoc comment, the catalog's Description column was blank for +// every page and snippet, and `describe page` emitted no documentation — so the +// comment looked, from every angle, like it had been dropped (ako/CapTrackV4 +// R12). +// +// It had not. The comment reaches the AST, the executor sets it, the writer +// stores it: `mxcli bson dump --type page` shows Documentation with the right +// value, and the LEGACY reader parses it back correctly. Only this engine's +// readers — the default — failed to carry it, and page and snippet were the two +// that did, out of five sibling readers in this file (layout, building block and +// page template all had it). +// +// That is why the report read as "javadoc does not work for pages": every +// symptom is downstream of the read. +func TestPageFromGen_CarriesDocumentation(t *testing.T) { + p := genPg.NewPage() + p.SetName("PG_Doc") + p.SetDocumentation("A documented page.") + + out := pageFromGen(p, "container-1") + if out.Documentation != "A documented page." { + t.Errorf("Documentation = %q, want %q — QUAL002 then reports a documented "+ + "page as undocumented, and DESCRIBE cannot round-trip it", + out.Documentation, "A documented page.") + } +} + +// CONTROL: an undocumented page stays undocumented, so the fix cannot be "always +// report something". +func TestPageFromGen_EmptyDocumentationStaysEmpty(t *testing.T) { + p := genPg.NewPage() + p.SetName("PG_Plain") + + if out := pageFromGen(p, "container-1"); out.Documentation != "" { + t.Errorf("Documentation = %q, want empty", out.Documentation) + } +} diff --git a/mdl/backend/mpr/convert.go b/mdl/backend/mpr/convert.go index 63bf8acadf..7e69963b1f 100644 --- a/mdl/backend/mpr/convert.go +++ b/mdl/backend/mpr/convert.go @@ -225,6 +225,7 @@ func convertNavProfile(in *mpr.NavigationProfile) *types.NavigationProfile { for i, oe := range in.OfflineEntities { p.OfflineEntities[i] = &types.NavOfflineEntity{ Entity: oe.Entity, SyncMode: oe.SyncMode, Constraint: oe.Constraint, + CompatibilityMode: oe.CompatibilityMode, } } } diff --git a/mdl/backend/mpr/convert_roundtrip_test.go b/mdl/backend/mpr/convert_roundtrip_test.go index d6f85a8cff..7c19838c81 100644 --- a/mdl/backend/mpr/convert_roundtrip_test.go +++ b/mdl/backend/mpr/convert_roundtrip_test.go @@ -628,4 +628,12 @@ func TestFieldCountDrift(t *testing.T) { assertFieldCount(t, "types.EntityMemberAccess", types.EntityMemberAccess{}, 3) assertFieldCount(t, "mpr.EntityAccessRevocation", mpr.EntityAccessRevocation{}, 6) assertFieldCount(t, "types.EntityAccessRevocation", types.EntityAccessRevocation{}, 6) + // Both are hand-copied in convertNavProfile and were unguarded: adding + // CompatibilityMode to NavOfflineEntity left this test passing while the + // new field was silently not carried, which is the exact drift the test + // exists to catch. + assertFieldCount(t, "mpr.NavigationProfile", mpr.NavigationProfile{}, 9) + assertFieldCount(t, "types.NavigationProfile", types.NavigationProfile{}, 9) + assertFieldCount(t, "mpr.NavOfflineEntity", mpr.NavOfflineEntity{}, 4) + assertFieldCount(t, "types.NavOfflineEntity", types.NavOfflineEntity{}, 4) } diff --git a/mdl/backend/mpr/offline_sync_carry_test.go b/mdl/backend/mpr/offline_sync_carry_test.go new file mode 100644 index 0000000000..575c5bb5a6 --- /dev/null +++ b/mdl/backend/mpr/offline_sync_carry_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mprbackend + +import ( + "testing" + + "github.com/mendixlabs/mxcli/sdk/mpr" +) + +// The properties Studio Pro writes on a web profile's offline entity config, +// measured against ako/TestApp's TabletOffline profile: seven configs spanning +// all six sync modes. +// +// CompatibilityMode is why this test exists. The semantic model carried three +// of the four written properties, so it was read and discarded — and a write +// path built on that model would have dropped it the way `create or modify +// entity` dropped access rules. Nothing else would have said so: the document +// stays valid, and `mx check` reports 0 errors either way. +func TestOfflineEntityConfigCarriesEveryStoredProperty(t *testing.T) { + // One case per observed sync mode, so a mode that stops round-tripping is + // named rather than hidden in a total. + cases := []*mpr.NavOfflineEntity{ + {Entity: "Mappings.Customer", SyncMode: "Never"}, + {Entity: "Pages.Bus", SyncMode: "All"}, + {Entity: "Rules.BusinessRule", SyncMode: "Online"}, + {Entity: "Rules.RuleAction", SyncMode: "Constrained", + Constraint: "[\n (\n contains(ActionValue, '''abc''')\n )\n]"}, + {Entity: "Rules.RuleCategory", SyncMode: "None"}, + {Entity: "Rules.RuleExecutionLog", SyncMode: "NoneAndPreserveData"}, + {Entity: "System.Language", SyncMode: "All"}, + // Not present in any reference config — every one carries false — but + // carrying only the false case would prove nothing about a bool. + {Entity: "Mod.Compat", SyncMode: "All", CompatibilityMode: true}, + } + + for _, want := range cases { + t.Run(want.Entity+"/"+want.SyncMode, func(t *testing.T) { + in := &mpr.NavigationDocument{ + Name: "Navigation", + Profiles: []*mpr.NavigationProfile{{ + Name: "TabletOffline", Kind: "TabletOffline", + OfflineEntities: []*mpr.NavOfflineEntity{want}, + }}, + } + out := convertNavDoc(in) + if len(out.Profiles) != 1 || len(out.Profiles[0].OfflineEntities) != 1 { + t.Fatalf("profile or config lost in conversion: %+v", out) + } + if got := *out.Profiles[0].OfflineEntities[0]; got != *want { + t.Errorf("conversion dropped a property:\n got %+v\nwant %+v", got, *want) + } + }) + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index eff6673f8f..c17074ef7e 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -2495,8 +2495,9 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { // Property names arrive verbatim from MDL (any case) — `set class on …` is as // valid as `set Class on …`, and `create page` reads them case-insensitively // (WidgetV3.GetStringProp). Match the first-class properties case-insensitively - // so the ALTER path behaves the same; the pluggable fallback (default) keeps the - // original casing, since pluggable property keys must match the template exactly. + // so the ALTER path behaves the same. The pluggable fallback (default) passes + // the author's spelling through and resolves it case-insensitively against the + // widget's own template keys — see setPluggableWidgetPropertyMut. switch strings.ToLower(propName) { case "caption": return setWidgetCaptionMut(widget, value) @@ -2781,6 +2782,24 @@ func setWidgetAttributeRefMut(widget bson.D, value any) error { return fmt.Errorf("widget does not have an AttributeRef property") } +// setPluggableWidgetPropertyMut writes one property of a pluggable widget's +// Object, resolving the author's spelling against the widget's template keys +// CASE-INSENSITIVELY. +// +// That last part is the fix for #1069. A pluggable property key is lowerCamel in +// the template (`pageSize`), while DESCRIBE PAGE prints it capitalised +// (`PageSize:`) and CREATE accepts either — the widget engine resolves it with +// lookupProperty, which lowercases both sides. Comparing byte-for-byte here made +// `alter page … set PageSize = 10` fail with `pluggable property "PageSize" not +// found` on a grid that `create page … (PageSize: 20)` had just written, so the +// tool refused to execute its own DESCRIBE output. +// +// It is unambiguous, not merely convenient: this searches one object type's +// PropertyTypes, and no shipped template or definition has two keys in the same +// list differing only in case (96 scopes, 1208 keys, 0 collisions — held by +// TestPluggablePropertyKeysAreUniqueIgnoringCase). An unknown property still +// errors, which is the author's only signal: `check --references` does not +// resolve pluggable property names. func setPluggableWidgetPropertyMut(widget bson.D, propName string, value any) error { obj := bsonnav.DGetDoc(widget, "Object") if obj == nil { @@ -2816,7 +2835,7 @@ func setPluggableWidgetPropertyMut(widget bson.D, propName string, value any) er } typePointerID := bsonnav.ExtractBinaryIDFromDoc(bsonnav.DGet(propDoc, "TypePointer")) propKey := propTypeKeyMap[typePointerID] - if propKey != propName { + if propKey == "" || !strings.EqualFold(propKey, propName) { continue } if valDoc := bsonnav.DGetDoc(propDoc, "Value"); valDoc != nil { diff --git a/mdl/backend/pagemutator/pluggable_property_casing_test.go b/mdl/backend/pagemutator/pluggable_property_casing_test.go new file mode 100644 index 0000000000..f3851d6766 --- /dev/null +++ b/mdl/backend/pagemutator/pluggable_property_casing_test.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// makePluggableWidget builds a CustomWidget carrying one pluggable property +// whose template key is propKey, with an initial primitive value. +func makePluggableWidget(name, propKey, initial string) bson.D { + typeID := primitive.Binary{Subtype: 0x04, Data: []byte{ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + }} + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidget"}, + {Key: "Name", Value: name}, + {Key: "Type", Value: bson.D{ + {Key: "$Type", Value: "CustomWidgets$CustomWidgetType"}, + {Key: "ObjectType", Value: bson.D{ + {Key: "PropertyTypes", Value: bson.A{ + int32(2), + bson.D{ + {Key: "$ID", Value: typeID}, + {Key: "PropertyKey", Value: propKey}, + }, + }}, + }}, + }}, + {Key: "Object", Value: bson.D{ + {Key: "Properties", Value: bson.A{ + int32(2), + bson.D{ + {Key: "TypePointer", Value: typeID}, + {Key: "Value", Value: bson.D{ + {Key: "PrimitiveValue", Value: initial}, + }}, + }, + }}, + }}, + } +} + +func pluggablePrimitive(t *testing.T, rawData bson.D, widgetName string) string { + t.Helper() + result := findBsonWidget(rawData, widgetName) + if result == nil { + t.Fatalf("widget %q not found after mutation", widgetName) + } + obj := bsonnav.DGetDoc(result.widget, "Object") + props := bsonnav.DGetArrayElements(bsonnav.DGet(obj, "Properties")) + if len(props) == 0 { + t.Fatalf("widget %q has no pluggable properties", widgetName) + } + propDoc, ok := props[0].(bson.D) + if !ok { + t.Fatalf("widget %q property 0 is %T, want bson.D", widgetName, props[0]) + } + return bsonnav.DGetString(bsonnav.DGetDoc(propDoc, "Value"), "PrimitiveValue") +} + +// TestSetPluggableProperty_MatchesTemplateKeyRegardlessOfCase is the regression +// test for mendixlabs/mxcli#1069. +// +// A DataGrid 2's paging property is keyed `pageSize` in the widget template. +// `CREATE PAGE` resolves the author's spelling case-insensitively +// (lookupProperty in the widget engine, and WidgetV3.GetStringProp before it), +// so `PageSize: 20` on a CREATE is accepted and lands. `ALTER PAGE … SET` went +// through a separate resolver that compared the template key BYTE-FOR-BYTE, so +// the same spelling on the same widget failed with +// +// pluggable property "PageSize" not found +// +// Measured end-to-end on a real 11.13.0 project before the fix: CREATE with +// `PageSize: 20` wrote pageSize=20; `set pageSize = 10` altered it; `set +// PageSize = 10` errored. DESCRIBE PAGE emits the capitalised `PageSize:`, so +// the tool's own round-trip output was a script it then refused to execute. +// +// The four spellings below are the four an author actually writes: the one +// DESCRIBE prints, the one the template stores, and the two flat cases. +func TestSetPluggableProperty_MatchesTemplateKeyRegardlessOfCase(t *testing.T) { + for _, spelling := range []string{"PageSize", "pageSize", "pagesize", "PAGESIZE"} { + t.Run(spelling, func(t *testing.T) { + rawData := makeRawPage(makePluggableWidget("dgProducts", "pageSize", "20")) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + + if err := m.SetWidgetProperty("dgProducts", spelling, 10); err != nil { + t.Fatalf("set %s: %v", spelling, err) + } + if got := pluggablePrimitive(t, rawData, "dgProducts"); got != "10" { + t.Errorf("pageSize = %q, want %q", got, "10") + } + }) + } +} + +// TestSetPluggableProperty_UnknownPropertyStillErrors is the control for the +// test above: relaxing the comparison to case-insensitive must not turn a +// genuinely unknown property into a silent no-op. A typo has to keep failing — +// that is the only signal the author gets, since `mxcli check --references` +// does not resolve pluggable property names. +func TestSetPluggableProperty_UnknownPropertyStillErrors(t *testing.T) { + rawData := makeRawPage(makePluggableWidget("dgProducts", "pageSize", "20")) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + + err := m.SetWidgetProperty("dgProducts", "PagSize", 10) + if err == nil { + t.Fatal("expected an error for an unknown pluggable property, got nil") + } + if !strings.Contains(err.Error(), "PagSize") { + t.Errorf("error should name the property the author wrote, got: %v", err) + } + if got := pluggablePrimitive(t, rawData, "dgProducts"); got != "20" { + t.Errorf("a failed set must not change the stored value: got %q, want %q", got, "20") + } +} + +// TestPluggablePropertyKeysAreUniqueIgnoringCase is the argument that matching +// case-insensitively is unambiguous rather than merely convenient. +// +// setPluggableWidgetPropertyMut searches ONE object type's PropertyTypes at a +// time. Case-insensitive matching is safe exactly when no such list holds two +// keys differing only in case. Measured across every shipped widget template +// and definition: 96 property scopes, 1208 keys, 0 collisions. This test keeps +// that true as templates are added — a colliding pair would make the resolver +// pick whichever came first in the BSON, which is the guess-instead-of-refuse +// hazard the mutator-addressing pattern warns about. +func TestPluggablePropertyKeysAreUniqueIgnoringCase(t *testing.T) { + roots := []string{ + filepath.Join("..", "..", "..", "modelsdk", "widgets"), + filepath.Join("..", "..", "..", "sdk", "widgets"), + } + + scopes, keys := 0, 0 + var collect func(t *testing.T, file string, node any) + collect = func(t *testing.T, file string, node any) { + switch v := node.(type) { + case map[string]any: + if pts, ok := v["PropertyTypes"].([]any); ok { + seen := map[string]string{} + local := 0 + for _, pt := range pts { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + key, ok := ptMap["PropertyKey"].(string) + if !ok || key == "" { + continue + } + local++ + if prev, dup := seen[strings.ToLower(key)]; dup { + t.Errorf("%s: property keys %q and %q differ only in case — "+ + "case-insensitive resolution would have to guess between them", + file, prev, key) + } + seen[strings.ToLower(key)] = key + } + if local > 0 { + scopes++ + keys += local + } + } + for _, child := range v { + collect(t, file, child) + } + case []any: + for _, child := range v { + collect(t, file, child) + } + } + } + + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".json") { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + return nil // not a widget document; the loaders skip these too + } + collect(t, path, doc) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + } + + // A positive control: an empty walk would pass vacuously. + if scopes == 0 || keys == 0 { + t.Fatalf("found no property scopes to check (scopes=%d keys=%d) — "+ + "the widget templates moved and this test is now vacuous", scopes, keys) + } + t.Logf("checked %d property scopes, %d keys", scopes, keys) +} diff --git a/mdl/catalog/builder_references.go b/mdl/catalog/builder_references.go index 2e32c485aa..71d3c40c53 100644 --- a/mdl/catalog/builder_references.go +++ b/mdl/catalog/builder_references.go @@ -35,6 +35,7 @@ const ( RefKindSchedule = "schedule" // Scheduled event runs a microflow RefKindValidate = "validate" // Attribute validation rule uses a regular expression RefKindWidget = "widget" // Page/snippet uses a pluggable or custom widget + RefKindSettings = "settings" // A project setting names a microflow ) // collectActionActivities returns all ActionActivity objects from an ObjectCollection, @@ -542,6 +543,14 @@ func (b *Builder) buildReferences() error { // pattern. refCount += b.extractRegexRuleRefs(stmt, projectID, snapshotID) + // Three project settings name a microflow the runtime calls. Same class as + // the scheduled-event edge above and found the same way: a microflow wired as + // AfterStartupMicroflow reported no callers and no references, QUAL004 said + // "not called from anywhere. Remove if unused", and dropping it left a + // dangling name that `mx check` did not catch either — it surfaced only when + // the runtime refused to start (ako/CapTrackV4 049). + refCount += b.extractProjectSettingsRefs(stmt, projectID, snapshotID) + b.report("References", refCount) return nil } @@ -974,3 +983,54 @@ func (b *Builder) extractWorkflowConditionOutcomeRefs(stmt *sql.Stmt, outcome wo } return b.extractWorkflowFlowRefs(stmt, outcome.GetFlow(), sourceID, sourceQN, moduleName, projectID, snapshotID) } + +// projectSettingsMicroflowRefs lists the project settings whose value is the +// qualified name of a microflow the RUNTIME calls, with the key each is stored +// under so a reference reads as the setting that made it. +// +// It is a literal list rather than reflection over ProjectSettings because most +// of that struct is strings that are not microflow names, and a wrong entry here +// would invent an edge rather than miss one. +var projectSettingsMicroflowRefs = []struct { + setting string + value func(*model.ModelSettings) string +}{ + {"AfterStartupMicroflow", func(ms *model.ModelSettings) string { return ms.AfterStartupMicroflow }}, + {"BeforeShutdownMicroflow", func(ms *model.ModelSettings) string { return ms.BeforeShutdownMicroflow }}, + {"HealthCheckMicroflow", func(ms *model.ModelSettings) string { return ms.HealthCheckMicroflow }}, +} + +// extractProjectSettingsRefs emits one `settings` edge per project setting that +// names a microflow, from the setting to the microflow it runs. +// +// The source is the SETTING, not the project, so `show references to ` +// names which setting depends on it — which is the thing you need before dropping +// it, and what `describe settings` would otherwise be the only way to learn. +func (b *Builder) extractProjectSettingsRefs(stmt *sql.Stmt, projectID, snapshotID string) int { + ps, err := b.reader.GetProjectSettings() + if err != nil || ps == nil || ps.Model == nil { + return 0 // a project may have no settings document; not an error + } + count := 0 + for _, s := range projectSettingsMicroflowRefs { + target := strings.TrimSpace(s.value(ps.Model)) + if target == "" { + continue + } + // The module is the microflow's own — a project setting belongs to no + // module, and leaving it blank would drop the edge out of any per-module + // view of the graph. + moduleName := "" + if i := strings.Index(target, "."); i > 0 { + moduleName = target[:i] + } + if _, err := stmt.Exec( + "PROJECT_SETTINGS", "", s.setting, + "MICROFLOW", "", target, + RefKindSettings, moduleName, projectID, snapshotID, + ); err == nil { + count++ + } + } + return count +} diff --git a/mdl/catalog/lint_rule_vocabulary_test.go b/mdl/catalog/lint_rule_vocabulary_test.go index a5ca29a6fc..30c0083a81 100644 --- a/mdl/catalog/lint_rule_vocabulary_test.go +++ b/mdl/catalog/lint_rule_vocabulary_test.go @@ -97,7 +97,7 @@ func TestQUAL004EntryKindsAreRealRefKinds(t *testing.T) { RefKindGeneralize, RefKindAssociate, RefKindLayout, RefKindDatasource, RefKindParameter, RefKindAction, RefKindHomePage, RefKindLoginPage, RefKindMenuItem, RefKindChange, RefKindDelete, RefKindCalculate, - RefKindReturn, RefKindSchedule, RefKindValidate, + RefKindReturn, RefKindSchedule, RefKindValidate, RefKindSettings, } { known[k] = true } @@ -123,6 +123,7 @@ func TestQUAL004CountsEveryEntryPointKind(t *testing.T) { for _, want := range []string{ RefKindCall, RefKindSchedule, RefKindDatasource, RefKindAction, RefKindCalculate, + RefKindSettings, } { if !contains(starListItems(src, "MICROFLOW_ENTRY_KINDS"), want) { t.Errorf("MICROFLOW_ENTRY_KINDS is missing %q — a microflow reached only that way "+ @@ -147,3 +148,51 @@ func contains(hay []string, needle string) bool { } return false } + +// An `if` in a microflow produces BOTH an ExclusiveSplit and the ExclusiveMerge +// that closes it. CONV010's ALLOWED_ACTIVITY_TYPES held the split and not the +// merge, so an ACT_ microflow that guards anything — "do not open a page with an +// empty parameter", the most ordinary thing an action microflow does — was +// flagged for its own closing brace while the branch it closes was permitted. +// +// Measured on a microflow whose only violation was the merge, and 122 times over +// on one real project (ako/CapTrackV4 R11). +// +// This package had already settled the question in the other direction: +// countMicroflowActivities excludes ExclusiveMerge as structural, with a comment +// saying so. CONV010 was the only place that treated it as business logic. +func TestCONV010AllowsBothHalvesOfAnIf(t *testing.T) { + src := readRule(t, "conv010_act_microflow_content.star") + allowed := starListItems(src, "ALLOWED_ACTIVITY_TYPES") + + split := getMicroflowObjectType(µflows.ExclusiveSplit{}) + merge := getMicroflowObjectType(µflows.ExclusiveMerge{}) + + if !contains(allowed, split) { + t.Fatalf("CONV010 does not allow %q — the control for the assertion below", split) + } + if !contains(allowed, merge) { + t.Errorf("CONV010 allows %q but not %q, and an `if` emits both. Every guard in "+ + "an ACT_ microflow is reported for the join it cannot avoid creating.", split, merge) + } +} + +// CONTROL: allowing the merge must not quietly allow the rest of the structural +// vocabulary. A LOOP in an ACT_ microflow is business logic and CONV010 is right +// to flag it, so a fix that widened the list to "anything not an ActionActivity" +// would pass the test above and gut the rule. +func TestCONV010StillFlagsALoop(t *testing.T) { + src := readRule(t, "conv010_act_microflow_content.star") + allowed := starListItems(src, "ALLOWED_ACTIVITY_TYPES") + + for _, obj := range []microflows.MicroflowObject{ + µflows.LoopedActivity{}, + µflows.InheritanceSplit{}, + } { + label := getMicroflowObjectType(obj) + if contains(allowed, label) { + t.Errorf("CONV010 now allows %q in an ACT_ microflow — that is business logic, "+ + "and the rule exists to move it to a SUB_ microflow", label) + } + } +} diff --git a/mdl/executor/cmd_glyphs.go b/mdl/executor/cmd_glyphs.go new file mode 100644 index 0000000000..8e59061c0f --- /dev/null +++ b/mdl/executor/cmd_glyphs.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strconv" + "strings" + + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" +) + +// listGlyphs handles SHOW GLYPHS [LIKE 'pattern']. +// +// The icon-collection pair (`show icon collections` / `describe icon collection`) +// covers icons that are model documents. A glyph is not one: `icon glyph ` +// stores a bare character code into a FONT, so there is nothing in the project to +// list and nothing a connection would add — which is exactly why the codes were +// unbrowsable, and why MDL078 previously had to spell its advice as a list of +// numeric ranges with holes in it. +// +// LIKE matches the name, because that is the direction an author needs: they +// know they want a star, not that a star is 57350. +func listGlyphs(ctx *ExecContext, like string) error { + needle := strings.ToLower(strings.TrimSpace(like)) + result := &TableResult{Columns: []string{"Code", "Name", "MDL"}} + for _, g := range GlyphIcons() { + if needle != "" && !glyphMatches(g, needle) { + continue + } + name := g.Name + if len(g.Aliases) > 0 { + name += " (" + strings.Join(g.Aliases, ", ") + ")" + } + result.Rows = append(result.Rows, []any{ + g.Code, name, fmt.Sprintf("icon glyph %d", g.Code), + }) + } + if len(result.Rows) == 0 { + // Say what was searched, or an empty table reads as "the font is empty". + result.Summary = fmt.Sprintf("(no glyph name contains %q — the font defines %d icons)", + like, len(GlyphIcons())) + return writeResult(ctx, result) + } + if needle != "" { + result.Summary = fmt.Sprintf("(%d of %d glyph(s) matching %q)", + len(result.Rows), len(GlyphIcons()), like) + } else { + result.Summary = fmt.Sprintf("(%d glyph(s) in the Mendix glyph font)", len(result.Rows)) + } + return writeResult(ctx, result) +} + +// glyphMatches reports whether the needle appears in the icon's name or any of +// its aliases. Aliases are searched too, or `show glyphs like 'btc'` would find +// nothing while `icon glyph 57895` is exactly what the author wants. +func glyphMatches(g GlyphIcon, needle string) bool { + if strings.Contains(strings.ToLower(g.Name), needle) { + return true + } + for _, a := range g.Aliases { + if strings.Contains(strings.ToLower(a), needle) { + return true + } + } + return false +} + +// describeGlyph handles DESCRIBE GLYPH 57350 and DESCRIBE GLYPH 'star'. +// +// Both directions are accepted because both are asked: a code when reading a +// menu somebody else wrote, a name when writing one. +func describeGlyph(ctx *ExecContext, subject string) error { + subject = strings.TrimSpace(subject) + if subject == "" { + return mdlerrors.NewValidation("describe glyph needs a character code or an icon name") + } + + if code, err := strconv.Atoi(subject); err == nil { + g, ok := LookupGlyph(code) + if !ok { + return mdlerrors.NewNotFoundMsg("glyph", strconv.Itoa(code), fmt.Sprintf( + "glyph %d not found: the Mendix glyph font does not define this code. Its codes "+ + "are sparse, so a nearby number is usually not defined either; browse them "+ + "with `show glyphs` or search by name with `show glyphs like ''`", code)) + } + return writeResult(ctx, glyphDetail(g)) + } + + // A name. Exact match wins over a substring, so `describe glyph 'star'` + // answers about star rather than about star-empty. + needle := strings.ToLower(subject) + var partial []GlyphIcon + for _, g := range GlyphIcons() { + if strings.EqualFold(g.Name, subject) { + return writeResult(ctx, glyphDetail(g)) + } + for _, a := range g.Aliases { + if strings.EqualFold(a, subject) { + return writeResult(ctx, glyphDetail(g)) + } + } + if glyphMatches(g, needle) { + partial = append(partial, g) + } + } + if len(partial) == 1 { + return writeResult(ctx, glyphDetail(partial[0])) + } + if len(partial) > 1 { + // Ambiguous: name them rather than picking one, since the codes differ. + names := make([]string, 0, len(partial)) + for _, g := range partial { + names = append(names, fmt.Sprintf("%s (%d)", g.Name, g.Code)) + } + return mdlerrors.NewValidationf("%q matches %d glyphs: %s. Name one exactly, "+ + "or list them with `show glyphs like '%s'`", + subject, len(partial), strings.Join(names, ", "), subject) + } + return mdlerrors.NewNotFoundMsg("glyph", subject, fmt.Sprintf( + "glyph %q not found: no icon in the Mendix glyph font has that name. "+ + "Search with `show glyphs like ''`", subject)) +} + +// glyphDetail renders one glyph, including the MDL to paste. +func glyphDetail(g GlyphIcon) *TableResult { + result := &TableResult{Columns: []string{"Property", "Value"}} + result.Rows = append(result.Rows, + []any{"Name", g.Name}, + []any{"Code", strconv.Itoa(g.Code)}, + []any{"Hex", fmt.Sprintf("0x%04X", g.Code)}, + []any{"CSS class", "glyphicon-" + g.Name}, + ) + if len(g.Aliases) > 0 { + result.Rows = append(result.Rows, []any{"Aliases", strings.Join(g.Aliases, ", ")}) + } + result.Rows = append(result.Rows, []any{"MDL", fmt.Sprintf("icon glyph %d", g.Code)}) + // The point of the pair, not a footnote: a glyph code is unchecked until + // MDL078 sees it, while a collection reference is resolved by + // `check --references` before anything is written. + result.Summary = "An icon collection reference is checked when a glyph code is not — " + + "prefer `icon Atlas_Core.Atlas.` where the icon exists there." + return result +} diff --git a/mdl/executor/cmd_glyphs_test.go b/mdl/executor/cmd_glyphs_test.go new file mode 100644 index 0000000000..8082a40904 --- /dev/null +++ b/mdl/executor/cmd_glyphs_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" +) + +// newGlyphTestContext returns a context whose output is captured. These two +// commands read the embedded font table, so no project and no backend is needed +// — which is the point: `show glyphs` works before you have connected. +func newGlyphTestContext() (*ExecContext, *bytes.Buffer) { + buf := &bytes.Buffer{} + return &ExecContext{Output: buf}, buf +} + +// `show icon collections` / `describe icon collection` cover icons that are +// model documents. A glyph is not one — `icon glyph ` stores a bare character +// code into a font — so there was nothing to list, and MDL078 had to spell its +// advice as numeric ranges with holes in them. These two close that. + +func TestListGlyphs_FindsByName(t *testing.T) { + ctx, out := newGlyphTestContext() + if err := listGlyphs(ctx, "star"); err != nil { + t.Fatalf("listGlyphs: %v", err) + } + got := out.String() + for _, want := range []string{"57350", "star", "57351", "star-empty", "icon glyph 57350"} { + if !strings.Contains(got, want) { + t.Errorf("output is missing %q:\n%s", want, got) + } + } + // The MDL column is the point: the answer has to be pasteable, not just + // looked at. + if !strings.Contains(got, "icon glyph 57350") { + t.Error("the listing does not give the MDL to write") + } +} + +// An ALIAS has to match, or `show glyphs like 'btc'` finds nothing while +// `icon glyph 57895` is exactly what the author is looking for. +func TestListGlyphs_MatchesAnAlias(t *testing.T) { + ctx, out := newGlyphTestContext() + if err := listGlyphs(ctx, "btc"); err != nil { + t.Fatalf("listGlyphs: %v", err) + } + if !strings.Contains(out.String(), "57895") { + t.Errorf("an alias did not match:\n%s", out.String()) + } +} + +// CONTROL: no filter lists the whole font, so a broken matcher cannot pass the +// tests above by returning everything. +func TestListGlyphs_UnfilteredListsTheWholeFont(t *testing.T) { + ctx, out := newGlyphTestContext() + if err := listGlyphs(ctx, ""); err != nil { + t.Fatalf("listGlyphs: %v", err) + } + if !strings.Contains(out.String(), "247") { + t.Errorf("the summary does not report the full count:\n%s", + lastLine(out.String())) + } +} + +// A miss must say what was searched. An empty table on its own reads as "the +// font is empty", which is the wrong conclusion to hand someone. +func TestListGlyphs_EmptyResultNamesTheSearch(t *testing.T) { + ctx, out := newGlyphTestContext() + if err := listGlyphs(ctx, "definitely-not-an-icon"); err != nil { + t.Fatalf("listGlyphs: %v", err) + } + got := out.String() + if !strings.Contains(got, "definitely-not-an-icon") || !strings.Contains(got, "247") { + t.Errorf("an empty result does not say what was searched or how many exist:\n%s", got) + } +} + +func TestDescribeGlyph_ByCodeAndByName(t *testing.T) { + for _, subject := range []string{"57350", "star"} { + ctx, out := newGlyphTestContext() + if err := describeGlyph(ctx, subject); err != nil { + t.Fatalf("describeGlyph(%q): %v", subject, err) + } + got := out.String() + for _, want := range []string{"star", "57350", "0xE006", "icon glyph 57350"} { + if !strings.Contains(got, want) { + t.Errorf("describeGlyph(%q) is missing %q:\n%s", subject, want, got) + } + } + } +} + +// An exact name must win over a substring, or `describe glyph 'star'` answers +// about star-empty depending on table order. +func TestDescribeGlyph_ExactNameBeatsASubstring(t *testing.T) { + ctx, out := newGlyphTestContext() + if err := describeGlyph(ctx, "star"); err != nil { + t.Fatalf("describeGlyph: %v", err) + } + if strings.Contains(out.String(), "57351") { + t.Errorf("an exact name resolved to the substring match instead:\n%s", out.String()) + } +} + +// An ambiguous name names the candidates rather than picking one — the codes +// differ, so a guess would be silently wrong. +func TestDescribeGlyph_AmbiguousNameListsTheCandidates(t *testing.T) { + ctx, _ := newGlyphTestContext() + err := describeGlyph(ctx, "arrow") + if err == nil { + t.Fatal("an ambiguous name was resolved to one glyph") + } + if !strings.Contains(err.Error(), "arrow-left") { + t.Errorf("the error does not name the candidates: %v", err) + } +} + +// The two misses point back at the listing, since neither a wrong code nor a +// wrong name tells you what the right one is. +func TestDescribeGlyph_MissesPointAtTheListing(t *testing.T) { + for _, subject := range []string{"57562", "nonesuch"} { + ctx, _ := newGlyphTestContext() + err := describeGlyph(ctx, subject) + if err == nil { + t.Fatalf("describeGlyph(%q) succeeded", subject) + } + if !strings.Contains(err.Error(), "show glyphs") { + t.Errorf("describeGlyph(%q) does not point at the listing: %v", subject, err) + } + } +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + return lines[len(lines)-1] +} diff --git a/mdl/executor/cmd_microflows_builder_calls.go b/mdl/executor/cmd_microflows_builder_calls.go index f88531983b..b5d452f384 100644 --- a/mdl/executor/cmd_microflows_builder_calls.go +++ b/mdl/executor/cmd_microflows_builder_calls.go @@ -665,10 +665,25 @@ func (fb *flowBuilder) resolveExternalActionReturnKind(serviceRef ast.QualifiedN return "", "" } -// externalParamKind is one action parameter's resolved Mendix type. +// externalParamKind is one action parameter's resolved Mendix type, plus +// whether the contract lets the argument be left empty. type externalParamKind struct { - kind string // "String", "Object", … — same vocabulary as the return type - entity string // set only for Object/List + kind string // "String", "Object", … — same vocabulary as the return type + entity string // set only for Object/List + canBeEmpty bool // the contract's Nullable, which Mendix stores as CanBeEmpty +} + +// paramCanBeEmpty reads a parameter's nullability the way Mendix does. +// +// CSDL makes Nullable OPTIONAL on and defaults it to true, so an +// absent attribute means nullable — the opposite of Go's zero value. Getting +// this backwards is invisible in a contract that spells every Nullable out and +// only shows up on one that does not. +func paramCanBeEmpty(p *types.EdmActionParameter) bool { + if p.Nullable == nil { + return true + } + return *p.Nullable } // resolveExternalActionParameterKinds types every parameter of the called action @@ -703,14 +718,16 @@ func (fb *flowBuilder) resolveExternalActionParameterKinds(serviceRef ast.Qualif continue } for _, p := range act.Parameters { + // Nullability is recorded even when the type does not resolve: + // CanBeEmpty is read straight off the contract and does not + // depend on mxcli being able to name the Mendix type. + pk := externalParamKind{canBeEmpty: paramCanBeEmpty(p)} if kind := edmReturnTypeToKind(p.Type); kind != "" && kind != "Void" { - out[strings.ToLower(p.Name)] = externalParamKind{kind: kind} - continue - } - // Entity-typed parameter: same resolution as an entity return. - if kind, entity := fb.resolveExternalActionReturnEntity(serviceRef, p.Type); kind != "" { - out[strings.ToLower(p.Name)] = externalParamKind{kind: kind, entity: entity} + pk.kind = kind + } else if kind, entity := fb.resolveExternalActionReturnEntity(serviceRef, p.Type); kind != "" { + pk.kind, pk.entity = kind, entity } + out[strings.ToLower(p.Name)] = pk } return out } @@ -847,6 +864,10 @@ func (fb *flowBuilder) addCallExternalActionAction(s *ast.CallExternalActionStmt if pk, ok := paramKinds[strings.ToLower(arg.Name)]; ok { mapping.ParameterDataType = pk.kind mapping.ParameterEntity = pk.entity + // Mendix compares CanBeEmpty against the contract's Nullable and + // raises CE7252 when they disagree, so leaving it at Go's false + // makes every call on a nullable parameter unbuildable. + mapping.CanBeEmpty = pk.canBeEmpty } mappings = append(mappings, mapping) } diff --git a/mdl/executor/cmd_navigation.go b/mdl/executor/cmd_navigation.go index 543c0ee58b..a53d52f9a6 100644 --- a/mdl/executor/cmd_navigation.go +++ b/mdl/executor/cmd_navigation.go @@ -96,6 +96,15 @@ func execAlterNavigation(ctx *ExecContext, s *ast.AlterNavigationStmt) error { spec.MenuItems = append(spec.MenuItems, convertMenuItemDef(mi)) } + spec.HasSync = s.HasSyncBlock + for _, se := range s.SyncEntries { + spec.OfflineEntities = append(spec.OfflineEntities, types.NavOfflineEntitySpec{ + Entity: se.Entity.String(), + SyncMode: se.Mode, + Constraint: se.Constraint, + }) + } + if err := ctx.Backend.UpdateNavigationProfile(nav.ID, s.ProfileName, spec); err != nil { return mdlerrors.NewBackend("update navigation profile", err) } @@ -341,15 +350,23 @@ func outputNavigationProfile(ctx *ExecContext, p *types.NavigationProfile) { fmt.Fprintln(ctx.Output, " )") } - // Offline entities (as comments since CREATE NAVIGATION doesn't handle sync yet) + // Offline entities. These are re-executable now, so they are emitted as a + // SYNC block rather than as the commented-out approximation that made + // describe -> exec lossy for every project using offline sync. if len(p.OfflineEntities) > 0 { - fmt.Fprintln(ctx.Output, " -- Offline Entities (not yet modifiable):") + fmt.Fprintln(ctx.Output, " sync (") + for _, oe := range p.OfflineEntities { + fmt.Fprintf(ctx.Output, " sync %s %s;\n", oe.Entity, syncModeMDL(oe.SyncMode, oe.Constraint)) + } + fmt.Fprintln(ctx.Output, " )") + // CompatibilityMode has no syntax: it is carried through a rewrite + // untouched, but a reader should know it is set rather than discover it + // missing later. Flagged, never silently dropped. for _, oe := range p.OfflineEntities { - constraint := "" - if oe.Constraint != "" { - constraint = fmt.Sprintf(" where '%s'", oe.Constraint) + if oe.CompatibilityMode { + fmt.Fprintf(ctx.Output, + " -- %s has compatibility mode on; mxcli preserves it but cannot author it\n", oe.Entity) } - fmt.Fprintf(ctx.Output, " -- SYNC %s MODE %s%s;\n", oe.Entity, oe.SyncMode, constraint) } } @@ -473,3 +490,93 @@ func menuItemIconNote(item *types.NavMenuItem, reproducer string) string { return fmt.Sprintf("-- icon %s (%s) is not reproducible by %s; set it in Studio Pro", target, item.IconType, reproducer) } + +// singleLine folds a stored multi-line value onto one line so it can appear +// inside a `--` comment. Studio Pro writes an offline sync constraint with +// embedded newlines and indentation; emitting it verbatim would terminate the +// comment mid-XPath and leave the remainder parsed as MDL. +func singleLine(s string) string { + // Collapsing whitespace with strings.Fields would also collapse it INSIDE + // string literals, so a constraint containing 'two spaces' would come back + // as 'two spaces' — a silent change to the value being matched on, in a + // place nothing would look. Quote state is tracked so only whitespace + // outside literals is folded. + var b strings.Builder + inLiteral := false + pendingSpace := false + + // write emits one byte, flushing a deferred separator first. Deferring is + // what keeps a fold from landing INSIDE the literal that follows it. + write := func(c byte) { + if pendingSpace { + if b.Len() > 0 { + b.WriteByte(' ') + } + pendingSpace = false + } + b.WriteByte(c) + } + + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '\'': + // A doubled quote inside a literal is an escaped quote, not a + // close: copy both and stay in the literal. + if inLiteral && i+1 < len(s) && s[i+1] == '\'' { + write('\'') + b.WriteByte('\'') + i++ + continue + } + write(c) + inLiteral = !inLiteral + case !inLiteral && (c == ' ' || c == '\t' || c == '\n' || c == '\r'): + pendingSpace = true + default: + write(c) + } + } + return b.String() +} + +// syncModeMDL renders a stored sync mode as the MDL that reproduces it. +// +// The inverse of the visitor's mapping, and the reason describe -> exec now +// round-trips: emitting the stored member verbatim would produce `sync X +// Constrained`, which is not MDL, and emitting a Studio Pro caption would +// produce a document mxbuild refuses. +func syncModeMDL(mode, constraint string) string { + switch mode { + case "Online": + return "online" + case "All": + return "all" + case "Never": + return "never" + case "None": + return "none" + case "NoneAndPreserveData": + return "none preserve data" + case "Constrained": + // The bracket form, so nothing is escaped. A stored constraint already + // carries Mendix's own quote escaping; wrapping it in a quoted MDL + // string doubles every one of those again, and the reference document's + // came back as six consecutive quotes — correct, unreadable, and the + // thing mendixlabs/mxcli#750 is about. + // + // Studio Pro stores the constraint bracketed, so the folded value is + // normally already `[...]`; one without them is wrapped rather than + // assumed to have them. + x := singleLine(constraint) + if !strings.HasPrefix(x, "[") || !strings.HasSuffix(x, "]") { + x = "[" + x + "]" + } + return "where " + x + default: + // An unknown member is not guessed at. Emitting a mode MDL cannot spell + // would produce a script that fails at check; saying so is honest and + // keeps the rest of the block re-executable. + return fmt.Sprintf("all -- UNKNOWN MODE %q, not reproducible", mode) + } +} diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index 49b3cbf267..0be346b2d9 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -315,6 +315,10 @@ func execDropUserRole(ctx *ExecContext, s *ast.DropUserRoleStmt) error { } } if !found { + if s.IfExists { + fmt.Fprintf(ctx.Output, "User role '%s' does not exist, skipping\n", s.Name) + return nil + } return mdlerrors.NewNotFound("user role", s.Name) } @@ -1393,6 +1397,10 @@ func execDropDemoUser(ctx *ExecContext, s *ast.DropDemoUserStmt) error { } } if !found { + if s.IfExists { + fmt.Fprintf(ctx.Output, "Demo user '%s' does not exist, skipping\n", s.UserName) + return nil + } return mdlerrors.NewNotFound("demo user", s.UserName) } diff --git a/mdl/executor/cmd_workflows_nested_autobind_test.go b/mdl/executor/cmd_workflows_nested_autobind_test.go new file mode 100644 index 0000000000..6a27937038 --- /dev/null +++ b/mdl/executor/cmd_workflows_nested_autobind_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/workflows" +) + +// newNestedAutoBindCtx builds an ExecContext whose backend knows one microflow, +// WF.ACT_Noop($Ctx), so autoBindCallMicroflow can resolve its parameters. +func newNestedAutoBindCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("WF") + mf := mkMicroflow(mod.ID, "ACT_Noop") + mf.Parameters = []*microflows.MicroflowParameter{{ + BaseElement: model.BaseElement{ID: nextID("param")}, + Name: "Ctx", + }} + + h := mkHierarchy(mod) + withContainer(h, mf.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +func mkCallMicroflowTask(name string) *workflows.CallMicroflowTask { + task := &workflows.CallMicroflowTask{Microflow: "WF.ACT_Noop"} + task.ID = model.ID(nextID("act")) + task.Name = name + task.Caption = name + return task +} + +func mkWfFlow(acts ...workflows.WorkflowActivity) *workflows.Flow { + f := &workflows.Flow{Activities: acts} + f.ID = model.ID(nextID("flow")) + return f +} + +// assertWired is the CE6685/CE6686 assertion: a call-microflow activity Mendix +// accepts carries one parameter mapping per target parameter and at least one +// outcome. Both are auto-generated; neither is written by the MDL statement. +func assertWired(t *testing.T, where string, task *workflows.CallMicroflowTask) { + t.Helper() + if len(task.ParameterMappings) != 1 { + t.Errorf("%s: got %d parameter mappings, want 1 (CE6685)", where, len(task.ParameterMappings)) + } else if got := task.ParameterMappings[0].Expression; got != "$WorkflowContext" { + t.Errorf("%s: parameter mapping expression = %q, want %q", where, got, "$WorkflowContext") + } + if len(task.Outcomes) == 0 { + t.Errorf("%s: got 0 outcomes, want >= 1 (CE6686)", where) + } +} + +// TestAutoBindReachesNestedCallMicroflow guards ako/mxcli#417: a call-microflow +// activity nested inside a decision's ENUMERATION outcome (or a boundary event +// body) was never visited by autoBindActivitiesInFlow, so it reached Mendix with +// no parameter mappings and no outcomes — CE6685 + CE6686. The MAIN-flow control +// in the same table was always wired; that is what made the gap invisible. +func TestAutoBindReachesNestedCallMicroflow(t *testing.T) { + cases := []struct { + name string + build func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity + }{ + {"decision/enum outcome", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + out := &workflows.EnumerationValueConditionOutcome{Value: "WF.Status.OutcomeA", Flow: mkWfFlow(task)} + out.ID = model.ID(nextID("out")) + split := &workflows.ExclusiveSplitActivity{ + Expression: "$WorkflowContext/Status", + Outcomes: []workflows.ConditionOutcome{out}, + } + split.ID = model.ID(nextID("act")) + split.Name = "decision1" + return split + }}, + {"decision/boolean outcome", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + out := &workflows.BooleanConditionOutcome{Value: true, Flow: mkWfFlow(task)} + out.ID = model.ID(nextID("out")) + split := &workflows.ExclusiveSplitActivity{Outcomes: []workflows.ConditionOutcome{out}} + split.ID = model.ID(nextID("act")) + split.Name = "decision1" + return split + }}, + {"call microflow/enum outcome", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + out := &workflows.EnumerationValueConditionOutcome{Value: "WF.Status.OutcomeA", Flow: mkWfFlow(task)} + out.ID = model.ID(nextID("out")) + outer := mkCallMicroflowTask("outerCall") + outer.Outcomes = []workflows.ConditionOutcome{out} + return outer + }}, + {"user task outcome", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + out := &workflows.UserTaskOutcome{Value: "Approve", Flow: mkWfFlow(task)} + out.ID = model.ID(nextID("out")) + ut := &workflows.UserTask{Outcomes: []*workflows.UserTaskOutcome{out}} + ut.ID = model.ID(nextID("act")) + ut.Name = "userTask1" + return ut + }}, + {"parallel split path", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + out := &workflows.ParallelSplitOutcome{Flow: mkWfFlow(task)} + out.ID = model.ID(nextID("out")) + ps := &workflows.ParallelSplitActivity{Outcomes: []*workflows.ParallelSplitOutcome{out}} + ps.ID = model.ID(nextID("act")) + ps.Name = "split1" + return ps + }}, + {"user task boundary event", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + be := &workflows.BoundaryEvent{EventType: "InterruptingTimer", Flow: mkWfFlow(task)} + be.ID = model.ID(nextID("be")) + ut := &workflows.UserTask{BoundaryEvents: []*workflows.BoundaryEvent{be}} + ut.ID = model.ID(nextID("act")) + ut.Name = "userTask1" + return ut + }}, + {"call microflow boundary event", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + be := &workflows.BoundaryEvent{EventType: "InterruptingTimer", Flow: mkWfFlow(task)} + be.ID = model.ID(nextID("be")) + outer := mkCallMicroflowTask("outerCall") + outer.BoundaryEvents = []*workflows.BoundaryEvent{be} + return outer + }}, + {"wait for notification boundary event", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + be := &workflows.BoundaryEvent{EventType: "InterruptingTimer", Flow: mkWfFlow(task)} + be.ID = model.ID(nextID("be")) + w := &workflows.WaitForNotificationActivity{BoundaryEvents: []*workflows.BoundaryEvent{be}} + w.ID = model.ID(nextID("act")) + w.Name = "wait1" + return w + }}, + {"call workflow boundary event", func(task *workflows.CallMicroflowTask) workflows.WorkflowActivity { + be := &workflows.BoundaryEvent{EventType: "InterruptingTimer", Flow: mkWfFlow(task)} + be.ID = model.ID(nextID("be")) + cw := &workflows.CallWorkflowActivity{Workflow: "WF.Other"} + cw.ID = model.ID(nextID("act")) + cw.Name = "callWorkflow1" + cw.BoundaryEvents = []*workflows.BoundaryEvent{be} + return cw + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := newNestedAutoBindCtx(t) + + // The MAIN-flow control: the identical activity, unnested. It was + // always wired, so a failure here means the test setup is wrong, + // not that the nesting gap is real. + control := mkCallMicroflowTask("controlCall") + nested := mkCallMicroflowTask("nestedCall") + + autoBindWorkflowParameters(ctx, []workflows.WorkflowActivity{control, tc.build(nested)}, "Ctx") + + assertWired(t, "MAIN flow control", control) + assertWired(t, "nested in "+tc.name, nested) + }) + } +} diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index 924767f8c9..22f00b1a9a 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -636,86 +636,26 @@ func deduplicateActivityNames(activities []workflows.WorkflowActivity) { // a jump nested in an outcome flow is still reached in the second pass. func deduplicateActivityNamesInFlow(activities []workflows.WorkflowActivity, nameCount map[string]int, jumpPass bool) { for _, act := range activities { - switch a := act.(type) { - case *workflows.UserTask: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - for _, outcome := range a.Outcomes { - if outcome.Flow != nil { - deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount, jumpPass) - } - } - case *workflows.CallMicroflowTask: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - for _, outcome := range a.Outcomes { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - case *workflows.EnumerationValueConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - case *workflows.VoidConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - } - } - case *workflows.CallWorkflowActivity: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - case *workflows.ExclusiveSplitActivity: + switch act.(type) { + case *workflows.UserTask, *workflows.CallMicroflowTask, *workflows.CallWorkflowActivity, + *workflows.ExclusiveSplitActivity, *workflows.ParallelSplitActivity, + *workflows.WaitForTimerActivity, *workflows.WaitForNotificationActivity, + *workflows.EndWorkflowActivity: if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - for _, outcome := range a.Outcomes { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - case *workflows.EnumerationValueConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - case *workflows.VoidConditionOutcome: - if o.Flow != nil { - deduplicateActivityNamesInFlow(o.Flow.Activities, nameCount, jumpPass) - } - } - } - case *workflows.ParallelSplitActivity: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - for _, outcome := range a.Outcomes { - if outcome.Flow != nil { - deduplicateActivityNamesInFlow(outcome.Flow.Activities, nameCount, jumpPass) - } + act.SetName(uniqueName(act.GetName(), nameCount)) } case *workflows.JumpToActivity: if jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - case *workflows.WaitForTimerActivity: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - case *workflows.WaitForNotificationActivity: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) - } - case *workflows.EndWorkflowActivity: - if !jumpPass { - a.Name = uniqueName(a.Name, nameCount) + act.SetName(uniqueName(act.GetName(), nameCount)) } } + + // Same nested-flow enumeration as the auto-bind walk, for the same + // reason: this switch was missing the enum-outcome and boundary-event + // branches too, so a name collision inside one went undetected. + for _, f := range nestedFlows(act) { + deduplicateActivityNamesInFlow(f.Activities, nameCount, jumpPass) + } } } @@ -756,6 +696,71 @@ func sanitizeActivityName(name string) string { return result } +// nestedFlows returns every flow nested inside a workflow activity: condition +// outcomes (a decision's and a call-microflow's), user-task outcomes, parallel +// split paths, and boundary-event bodies. +// +// It exists because both tree walks over a workflow — autoBindActivitiesInFlow +// and deduplicateActivityNamesInFlow — used to enumerate the nested flows +// themselves, with a type switch per outcome kind. Each switch was missing +// cases: auto-bind never entered an EnumerationValueConditionOutcome or any +// boundary event, so a `call microflow` inside a decision's enum branch reached +// Mendix with no parameter mappings and no outcomes (CE6685 + CE6686, +// ako/mxcli#417) while the identical activity in the MAIN flow was wired. +// Enumerating the flows in ONE place is what stops the next walk from +// re-acquiring the gap; the ConditionOutcome interface already exposes GetFlow, +// so no outcome kind can be silently skipped here. +func nestedFlows(act workflows.WorkflowActivity) []*workflows.Flow { + var flows []*workflows.Flow + add := func(f *workflows.Flow) { + if f != nil { + flows = append(flows, f) + } + } + addConditions := func(outcomes []workflows.ConditionOutcome) { + for _, o := range outcomes { + if o != nil { + add(o.GetFlow()) + } + } + } + addBoundary := func(events []*workflows.BoundaryEvent) { + for _, be := range events { + if be != nil { + add(be.Flow) + } + } + } + + switch a := act.(type) { + case *workflows.CallMicroflowTask: + addConditions(a.Outcomes) + addBoundary(a.BoundaryEvents) + case *workflows.SystemTask: + addConditions(a.Outcomes) + case *workflows.ExclusiveSplitActivity: + addConditions(a.Outcomes) + case *workflows.UserTask: + for _, o := range a.Outcomes { + if o != nil { + add(o.Flow) + } + } + addBoundary(a.BoundaryEvents) + case *workflows.ParallelSplitActivity: + for _, o := range a.Outcomes { + if o != nil { + add(o.Flow) + } + } + case *workflows.CallWorkflowActivity: + addBoundary(a.BoundaryEvents) + case *workflows.WaitForNotificationActivity: + addBoundary(a.BoundaryEvents) + } + return flows +} + // autoBindWorkflowParameters resolves microflow/workflow parameters and generates // ParameterMappings, default outcomes, and sanitized names for workflow activities. // declaredContextVar is the variable name from the workflow header's @@ -769,19 +774,6 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA switch a := act.(type) { case *workflows.CallMicroflowTask: autoBindCallMicroflow(ctx, a, norm) - // Recurse into outcomes - for _, outcome := range a.Outcomes { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) - } - case *workflows.VoidConditionOutcome: - if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) - } - } - } case *workflows.CallWorkflowActivity: autoBindCallWorkflow(ctx, a) case *workflows.UserTask: @@ -791,19 +783,9 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA if xp, ok := a.UserSource.(*workflows.XPathBasedUserSource); ok { xp.XPath = norm.rewrite(xp.XPath) } - for _, outcome := range a.Outcomes { - if outcome.Flow != nil { - autoBindActivitiesInFlow(ctx, outcome.Flow.Activities, norm) - } - } case *workflows.ParallelSplitActivity: // Sanitize name (spaces not allowed) a.Name = sanitizeActivityName(a.Name) - for _, outcome := range a.Outcomes { - if outcome.Flow != nil { - autoBindActivitiesInFlow(ctx, outcome.Flow.Activities, norm) - } - } case *workflows.ExclusiveSplitActivity: a.Name = sanitizeActivityName(a.Name) // A decision's condition is an expression over the workflow context, @@ -813,18 +795,6 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA // name) reached Mendix as undefined variables → CE0117 (issuetracker // #17). Normalize it the same way. a.Expression = norm.rewrite(a.Expression) - for _, outcome := range a.Outcomes { - switch o := outcome.(type) { - case *workflows.BooleanConditionOutcome: - if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) - } - case *workflows.VoidConditionOutcome: - if o.Flow != nil { - autoBindActivitiesInFlow(ctx, o.Flow.Activities, norm) - } - } - } case *workflows.WaitForNotificationActivity: a.Name = sanitizeActivityName(a.Name) case *workflows.WaitForTimerActivity: @@ -833,6 +803,13 @@ func autoBindActivitiesInFlow(ctx *ExecContext, activities []workflows.WorkflowA case *workflows.JumpToActivity: a.Name = sanitizeActivityName(a.Name) } + + // Every nested flow, whatever the activity — enumerating them here rather + // than per case is what keeps a decision's enum branch and a boundary + // event body from being skipped (ako/mxcli#417). + for _, f := range nestedFlows(act) { + autoBindActivitiesInFlow(ctx, f.Activities, norm) + } } } diff --git a/mdl/executor/executor_query.go b/mdl/executor/executor_query.go index 672285bc1d..f612eb1aa1 100644 --- a/mdl/executor/executor_query.go +++ b/mdl/executor/executor_query.go @@ -8,7 +8,11 @@ import ( ) func execShow(ctx *ExecContext, s *ast.ShowStmt) error { - if !ctx.Connected() && s.ObjectType != ast.ShowModules && s.ObjectType != ast.ShowFragments { + // SHOW GLYPHS reads the Mendix glyph font, not the project, so a connection + // would be an arbitrary requirement — and the rule that sends people here + // (MDL078) runs in the project-free pass too. + if !ctx.Connected() && s.ObjectType != ast.ShowModules && s.ObjectType != ast.ShowFragments && + s.ObjectType != ast.ShowGlyphs { return mdlerrors.NewNotConnected() } @@ -131,6 +135,8 @@ func execShow(ctx *ExecContext, s *ast.ShowStmt) error { return listImageCollections(ctx, s.InModule) case ast.ShowIconCollections: return listIconCollections(ctx, s.InModule) + case ast.ShowGlyphs: + return listGlyphs(ctx, s.Like) case ast.ShowModels: return listAgentEditorModels(ctx, s.InModule) case ast.ShowAgents: @@ -175,7 +181,8 @@ func execDescribe(ctx *ExecContext, s *ast.DescribeStmt) error { // is better — the installed .mpk is version-accurate and covers Marketplace // widgets — but requiring one would make the statement useless for exactly // the "what can I write here?" question it exists to answer. - if !ctx.Connected() && s.ObjectType != ast.DescribeFragment && s.ObjectType != ast.DescribeWidget { + if !ctx.Connected() && s.ObjectType != ast.DescribeFragment && s.ObjectType != ast.DescribeWidget && + s.ObjectType != ast.DescribeGlyph { return mdlerrors.NewNotConnected() } @@ -265,6 +272,8 @@ func execDescribe(ctx *ExecContext, s *ast.DescribeStmt) error { return describeImageCollection(ctx, s.Name) case ast.DescribeIconCollection: return describeIconCollection(ctx, s.Name) + case ast.DescribeGlyph: + return describeGlyph(ctx, s.Qualifier) case ast.DescribeModel: return describeAgentEditorModel(ctx, s.Name) case ast.DescribeAgent: @@ -364,6 +373,8 @@ func describeObjectTypeLabel(t ast.DescribeObjectType) string { return "imagecollection" case ast.DescribeIconCollection: return "iconcollection" + case ast.DescribeGlyph: + return "glyph" case ast.DescribeModel: return "model" case ast.DescribeAgent: diff --git a/mdl/executor/external_action_can_be_empty_test.go b/mdl/executor/external_action_can_be_empty_test.go new file mode 100644 index 0000000000..e7f20cf46c --- /dev/null +++ b/mdl/executor/external_action_can_be_empty_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Mendix stores each external-action parameter's nullability on the mapping as +// CanBeEmpty, and compares it against the contract on every build. mxcli left it +// at Go's zero value, so a call on a NULLABLE parameter was CE7252 "the +// parameters for remote action '' have changed" — with no way to clear it +// from MDL, because nothing in the language reaches the field. +// +// The three parameter shapes below are the ones that behave differently. +// `note` is the subtle one: CSDL makes Nullable optional on and +// defaults it to TRUE, so an absent attribute is the opposite of Go's zero +// value. A contract that spells every Nullable out cannot tell the two apart. +const canBeEmptyMetadata = ` + + + + + + + + + + + + + +` + +// paramCanBeEmpty is where the default lives, so it is pinned on its own: the +// end-to-end test below would still pass if `nil` and `false` were conflated, +// as long as no contract in the suite omitted the attribute. +func TestParamCanBeEmpty_AbsentNullableMeansNullable(t *testing.T) { + f, tr := false, true + for _, tc := range []struct { + name string + nullable *bool + want bool + }{ + {"explicit false", &f, false}, + {"explicit true", &tr, true}, + {"absent", nil, true}, // CSDL default, confirmed against mxbuild 11.14 + } { + if got := paramCanBeEmpty(&types.EdmActionParameter{Nullable: tc.nullable}); got != tc.want { + t.Errorf("%s: CanBeEmpty = %v, want %v", tc.name, got, tc.want) + } + } +} + +// buildCanBeEmptyCall runs the real builder against the contract above and +// returns the mappings it wrote. +func buildCanBeEmptyCall(t *testing.T) []*microflows.ExternalActionParameterMapping { + t.Helper() + + svcID := model.ID("svc-1") + modID := model.ID("mod-1") + mb := &mock.MockBackend{ + ListConsumedODataServicesFunc: func() ([]*model.ConsumedODataService, error) { + return []*model.ConsumedODataService{{ + BaseElement: model.BaseElement{ID: svcID}, + ContainerID: modID, + Name: "Bug1073", + Metadata: canBeEmptyMetadata, + }}, nil + }, + } + fb := &flowBuilder{ + posX: 100, posY: 100, spacing: HorizontalSpacing, backend: mb, + hierarchy: &ContainerHierarchy{moduleNames: map[model.ID]string{modID: "Odata"}}, + varTypes: map[string]string{}, + declaredVars: map[string]string{}, + } + + // Parsed rather than hand-built: the argument expressions have to be the + // ones the real front end produces, or the Argument assertion below is + // testing the fixture instead of the builder. + prog, errs := visitor.Build( + "create microflow Odata.ACT_Probe()\nbegin\n" + + " call external action Odata.Bug1073.RunCommand(" + + "command = empty, additional = empty, note = empty);\nend;") + if len(errs) > 0 { + t.Fatalf("parsing the probe script: %v", errs) + } + call := prog.Statements[0].(*ast.CreateMicroflowStmt).Body[0].(*ast.CallExternalActionStmt) + fb.addCallExternalActionAction(call) + + for _, obj := range fb.objects { + act, ok := obj.(*microflows.ActionActivity) + if !ok { + continue + } + if call, ok := act.Action.(*microflows.CallExternalAction); ok { + return call.ParameterMappings + } + } + t.Fatal("the builder produced no CallExternalAction") + return nil +} + +// The measured shape of a Studio Pro document: CanBeEmpty tracks the contract's +// Nullable per parameter, and Argument is the expression `empty` — NOT an empty +// string. Pinned against Odata.UnboundedActionMF in ako/TestApp, whose two +// mappings are {command, "empty", false} and {additional, "empty", true}. +func TestCallExternalAction_CanBeEmptyFollowsTheContract(t *testing.T) { + want := map[string]bool{ + "command": false, // Nullable="false" + "additional": true, // Nullable="true" + "note": true, // no Nullable attribute at all + } + + mappings := buildCanBeEmptyCall(t) + if len(mappings) != len(want) { + t.Fatalf("got %d mappings, want %d", len(mappings), len(want)) + } + for _, pm := range mappings { + w, ok := want[pm.ParameterName] + if !ok { + t.Errorf("unexpected mapping for %q", pm.ParameterName) + continue + } + if pm.CanBeEmpty != w { + t.Errorf("%s: CanBeEmpty = %v, want %v — Mendix compares this against "+ + "the contract and reports CE7252 when they disagree", + pm.ParameterName, pm.CanBeEmpty, w) + } + if pm.Argument != "empty" { + t.Errorf("%s: Argument = %q, want %q", pm.ParameterName, pm.Argument, "empty") + } + } + + // Control: the values are not uniformly true. A fix that set CanBeEmpty on + // every mapping would satisfy the nullable cases and silently break the + // required one, which is the same CE7252 in the other direction. + var sawFalse bool + for _, pm := range mappings { + if !pm.CanBeEmpty { + sawFalse = true + } + } + if !sawFalse { + t.Error("every mapping came back CanBeEmpty=true; the value is not being " + + "read from the contract") + } +} + +// ParameterType is resolved in the same loop, so a regression there would show +// up as a wrong CanBeEmpty and vice versa. #1020 is what put it in. +func TestCallExternalAction_ParameterTypeStillResolved(t *testing.T) { + for _, pm := range buildCanBeEmptyCall(t) { + if pm.ParameterDataType != "String" { + t.Errorf("%s: ParameterDataType = %q, want String", + pm.ParameterName, pm.ParameterDataType) + } + } +} diff --git a/mdl/executor/glyph_icons.go b/mdl/executor/glyph_icons.go new file mode 100644 index 0000000000..126d68272c --- /dev/null +++ b/mdl/executor/glyph_icons.go @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "sort" + +// GlyphIcon is one entry of the Mendix glyph font: the numeric character code +// `icon glyph ` stores, and the name it is known by. +type GlyphIcon struct { + // Code is the character code, which is what the model stores. This is the + // whole identity of a glyph icon — Forms$GlyphIcon holds no name. + Code int + // Name is the icon's name without the `glyphicon-` prefix. + Name string + // Aliases are further names for the same code (only `bitcoin` has any). + Aliases []string +} + +// glyphIcons is every icon the Mendix glyph font defines: 247 codes, sorted. +// +// Both halves come from assets Mendix ships rather than from a hand-written +// list. The CODES are the private-use range of the cmap in Atlas_Core's +// glyphicons-halflings-regular.woff — the same table mxbuild resolves against, +// verified on two full deploy builds (see validateMenuItemGlyphCodes). The NAMES +// are the `.glyphicon-:before { content: "\eXXX" }` rules in Atlas_Core's +// bundled Bootstrap stylesheet, which covers all 247 with none left over. +// +// One table, not two: glyphCodeDefined searches this, so the set MDL078 accepts +// and the set `show glyphs` lists cannot drift apart. +var glyphIcons = []GlyphIcon{ + {57345, "glass", nil}, + {57346, "music", nil}, + {57347, "search", nil}, + {57349, "heart", nil}, + {57350, "star", nil}, + {57351, "star-empty", nil}, + {57352, "user", nil}, + {57353, "film", nil}, + {57360, "th-large", nil}, + {57361, "th", nil}, + {57362, "th-list", nil}, + {57363, "ok", nil}, + {57364, "remove", nil}, + {57365, "zoom-in", nil}, + {57366, "zoom-out", nil}, + {57367, "off", nil}, + {57368, "signal", nil}, + {57369, "cog", nil}, + {57376, "trash", nil}, + {57377, "home", nil}, + {57378, "file", nil}, + {57379, "time", nil}, + {57380, "road", nil}, + {57381, "download-alt", nil}, + {57382, "download", nil}, + {57383, "upload", nil}, + {57384, "inbox", nil}, + {57385, "play-circle", nil}, + {57392, "repeat", nil}, + {57393, "refresh", nil}, + {57394, "list-alt", nil}, + {57395, "lock", nil}, + {57396, "flag", nil}, + {57397, "headphones", nil}, + {57398, "volume-off", nil}, + {57399, "volume-down", nil}, + {57400, "volume-up", nil}, + {57401, "qrcode", nil}, + {57408, "barcode", nil}, + {57409, "tag", nil}, + {57410, "tags", nil}, + {57411, "book", nil}, + {57412, "bookmark", nil}, + {57413, "print", nil}, + {57414, "camera", nil}, + {57415, "font", nil}, + {57416, "bold", nil}, + {57417, "italic", nil}, + {57424, "text-height", nil}, + {57425, "text-width", nil}, + {57426, "align-left", nil}, + {57427, "align-center", nil}, + {57428, "align-right", nil}, + {57429, "align-justify", nil}, + {57430, "list", nil}, + {57431, "indent-left", nil}, + {57432, "indent-right", nil}, + {57433, "facetime-video", nil}, + {57440, "picture", nil}, + {57442, "map-marker", nil}, + {57443, "adjust", nil}, + {57444, "tint", nil}, + {57445, "edit", nil}, + {57446, "share", nil}, + {57447, "check", nil}, + {57448, "move", nil}, + {57449, "step-backward", nil}, + {57456, "fast-backward", nil}, + {57457, "backward", nil}, + {57458, "play", nil}, + {57459, "pause", nil}, + {57460, "stop", nil}, + {57461, "forward", nil}, + {57462, "fast-forward", nil}, + {57463, "step-forward", nil}, + {57464, "eject", nil}, + {57465, "chevron-left", nil}, + {57472, "chevron-right", nil}, + {57473, "plus-sign", nil}, + {57474, "minus-sign", nil}, + {57475, "remove-sign", nil}, + {57476, "ok-sign", nil}, + {57477, "question-sign", nil}, + {57478, "info-sign", nil}, + {57479, "screenshot", nil}, + {57480, "remove-circle", nil}, + {57481, "ok-circle", nil}, + {57488, "ban-circle", nil}, + {57489, "arrow-left", nil}, + {57490, "arrow-right", nil}, + {57491, "arrow-up", nil}, + {57492, "arrow-down", nil}, + {57493, "share-alt", nil}, + {57494, "resize-full", nil}, + {57495, "resize-small", nil}, + {57601, "exclamation-sign", nil}, + {57602, "gift", nil}, + {57603, "leaf", nil}, + {57604, "fire", nil}, + {57605, "eye-open", nil}, + {57606, "eye-close", nil}, + {57607, "warning-sign", nil}, + {57608, "plane", nil}, + {57609, "calendar", nil}, + {57616, "random", nil}, + {57617, "comment", nil}, + {57618, "magnet", nil}, + {57619, "chevron-up", nil}, + {57620, "chevron-down", nil}, + {57621, "retweet", nil}, + {57622, "shopping-cart", nil}, + {57623, "folder-close", nil}, + {57624, "folder-open", nil}, + {57625, "resize-vertical", nil}, + {57632, "resize-horizontal", nil}, + {57633, "hdd", nil}, + {57634, "bullhorn", nil}, + {57635, "bell", nil}, + {57636, "certificate", nil}, + {57637, "thumbs-up", nil}, + {57638, "thumbs-down", nil}, + {57639, "hand-right", nil}, + {57640, "hand-left", nil}, + {57641, "hand-up", nil}, + {57648, "hand-down", nil}, + {57649, "circle-arrow-right", nil}, + {57650, "circle-arrow-left", nil}, + {57651, "circle-arrow-up", nil}, + {57652, "circle-arrow-down", nil}, + {57653, "globe", nil}, + {57654, "wrench", nil}, + {57655, "tasks", nil}, + {57656, "filter", nil}, + {57657, "briefcase", nil}, + {57664, "fullscreen", nil}, + {57665, "dashboard", nil}, + {57666, "paperclip", nil}, + {57667, "heart-empty", nil}, + {57668, "link", nil}, + {57669, "phone", nil}, + {57670, "pushpin", nil}, + {57672, "usd", nil}, + {57673, "gbp", nil}, + {57680, "sort", nil}, + {57681, "sort-by-alphabet", nil}, + {57682, "sort-by-alphabet-alt", nil}, + {57683, "sort-by-order", nil}, + {57684, "sort-by-order-alt", nil}, + {57685, "sort-by-attributes", nil}, + {57686, "sort-by-attributes-alt", nil}, + {57687, "unchecked", nil}, + {57688, "expand", nil}, + {57689, "collapse-down", nil}, + {57696, "collapse-up", nil}, + {57697, "log-in", nil}, + {57698, "flash", nil}, + {57699, "log-out", nil}, + {57700, "new-window", nil}, + {57701, "record", nil}, + {57702, "save", nil}, + {57703, "open", nil}, + {57704, "saved", nil}, + {57705, "import", nil}, + {57712, "export", nil}, + {57713, "send", nil}, + {57714, "floppy-disk", nil}, + {57715, "floppy-saved", nil}, + {57716, "floppy-remove", nil}, + {57717, "floppy-save", nil}, + {57718, "floppy-open", nil}, + {57719, "credit-card", nil}, + {57720, "transfer", nil}, + {57721, "cutlery", nil}, + {57728, "header", nil}, + {57729, "compressed", nil}, + {57730, "earphone", nil}, + {57731, "phone-alt", nil}, + {57732, "tower", nil}, + {57733, "stats", nil}, + {57734, "sd-video", nil}, + {57735, "hd-video", nil}, + {57736, "subtitles", nil}, + {57737, "sound-stereo", nil}, + {57744, "sound-dolby", nil}, + {57745, "sound-5-1", nil}, + {57746, "sound-6-1", nil}, + {57747, "sound-7-1", nil}, + {57748, "copyright-mark", nil}, + {57749, "registration-mark", nil}, + {57751, "cloud-download", nil}, + {57752, "cloud-upload", nil}, + {57753, "tree-conifer", nil}, + {57856, "tree-deciduous", nil}, + {57857, "cd", nil}, + {57858, "save-file", nil}, + {57859, "open-file", nil}, + {57860, "level-up", nil}, + {57861, "copy", nil}, + {57862, "paste", nil}, + {57865, "alert", nil}, + {57872, "equalizer", nil}, + {57873, "king", nil}, + {57874, "queen", nil}, + {57875, "pawn", nil}, + {57876, "bishop", nil}, + {57877, "knight", nil}, + {57878, "baby-formula", nil}, + {57880, "blackboard", nil}, + {57881, "bed", nil}, + {57889, "erase", nil}, + {57891, "lamp", nil}, + {57892, "duplicate", nil}, + {57893, "piggy-bank", nil}, + {57894, "scissors", nil}, + {57895, "bitcoin", []string{"btc", "xbt"}}, + {57904, "scale", nil}, + {57905, "ice-lolly", nil}, + {57906, "ice-lolly-tasted", nil}, + {57907, "education", nil}, + {57908, "option-horizontal", nil}, + {57909, "option-vertical", nil}, + {57910, "menu-hamburger", nil}, + {57911, "modal-window", nil}, + {57912, "oil", nil}, + {57913, "grain", nil}, + {57920, "sunglasses", nil}, + {57921, "text-size", nil}, + {57922, "text-color", nil}, + {57923, "text-background", nil}, + {57924, "object-align-top", nil}, + {57925, "object-align-bottom", nil}, + {57926, "object-align-horizontal", nil}, + {57927, "object-align-left", nil}, + {57928, "object-align-vertical", nil}, + {57929, "object-align-right", nil}, + {57936, "triangle-right", nil}, + {57937, "triangle-left", nil}, + {57938, "triangle-bottom", nil}, + {57939, "triangle-top", nil}, + {57940, "console", nil}, + {57941, "superscript", nil}, + {57942, "subscript", nil}, + {57943, "menu-left", nil}, + {57944, "menu-right", nil}, + {57945, "menu-down", nil}, + {57952, "menu-up", nil}, + {63743, "apple", nil}, +} + +// GlyphIcons returns every glyph the Mendix font defines, ordered by code. +func GlyphIcons() []GlyphIcon { return glyphIcons } + +// LookupGlyph returns the icon for a character code. +func LookupGlyph(code int) (GlyphIcon, bool) { + i := sort.Search(len(glyphIcons), func(i int) bool { return glyphIcons[i].Code >= code }) + if i < len(glyphIcons) && glyphIcons[i].Code == code { + return glyphIcons[i], true + } + return GlyphIcon{}, false +} + +// glyphCodeDefined reports whether the Mendix glyph font defines this code. +func glyphCodeDefined(code int) bool { + _, ok := LookupGlyph(code) + return ok +} diff --git a/mdl/executor/helpers.go b/mdl/executor/helpers.go index 7da52990eb..f3f0ab65f2 100644 --- a/mdl/executor/helpers.go +++ b/mdl/executor/helpers.go @@ -157,6 +157,22 @@ func createFolder(ctx *ExecContext, name string, containerID model.ID) (model.ID // ---------------------------------------------------------------------------- // enumerationExists checks if an enumeration exists in the project. +// +// It defers to findEnumeration rather than matching containers itself. The +// second implementation this used to carry compared `enum.ContainerID == +// module.ID`, which only ever matches an enumeration sitting directly in the +// module root: one inside a FOLDER has the folder as its container, so +// `mxcli check --references` reported it missing while DESCRIBE, SHOW, ALTER +// and mxbuild all resolved it — a false negative on a script that executes +// cleanly (mendixlabs/mxcli#1071). +// +// That is the same defect upstream #976 fixed in DROP, which patched the one +// reported command and left the other caller of this question behind. Deleting +// the duplicate is the point of this change: two functions answering "does this +// enumeration exist" is what let them drift, and only one of them was exercised +// by the commands people run interactively. findEnumeration also prefers a live +// enumeration over an excluded twin of the same name (#914), which the copy did +// not do. func enumerationExists(ctx *ExecContext, qualifiedName string) bool { if !ctx.Connected() { return false @@ -167,26 +183,8 @@ func enumerationExists(ctx *ExecContext, qualifiedName string) bool { if len(parts) != 2 { return false } - moduleName, enumName := parts[0], parts[1] - - // Find the module to get its ID - module, err := findModule(ctx, moduleName) - if err != nil { - return false - } - // Get all enumerations and check if one matches - enums, err := ctx.Backend.ListEnumerations() - if err != nil { - return false - } - - for _, enum := range enums { - if enum.ContainerID == module.ID && enum.Name == enumName { - return true - } - } - return false + return findEnumeration(ctx, parts[0], parts[1]) != nil } // ---------------------------------------------------------------------------- @@ -582,6 +580,55 @@ func buildEntityEnumAttrMap(ctx *ExecContext, entityQN string) map[string]string return result } +// buildAssociationQualifiedNames returns a set of all association qualified names +// in the project, covering both intra-module associations and cross-module ones +// (which live on the FROM entity's domain model, so both come off the same walk). +func buildAssociationQualifiedNames(ctx *ExecContext) map[string]bool { + result := make(map[string]bool) + modules, err := getModulesFromCache(ctx) + if err != nil { + return result + } + moduleNames := make(map[model.ID]string) + for _, m := range modules { + moduleNames[m.ID] = m.Name + } + dms, err := ctx.Backend.ListDomainModels() + if err != nil { + return result + } + for _, dm := range dms { + modName := moduleNames[dm.ContainerID] + if modName == "" { + continue + } + for _, assoc := range dm.Associations { + result[modName+"."+assoc.Name] = true + } + for _, ca := range dm.CrossAssociations { + result[modName+"."+ca.Name] = true + } + } + return result +} + +// buildRuleQualifiedNames returns a set of all rule qualified names in the project. +func buildRuleQualifiedNames(ctx *ExecContext) map[string]bool { + result := make(map[string]bool) + h, err := getHierarchy(ctx) + if err != nil { + return result + } + rules, err := ctx.Backend.ListRules() + if err != nil { + return result + } + for _, r := range rules { + result[h.GetQualifiedName(r.ContainerID, r.Name)] = true + } + return result +} + // buildJavaActionQualifiedNames returns a set of all java action qualified names in the project. func buildJavaActionQualifiedNames(ctx *ExecContext) map[string]bool { result := make(map[string]bool) diff --git a/mdl/executor/nav_singleline_test.go b/mdl/executor/nav_singleline_test.go new file mode 100644 index 0000000000..f4813894cc --- /dev/null +++ b/mdl/executor/nav_singleline_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "testing" + +// Whitespace inside a string literal is part of the value being matched on. +// Folding it would change what the constraint selects, silently, in a place +// nobody would look — the naive strings.Fields version did exactly that. +func TestSingleLinePreservesWhitespaceInsideLiterals(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + { + name: "newlines and indentation outside literals are folded", + in: "[\n (\n contains(Value, 'abc')\n )\n]", + want: "[ ( contains(Value, 'abc') ) ]", + }, + { + name: "two spaces inside a literal survive", + in: "[Name = 'two spaces']", + want: "[Name = 'two spaces']", + }, + { + name: "a newline inside a literal survives", + in: "[Name = 'a\nb']", + want: "[Name = 'a\nb']", + }, + { + name: "an escaped quote does not end the literal", + in: "[contains(V, '''a b''')]", + want: "[contains(V, '''a b''')]", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := singleLine(tc.in); got != tc.want { + t.Errorf("singleLine(%q)\n got %q\nwant %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/mdl/executor/roundtrip_doctype_test.go b/mdl/executor/roundtrip_doctype_test.go index df4a208bd1..6ac41b2146 100644 --- a/mdl/executor/roundtrip_doctype_test.go +++ b/mdl/executor/roundtrip_doctype_test.go @@ -62,6 +62,15 @@ var engineScriptSkip = map[string]string{ // a half-written one would look valid, so the legacy backend refuses // create/modify/drop rather than emitting one. Reads work on both engines. "legacy/rules.mdl": "rule authoring is modelsdk-only by design; the legacy backend refuses it", + // Same shape once more. The script creates a PhoneOffline profile, and + // creating a profile means writing a fourteen-key + // Navigation$NavigationProfile pinned against a Studio Pro reference. The + // legacy writer has no such path and refuses rather than approximating — + // "a profile assembled from a guess builds clean and will not open in + // Studio Pro" (mdl/backend/mpr/backend.go). The SYNC block itself is + // implemented on BOTH engines and covered by unit tests on each; it is + // reaching an offline profile that legacy cannot do. + "legacy/navigation-offline-sync.mdl": "creating a navigation profile is modelsdk-only by design; the legacy backend refuses it", // Same shape again: layout authoring is modelsdk-only. A layout's widget tree // hangs off a Forms$WebLayoutContent wrapper the legacy writer cannot build — // its serializeLayout emitted four header keys, a string $ID where Studio Pro diff --git a/mdl/executor/theme_reader.go b/mdl/executor/theme_reader.go index f457cea972..d6724b6cd1 100644 --- a/mdl/executor/theme_reader.go +++ b/mdl/executor/theme_reader.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" ) @@ -99,8 +100,13 @@ func (r *ThemeRegistry) GetPropertiesForWidget(widgetTypeKey string) []ThemeProp return result } -// mdlKeywordToDesignPropsKey maps MDL widget type keywords (uppercase) to -// the keys used in design-properties.json. +// mdlKeywordToDesignPropsKey maps MDL widget type keywords to the keys used in +// design-properties.json — for the NATIVE widgets only. +// +// A pluggable widget is keyed in design-properties.json by its widget id, and +// which widget a keyword produces is decided elsewhere (keywordDispatchTable and +// the embedded widget definitions). Naming one here is how DATAGRID came to be +// validated against the wrong widget: see pluggableKeywordIDs. var mdlKeywordToDesignPropsKey = map[string]string{ "container": "DivContainer", "customcontainer": "DivContainer", @@ -111,17 +117,13 @@ var mdlKeywordToDesignPropsKey = map[string]string{ "datepicker": "DatePicker", "checkbox": "CheckBox", "radiobuttons": "RadioButtons", - "combobox": "ReferenceSelector", "dropdown": "DropDown", "referenceselector": "ReferenceSelector", - "datagrid": "DataGrid", "dataview": "DataView", "listview": "ListView", - "gallery": "Gallery", "layoutgrid": "LayoutGrid", "dynamictext": "DynamicText", "statictext": "Label", - "image": "Image", "staticimage": "StaticImageViewer", "dynamicimage": "DynamicImageViewer", "navigationlist": "NavigationList", @@ -130,12 +132,60 @@ var mdlKeywordToDesignPropsKey = map[string]string{ "footer": "Footer", } +// pluggableKeywordIDs maps an MDL keyword to the pluggable widget id it writes, +// built once from the two places that already decide it: keywordDispatchTable +// (version-aware keywords, today just DATAGRID) and the embedded widget +// definitions (COMBOBOX, GALLERY, IMAGE, the DataGrid filters, …). +// +// Deriving it rather than listing it is the point. The hand-written table above +// named DataGrid for `datagrid`, which is Atlas Core's DEPRECATED data grid, +// while MDL's `datagrid` has always written Data grid 2 from the DataWidgets +// module. The two have disjoint design properties, so MDL-WIDGET11 warned that +// Compact / Hover / Striped were "not defined for this widget type" — they are +// exactly its properties — and suggested Style and Row size, which mxbuild then +// refuses with CE6083 "not supported by your theme". Taking the tool's advice +// turned 16 warnings into 17 build errors (ako/CapTrackV4 010). +// +// The other three were wrong in the quieter direction: `combobox`, `gallery` and +// `image` named keys no web design-properties.json defines, so the registry +// lookup missed and validateWidgetDesignProps skipped those widgets entirely. +// Silence read as approval. +var pluggableKeywordIDs = sync.OnceValue(func() map[string]string { + out := map[string]string{} + for _, mapping := range keywordDispatchTable { + for _, b := range mapping.Bindings { + if b.Kind == bindingKindPluggable && b.WidgetID != "" { + out[strings.ToLower(mapping.Keyword)] = b.WidgetID + break + } + } + } + // A definition's own MDLName is what the builder dispatches on, so this is + // the same answer the writer gives. A registry that fails to load leaves the + // dispatch-table entries, which is the case that matters most. + if reg, err := NewWidgetRegistry(); err == nil && reg != nil { + for _, def := range reg.All() { + if def.MDLName != "" && def.WidgetID != "" { + out[strings.ToLower(def.MDLName)] = def.WidgetID + } + } + } + return out +}) + // resolveDesignPropsKey converts an MDL widget type keyword (e.g., "container", -// "CONTAINER") to the design-properties.json key (e.g., "DivContainer"). The -// lookup is case-insensitive against the lowercase-keyed map. Falls back to the -// input as-is for unrecognized types (e.g., pluggable widget identifiers). +// "CONTAINER") to the design-properties.json key (e.g., "DivContainer"). +// +// A keyword that writes a PLUGGABLE widget resolves to that widget's id, which +// is how design-properties.json keys them. Native keywords use the table above. +// An unrecognised type falls through as-is — a pluggable id written directly is +// already the right key. func resolveDesignPropsKey(mdlKeyword string) string { - if key, ok := mdlKeywordToDesignPropsKey[strings.ToLower(mdlKeyword)]; ok { + lower := strings.ToLower(mdlKeyword) + if id, ok := pluggableKeywordIDs()[lower]; ok { + return id + } + if key, ok := mdlKeywordToDesignPropsKey[lower]; ok { return key } return mdlKeyword diff --git a/mdl/executor/validate_design_properties_test.go b/mdl/executor/validate_design_properties_test.go index 289a743dd6..981d967f1a 100644 --- a/mdl/executor/validate_design_properties_test.go +++ b/mdl/executor/validate_design_properties_test.go @@ -152,3 +152,100 @@ func TestValidateDesignProperties_UnknownWidgetSkipped(t *testing.T) { t.Errorf("expected no violations when widget type has no registry metadata, got %d", n) } } + +// --------------------------------------------------------------------------- +// The design-properties key of a keyword that writes a PLUGGABLE widget +// --------------------------------------------------------------------------- + +// MDL-WIDGET11 resolved `datagrid` against Atlas Core's `DataGrid` — the +// DEPRECATED data grid — while MDL's `datagrid` has always written Data grid 2 +// from the DataWidgets module. Their design properties are disjoint: +// +// Atlas Core DataGrid Style, Hover style, Row size +// DataWidgets com.mendix.widget.web.datagrid.Datagrid Borders, Compact, Hover, Striped +// +// So the tool warned that Compact / Hover / Striped were "not defined for this +// widget type" — they are exactly its properties — and suggested Style and Row +// size, which mxbuild refuses with CE6083 "not supported by your theme". Taking +// the advice turned 16 warnings into 17 build errors (ako/CapTrackV4 010). +// +// Three more were wrong in the quieter direction. `combobox`, `gallery` and +// `image` named keys that no web design-properties.json defines, so the registry +// lookup missed and validateWidgetDesignProps skipped those widgets entirely — +// silence that reads as approval. +// +// Each pairing below was measured by writing the widget and reading its type +// back out of the catalog, not inferred from the builder. +func TestResolveDesignPropsKey_PluggableKeywordsUseTheirWidgetID(t *testing.T) { + for keyword, want := range map[string]string{ + "datagrid": "com.mendix.widget.web.datagrid.Datagrid", + "gallery": "com.mendix.widget.web.gallery.Gallery", + "combobox": "com.mendix.widget.web.combobox.Combobox", + "image": "com.mendix.widget.web.image.Image", + } { + if got := resolveDesignPropsKey(keyword); got != want { + t.Errorf("resolveDesignPropsKey(%q) = %q, want %q — design-properties.json "+ + "keys a pluggable widget by its id, and this keyword writes one", + keyword, got, want) + } + // Case-insensitively too: the validator is handed whatever the author typed. + if got := resolveDesignPropsKey(strings.ToUpper(keyword)); got != want { + t.Errorf("resolveDesignPropsKey(%q) = %q, want %q", strings.ToUpper(keyword), got, want) + } + } +} + +// CONTROL: the native widgets must keep their Atlas keys. A fix that routed +// every keyword through the widget registry would break these, and they are the +// majority — `container` alone carries most of the design properties an author +// ever writes. +func TestResolveDesignPropsKey_NativeKeywordsUnchanged(t *testing.T) { + for keyword, want := range map[string]string{ + "container": "DivContainer", + "actionbutton": "Button", + "dataview": "DataView", + "listview": "ListView", + "layoutgrid": "LayoutGrid", + "referenceselector": "ReferenceSelector", + "staticimage": "StaticImageViewer", + } { + if got := resolveDesignPropsKey(keyword); got != want { + t.Errorf("resolveDesignPropsKey(%q) = %q, want %q", keyword, got, want) + } + } +} + +// CONTROL: an unrecognised type falls through unchanged, so a pluggable id +// written directly with PLUGGABLEWIDGET is already the key it needs to be. +func TestResolveDesignPropsKey_UnknownFallsThrough(t *testing.T) { + const id = "com.example.widget.web.thing.Thing" + if got := resolveDesignPropsKey(id); got != id { + t.Errorf("resolveDesignPropsKey(%q) = %q, want it unchanged", id, got) + } +} + +// The two halves have to stay disjoint. A keyword named in both tables is a +// silent ambiguity: pluggableKeywordIDs wins, so the native entry becomes dead +// and the next person to edit it changes nothing. +func TestDesignPropsKeyTablesDoNotOverlap(t *testing.T) { + for keyword := range pluggableKeywordIDs() { + if native, ok := mdlKeywordToDesignPropsKey[keyword]; ok { + t.Errorf("%q is in both tables (native %q and a pluggable id). "+ + "The native entry is dead — remove it.", keyword, native) + } + } +} + +// The registry half must actually load. If NewWidgetRegistry ever fails here the +// map silently falls back to the dispatch table alone, and the three quiet cases +// go back to being skipped with no test failing. +func TestPluggableKeywordIDs_IncludesRegistryDefinitions(t *testing.T) { + ids := pluggableKeywordIDs() + if _, ok := ids["datagrid"]; !ok { + t.Error("datagrid missing — the keyword dispatch table did not contribute") + } + if _, ok := ids["gallery"]; !ok { + t.Error("gallery missing — the embedded widget definitions did not load, so " + + "every registry-defined keyword silently keeps its old (wrong) key") + } +} diff --git a/mdl/executor/validate_duplicates.go b/mdl/executor/validate_duplicates.go index 5eaad13098..7b00e900c6 100644 --- a/mdl/executor/validate_duplicates.go +++ b/mdl/executor/validate_duplicates.go @@ -80,14 +80,22 @@ func (r *nameRegistry) renameModule(oldMod, newMod string) { // ---------------------------------------------------------------------------- // stmtCreateInfo returns the doc-type key, qualified name, and whether the -// CREATE is idempotent (OR MODIFY / OR REPLACE). Returns empty strings when -// the statement is not a tracked CREATE. +// CREATE is idempotent. Returns empty strings when the statement is not a +// tracked CREATE. +// +// Idempotent means "exec will not fail on an element that already exists". +// There are three spellings, not two: OR MODIFY, OR REPLACE, and IF NOT EXISTS +// — the last skips the element rather than rewriting it, which is why +// re-runnable domain scripts use it. Missing it makes the check disagree with +// what exec does, and the check is wrong: a `create entity if not exists` was +// reported as a conflict for a statement exec cleanly skips. +// TestIfNotExistsCountsAsIdempotent guards the mapping. func stmtCreateInfo(stmt ast.Statement) (docType, name string, idempotent bool) { switch s := stmt.(type) { case *ast.CreateModuleStmt: return "module", s.Name, false case *ast.CreateEntityStmt: - return "entity", s.Name.String(), s.CreateOrModify + return "entity", s.Name.String(), s.CreateOrModify || s.IfNotExists case *ast.CreateViewEntityStmt: return "entity", s.Name.String(), s.CreateOrModify || s.CreateOrReplace case *ast.CreateExternalEntityStmt: @@ -95,7 +103,7 @@ func stmtCreateInfo(stmt ast.Statement) (docType, name string, idempotent bool) case *ast.CreateEnumerationStmt: return "enumeration", s.Name.String(), s.CreateOrModify case *ast.CreateAssociationStmt: - return "association", s.Name.String(), s.CreateOrModify + return "association", s.Name.String(), s.CreateOrModify || s.IfNotExists case *ast.CreateConstantStmt: return "constant", s.Name.String(), s.CreateOrModify case *ast.CreateMicroflowStmt: @@ -158,6 +166,8 @@ func stmtDropInfo(stmt ast.Statement) (docType, name string) { return "microflow", s.Name.String() case *ast.DropNanoflowStmt: return "nanoflow", s.Name.String() + case *ast.DropRuleStmt: + return "rule", s.Name.String() case *ast.DropPageStmt: return "page", s.Name.String() case *ast.DropSnippetStmt: @@ -239,6 +249,8 @@ func friendlyDocType(docType string) string { return "import mapping" case "javaaction": return "java action" + case "javascriptaction": + return "javascript action" case "json-structure": return "JSON structure" case "knowledge-base": @@ -344,6 +356,9 @@ type projectNameSets struct { consumedMcp map[string]bool agents map[string]bool imageCollections map[string]bool + associations map[string]bool + rules map[string]bool + javaScriptActs map[string]bool } // projectSetFor returns the existence set for the given doc-type key, or nil @@ -390,7 +405,18 @@ func (ps *projectNameSets) setFor(docType string) map[string]bool { return ps.agents case "image-collection": return ps.imageCollections + case "association": + return ps.associations + case "rule": + return ps.rules + case "javascriptaction": + return ps.javaScriptActs } + // "module" is deliberately absent: CREATE MODULE on an existing module is a + // no-op that prints "already exists" and exits 0, so `create module M;` is + // the standard script preamble. Flagging it would be a false positive on + // essentially every script. TestEveryCreateDocTypeIsProjectChecked carries + // this exemption explicitly so it stays a decision rather than an omission. return nil } @@ -514,6 +540,15 @@ func loadProjectNameSets(ctx *ExecContext) *projectNameSets { } } + // Associations (intra-module and cross-module) + ps.associations = buildAssociationQualifiedNames(ctx) + + // Rules + ps.rules = buildRuleQualifiedNames(ctx) + + // JavaScript actions + ps.javaScriptActs = buildJavaScriptActionQualifiedNames(ctx) + // Image collections ps.imageCollections = make(map[string]bool) if ics, err := ctx.Backend.ListImageCollections(); err == nil { diff --git a/mdl/executor/validate_duplicates_coverage_test.go b/mdl/executor/validate_duplicates_coverage_test.go new file mode 100644 index 0000000000..a2c8917d62 --- /dev/null +++ b/mdl/executor/validate_duplicates_coverage_test.go @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "go/ast" + "go/parser" + "go/token" + "sort" + "strconv" + "testing" +) + +// projectCheckExemptDocTypes lists the doc types stmtCreateInfo can return that +// deliberately have NO project-existence check, with the reason. An entry here +// is a decision; anything else missing from setFor is the bug this test exists +// to catch. +var projectCheckExemptDocTypes = map[string]string{ + // CREATE MODULE on an existing module is a no-op: execCreateModule prints + // "already exists" and returns nil (exit 0). `create module M;` is the + // standard script preamble, so flagging it would be a false positive on + // essentially every script. + "module": "CREATE MODULE is idempotent at exec — it prints 'already exists' and exits 0", +} + +// docTypesFromSwitch parses validate_duplicates.go and returns, for the named +// function, either the first string literal of every return statement (kind +// "return") or every case-clause string literal (kind "case"). +// +// Reading the real source rather than restating the lists is the point: this is +// the "two lists, nothing comparing them" defect class, and a guard that keeps +// its own third copy of the list would join the problem rather than fix it. +func docTypesFromSwitch(t *testing.T, funcName, kind string) map[string]bool { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "validate_duplicates.go", nil, 0) + if err != nil { + t.Fatalf("parse validate_duplicates.go: %v", err) + } + + out := map[string]bool{} + var fn *ast.FuncDecl + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == funcName { + fn = fd + break + } + } + if fn == nil { + t.Fatalf("function %s not found in validate_duplicates.go", funcName) + } + + lit := func(e ast.Expr) (string, bool) { + bl, ok := e.(*ast.BasicLit) + if !ok || bl.Kind != token.STRING { + return "", false + } + v, err := strconv.Unquote(bl.Value) + if err != nil || v == "" { + return "", false + } + return v, true + } + + ast.Inspect(fn, func(n ast.Node) bool { + switch kind { + case "return": + if r, ok := n.(*ast.ReturnStmt); ok && len(r.Results) > 0 { + if v, ok := lit(r.Results[0]); ok { + out[v] = true + } + } + case "case": + if c, ok := n.(*ast.CaseClause); ok { + for _, e := range c.List { + if v, ok := lit(e); ok { + out[v] = true + } + } + } + } + return true + }) + if len(out) == 0 { + t.Fatalf("extracted no doc types from %s (%s) — the guard would pass vacuously", funcName, kind) + } + return out +} + +// TestEveryCreateDocTypeIsProjectChecked is the guard for the defect behind the +// `check --references` under-report: stmtCreateInfo classified more document +// types than projectNameSets.setFor knew about, so a plain CREATE of an +// existing association, rule or javascript action passed `check --references` +// and then died half-way through `exec`, leaving the project part-modified. +// +// Nothing compared the two switches, so the gap was silent. This does. +func TestEveryCreateDocTypeIsProjectChecked(t *testing.T) { + created := docTypesFromSwitch(t, "stmtCreateInfo", "return") + checked := docTypesFromSwitch(t, "setFor", "case") + + var missing []string + for dt := range created { + if checked[dt] { + continue + } + if _, exempt := projectCheckExemptDocTypes[dt]; exempt { + continue + } + missing = append(missing, dt) + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("stmtCreateInfo returns doc types that projectNameSets.setFor does not check: %v\n"+ + "A plain CREATE of one of these against a project that already has it passes "+ + "`check --references` and then fails at exec, part-way through the script.\n"+ + "Add a set for it in projectNameSets/loadProjectNameSets/setFor, or, if the CREATE is "+ + "genuinely idempotent at exec, record it in projectCheckExemptDocTypes with the reason.", + missing) + } + + // The exemption list must not outlive its reason: an entry that setFor has + // since grown a case for, or that stmtCreateInfo no longer produces, is + // stale and would mask a real gap. + for dt := range projectCheckExemptDocTypes { + if !created[dt] { + t.Errorf("projectCheckExemptDocTypes has %q, which stmtCreateInfo no longer returns — drop it", dt) + } + if checked[dt] { + t.Errorf("projectCheckExemptDocTypes has %q, but setFor now checks it — drop the exemption", dt) + } + } +} + +// TestEveryDropDocTypeIsCreatable is the mirror: stmtDropInfo feeds the +// droppedFromProject registry that suppresses a conflict after a DROP. A doc +// type DROP knows and CREATE does not is harmless, but the reverse means a +// `drop X; create X;` pair reports a conflict the script already resolved. +func TestEveryDropDocTypeIsCreatable(t *testing.T) { + created := docTypesFromSwitch(t, "stmtCreateInfo", "return") + dropped := docTypesFromSwitch(t, "stmtDropInfo", "return") + + var missing []string + for dt := range created { + if dropped[dt] { + continue + } + missing = append(missing, dt) + } + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("stmtCreateInfo returns doc types stmtDropInfo does not classify: %v\n"+ + "`drop X; create X;` would then report a project conflict the script already resolved.", + missing) + } +} + +// TestIfNotExistsCountsAsIdempotent guards the third idempotency spelling. +// +// stmtCreateInfo reported OR MODIFY and OR REPLACE but not IF NOT EXISTS, so a +// re-runnable domain script — the form that exists precisely to be re-run — +// was told its `create entity if not exists` conflicted with the project, for +// a statement exec skips with "already exists — skipped". +// +// The guard reads both sides out of source: every `case *ast.XStmt` in +// stmtCreateInfo whose AST type declares an IfNotExists field must mention +// IfNotExists in that case's return. Neither list is restated here. +func TestIfNotExistsCountsAsIdempotent(t *testing.T) { + // Which mdl/ast CREATE types declare an IfNotExists field. + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, "../ast", nil, 0) + if err != nil { + t.Fatalf("parse mdl/ast: %v", err) + } + hasIfNotExists := map[string]bool{} + for _, pkg := range pkgs { + for _, f := range pkg.Files { + ast.Inspect(f, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok || st.Fields == nil { + return true + } + for _, fld := range st.Fields.List { + for _, nm := range fld.Names { + if nm.Name == "IfNotExists" { + hasIfNotExists[ts.Name.Name] = true + } + } + } + return true + }) + } + } + if len(hasIfNotExists) == 0 { + t.Fatal("found no mdl/ast type with an IfNotExists field — the guard would pass vacuously") + } + + // Which of them stmtCreateInfo handles, and whether its case consults the field. + fset2 := token.NewFileSet() + f, err := parser.ParseFile(fset2, "validate_duplicates.go", nil, 0) + if err != nil { + t.Fatalf("parse validate_duplicates.go: %v", err) + } + var fn *ast.FuncDecl + for _, d := range f.Decls { + if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "stmtCreateInfo" { + fn = fd + break + } + } + if fn == nil { + t.Fatal("stmtCreateInfo not found") + } + + checked := 0 + ast.Inspect(fn, func(n ast.Node) bool { + c, ok := n.(*ast.CaseClause) + if !ok { + return true + } + for _, e := range c.List { + star, ok := e.(*ast.StarExpr) + if !ok { + continue + } + sel, ok := star.X.(*ast.SelectorExpr) + if !ok || !hasIfNotExists[sel.Sel.Name] { + continue + } + checked++ + consulted := false + ast.Inspect(c, func(m ast.Node) bool { + if id, ok := m.(*ast.Ident); ok && id.Name == "IfNotExists" { + consulted = true + } + return true + }) + if !consulted { + t.Errorf("stmtCreateInfo case *ast.%s ignores its IfNotExists field: "+ + "`create ... if not exists` on an element the project already has would be "+ + "reported as a conflict, for a statement exec cleanly skips. "+ + "Return `s.CreateOrModify || s.IfNotExists`.", sel.Sel.Name) + } + } + return true + }) + if checked == 0 { + t.Fatal("stmtCreateInfo handles no type with an IfNotExists field — the guard would pass vacuously") + } +} diff --git a/mdl/executor/validate_duplicates_test.go b/mdl/executor/validate_duplicates_test.go index 95b39e106f..650bdfa697 100644 --- a/mdl/executor/validate_duplicates_test.go +++ b/mdl/executor/validate_duplicates_test.go @@ -245,8 +245,10 @@ create persistent entity Dup.Customer (Name: string); // Phase 2: CheckProjectConflicts — project-side existence checks // --------------------------------------------------------------------------- -// setupProjectConflictCtx creates a mock context with a workflow "M.ExistingWF" -// and a microflow "M.ExistingMF" already present in the project. +// setupProjectConflictCtx creates a mock context with a workflow "M.ExistingWF", +// a microflow "M.ExistingMF", an association "M.ExistingAssoc", a rule +// "M.ExistingRule" and a javascript action "M.ExistingJS" already present in +// the project. func setupProjectConflictCtx(t *testing.T) (*ExecContext, *model.Module) { t.Helper() mod := mkModule("M") @@ -255,14 +257,38 @@ func setupProjectConflictCtx(t *testing.T) (*ExecContext, *model.Module) { wf := mkWorkflow(mod.ID, "ExistingWF") mf := mkMicroflow(mod.ID, "ExistingMF") + parent := mkEntity(mod.ID, "Parent") + child := mkEntity(mod.ID, "Child") + assoc := mkAssociation(mod.ID, "ExistingAssoc", child.ID, parent.ID) + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: nextID("dm")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{parent, child}, + Associations: []*domainmodel.Association{assoc}, + } + rule := µflows.Rule{ + BaseElement: model.BaseElement{ID: nextID("rule")}, + ContainerID: mod.ID, + Name: "ExistingRule", + } + jsa := &types.JavaScriptAction{ + BaseElement: model.BaseElement{ID: nextID("jsa")}, + ContainerID: mod.ID, + Name: "ExistingJS", + } + mb := &mock.MockBackend{ IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, ListWorkflowsFunc: func() ([]*workflows.Workflow, error) { return []*workflows.Workflow{wf}, nil }, ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return []*microflows.Microflow{mf}, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { + return []*microflows.Rule{rule}, nil + }, // Other list functions return empty (no conflicts for those types) ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return nil, nil }, ListConstantsFunc: func() ([]*model.Constant, error) { return nil, nil }, @@ -279,12 +305,16 @@ func setupProjectConflictCtx(t *testing.T) (*ExecContext, *model.Module) { }, ListAgentEditorAgentsFunc: func() ([]*agenteditor.Agent, error) { return nil, nil }, ListImageCollectionsFunc: func() ([]*types.ImageCollection, error) { return nil, nil }, - ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return nil, nil }, - ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, - ListPagesFunc: func() ([]*pages.Page, error) { return nil, nil }, - ListSnippetsFunc: func() ([]*pages.Snippet, error) { return nil, nil }, - ListJavaActionsFunc: func() ([]*types.JavaAction, error) { return nil, nil }, - ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { return nil, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { + return []*domainmodel.DomainModel{dm}, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return nil, nil }, + ListSnippetsFunc: func() ([]*pages.Snippet, error) { return nil, nil }, + ListJavaActionsFunc: func() ([]*types.JavaAction, error) { return nil, nil }, + ListJavaScriptActionsFunc: func() ([]*types.JavaScriptAction, error) { + return []*types.JavaScriptAction{jsa}, nil + }, } ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) @@ -415,3 +445,121 @@ func TestCheckProjectConflicts_NotConnected_NoErrors(t *testing.T) { t.Errorf("expected no errors when not connected, got: %v", errs) } } + +// --------------------------------------------------------------------------- +// The types the project check used to skip silently. stmtCreateInfo classified +// each of them, projectNameSets.setFor did not, so a plain CREATE against a +// project that already had one passed `check --references` and then failed at +// exec — part-way through, with the earlier statements already written. +// --------------------------------------------------------------------------- + +func TestCheckProjectConflicts_CreateExistingAssociation_Error(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertHasConflict(t, ctx, ` +create association M.ExistingAssoc from M.Child to M.Parent; +`, "M.ExistingAssoc") +} + +func TestCheckProjectConflicts_CreateNewAssociation_NoError(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, ` +create association M.BrandNewAssoc from M.Child to M.Parent; +`) +} + +func TestCheckProjectConflicts_CreateOrModifyAssociation_NoError(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, ` +create or modify association M.ExistingAssoc from M.Child to M.Parent; +`) +} + +func TestCheckProjectConflicts_CreateExistingRule_Error(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertHasConflict(t, ctx, ` +create rule M.ExistingRule ( Amount : Decimal ) returns Boolean +begin + return $Amount > 10; +end; +`, "M.ExistingRule") +} + +func TestCheckProjectConflicts_CreateNewRule_NoError(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, ` +create rule M.BrandNewRule ( Amount : Decimal ) returns Boolean +begin + return $Amount > 10; +end; +`) +} + +// A DROP earlier in the script removes the name from the project set, so the +// re-create is clean. This is what stmtDropInfo's missing DropRuleStmt case +// would have broken the moment rules became project-checked. +func TestCheckProjectConflicts_DropThenCreateRule_NoConflict(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, ` +drop rule M.ExistingRule; +create rule M.ExistingRule ( Amount : Decimal ) returns Boolean +begin + return $Amount > 10; +end; +`) +} + +func TestCheckProjectConflicts_CreateExistingJavaScriptAction_Error(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertHasConflict(t, ctx, ` +create javascript action M.ExistingJS() returns Boolean +as $$ + return true; +$$; +`, "M.ExistingJS") +} + +func TestCheckProjectConflicts_CreateNewJavaScriptAction_NoError(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, ` +create javascript action M.BrandNewJS() returns Boolean +as $$ + return true; +$$; +`) +} + +// CREATE MODULE for a module that already exists is NOT a conflict: +// execCreateModule prints "already exists" and returns nil, and `create module +// M;` opens nearly every script. The exemption is recorded in +// projectCheckExemptDocTypes; this is the behaviour it protects. +func TestCheckProjectConflicts_CreateExistingModule_NoError(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + assertNoConflicts(t, ctx, `create module M;`) +} + +// IF NOT EXISTS is the third idempotency spelling and was not recognised: +// exec skips such a statement with "already exists — skipped", but the check +// reported a conflict. That made a re-runnable domain script — the form that +// exists precisely to be re-run — fail its own second run at check time. +func TestCheckProjectConflicts_IfNotExists_NoError(t *testing.T) { + ctx, mod := setupProjectConflictCtx(t) + _ = mod + assertNoConflicts(t, ctx, ` +create entity if not exists M.Parent ( Name : String(50) ); +create association if not exists M.ExistingAssoc from M.Child to M.Parent; +`) +} + +// The control for the test above: without IF NOT EXISTS the same two +// statements are conflicts, so the test is detecting the modifier and not +// simply failing to find the elements. +func TestCheckProjectConflicts_WithoutIfNotExists_Error(t *testing.T) { + ctx, _ := setupProjectConflictCtx(t) + msgs := conflictErrorMessages(ctx, ` +create entity M.Parent ( Name : String(50) ); +create association M.ExistingAssoc from M.Child to M.Parent; +`, t) + if len(msgs) != 2 { + t.Errorf("expected 2 conflicts without IF NOT EXISTS, got %d: %v", len(msgs), msgs) + } +} diff --git a/mdl/executor/validate_enum_folder_test.go b/mdl/executor/validate_enum_folder_test.go new file mode 100644 index 0000000000..aaca787489 --- /dev/null +++ b/mdl/executor/validate_enum_folder_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1071: `mxcli check --references` reported every enumeration +// as missing — +// +// statement 4: attribute 'CriticalPathStation': enumeration not found: Approval.StationKey +// +// while DESCRIBE ENUMERATION returned its 19 values, SHOW ENUMERATIONS listed +// it, and mxbuild built the same declaration at 0 errors. A pure false +// negative: `exec` writes the attribute and the project checks clean. +// +// The discriminator is a FOLDER. enumerationExists compared containers directly +// +// if enum.ContainerID == module.ID && enum.Name == enumName +// +// and an enumeration inside a folder has the FOLDER as its container, so the +// equality can never hold. Every other command resolves through the container +// hierarchy, which walks folders up to the module. +// +// This is the same defect as upstream #976, which fixed DROP and did not sweep +// for the other callers of this question — see cmd_enumerations_drop_folder_test.go. +// The reference checker was the one left. + +// enumRefFixture builds a module holding `RootEnum` at the module root and +// `StationKey` inside a folder, with an entity to hang attributes off. +func enumRefFixture(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("Approval") + folderID := model.ID("folder-enums") + + root := mkEnumeration(mod.ID, "RootEnum", "A", "B") + filed := mkEnumeration(folderID, "StationKey", "S1", "S2") + + h := mkHierarchy(mod) + withContainer(h, root.ContainerID, mod.ID) + // `filed.ContainerID` IS the folder, so the link to register is the folder's + // own parent — the same shape as the #976 fixture. + withContainer(h, folderID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { + return []*model.Enumeration{root, filed}, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +// addAttrProgram is `ALTER ENTITY Approval.ApprovalRun ADD ATTRIBUTE : +// Enumeration(.)`. +func addAttrProgram(name, enumModule, enumName string) *ast.Program { + return &ast.Program{Statements: []ast.Statement{ + &ast.AlterEntityStmt{ + Name: ast.QualifiedName{Module: "Approval", Name: "ApprovalRun"}, + Operation: ast.AlterEntityAddAttribute, + Attribute: &ast.Attribute{ + Name: name, + Type: ast.DataType{ + Kind: ast.TypeEnumeration, + EnumRef: &ast.QualifiedName{Module: enumModule, Name: enumName}, + }, + }, + }, + }} +} + +func enumErrors(errs []error) string { + var out []string + for _, e := range errs { + out = append(out, e.Error()) + } + return strings.Join(out, "\n") +} + +// The reported statement, verbatim in shape. +func TestReferences_FolderedEnumerationResolves(t *testing.T) { + errs := validateProgram(enumRefFixture(t), addAttrProgram("CriticalPathStation", "Approval", "StationKey")) + + for _, e := range errs { + if strings.Contains(e.Error(), "enumeration not found") { + t.Fatalf("an enumeration in a folder was reported missing: %v\n"+ + "DESCRIBE, SHOW and ALTER all resolve it; this is mendixlabs/mxcli#1071", enumErrors(errs)) + } + } +} + +// The control. This one passed before the fix too, which is what makes the test +// above a container-resolution bug rather than "references are broken". +func TestReferences_RootEnumerationStillResolves(t *testing.T) { + errs := validateProgram(enumRefFixture(t), addAttrProgram("RootAttr", "Approval", "RootEnum")) + + for _, e := range errs { + if strings.Contains(e.Error(), "enumeration not found") { + t.Fatalf("an enumeration at the module root must keep resolving: %v", enumErrors(errs)) + } + } +} + +// Resolving through the hierarchy must not make the check meaningless: an +// enumeration that genuinely is not there is still an error, otherwise the fix +// would be "stop checking" rather than "check correctly". +func TestReferences_MissingEnumerationIsStillReported(t *testing.T) { + errs := validateProgram(enumRefFixture(t), addAttrProgram("Ghost", "Approval", "NoSuchEnum")) + + if !strings.Contains(enumErrors(errs), "enumeration not found: Approval.NoSuchEnum") { + t.Errorf("a genuinely missing enumeration was not reported: %v", enumErrors(errs)) + } +} + +// A foldered enumeration still belongs to its own module. Naming another +// module's must not find it — the same guard the #976 fix needed. +func TestReferences_FolderedEnumerationDoesNotAnswerForAnotherModule(t *testing.T) { + errs := validateProgram(enumRefFixture(t), addAttrProgram("Wrong", "SomeOtherModule", "StationKey")) + + if !strings.Contains(enumErrors(errs), "not found") { + t.Errorf("Approval.StationKey answered for SomeOtherModule.StationKey: %v", enumErrors(errs)) + } +} + +// The other call site. The report showed ALTER ENTITY; CREATE ENTITY resolves +// enumerated attributes through the same helper and failed the same way, which +// the report did not mention — measured before fixing. +func TestReferences_FolderedEnumerationResolvesOnCreateEntity(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateEntityStmt{ + Name: ast.QualifiedName{Module: "Approval", Name: "NewThing"}, + Attributes: []ast.Attribute{{ + Name: "Station", + Type: ast.DataType{ + Kind: ast.TypeEnumeration, + EnumRef: &ast.QualifiedName{Module: "Approval", Name: "StationKey"}, + }, + }}, + }, + }} + + for _, e := range validateProgram(enumRefFixture(t), prog) { + if strings.Contains(e.Error(), "enumeration not found") { + t.Fatalf("CREATE ENTITY could not resolve a foldered enumeration either: %v", e) + } + } +} diff --git a/mdl/executor/validate_glyph_codes.go b/mdl/executor/validate_glyph_codes.go new file mode 100644 index 0000000000..d0ffa540c5 --- /dev/null +++ b/mdl/executor/validate_glyph_codes.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// validateMenuItemGlyphCodes (MDL078) flags `icon glyph ` with a code the +// Mendix glyph font does not define. +// +// # Why this needs its own rule +// +// A glyph code is a bare integer: nothing resolves it, so it passes `mxcli check` +// AND `mx check` at 0 errors, and fails only at `mxbuild --target=deploy` — which +// is what `mxcli run --local` does. The message it fails with names a document +// that is not the problem: +// +// ERROR: One or more errors occurred. +// (An exception occurred while exporting layout 'CapTrack.App_Default.') +// +// Neither the navigation nor the menu item is mentioned. Bisecting it cost three +// build cycles (ako/CapTrackV4 007, R2). +// +// # The mechanism, and why a code-point table is the right check +// +// mxbuild resolves the code through GlyphFont.GetClass(Int32), which is a LINQ +// `.First(...)` over its glyph table and throws `InvalidOperationException: +// Sequence contains no matching element` when the code is absent. Measured on +// 11.14.0 with two builds: 57562 (0xEBDA, past the font's 0xE260 end) produces +// exactly that stack, and 57377 (0xE021, in the font) exports pages and layouts +// cleanly. The font's cmap and mxbuild's table agree on both points. +// +// # A warning, not an error +// +// The table is a snapshot of a Mendix asset. If Mendix ever extends the font, +// a correct code would be reported here, and refusing it would be worse than the +// gap this closes — so `exec` still writes it and the author still gets told. +// +// # It needs no project +// +// The code is in the script. This runs in the project-free pass alongside +// MDL077, which is how CI reaches it. +func validateMenuItemGlyphCodes(stmt ast.Statement) []linter.Violation { + var items []ast.NavMenuItemDef + var where string + + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + items, where = s.MenuItems, "navigation "+s.ProfileName + case *ast.CreateMenuStmt: + items, where = s.Items, "menu "+s.Name.String() + default: + return nil + } + + var out []linter.Violation + var walk func(list []ast.NavMenuItemDef) + walk = func(list []ast.NavMenuItemDef) { + for _, item := range list { + if item.IconKind == types.MenuIconGlyph && !glyphCodeDefined(item.IconCode) { + out = append(out, linter.Violation{ + RuleID: "MDL078", + Severity: linter.SeverityWarning, + Message: fmt.Sprintf( + "%s: menu item %q uses `icon glyph %d`, which the Mendix glyph font does not "+ + "define — the build fails at `mxbuild --target=deploy` with "+ + "\"An exception occurred while exporting layout ''\", naming a "+ + "document that is not the cause", + where, item.Caption, item.IconCode), + Suggestion: "prefer an icon collection reference, which IS resolved before anything " + + "is written: `icon Atlas_Core.Atlas.` (list them with " + + "`describe icon collection Atlas_Core.Atlas`). To keep a glyph, pick a code from " + + "`show glyphs` — the font's codes are sparse, so nearby numbers are usually " + + "not defined either.", + }) + } + walk(item.Items) + } + } + walk(items) + return out +} diff --git a/mdl/executor/validate_glyph_codes_test.go b/mdl/executor/validate_glyph_codes_test.go new file mode 100644 index 0000000000..83a7b5f5bc --- /dev/null +++ b/mdl/executor/validate_glyph_codes_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// A glyph code is a bare integer, so nothing resolved it: `icon glyph 57562` +// passed `mxcli check` AND `mx check` at 0 errors and broke the deploy with a +// message naming a layout nobody had touched (ako/CapTrackV4 007, R2). +// +// MEASURED on 11.14.0, two full builds of the same project differing only in the +// code: +// +// 57562 (0xEBDA) ERROR: An exception occurred while exporting layout +// 'CapTrack.App_Default' +// -> System.InvalidOperationException: Sequence contains no +// matching element +// at ...Forms.Icons.GlyphFont.GetClass(Int32 code) +// 57377 (0xE021) pages and layouts export cleanly +// +// GetClass is a LINQ `.First(...)` over mxbuild's glyph table, which throws +// rather than reporting. The table this rule checks is the cmap of the font +// Atlas_Core ships (glyphicons-halflings-regular.woff): 247 codes, and it agrees +// with mxbuild on both measured points. + +func glyphStmt(code int) *ast.AlterNavigationStmt { + return &ast.AlterNavigationStmt{ + ProfileName: "Responsive", + MenuItems: []ast.NavMenuItemDef{{ + Caption: "Overview", + IconKind: types.MenuIconGlyph, + IconCode: code, + }}, + } +} + +func TestMDL078_ReportsACodeTheFontDoesNotDefine(t *testing.T) { + v := validateMenuItemGlyphCodes(glyphStmt(57562)) + if len(v) != 1 { + t.Fatalf("got %d violations, want 1 — this code breaks the deploy and every "+ + "static gate passes it", len(v)) + } + if !strings.Contains(v[0].Message, "57562") { + t.Errorf("the message does not name the code: %s", v[0].Message) + } + // The author cannot get from mxbuild's stack trace to the cause, so the rule + // has to carry the way out. + if !strings.Contains(v[0].Suggestion, "icon collection") { + t.Errorf("the suggestion does not offer the checked alternative: %s", v[0].Suggestion) + } +} + +// CONTROL: the code the blank app ships must not be reported. Without this, a +// rule that flagged every glyph would pass the test above and reject every +// working navigation in existence. +func TestMDL078_SilentOnCodesTheFontDefines(t *testing.T) { + for _, code := range []int{ + 57377, // 0xE021 — the measured working case + 57345, // 0xE001 — first code in the font + 57952, // 0xE260 — last icon + 63743, // 0xF8FF — the outlier at the end of the private use area + 57440, // 0xE060 — a single-code run, the shape a range check gets wrong + } { + if v := validateMenuItemGlyphCodes(glyphStmt(code)); len(v) != 0 { + t.Errorf("glyph %d was reported but the font defines it: %s", code, v[0].Message) + } + } +} + +// The gaps are real: the font is 247 codes in 35 runs, not one range. A check +// written as "between the first and last code" would accept all of these. +func TestMDL078_ReportsCodesInsideTheGaps(t *testing.T) { + for _, code := range []int{ + 57348, // 0xE004 — between the first two runs + 57441, // 0xE061 — the single-code gap after 0xE060 + 57750, // 0xE196 — inside the 0xE190-0xE199 gap + 57800, // 0xE1C8 — between the 0xE1xx and 0xE2xx blocks + } { + if v := validateMenuItemGlyphCodes(glyphStmt(code)); len(v) != 1 { + t.Errorf("glyph %d was accepted, but it falls in a gap the font does not fill", code) + } + } +} + +// CONTROL: a menu item with no glyph, or with an icon-collection reference, is +// not this rule's business — MDL077 owns the missing-icon case and MDL-ICON01 +// resolves collection references. +func TestMDL078_IgnoresNonGlyphIcons(t *testing.T) { + for _, kind := range []types.MenuIconKind{types.MenuIconNone, types.MenuIconCollection} { + stmt := glyphStmt(57562) + stmt.MenuItems[0].IconKind = kind + if v := validateMenuItemGlyphCodes(stmt); len(v) != 0 { + t.Errorf("icon kind %v was reported by the glyph rule: %s", kind, v[0].Message) + } + } + // ...and neither is a statement of another type. + if v := validateMenuItemGlyphCodes(&ast.CreateEntityStmt{}); len(v) != 0 { + t.Errorf("a non-navigation statement was reported: %+v", v) + } +} + +// Sub-items render in the flyout a collapsed rail opens, so a bad code there +// breaks the same build. The walk is recursive for the same reason MDL077's is. +func TestMDL078_WalksSubItems(t *testing.T) { + stmt := &ast.AlterNavigationStmt{ + ProfileName: "Responsive", + MenuItems: []ast.NavMenuItemDef{{ + Caption: "Group", + IconKind: types.MenuIconGlyph, + IconCode: 57377, // fine + Items: []ast.NavMenuItemDef{{ + Caption: "Nested", + IconKind: types.MenuIconGlyph, + IconCode: 57562, // not fine + }}, + }}, + } + v := validateMenuItemGlyphCodes(stmt) + if len(v) != 1 || !strings.Contains(v[0].Message, "Nested") { + t.Errorf("a sub-item's bad glyph was missed: %+v", v) + } +} + +// The table is the whole set MDL078 accepts AND the set `show glyphs` lists, so +// a regeneration that lost entries would quietly narrow both at once. +func TestGlyphIconsMatchTheFont(t *testing.T) { + icons := GlyphIcons() + if len(icons) != 247 { + t.Errorf("table holds %d icons, want 247 (the private-use range of "+ + "glyphicons-halflings-regular.woff)", len(icons)) + } + seenCode := map[int]bool{} + seenName := map[string]bool{} + prev := -1 + for i, g := range icons { + if g.Code <= prev { + t.Fatalf("entry %d (%d) is out of order — LookupGlyph binary-searches, "+ + "so the table must be sorted by code", i, g.Code) + } + prev = g.Code + if seenCode[g.Code] { + t.Errorf("code %d appears twice", g.Code) + } + seenCode[g.Code] = true + if g.Name == "" { + t.Errorf("code %d has no name — every code in the font is named by Atlas's "+ + "own bootstrap stylesheet, so a blank one means the extraction lost it", g.Code) + } + for _, n := range append([]string{g.Name}, g.Aliases...) { + if seenName[n] { + t.Errorf("name %q appears twice — `describe glyph %q` would be ambiguous "+ + "between two codes", n, n) + } + seenName[n] = true + } + } + // The measured endpoints, and the alias the extraction has to preserve. + for code, name := range map[int]string{57345: "glass", 57377: "home", 57952: "menu-up", 63743: "apple"} { + g, ok := LookupGlyph(code) + if !ok || g.Name != name { + t.Errorf("LookupGlyph(%d) = %+v, %v; want name %q", code, g, ok, name) + } + } + if g, _ := LookupGlyph(57895); len(g.Aliases) != 2 { + t.Errorf("bitcoin lost its aliases: %+v — `show glyphs like 'btc'` then finds nothing", g) + } +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 842baed239..461f6cd3a0 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -56,6 +56,7 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // collapsed to its icon rail (MDL077). Covers both statements that carry // menu items, which share one AST node so they cannot diverge. violations = append(violations, validateMenuItemIcons(stmt)...) + violations = append(violations, validateMenuItemGlyphCodes(stmt)...) // A page with parameters and a Url must name each parameter in it (CE5601). if pageStmt, ok := stmt.(*ast.CreatePageStmtV3); ok { violations = append(violations, ValidatePageURLParameters(pageStmt)...) @@ -81,6 +82,11 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { if wfStmt, ok := stmt.(*ast.CreateWorkflowStmt); ok { violations = append(violations, ValidateWorkflow(wfStmt)...) } + // ALTER WORKFLOW … INSERT BRANCH writes the same outcome value, so it + // carries the same load-time trap (MDL-WF03). + if awfStmt, ok := stmt.(*ast.AlterWorkflowStmt); ok { + violations = append(violations, ValidateAlterWorkflow(awfStmt)...) + } // Check GRANT for member rights Mendix cannot store if grantStmt, ok := stmt.(*ast.GrantEntityAccessStmt); ok { violations = append(violations, ValidateGrantEntityAccess(grantStmt)...) diff --git a/mdl/executor/validate_workflow.go b/mdl/executor/validate_workflow.go index bad0aa3bed..d64c40e4c3 100644 --- a/mdl/executor/validate_workflow.go +++ b/mdl/executor/validate_workflow.go @@ -11,31 +11,50 @@ package executor import ( "fmt" "regexp" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/linter" ) -// wfOutcomeIdentRe matches a valid Mendix EnumerationValueIdentifier: dotted -// identifier segments, no spaces or other punctuation. Decision / -// call-microflow outcome names must be enum value identifiers; free text like -// 'Confirmed closed' is rejected by MxBuild. +// A decision / call-microflow outcome is stored in +// EnumerationValueConditionOutcome.Value, which Mendix loads through +// EnumerationValueIdentifier.FromString. That parse is strict and it runs at +// LOAD time, before any consistency check: a value it rejects does not produce +// a CE number, it makes the whole project unopenable in Studio Pro and mxbuild +// (`StorageLoadException`, UnitLoader). mxcli wrote the outcome label verbatim, +// so a perfectly ordinary script corrupted the model while `check`, `exec` and +// `describe` all reported success — ako/mxcli#1031, ako/mxcli#1065. // -// The qualified form is what Studio Pro actually stores — every -// EnumerationValueConditionOutcome in the demo corpus holds -// Module.Enum.Value (7 of 7 non-empty), so `describe workflow` emits it and a -// bare-identifier-only rule refused mxcli's own output (ako/mxcli#408). Bare -// values stay accepted: the rule's job is to catch free text, not to pick a -// spelling. -var wfOutcomeIdentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) +// The identifier is qualified to exactly three segments, +// Module.Enumeration.Value. Measured on 11.10.0, one workflow per copy of the +// same app, verdict = the literal mx check line: +// +// 'OutcomeA' (1 segment) -> StorageLoadException, project unloadable +// 'Status.OutcomeA' (2 segments) -> StorageLoadException, project unloadable +// 'WFP.Status.OutcomeA' (3 segments) -> 0 errors +// +// which agrees with the stored corpus: every EnumerationValueConditionOutcome +// in the demo apps holds Module.Enum.Value (7 of 7 non-empty). The two-segment +// row is the one worth keeping — "qualify it" is ambiguous without it, and +// enum-in-the-same-module is exactly the case an author would shorten. +// +// wfOutcomeQualifiedRe is what Mendix accepts; wfOutcomeIdentRe is only used to +// tell an author who wrote a plausible identifier from one who wrote free text, +// so the two get different advice. +var ( + wfOutcomeQualifiedRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$`) + wfOutcomeIdentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) +) // ValidateWorkflow checks a workflow for constructs that pass parsing but are // rejected by MxBuild, without requiring a project connection. // // - MDL-WF01: user task without a page (CE1834) // - MDL-WF02: single-outcome user task containing nested activities (CE1876) -// - MDL-WF03: decision / call-microflow outcome that is not a valid -// enumeration value identifier +// - MDL-WF03: decision / call-microflow outcome that is not a qualified +// enumeration value identifier (Module.Enumeration.Value) — a value the +// loader rejects makes the project unopenable, not merely un-buildable // - MDL-WF04: standalone `annotation` in a workflow body (unloadable model) // - MDL-WF05: `jump to` a target that names no activity (see validate_workflow_jump.go) func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { @@ -93,24 +112,72 @@ func ValidateWorkflow(stmt *ast.CreateWorkflowStmt) []linter.Violation { } // checkWorkflowOutcomeNames flags condition-outcome values (decision / call -// microflow branches) that are not valid enumeration value identifiers (MDL-WF03). +// microflow branches) that Mendix cannot load as an EnumerationValueIdentifier +// (MDL-WF03). See the regex block above for the measurements. +// +// The empty value is skipped deliberately: an enumeration decision carries one +// extra outcome with Value "" for "none of the above", which Studio Pro writes +// on every enum decision and the loader accepts. func checkWorkflowOutcomeNames(outcomes []ast.WorkflowConditionOutcomeNode, kind string, loc linter.Location) []linter.Violation { var out []linter.Violation for _, o := range outcomes { - if o.Value == "" || wfOutcomeIdentRe.MatchString(o.Value) { - continue + if v := checkWorkflowOutcomeValue(o.Value, kind, loc); v != nil { + out = append(out, *v) } - out = append(out, linter.Violation{ - RuleID: "MDL-WF03", - Severity: linter.SeverityError, - Location: loc, - Message: fmt.Sprintf("%s outcome '%s' is not a valid enumeration value identifier — MxBuild rejects outcome names with spaces or punctuation", kind, o.Value), - Suggestion: "Use an enumeration value identifier — bare ('ConfirmedClosed') or qualified ('Module.Enum.ConfirmedClosed'); a decision branches on the enumeration returned by its expression, so outcome names must match that enum's value identifiers.", - }) } return out } +// checkWorkflowOutcomeValue applies MDL-WF03 to a single outcome value. It is +// split out because ALTER WORKFLOW … INSERT BRANCH writes the same field +// through a different door (wfmutator.InsertBranch), and a guard that covered +// only CREATE would leave the corrupting write one statement away. +func checkWorkflowOutcomeValue(value, kind string, loc linter.Location) *linter.Violation { + if value == "" || wfOutcomeQualifiedRe.MatchString(value) { + return nil + } + // "True" / "False" / "Default" never reach storage as an enumeration value — + // the builder turns them into a Boolean or Void outcome. + switch value { + case "True", "False", "Default": + return nil + } + + var why, fix string + if wfOutcomeIdentRe.MatchString(value) { + why = "is not fully qualified" + fix = fmt.Sprintf( + "Write the outcome as Module.Enumeration.Value (e.g. 'Sales.ENUM_Status.%s'). "+ + "Mendix stores it as an EnumerationValueIdentifier and parses it when the project is LOADED, "+ + "so a short name is not a build error — it makes the project unopenable in Studio Pro and mxbuild. "+ + "Two segments are refused as firmly as one, including when the enumeration is in the same module.", + lastSegment(value)) + } else { + why = "is not an enumeration value identifier" + fix = "Write the outcome as Module.Enumeration.Value. A decision branches on the enumeration returned by " + + "its expression, so each outcome must name one of that enumeration's values — free text with spaces or " + + "punctuation is not a name Mendix can resolve." + } + + return &linter.Violation{ + RuleID: "MDL-WF03", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf( + "%s outcome '%s' %s — Mendix cannot load a project whose EnumerationValueConditionOutcome.Value "+ + "is not Module.Enumeration.Value (StorageLoadException, no CE number)", kind, value, why), + Suggestion: fix, + } +} + +// lastSegment returns the part after the final dot, for use in a suggestion. +func lastSegment(s string) string { + if i := strings.LastIndex(s, "."); i >= 0 { + return s[i+1:] + } + return s +} + // workflowUserTaskLabel returns a human-readable label for a user task. func workflowUserTaskLabel(n *ast.WorkflowUserTaskNode) string { if n.Name != "" { @@ -161,3 +228,36 @@ func walkWorkflowActivities(acts []ast.WorkflowActivityNode, visit func(ast.Work } } } + +// ValidateAlterWorkflow applies MDL-WF03 to ALTER WORKFLOW … INSERT CONDITION. +// The condition lands in the same EnumerationValueConditionOutcome.Value as a +// CREATE-time outcome (wfmutator.InsertBranch), so it corrupts the project +// identically; guarding only CREATE would leave the same write one keyword away. +func ValidateAlterWorkflow(stmt *ast.AlterWorkflowStmt) []linter.Violation { + var out []linter.Violation + loc := linter.Location{ + Module: stmt.Name.Module, + DocumentType: "workflow", + DocumentName: stmt.Name.Name, + } + for _, op := range stmt.Operations { + ins, ok := op.(*ast.InsertBranchOp) + if !ok { + continue + } + // The mutator lower-cases before dispatching, so any casing of these + // three becomes a Boolean or Void outcome and never reaches the + // enumeration field. CREATE is stricter — there a quoted 'true' IS + // written as an enumeration value — so the fold lives here, not in the + // shared check. + if strings.EqualFold(ins.Condition, "true") || + strings.EqualFold(ins.Condition, "false") || + strings.EqualFold(ins.Condition, "default") { + continue + } + if v := checkWorkflowOutcomeValue(ins.Condition, "insert condition", loc); v != nil { + out = append(out, *v) + } + } + return out +} diff --git a/mdl/executor/validate_workflow_test.go b/mdl/executor/validate_workflow_test.go index ec31b4fcdb..73962caddb 100644 --- a/mdl/executor/validate_workflow_test.go +++ b/mdl/executor/validate_workflow_test.go @@ -29,6 +29,21 @@ func workflowViolations(t *testing.T, src string) [][2]string { return out } +// programViolations runs the real ValidateProgram wiring, so a rule that is +// written but never reached by `check` / `exec` fails the test. +func programViolations(t *testing.T, src string) [][2]string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + var out [][2]string + for _, v := range ValidateProgram(prog, "") { + out = append(out, [2]string{v.RuleID, v.Message}) + } + return out +} + func hasRule(vs [][2]string, ruleID string) bool { for _, v := range vs { if v[0] == ruleID { @@ -103,7 +118,14 @@ end workflow;` } } -// MDL-WF03 — a decision outcome that is not a valid identifier (has a space) is flagged. +// MDL-WF03 — every outcome that is not Module.Enumeration.Value is flagged. +// +// This test used to assert that the bare 'Reopened' was ACCEPTED and only the +// free-text 'Confirmed closed' flagged. That was the defect: a bare identifier +// is written verbatim into EnumerationValueConditionOutcome.Value, which the +// Mendix loader refuses, leaving a project Studio Pro and mxbuild cannot open +// (ako/mxcli#1031, ako/mxcli#1065). Both values are now errors — with different +// advice, since one author needs a qualifier and the other needs a real name. func TestValidateWorkflow_FreeTextDecisionOutcome(t *testing.T) { src := wfPreamble + `create workflow WF.W parameter $Ctx: WF.Ctx begin @@ -117,18 +139,26 @@ end workflow;` if !hasRule(vs, "MDL-WF03") { t.Fatalf("expected MDL-WF03 for free-text decision outcome, got %v", vs) } - // 'Reopened' is a valid identifier and must NOT be flagged; only 'Confirmed closed'. - var wf03 int + var flagged []string for _, v := range vs { if v[0] == "MDL-WF03" { - wf03++ - if !strings.Contains(v[1], "Confirmed closed") { - t.Errorf("MDL-WF03 should name 'Confirmed closed', got %q", v[1]) - } + flagged = append(flagged, v[1]) + } + } + if len(flagged) != 2 { + t.Fatalf("expected MDL-WF03 on BOTH outcomes, got %d: %v", len(flagged), flagged) + } + var sawBare, sawFreeText bool + for _, m := range flagged { + if strings.Contains(m, "Reopened") { + sawBare = true + } + if strings.Contains(m, "Confirmed closed") { + sawFreeText = true } } - if wf03 != 1 { - t.Fatalf("expected exactly one MDL-WF03 (only 'Confirmed closed'), got %d in %v", wf03, vs) + if !sawBare || !sawFreeText { + t.Errorf("MDL-WF03 must name both outcomes, got %v", flagged) } } @@ -238,3 +268,78 @@ end workflow;` t.Fatalf("jump to a named decision/split must resolve, got %v", vs) } } + +// MDL-WF03 — the three-row control that fixes the rule's threshold. +// EnumerationValueConditionOutcome.Value is parsed by the Mendix LOADER, so a +// value it rejects is not a CE number: the project will not open at all +// (StorageLoadException). Measured on 11.10.0, one workflow per copy of the +// same app: 1 segment and 2 segments both make the project unloadable, 3 +// segments checks at 0 errors. See ako/mxcli#1031 and ako/mxcli#1065. +func TestValidateWorkflow_EnumOutcomeMustBeQualified(t *testing.T) { + for _, tc := range []struct { + name string + value string + flagged bool + }{ + {"bare value", "OutcomeA", true}, + {"enum-qualified only", "Status.OutcomeA", true}, + {"fully qualified", "WFP.Status.OutcomeA", false}, + {"four segments", "A.B.C.D", true}, + {"free text", "Confirmed closed", true}, + } { + t.Run(tc.name, func(t *testing.T) { + src := wfPreamble + `create workflow WF.W parameter $Ctx: WF.Ctx +begin + decision '$Ctx/Status' + outcomes + '` + tc.value + `' -> { } + '' -> { } + ; +end workflow;` + vs := workflowViolations(t, src) + if got := hasRule(vs, "MDL-WF03"); got != tc.flagged { + t.Fatalf("MDL-WF03 fired = %v, want %v for %q (violations: %v)", got, tc.flagged, tc.value, vs) + } + }) + } +} + +// The empty outcome an enumeration decision must carry ("none of the above", +// which Studio Pro writes on every enum decision) is not an identifier and must +// never be flagged — otherwise the rule refuses the only shape that builds. +func TestValidateWorkflow_EmptyEnumOutcomeAccepted(t *testing.T) { + src := wfPreamble + `create workflow WF.W parameter $Ctx: WF.Ctx +begin + decision '$Ctx/Status' + outcomes + 'WFP.Status.OutcomeA' -> { } + '' -> { } + ; +end workflow;` + if vs := workflowViolations(t, src); hasRule(vs, "MDL-WF03") { + t.Fatalf("the empty enum outcome must not trigger MDL-WF03, got %v", vs) + } +} + +// A bare value reaches storage through ALTER WORKFLOW … INSERT BRANCH too, so +// the guard covers it. Without this the corrupting write is one keyword away +// from the one that was fixed. +func TestValidateAlterWorkflow_InsertBranchOutcomeMustBeQualified(t *testing.T) { + bare := `alter workflow WF.W insert condition 'OutcomeA' on 'Decision' { };` + if vs := programViolations(t, bare); !hasRule(vs, "MDL-WF03") { + t.Fatalf("expected MDL-WF03 for a bare INSERT CONDITION value, got %v", vs) + } + qualified := `alter workflow WF.W insert condition 'WFP.Status.OutcomeA' on 'Decision' { };` + if vs := programViolations(t, qualified); hasRule(vs, "MDL-WF03") { + t.Fatalf("a qualified INSERT CONDITION value must be accepted, got %v", vs) + } + // The three keyword conditions are Boolean/Void outcomes whatever their + // casing — the mutator lower-cases before dispatching — so they must not be + // mistaken for an unqualified enumeration value. + for _, kw := range []string{"Default", "default", "true", "FALSE"} { + src := `alter workflow WF.W insert condition '` + kw + `' on 'Decision' { };` + if vs := programViolations(t, src); hasRule(vs, "MDL-WF03") { + t.Errorf("INSERT CONDITION %q must not trigger MDL-WF03, got %v", kw, vs) + } + } +} diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index 40896bbff6..93a8c2b54c 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -386,6 +386,7 @@ IMAGE: I M A G E; // GLYPH names Mendix's legacy icon element (Forms$GlyphIcon), a numeric // character code rather than a reference into a collection. GLYPH: G L Y P H; +GLYPHS: G L Y P H S; QUEUE: Q U E U E; QUEUES: Q U E U E S; SCHEDULED: S C H E D U L E D; @@ -600,6 +601,13 @@ HEADERS: H E A D E R S; // Navigation keywords NAVIGATION: N A V I G A T I O N; +// Offline synchronization. SYNC is safe beside SYNCHRONIZE (declared earlier, +// for the nanoflow activity): ANTLR takes the longest match, so "synchronize" +// is never lexed as SYNC followed by an identifier. +SYNC: S Y N C; +ONLINE: O N L I N E; +NEVER: N E V E R; +PRESERVE: P R E S E R V E; MENU_KW: M E N U; HOMES: H O M E S; HOME: H O M E; diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index a78fc1534f..ec4265c58f 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -330,6 +330,39 @@ navigationClause | LOGIN PAGE qualifiedName | NOT FOUND PAGE qualifiedName | MENU_KW LPAREN navMenuItemDef* RPAREN + | SYNC LPAREN navSyncDef* RPAREN + ; + +// Offline synchronization, one statement per entity, mirroring the MENU block: +// a list of rules rather than a property bag, so it diffs a line at a time. +// +// WHERE implies the Constrained mode rather than naming it. A constrained +// entity with no constraint and a constraint with no mode are both nonsense, +// so deriving one from the other makes the invalid pair unspellable instead of +// merely diagnosable — and leaves Constrained with no bare word, which is +// correct because there is nothing to say without the XPath. +navSyncDef + : SYNC qualifiedName navSyncMode SEMICOLON? + ; + +// Every alternative maps to exactly one Navigation$SyncMode member. The words +// are not the captions Studio Pro shows -- "All Objects" and "By XPath" are not +// members of the enumeration at all -- so the mapping lives in the visitor with +// a test asserting each target is a declared member. +navSyncMode + : ONLINE + | ALL + | NEVER + | NONE PRESERVE DATA + | NONE + // The bracket form is the first-class one and is what DESCRIBE emits: an + // XPath constraint routinely contains quoted literals, and inside a quoted + // MDL string every one of them doubles — the stored value already carries + // Mendix's own escaping, so the two compose into runs of six quotes + // (mendixlabs/mxcli#750). Brackets take the XPath verbatim. + // + // The quoted form still parses, because scripts already use it. + | WHERE (xpathConstraint | STRING_LITERAL) ; // The icon is a qualifiedName, like every other reference into the model, and diff --git a/mdl/grammar/domains/MDLCatalog.g4 b/mdl/grammar/domains/MDLCatalog.g4 index 02b1b5b3df..88b696975c 100644 --- a/mdl/grammar/domains/MDLCatalog.g4 +++ b/mdl/grammar/domains/MDLCatalog.g4 @@ -43,6 +43,11 @@ showStatement | showOrList JAVASCRIPT ACTIONS (IN (qualifiedName | IDENTIFIER))? | showOrList IMAGE COLLECTION (IN (qualifiedName | IDENTIFIER))? | showOrList ICON COLLECTION (IN (qualifiedName | IDENTIFIER))? + // A glyph is a character code in a FONT, not an element in the project, so + // there is nothing to scope with IN and nothing a connection would add. + // LIKE filters on the name, which is the direction an author needs: they + // know they want a star and not that a star is 57350. + | showOrList GLYPHS (LIKE STRING_LITERAL)? | showOrList MODELS (IN (qualifiedName | IDENTIFIER))? | showOrList AGENTS (IN (qualifiedName | IDENTIFIER))? | showOrList KNOWLEDGE BASES (IN (qualifiedName | IDENTIFIER))? @@ -186,6 +191,7 @@ describeStatement | DESCRIBE FRAGMENT FROM SNIPPET qualifiedName WIDGET identifierOrKeyword // DESCRIBE FRAGMENT FROM SNIPPET Module.Snippet WIDGET name | DESCRIBE IMAGE COLLECTION qualifiedName // DESCRIBE IMAGE COLLECTION Module.Name | DESCRIBE ICON COLLECTION qualifiedName // DESCRIBE ICON COLLECTION Module.Name + | DESCRIBE GLYPH (NUMBER_LITERAL | STRING_LITERAL) // DESCRIBE GLYPH 57350 | DESCRIBE GLYPH 'star' | DESCRIBE MODEL qualifiedName // DESCRIBE MODEL Module.Name (agent-editor) | DESCRIBE AGENT qualifiedName // DESCRIBE AGENT Module.Name (agent-editor) | DESCRIBE KNOWLEDGE BASE qualifiedName // DESCRIBE KNOWLEDGE BASE Module.Name diff --git a/mdl/grammar/domains/MDLSecurity.g4 b/mdl/grammar/domains/MDLSecurity.g4 index 4addb9c221..a301f28f1b 100644 --- a/mdl/grammar/domains/MDLSecurity.g4 +++ b/mdl/grammar/domains/MDLSecurity.g4 @@ -32,8 +32,13 @@ alterUserRoleStatement | ALTER USER ROLE identifierOrKeyword REMOVE MODULE ROLES LPAREN moduleRoleList RPAREN ; +// IF EXISTS makes a cleanup script re-runnable. Without it the statement fails +// the second time, so a one-time cleanup either breaks every later run of the +// slice or has to be commented out — which is what happened to +// `drop demo user` / `drop user role` in a real project (ako/CapTrackV4 R5). +// Same spelling as ALTER ENTITY's DROP ATTRIBUTE IF EXISTS. dropUserRoleStatement - : DROP USER ROLE (identifierOrKeyword | STRING_LITERAL) + : DROP USER ROLE ifExists? (identifierOrKeyword | STRING_LITERAL) ; grantEntityAccessStatement @@ -112,7 +117,7 @@ createDemoUserStatement ; dropDemoUserStatement - : DROP DEMO USER STRING_LITERAL + : DROP DEMO USER ifExists? STRING_LITERAL ; // IN is optional before the module name, not just before the whole clause. diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index b042833adf..a27ba981a7 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -628,7 +628,7 @@ keyword | CAPTION | CAPTIONPARAMS | CLASS | COLUMN | COLUMNS | CONTENT | CONTENTPARAMS | DATASOURCE | DEFAULT | DESIGNPROPERTIES | DESKTOPWIDTH | DISPLAY | DOCUMENTATION | EDITABLE | FILTER | FILTERTYPE | HEADER | FOOTER - | ICON | GLYPH | DARK | LABEL | ONCLICK | ONCHANGE | PARAMS | PASSING + | ICON | GLYPH | GLYPHS | DARK | LABEL | ONCLICK | ONCHANGE | PARAMS | PASSING | PHONEWIDTH | TABLETWIDTH | READONLY | RENDERMODE | REQUIRED | NULLABLE | SELECTION | STYLE | STYLING | TABINDEX | TITLE | TOOLTIP | URL | POSITION | VISIBLE | WIDTH | HEIGHT | WIDGETTYPE @@ -654,6 +654,12 @@ keyword // Navigation | FOUND | HOME | HOMES | LOGIN | MENU_KW | NAVIGATION + // Offline synchronization. These have to stay usable as identifiers: NEVER + // is already a value elsewhere in MDL (`editable: never` on a list view), + // and ONLINE/SYNC/PRESERVE are plausible entity and attribute names. A new + // keyword that is not listed here silently steals every existing use of + // that word — which is what TestKeywordRuleCoverage exists to catch. + | SYNC | ONLINE | NEVER | PRESERVE // Log levels | CRITICAL | DEBUG | ERROR | INFO | SUCCESS | WARNING diff --git a/mdl/linter/context.go b/mdl/linter/context.go index a450cbc8d0..efa313f8d7 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1561,8 +1561,14 @@ type documentableSource struct { // most of them, and flagging every widget would drown the rule. // - contract_entities — generated from a remote service's $metadata. Not the // user's text to write, so not the user's omission to report. +// - modules — a Mendix module HAS no documentation property, so the rule was +// asking for something no editor can supply. Measured three ways: the +// metamodel's ProjectsModule declares none, modelsdk/gen's Module offers no +// Documentation accessor, and none of a real project's stored +// Projects$ModuleImpl units contains the key. It reported every module of +// every project forever, which is what a report of 39 unanswerable warnings +// looks like from the inside (ako/CapTrackV4 R12). var documentableSources = []documentableSource{ - {"modules", "Module", "Description"}, {"entities", "Entity", "Description"}, {"associations", "Association", "Description"}, {"pages", "Page", "Description"}, diff --git a/mdl/linter/starlark_javaactions_test.go b/mdl/linter/starlark_javaactions_test.go index 09fb292c92..63548feae3 100644 --- a/mdl/linter/starlark_javaactions_test.go +++ b/mdl/linter/starlark_javaactions_test.go @@ -261,7 +261,12 @@ func allKindsFixture(t *testing.T) (*catalog.Catalog, map[string]string) { // export_mappings declare `Id INTEGER PRIMARY KEY AUTOINCREMENT` while every // other table uses `Id TEXT PRIMARY KEY`, so a synthetic string id is a // datatype mismatch on exactly those three. - want := map[string]string{"Module": "Racing"} + // Module is deliberately NOT in here. A Mendix module has no documentation + // property at all — the metamodel declares none, modelsdk/gen offers no + // accessor, and no stored Projects$ModuleImpl carries the key — so QUAL002 + // no longer sweeps modules. The module row above still exists because every + // other element in this fixture lives in it. + want := map[string]string{} for table, meta := range tables { name := meta.kind + "X" q := fmt.Sprintf( @@ -439,7 +444,10 @@ func TestQUAL002_ExcludesTheSystemModule(t *testing.T) { if strings.Contains(joined, "'System'") { t.Errorf("the System module itself was reported:\n%s", joined) } - if !strings.Contains(joined, "'Racing'") { - t.Errorf("the user's own module stopped being reported:\n%s", joined) + // The positive control. Excluding System must not exclude the user's own + // module along with it, and an entity is what carries that now — a module is + // no longer reported at all, so its absence proves nothing here. + if !strings.Contains(joined, "'EntityX'") { + t.Errorf("the user's own module's contents stopped being reported:\n%s", joined) } } diff --git a/mdl/types/navigation.go b/mdl/types/navigation.go index e2512c838b..55902136c9 100644 --- a/mdl/types/navigation.go +++ b/mdl/types/navigation.go @@ -167,10 +167,25 @@ type MenuDocument struct { func (m *MenuDocument) GetName() string { return m.Name } // NavOfflineEntity declares offline sync rules for an entity. +// +// These are the four properties Studio Pro writes on a web profile, measured +// against ako/TestApp's TabletOffline profile (seven configs, all six sync +// modes). modelsdk/gen declares two more — DownloadMode and ShouldDownload — +// which occur ZERO times in that document; they are presumably native-only, and +// a writer must not start emitting them. A property absent from every real +// document is one Studio Pro fills in on load, so writing it is how a document +// mxbuild accepts becomes one Studio Pro cannot open. +// +// CompatibilityMode is carried but not authorable. It exists so a future write +// path can put it back unchanged instead of dropping it — the mistake that had +// `create or modify entity` deleting access rules. type NavOfflineEntity struct { Entity string `json:"entity"` SyncMode string `json:"syncMode"` Constraint string `json:"constraint,omitempty"` + // CompatibilityMode is read and preserved, never authored. Every reference + // config carries false; the true case has not been observed. + CompatibilityMode bool `json:"compatibilityMode,omitempty"` } // NavigationProfileSpec specifies changes to a navigation profile. @@ -180,6 +195,22 @@ type NavigationProfileSpec struct { NotFoundPage string MenuItems []NavMenuItemSpec HasMenu bool + // OfflineEntities is the SYNC block. HasSync distinguishes "no block was + // written, leave the stored list alone" from "an empty block was written, + // clear it" — the same distinction HasMenu draws, and the reason a spec + // field alone is not enough. + OfflineEntities []NavOfflineEntitySpec + HasSync bool +} + +// NavOfflineEntitySpec is one entity's offline sync rule, as MDL can express +// it. CompatibilityMode is deliberately absent: it is stored, carried on read +// and preserved on write, but there is no syntax for it — so a spec that could +// express it would invite a writer to set it from a value nobody supplied. +type NavOfflineEntitySpec struct { + Entity string + SyncMode string + Constraint string } // NavHomePageSpec specifies a home page assignment. diff --git a/mdl/visitor/drop_if_exists_test.go b/mdl/visitor/drop_if_exists_test.go new file mode 100644 index 0000000000..2c92beef0b --- /dev/null +++ b/mdl/visitor/drop_if_exists_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// `drop demo user` and `drop user role` had no IF EXISTS form, so a one-time +// cleanup of what `mxcli new` ships either broke every later run of its slice +// script or had to be commented out — which is what a real project did +// (ako/CapTrackV4 R5, 024). Re-running every mdlsource/*.mdl in order is what a +// fresh clone does, so a statement that only works once is a script that only +// works for whoever wrote it. +// +// The spelling is the one ALTER ENTITY already uses (DROP ATTRIBUTE IF EXISTS), +// so the grammar's own ifExists rule is reused rather than a second spelling +// invented. + +func TestDropUserRole_IfExistsIsParsed(t *testing.T) { + for _, tc := range []struct { + src string + want bool + }{ + {"drop user role if exists Admin;", true}, + {"drop user role Admin;", false}, + {"drop user role if exists 'Admin';", true}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + if len(prog.Statements) != 1 { + t.Fatalf("%s: got %d statements", tc.src, len(prog.Statements)) + } + s, ok := prog.Statements[0].(*ast.DropUserRoleStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.IfExists != tc.want { + t.Errorf("%s: IfExists = %v, want %v", tc.src, s.IfExists, tc.want) + } + if s.Name != "Admin" { + t.Errorf("%s: Name = %q, want Admin — IF EXISTS must not be eaten as the name", + tc.src, s.Name) + } + } +} + +func TestDropDemoUser_IfExistsIsParsed(t *testing.T) { + for _, tc := range []struct { + src string + want bool + }{ + {"drop demo user if exists 'demo';", true}, + {"drop demo user 'demo';", false}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + s, ok := prog.Statements[0].(*ast.DropDemoUserStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.IfExists != tc.want { + t.Errorf("%s: IfExists = %v, want %v", tc.src, s.IfExists, tc.want) + } + if s.UserName != "demo" { + t.Errorf("%s: UserName = %q, want demo", tc.src, s.UserName) + } + } +} diff --git a/mdl/visitor/glyph_query_test.go b/mdl/visitor/glyph_query_test.go new file mode 100644 index 0000000000..a042436a54 --- /dev/null +++ b/mdl/visitor/glyph_query_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestShowGlyphs_Parses(t *testing.T) { + for _, tc := range []struct { + src string + like string + }{ + {"show glyphs;", ""}, + {"list glyphs;", ""}, + {"show glyphs like 'star';", "star"}, + {"SHOW GLYPHS LIKE 'user';", "user"}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + if len(prog.Statements) != 1 { + t.Fatalf("%s: got %d statements", tc.src, len(prog.Statements)) + } + s, ok := prog.Statements[0].(*ast.ShowStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.ObjectType != ast.ShowGlyphs { + t.Errorf("%s: ObjectType = %v, want ShowGlyphs", tc.src, s.ObjectType) + } + if s.Like != tc.like { + t.Errorf("%s: Like = %q, want %q", tc.src, s.Like, tc.like) + } + } +} + +// The subject is a code or a name, and both have to survive the visitor — +// `describe glyph 57350` when reading a menu someone else wrote, +// `describe glyph 'star'` when writing one. +func TestDescribeGlyph_Parses(t *testing.T) { + for _, tc := range []struct{ src, want string }{ + {"describe glyph 57350;", "57350"}, + {"describe glyph 'star';", "star"}, + {"DESCRIBE GLYPH 'star-empty';", "star-empty"}, + } { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("%s: parse: %v", tc.src, errs[0]) + } + s, ok := prog.Statements[0].(*ast.DescribeStmt) + if !ok { + t.Fatalf("%s: got %T", tc.src, prog.Statements[0]) + } + if s.ObjectType != ast.DescribeGlyph { + t.Errorf("%s: ObjectType = %v, want DescribeGlyph", tc.src, s.ObjectType) + } + if s.Qualifier != tc.want { + t.Errorf("%s: Qualifier = %q, want %q", tc.src, s.Qualifier, tc.want) + } + } +} + +// CONTROL: adding the GLYPHS token must not shadow GLYPH in the icon clause it +// was originally added for. ANTLR's maximal munch should keep them apart, and +// this is the test that says so out loud. +func TestGlyphKeywordStillParsesAnIconClause(t *testing.T) { + prog, errs := Build(`create or modify menu M.Nav ( + menu item 'Home' page M.Home icon glyph 57377; +)`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + s, ok := prog.Statements[0].(*ast.CreateMenuStmt) + if !ok { + t.Fatalf("got %T", prog.Statements[0]) + } + if len(s.Items) != 1 || s.Items[0].IconCode != 57377 { + t.Errorf("the icon clause no longer parses: %+v", s.Items) + } +} diff --git a/mdl/visitor/nav_sync_mode_enum_test.go b/mdl/visitor/nav_sync_mode_enum_test.go new file mode 100644 index 0000000000..4ccf0af136 --- /dev/null +++ b/mdl/visitor/nav_sync_mode_enum_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/generated/metamodel" + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// Every MDL sync word must map to a DECLARED member of Navigation$SyncMode. +// +// This is the guard #1035 earned. There, "Below grid" — the caption of the +// member `bottom` — reached gallery.def.json as a value, and mxbuild rejected +// every mxcli-authored gallery with CE0463. The same trap is set here and is +// worse: Studio Pro's dropdown shows "All Objects" and "By XPath", neither of +// which is a member of this enumeration, and six members map onto far fewer +// visible captions — so the mapping cannot be read off the UI at all. +// +// The right column comes from ako/TestApp, whose TabletOffline profile stores +// all six members. +func TestNavSyncModesAreDeclaredEnumMembers(t *testing.T) { + declared := map[string]bool{ + string(metamodel.NavigationSyncModeAll): true, + string(metamodel.NavigationSyncModeConstrained): true, + string(metamodel.NavigationSyncModeNever): true, + string(metamodel.NavigationSyncModeNone): true, + string(metamodel.NavigationSyncModeNoneAndPreserveData): true, + string(metamodel.NavigationSyncModeOnline): true, + } + + // The mapping the visitor implements, restated so a change to it has to be + // made here too — and so a caption slipping in is caught by name. + mdlToStored := map[string]string{ + "ONLINE": "Online", + "ALL": "All", + "NEVER": "Never", + "NONE": "None", + "NONE PRESERVE DATA": "NoneAndPreserveData", + "WHERE ''": "Constrained", + } + + for word, stored := range mdlToStored { + if !declared[stored] { + t.Errorf("MDL %q maps to %q, which is not a declared Navigation$SyncMode member — "+ + "a caption where a key belongs is CE0463 (#1035) all over again", word, stored) + } + } + + // And the other direction: every member needs a way to be written, or a + // project using it cannot round-trip through MDL. + written := map[string]bool{} + for _, stored := range mdlToStored { + written[stored] = true + } + for member := range declared { + if !written[member] { + t.Errorf("Navigation$SyncMode member %q has no MDL spelling — a project using it "+ + "cannot survive describe -> exec", member) + } + } +} + +// Captions must never be accepted as modes. Studio Pro shows these; they are +// not members, and a user copying what they see must get an error rather than +// a document mxbuild refuses. +func TestStudioProCaptionsAreNotSyncModes(t *testing.T) { + for _, caption := range []string{"All Objects", "By XPath", "AllObjects", "ByXPath"} { + prog, errs := Build("create or replace navigation TabletOffline sync ( sync Mod.E " + caption + "; )") + if len(errs) == 0 && prog != nil && len(prog.Statements) > 0 { + t.Errorf("caption %q parsed as a sync mode; it is not a member of the enumeration", caption) + } + } +} + +// A new keyword silently steals every existing use of that word as a name. +// NEVER collided immediately: `editable: never` is a real page property value, +// and adding the token broke mdl-examples/bug-tests/maint2-editable-never- +// create-page.mdl until the four words were added to the keyword rule. +// +// TestKeywordRuleCoverage checks the rule LISTS them; this checks they actually +// parse, which is the property that matters. +func TestSyncKeywordsStayUsableAsIdentifiers(t *testing.T) { + for _, word := range []string{"sync", "online", "never", "preserve"} { + t.Run(word, func(t *testing.T) { + // As an entity name, an attribute name, and a page property value. + src := "create entity Mod." + word + " ( " + word + ": String(10) );" + if _, errs := Build(src); len(errs) > 0 { + t.Errorf("%q is no longer usable as an identifier: %v", word, errs[0]) + } + }) + } +} + +// Both WHERE forms must reach the same stored constraint. The bracket form is +// what DESCRIBE emits and is the one to use; the quoted form still parses +// because scripts already contain it. +// +// The escaping is the whole point of the pair: inside the quoted form every +// quote doubles, and a stored constraint already carries Mendix's own escaping, +// so the two compose — which is how the reference document's constraint came +// back as six consecutive quotes (mendixlabs/mxcli#750). +func TestBothWhereFormsProduceTheSameConstraint(t *testing.T) { + bracket := "create or replace navigation TabletOffline sync ( sync Mod.E where [contains(V, 'abc')]; )" + quoted := "create or replace navigation TabletOffline sync ( sync Mod.E where '[contains(V, ''abc'')]'; )" + + got := map[string]string{} + for name, src := range map[string]string{"bracket": bracket, "quoted": quoted} { + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("%s form failed to parse: %v", name, errs[0]) + } + stmt, ok := prog.Statements[0].(*ast.AlterNavigationStmt) + if !ok || len(stmt.SyncEntries) != 1 { + t.Fatalf("%s form did not produce one sync entry: %+v", name, prog.Statements[0]) + } + e := stmt.SyncEntries[0] + if e.Mode != "Constrained" { + t.Errorf("%s form: mode = %q, want Constrained (WHERE implies it)", name, e.Mode) + } + got[name] = e.Constraint + } + + if got["bracket"] != got["quoted"] { + t.Errorf("the two WHERE forms disagree:\n bracket %q\n quoted %q", got["bracket"], got["quoted"]) + } + if !strings.Contains(got["bracket"], "'abc'") { + t.Errorf("the literal lost its quotes: %q", got["bracket"]) + } +} diff --git a/mdl/visitor/snippet_param_quoted_entity_test.go b/mdl/visitor/snippet_param_quoted_entity_test.go new file mode 100644 index 0000000000..4ebd2bcbbe --- /dev/null +++ b/mdl/visitor/snippet_param_quoted_entity_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// A quoted entity name in a SNIPPET parameter failed at execution: +// +// create or modify snippet M.S (params: { $T: Pd."Thing" }) +// -> failed to resolve entity Pd."Thing": entity not found +// +// while the identical quoted form in a PAGE parameter resolved fine +// (ako/CapTrackV4 019). The project convention is to quote every identifier, so +// this was reached by following the house style — and the asymmetry gives no +// clue which of the two spellings is the odd one. +// +// The cause was one line. buildSnippetParameterListAsPage re-split the parse +// node's TEXT (`parseQualifiedName(dt.GetText())`), and GetText() returns the +// source verbatim, quotes included. The page path has always walked the parse +// tree instead, where buildQualifiedName unquotes each part. +// +// A correct implementation already existed beside it — buildSnippetParameters, +// which nothing called. Two copies of one conversion, one of them dead, is how +// they drifted; the dead one is gone. + +func snippetParamEntity(t *testing.T, src string) ast.QualifiedName { + t.Helper() + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + for _, stmt := range prog.Statements { + s, ok := stmt.(*ast.CreateSnippetStmtV3) + if !ok { + continue + } + if len(s.Parameters) != 1 { + t.Fatalf("got %d parameters, want 1", len(s.Parameters)) + } + return s.Parameters[0].EntityType + } + t.Fatal("no snippet statement built") + return ast.QualifiedName{} +} + +func TestSnippetParameter_QuotedEntityNameIsUnquoted(t *testing.T) { + got := snippetParamEntity(t, `CREATE OR MODIFY SNIPPET M.S (Params: { $T: Mod."Thing" }) +{ CONTAINER r { DYNAMICTEXT t (Content: 'x') } }`) + + if got.Module != "Mod" || got.Name != "Thing" { + t.Errorf(`snippet param type = %+v, want {Module:Mod Name:Thing} — the quotes `+ + `reached the resolver and exec failed with "entity not found: Mod.\"Thing\""`, got) + } +} + +// CONTROL: the unquoted form must be unchanged, and a quoted MODULE name has to +// work too — the convention quotes both halves. +func TestSnippetParameter_UnquotedAndFullyQuotedAgree(t *testing.T) { + for _, src := range []string{ + `CREATE OR MODIFY SNIPPET M.S (Params: { $T: Mod.Thing }) +{ CONTAINER r { DYNAMICTEXT t (Content: 'x') } }`, + `CREATE OR MODIFY SNIPPET M.S (Params: { $T: "Mod"."Thing" }) +{ CONTAINER r { DYNAMICTEXT t (Content: 'x') } }`, + } { + got := snippetParamEntity(t, src) + if got.Module != "Mod" || got.Name != "Thing" { + t.Errorf("snippet param type = %+v, want {Module:Mod Name:Thing}", got) + } + } +} + +// CONTROL: the page parameter this was compared against still resolves the same +// way, so the fix is "make the snippet agree with the page", not a change to +// both. +func TestPageParameter_QuotedEntityNameStillUnquoted(t *testing.T) { + prog, errs := Build(`CREATE OR REPLACE PAGE M.P (Title: 'P', Params: { $T: Mod."Thing" }) +{ CONTAINER r { DYNAMICTEXT t (Content: 'x') } }`) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs[0]) + } + for _, stmt := range prog.Statements { + p, ok := stmt.(*ast.CreatePageStmtV3) + if !ok { + continue + } + if len(p.Parameters) != 1 { + t.Fatalf("got %d parameters, want 1", len(p.Parameters)) + } + if got := p.Parameters[0].EntityType; got.Module != "Mod" || got.Name != "Thing" { + t.Errorf("page param type = %+v, want {Module:Mod Name:Thing}", got) + } + return + } + t.Fatal("no page statement built") +} diff --git a/mdl/visitor/visitor_navigation.go b/mdl/visitor/visitor_navigation.go index 787761c1ca..4e24c33ce0 100644 --- a/mdl/visitor/visitor_navigation.go +++ b/mdl/visitor/visitor_navigation.go @@ -3,7 +3,9 @@ package visitor import ( + "github.com/antlr4-go/antlr/v4" "strconv" + "strings" "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/grammar/parser" @@ -82,7 +84,69 @@ func (b *Builder) processNavigationClause(stmt *ast.AlterNavigationStmt, ctx *pa item := buildNavMenuItemDef(itemCtx) stmt.MenuItems = append(stmt.MenuItems, item) } + } else if ctx.SYNC() != nil { + // SYNC (navSyncDef*) + stmt.HasSyncBlock = true + for _, defCtx := range ctx.AllNavSyncDef() { + stmt.SyncEntries = append(stmt.SyncEntries, buildNavSyncDef(defCtx)) + } + } +} + +// syncModeFor maps an MDL mode word onto the stored Navigation$SyncMode member. +// +// The words are deliberately NOT Studio Pro's captions: "All Objects" and "By +// XPath" are captions of All and Constrained and are not members of the +// enumeration at all. Writing a caption where a key belongs is what made every +// mxcli-authored gallery fail with CE0463 (mendixlabs/mxcli#1035), so every +// value on the right of this table is asserted to be a declared member by +// TestNavSyncModesAreDeclaredEnumMembers. +func buildNavSyncDef(ctx parser.INavSyncDefContext) ast.NavSyncDef { + c := ctx.(*parser.NavSyncDefContext) + + def := ast.NavSyncDef{} + if qn := c.QualifiedName(); qn != nil { + def.Entity = buildQualifiedName(qn) + } + + m := c.NavSyncMode() + if m == nil { + return def + } + mc := m.(*parser.NavSyncModeContext) + switch { + case mc.ONLINE() != nil: + def.Mode = "Online" + case mc.ALL() != nil: + def.Mode = "All" + case mc.NEVER() != nil: + def.Mode = "Never" + case mc.NONE() != nil && mc.PRESERVE() != nil: + def.Mode = "NoneAndPreserveData" + case mc.NONE() != nil: + def.Mode = "None" + case mc.WHERE() != nil: + // WHERE implies Constrained: the mode and the constraint come from one + // alternative so they cannot disagree. + def.Mode = "Constrained" + if xc := mc.XpathConstraint(); xc != nil { + // First-class form. The source text is taken verbatim and stored + // bracketed, exactly as a RETRIEVE's multi-predicate WHERE does — + // no unescaping, because nothing was escaped. + xcCtx := xc.(*parser.XpathConstraintContext) + if xe := xcCtx.XpathExpr(); xe != nil { + if prc, ok := xe.(antlr.ParserRuleContext); ok { + if src := strings.TrimSpace(extractExpressionText(prc)); src != "" { + def.Constraint = normalizeXPathTokens("[" + src + "]") + } + } + } + } else if lit := mc.STRING_LITERAL(); lit != nil { + // Legacy quoted form: the '' pairs are MDL escaping and come off here. + def.Constraint = unquoteString(lit.GetText()) + } } + return def } // buildNavMenuItemDef recursively builds a NavMenuItemDef from the parse context. diff --git a/mdl/visitor/visitor_page.go b/mdl/visitor/visitor_page.go index 793743a80b..74d9a2fd22 100644 --- a/mdl/visitor/visitor_page.go +++ b/mdl/visitor/visitor_page.go @@ -64,42 +64,6 @@ func buildPageParameters(ctx parser.IPageParameterListContext) []ast.PageParamet return params } -// buildSnippetParameters converts snippet parameter list to []ast.PageParameter. -func buildSnippetParameters(ctx parser.ISnippetParameterListContext) []ast.PageParameter { - if ctx == nil { - return nil - } - listCtx := ctx.(*parser.SnippetParameterListContext) - var params []ast.PageParameter - - for _, param := range listCtx.AllSnippetParameter() { - paramCtx := param.(*parser.SnippetParameterContext) - name := "" - if id := paramCtx.IDENTIFIER(); id != nil { - name = strings.TrimPrefix(id.GetText(), "$") - } - if v := paramCtx.VARIABLE(); v != nil { - name = strings.TrimPrefix(v.GetText(), "$") - } - if qid := paramCtx.QUOTED_IDENTIFIER(); qid != nil { - // Quoted name for reserved-keyword params, e.g. "List". See issue #114. - name = unquoteIdentifier(qid.GetText()) - } - var entityType ast.QualifiedName - if dt := paramCtx.DataType(); dt != nil { - dtCtx := dt.(*parser.DataTypeContext) - if qn := dtCtx.QualifiedName(); qn != nil { - entityType = buildQualifiedName(qn) - } - } - params = append(params, ast.PageParameter{ - Name: name, - EntityType: entityType, - }) - } - return params -} - // ExitCreateLayoutStatement is called when exiting the createLayoutStatement production. func (b *Builder) ExitCreateLayoutStatement(ctx *parser.CreateLayoutStatementContext) { b.statements = append(b.statements, b.buildLayoutV3(ctx)) diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 3cbde7ea31..798497a786 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -286,8 +286,17 @@ func buildSnippetParameterListAsPage(ctx parser.ISnippetParameterListContext) [] param.Name = unquoteIdentifier(qid.GetText()) } + // Walk the parse tree rather than re-splitting its TEXT. GetText() hands + // back the source verbatim, so a quoted entity name arrived as + // `Pd."Thing"` and exec failed with `entity not found: Pd."Thing"` — + // while the identical quoted form in a PAGE parameter resolved, because + // that path has always used buildQualifiedName (ako/CapTrackV4 019). The + // project convention is to quote every identifier, so this was reached by + // following the house style. if dt := spCtx.DataType(); dt != nil { - param.EntityType = parseQualifiedName(dt.GetText()) + if qn := dt.(*parser.DataTypeContext).QualifiedName(); qn != nil { + param.EntityType = buildQualifiedName(qn) + } } params = append(params, param) diff --git a/mdl/visitor/visitor_query.go b/mdl/visitor/visitor_query.go index 8d042a31de..904bb15207 100644 --- a/mdl/visitor/visitor_query.go +++ b/mdl/visitor/visitor_query.go @@ -592,6 +592,14 @@ func (b *Builder) ExitShowStatement(ctx *parser.ShowStatementContext) { } } b.statements = append(b.statements, stmt) + } else if ctx.GLYPHS() != nil { + // SHOW GLYPHS [LIKE 'pattern'] — the Mendix glyph font, not a project + // document, so there is no IN clause. + stmt := &ast.ShowStmt{ObjectType: ast.ShowGlyphs} + if sl := ctx.STRING_LITERAL(); sl != nil { + stmt.Like = unquoteString(sl.GetText()) + } + b.statements = append(b.statements, stmt) } else if ctx.ICON() != nil && ctx.COLLECTION() != nil { // SHOW ICON COLLECTION [IN module] stmt := &ast.ShowStmt{ObjectType: ast.ShowIconCollections} @@ -806,6 +814,22 @@ func (b *Builder) ExitDescribeStatement(ctx *parser.DescribeStatementContext) { return } + // DESCRIBE GLYPH 57350 | DESCRIBE GLYPH 'star'. Placed with the other + // no-document statements: a glyph is a character code in a font, so it has no + // qualified name for the chain below to build. + if ctx.GLYPH() != nil { + stmt := &ast.DescribeStmt{ObjectType: ast.DescribeGlyph} + if n := ctx.NUMBER_LITERAL(); n != nil { + stmt.Qualifier = n.GetText() + } else if sl := ctx.STRING_LITERAL(); sl != nil { + stmt.Qualifier = unquoteString(sl.GetText()) + } + if stmt.Qualifier != "" { + b.statements = append(b.statements, stmt) + } + return + } + // DESCRIBE QUEUE Module.Name if ctx.QUEUE() != nil { if qn := ctx.QualifiedName(); qn != nil { diff --git a/mdl/visitor/visitor_security.go b/mdl/visitor/visitor_security.go index cc0e0af774..3057b46705 100644 --- a/mdl/visitor/visitor_security.go +++ b/mdl/visitor/visitor_security.go @@ -94,7 +94,10 @@ func (b *Builder) ExitDropUserRoleStatement(ctx *parser.DropUserRoleStatementCon name = unquoteString(sl.GetText()) } if name != "" { - b.statements = append(b.statements, &ast.DropUserRoleStmt{Name: name}) + b.statements = append(b.statements, &ast.DropUserRoleStmt{ + Name: name, + IfExists: ctx.IfExists() != nil, + }) } } @@ -464,6 +467,7 @@ func (b *Builder) ExitDropDemoUserStatement(ctx *parser.DropDemoUserStatementCon if sl := ctx.STRING_LITERAL(); sl != nil { b.statements = append(b.statements, &ast.DropDemoUserStmt{ UserName: unquoteString(sl.GetText()), + IfExists: ctx.IfExists() != nil, }) } } diff --git a/mdl/visitor/visitor_workflow.go b/mdl/visitor/visitor_workflow.go index ddd2524a92..1526df2274 100644 --- a/mdl/visitor/visitor_workflow.go +++ b/mdl/visitor/visitor_workflow.go @@ -575,14 +575,7 @@ func buildWorkflowCallMicroflow(ctx parser.IWorkflowCallMicroflowStmtContext) *a } // Parameter mappings (Issue #10) - for _, pmCtx := range cmCtx.AllWorkflowParameterMapping() { - pmCtx2 := pmCtx.(*parser.WorkflowParameterMappingContext) - mapping := ast.WorkflowParameterMappingNode{ - Parameter: bareWorkflowParameterName(pmCtx2.QualifiedName().GetText()), - Expression: unquoteString(pmCtx2.STRING_LITERAL().GetText()), - } - node.ParameterMappings = append(node.ParameterMappings, mapping) - } + node.ParameterMappings = buildWorkflowParameterMappings(cmCtx.AllWorkflowParameterMapping()) // BoundaryEvents (Issue #7) for _, beCtx := range cmCtx.AllWorkflowBoundaryEventClause() { @@ -606,6 +599,35 @@ func bareWorkflowParameterName(raw string) string { return unquoteIdentifier(strings.TrimSpace(raw)) } +// buildWorkflowParameterMappings builds the `with (Name = 'expr')` list shared by +// CALL MICROFLOW and CALL WORKFLOW. +// +// Both children are nil-checked because the AST builder runs over the parse tree +// even when the parse failed — Build() walks first and returns the syntax errors +// alongside the partial program, which is what lets `check` report more than the +// first error. Under ANTLR error recovery a rule can therefore be visited with a +// required child missing, and reading it unguarded takes the process down. The +// grammar requires a STRING_LITERAL value, so an unquoted one (`Ctx = $Var`, the +// spelling used everywhere else in MDL) left STRING_LITERAL() nil and every +// command that parses the script — check, check --references, exec — died on a +// nil dereference with no diagnostic at all (ako/mxcli#1023). Skipping the +// mapping keeps the syntax error the listener already recorded as the thing the +// author is told about. +func buildWorkflowParameterMappings(ctxs []parser.IWorkflowParameterMappingContext) []ast.WorkflowParameterMappingNode { + var out []ast.WorkflowParameterMappingNode + for _, pmCtx := range ctxs { + pmCtx2, ok := pmCtx.(*parser.WorkflowParameterMappingContext) + if !ok || pmCtx2.QualifiedName() == nil || pmCtx2.STRING_LITERAL() == nil { + continue + } + out = append(out, ast.WorkflowParameterMappingNode{ + Parameter: bareWorkflowParameterName(pmCtx2.QualifiedName().GetText()), + Expression: unquoteString(pmCtx2.STRING_LITERAL().GetText()), + }) + } + return out +} + // buildWorkflowCallWorkflow builds a WorkflowCallWorkflowNode. func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast.WorkflowCallWorkflowNode { cwCtx := ctx.(*parser.WorkflowCallWorkflowStmtContext) @@ -619,14 +641,7 @@ func buildWorkflowCallWorkflow(ctx parser.IWorkflowCallWorkflowStmtContext) *ast } // Parameter mappings - for _, pmCtx := range cwCtx.AllWorkflowParameterMapping() { - pmCtx2 := pmCtx.(*parser.WorkflowParameterMappingContext) - mapping := ast.WorkflowParameterMappingNode{ - Parameter: bareWorkflowParameterName(pmCtx2.QualifiedName().GetText()), - Expression: unquoteString(pmCtx2.STRING_LITERAL().GetText()), - } - node.ParameterMappings = append(node.ParameterMappings, mapping) - } + node.ParameterMappings = buildWorkflowParameterMappings(cwCtx.AllWorkflowParameterMapping()) return node } diff --git a/mdl/visitor/visitor_workflow_test.go b/mdl/visitor/visitor_workflow_test.go index 2a2298098c..2f1e93235e 100644 --- a/mdl/visitor/visitor_workflow_test.go +++ b/mdl/visitor/visitor_workflow_test.go @@ -1247,3 +1247,59 @@ func TestBareWorkflowParameterName(t *testing.T) { } } } + +// An unquoted value in a workflow `with (...)` mapping is a syntax error — the +// grammar requires a STRING_LITERAL. It used to be a SIGSEGV instead: Build() +// walks the parse tree even when the parse failed, so the mapping rule was +// visited with a nil STRING_LITERAL child and every command that parses a +// script (check, check --references, exec) died with no diagnostic. +// See ako/mxcli#1023. +func TestWorkflowVisitor_UnquotedParameterMappingDoesNotPanic(t *testing.T) { + for _, tc := range []struct{ name, input string }{ + {"call microflow", `CREATE WORKFLOW M.T PARAMETER $Ctx: M.E +BEGIN + CALL MICROFLOW M.ACT WITH (Ctx = $WorkflowContext); +END WORKFLOW;`}, + {"call workflow", `CREATE WORKFLOW M.T PARAMETER $Ctx: M.E +BEGIN + CALL WORKFLOW M.Sub WITH (Ctx = $WorkflowContext); +END WORKFLOW;`}, + {"qualified parameter", `CREATE WORKFLOW M.T PARAMETER $Ctx: M.E +BEGIN + CALL MICROFLOW M.ACT WITH (M.ACT.Ctx = $WorkflowContext); +END WORKFLOW;`}, + } { + t.Run(tc.name, func(t *testing.T) { + _, errs := Build(tc.input) // must not panic + if len(errs) == 0 { + t.Fatal("expected a syntax error for an unquoted mapping value, got none — " + + "the guard must not turn the crash into a silently accepted script") + } + }) + } +} + +// The control: the quoted form still parses and still yields the mapping, so +// the nil guard skips only what the parser could not build. +func TestWorkflowVisitor_QuotedParameterMappingStillBuilds(t *testing.T) { + input := `CREATE WORKFLOW M.T PARAMETER $Ctx: M.E +BEGIN + CALL MICROFLOW M.ACT WITH (Ctx = '$WorkflowContext'); +END WORKFLOW;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("quoted mapping must parse cleanly, got %v", errs) + } + stmt := prog.Statements[0].(*ast.CreateWorkflowStmt) + call, ok := stmt.Activities[0].(*ast.WorkflowCallMicroflowNode) + if !ok { + t.Fatalf("expected a call-microflow activity, got %T", stmt.Activities[0]) + } + if len(call.ParameterMappings) != 1 { + t.Fatalf("expected 1 parameter mapping, got %d", len(call.ParameterMappings)) + } + if got := call.ParameterMappings[0]; got.Parameter != "Ctx" || got.Expression != "$WorkflowContext" { + t.Errorf("mapping = %+v, want {Ctx $WorkflowContext}", got) + } +} diff --git a/sdk/mpr/parser_misc.go b/sdk/mpr/parser_misc.go index bd9adcb702..16f3e21a7c 100644 --- a/sdk/mpr/parser_misc.go +++ b/sdk/mpr/parser_misc.go @@ -550,9 +550,10 @@ func parseNavigationProfile(raw map[string]any) *NavigationProfile { for _, item := range extractBsonArray(raw["OfflineEntityConfigs"]) { if oeMap, ok := item.(map[string]any); ok { oe := &NavOfflineEntity{ - Entity: extractString(oeMap["Entity"]), - SyncMode: extractString(oeMap["SyncMode"]), - Constraint: extractString(oeMap["Constraint"]), + Entity: extractString(oeMap["Entity"]), + SyncMode: extractString(oeMap["SyncMode"]), + Constraint: extractString(oeMap["Constraint"]), + CompatibilityMode: extractBool(oeMap["CompatibilityMode"], false), } if oe.Entity != "" { profile.OfflineEntities = append(profile.OfflineEntities, oe) diff --git a/sdk/mpr/writer_navigation.go b/sdk/mpr/writer_navigation.go index 44a90559f1..3efdd68fb3 100644 --- a/sdk/mpr/writer_navigation.go +++ b/sdk/mpr/writer_navigation.go @@ -15,6 +15,7 @@ import ( // NavigationProfileSpec describes the desired state for a navigation profile. // Aliased from mdl/types to avoid duplicate definitions. type NavigationProfileSpec = types.NavigationProfileSpec +type NavOfflineEntitySpec = types.NavOfflineEntitySpec // NavHomePageSpec describes a home page entry. type NavHomePageSpec = types.NavHomePageSpec @@ -193,9 +194,55 @@ func patchWebProfile(doc bson.D, spec NavigationProfileSpec) bson.D { }) } + // --- Offline synchronization --- + if spec.HasSync { + doc = setBsonField(doc, "OfflineEntityConfigs", + buildOfflineConfigsBson(getBsonArray(doc, "OfflineEntityConfigs"), spec.OfflineEntities)) + } + return doc } +// buildOfflineConfigsBson rebuilds OfflineEntityConfigs from the spec, carrying +// forward the properties MDL cannot express. +// +// Kept deliberately identical in behaviour to the modelsdk engine's +// navOfflineConfigs: CompatibilityMode is preserved per entity, and +// DownloadMode/ShouldDownload are not written at all — they occur zero times in +// ako/TestApp's configs, and a property absent from every real document is one +// Studio Pro fills in on load. A cross-engine test asserts the two agree, +// because two writers drifting apart is how an engine-specific defect hides. +func buildOfflineConfigsBson(stored bson.A, specs []NavOfflineEntitySpec) bson.A { + compat := map[string]bool{} + for _, item := range stored { + var cfg map[string]any + switch v := item.(type) { + case bson.D: + cfg = v.Map() + case map[string]any: + cfg = v + default: + continue // the leading typed-array marker + } + if e := extractString(cfg["Entity"]); e != "" { + compat[e] = extractBool(cfg["CompatibilityMode"], false) + } + } + + out := bson.A{navMarkerItems} + for _, sp := range specs { + out = append(out, bson.D{ + {Key: "$ID", Value: idToBsonBinary(generateUUID())}, + {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, + {Key: "CompatibilityMode", Value: compat[sp.Entity]}, + {Key: "Constraint", Value: sp.Constraint}, + {Key: "Entity", Value: sp.Entity}, + {Key: "SyncMode", Value: sp.SyncMode}, + }) + } + return out +} + // patchNativeProfile applies the spec to a native navigation profile. func patchNativeProfile(doc bson.D, spec NavigationProfileSpec) bson.D { var defaultHome *NavHomePageSpec diff --git a/sdk/mpr/writer_navigation_offline_test.go b/sdk/mpr/writer_navigation_offline_test.go new file mode 100644 index 0000000000..73c5e4489e --- /dev/null +++ b/sdk/mpr/writer_navigation_offline_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpr + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +// The legacy engine must behave identically to modelsdk here. Two writers that +// drift apart is how an engine-specific defect hides: a project written on one +// engine and rewritten on the other would lose the property on exactly one of +// the two paths, and nothing reports it. +func TestLegacyOfflineWriteCarriesCompatibilityMode(t *testing.T) { + stored := bson.A{ + navMarkerItems, + bson.D{ + {Key: "$Type", Value: "Navigation$OfflineEntityConfig"}, + {Key: "CompatibilityMode", Value: true}, + {Key: "Entity", Value: "Rules.RuleAction"}, + {Key: "SyncMode", Value: "All"}, + }, + } + out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{ + {Entity: "Rules.RuleAction", SyncMode: "Never"}, + }) + if len(out) != 2 { + t.Fatalf("expected marker + 1 config, got %d", len(out)) + } + got := out[1].(bson.D).Map() + if got["CompatibilityMode"] != true { + t.Error("legacy dropped CompatibilityMode on a rewrite that never mentioned it") + } + if got["SyncMode"] != "Never" { + t.Errorf("SyncMode = %v, want Never", got["SyncMode"]) + } +} + +// The reader hands back map[string]any rather than bson.D on some paths, and a +// carry that only understood one shape would silently default to false for the +// other — which is the shape the legacy parser actually produces. +func TestLegacyOfflineWriteReadsEitherStoredShape(t *testing.T) { + for name, stored := range map[string]bson.A{ + "bson.D": {navMarkerItems, bson.D{ + {Key: "CompatibilityMode", Value: true}, {Key: "Entity", Value: "Mod.E"}}}, + "map": {navMarkerItems, map[string]any{ + "CompatibilityMode": true, "Entity": "Mod.E"}}, + } { + t.Run(name, func(t *testing.T) { + out := buildOfflineConfigsBson(stored, []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) + if got := out[1].(bson.D).Map(); got["CompatibilityMode"] != true { + t.Errorf("carry failed for a stored config shaped as %s", name) + } + }) + } +} + +func TestLegacyOfflineWriteEmitsTheSamePropertiesAsModelsdk(t *testing.T) { + out := buildOfflineConfigsBson(bson.A{navMarkerItems}, + []NavOfflineEntitySpec{{Entity: "Mod.E", SyncMode: "All"}}) + if out[0] != navMarkerItems { + t.Errorf("marker = %v, want %v", out[0], navMarkerItems) + } + got := out[1].(bson.D).Map() + for _, absent := range []string{"DownloadMode", "ShouldDownload"} { + if _, present := got[absent]; present { + t.Errorf("%s must not be written", absent) + } + } + if len(got) != 6 { + t.Errorf("wrote %d properties (%v), want $ID + $Type + the four Studio Pro writes", len(got), got) + } +}