diff --git a/.claude/commands/mxcli-dev/review.md b/.claude/commands/mxcli-dev/review.md index fc9ae79710..3ef98554b9 100644 --- a/.claude/commands/mxcli-dev/review.md +++ b/.claude/commands/mxcli-dev/review.md @@ -64,6 +64,7 @@ proactively. Add a row after every review that surfaces something new. | 31 | A test helper that needs a heavyweight object only to satisfy a signature (`NewLintContext(nil, nil)`, which panics) invites a nil-guard added purely to make the test compile — behaviour nothing in production needs, defended forever | Test coverage | Narrow the signature instead: if the helper does not use the parameter, drop it and let the caller apply the part it owns. A test that cannot construct an argument is usually telling you the argument does not belong | | 32 | A fix adds a diagnostic for a capability the model lacks while leaving in place the code that asserts the capability EXISTS — MDL042 telling the author a loop's `@caption` is dropped, while `cmd_microflows_builder_annotations.go` still ran `case *microflows.LoopedActivity: activity.Caption = ann.Caption` under the comment "LOOP / WHILE activities can carry a caption just like splits", and the describer still emitted one. Nothing read either back. The next reader trusts the code over the warning, deletes the check, and reopens the bug from the other side. The reason it survives is that it usually has TESTS — three here asserted the caption was carried, all of them against the semantic object and none against storage, so they passed throughout and failed only on the correct fix | Code correctness | `generated/metamodel` is the arbiter: a field on the semantic type it does not declare cannot survive a write, so an assignment to it is dead by construction. Grep the writer, the describer and the semantic struct and delete (or re-comment) whatever sets it. MEASURE before deleting — `exec` then `describe` on a real project, with the UNMODIFIED build, so the deletion rests on the stored document rather than on reading the codec. Invert the tests that defended it rather than deleting them, keeping any half still true (escaping coverage belongs on a type that can carry a caption), and check the inverted test fails when the assignment is put back | | 33 | A column added to `createTables` without bumping `CatalogSchemaVersion` — the version guard only drops tables when it CHANGES, and `CREATE TABLE IF NOT EXISTS` never adds a column, so every user with a cached catalog keeps a table the new SELECT cannot read. Measured on #1181: `activities_for` yielded 302 on `main` and **0** on the branch against the same cache, `no such column: UseRequestTimeout`, `mxcli lint` exit 1. `mxcli report` runs the same rules and never checks `QueryErrors()`, so there it would score silently | Code correctness | Bump the constant in the same commit — its doc comment says so and `62913741` is the precedent (four columns + 11→12 + a builder test). To REPRODUCE, the cache must actually be reused: build it with the old binary and run the new one with the **same spelling of `-p`**, because a relative-vs-absolute path invalidates on "MPR path changed" and hides the bug; confirm the run says "Loading cached catalog … (from cache)" before believing a green result | +| 34 | An upstream PR cut from a months-old base is written in conventions `main` has since replaced: its docs, syntax help and bug-test script use `set X = v` (now MDL-DEPR101, `set (X: v)`), and its finding is appended to `findings/.jsonl`, which `main` split into one file per record. It is green on its own base, and the code change itself is correct, so it reads as ready (mendixlabs/mxcli#1229) | Merge hygiene | Port it rather than merge it as-is: cherry-pick onto current `main`, rewrite every new MDL line in the current syntax, and move the finding to `findings//-.json`. Re-run the PR's own tests against the ported code AND against `main` without the fix (the control), since a hand-resolved conflict can drop the case it adds | --- diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-new-10-24-layout-names-atlas-4-design-properties.json b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-new-10-24-layout-names-atlas-4-design-properties.json new file mode 100644 index 0000000000..c07961be24 --- /dev/null +++ b/.claude/skills/fix-issue/findings/cmd-mxcli/2026-10-15-mxcli-new-10-24-layout-names-atlas-4-design-properties.json @@ -0,0 +1 @@ +{"area": "cmd/mxcli", "date": "2026-10-15", "symptom": "`mxcli new --version 10.24.0` printed \"Project created\" and exited 0, but the first build failed: mx check reports 4x CE6083 \"Design property Flex container / Align items Y / Disable row wrap / Grow / shrink (self) is not supported by your theme\" on MyFirstModule.App_Default's topbarContent", "cause": "Two defects. (1) The scaffold layout hardcoded Atlas 4's flex design properties, but Atlas is versioned with the project: 10.24's template ships Atlas Core 3.17, whose design-properties.json has no flex group at all. (2) `new` treated every first-build failure as a warning, including mxbuild refusing the MODEL, so a project that does not build was reported as created with exit 0", "file": "`cmd/mxcli/cmd_new_layout.go` (`topbarDesignProps`, `scaffoldDesignProps`, `renderScaffoldScript`) filtered through `executor.ThemeDeclaresDesignProperty`; `cmd/mxcli/docker/settle.go` (`classifySettleFailure`, `ModelErrorsError`); `cmd/mxcli/cmd_new.go` (`reportFirstBuildFailure`, exit 1 after step 7)", "insight": "Generated MDL that writes design properties must be filtered by the project's own themesource, not by the Mendix major mxcli was tested on. Nothing fails on Atlas 3 without them: its `.topbar-content` rule is already `display: flex; align-items: center`. `mxcli check -p` could not have caught it either: `documentWidgets` covers pages and snippets but not `create layout`, so the test feeds the layout's widget tree to `ValidateDesignPropertiesForStatement` as a snippet, an oracle that reproduced exactly the 4 CE6083 keys. Only a model error is fatal (mxbuild's `ERROR at` lines); a missing JDK says nothing about the project and stays a warning. Measured: 10.24.0 mx check 4 errors before, 0 after; 11.6.6 0 both, flex props kept; with the filter stubbed out, `new` now exits 1 listing the 4 errors", "refs": ["mendixlabs/mxcli#1433"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-rendermode-dynamictext.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-rendermode-dynamictext.json new file mode 100644 index 0000000000..69a8d90982 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-alter-set-rendermode-dynamictext.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-15", "symptom": "`alter page P { set (RenderMode: H1) on }` is refused: `failed to set RenderMode on compTitle: property \"RenderMode\" not found (widget has no pluggable Object)` (later builds: `not a property of this built-in widget … use alter styling`), while `create page … dynamictext x (RenderMode: H1)` and `replace x with { dynamictext … }` accept it. `check -p` refuses it too, since it dry-runs the same setter", "cause": "setRawWidgetPropertyMut is a hand-kept switch of first-class built-in properties (caption/content/label/class/…); anything not listed falls through to the pluggable-property setter. RenderMode had no case, although the MCP mutator (mdl/backend/mcp/page_mutator.go) has had one all along — the two backends' SET vocabularies are separate lists", "file": "`mdl/backend/pagemutator/mutator.go` (setDynamicTextRenderModeMut)", "insight": "When a CREATE property is refused by ALTER SET on a built-in widget, compare the CREATE builder's property list with the case list in setRawWidgetPropertyMut (and the MCP mutator's) — every such refusal so far (DynamicClasses, lowercase class, RenderMode) was a missing case, not a storage question. Dispatch RenderMode on the stored `$Type` (Forms$DynamicText): containers and ActionButtons store differently-valued RenderMode/RenderType, and a pluggable widget may own a `renderMode` key, so those keep the old path. Validate against pages.TextRenderMode (Text, Paragraph, H1–H6) case-insensitively and store the canonical spelling — the visitor passes `h2` through as typed. Still missing from SET on dynamictext: ContentParams (CREATE accepts it; SET has no case). Control: the unfixed mutator.go makes the new tests fail with the reported refusal; upstream author's real 11.14.0 copy: exec → describe shows H2 → mx check 0 errors", "refs": ["mendixlabs/mxcli#1228", "mendixlabs/mxcli#1229"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-fileuploader-hidden-item-texttemplate-not-nulled.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-fileuploader-hidden-item-texttemplate-not-nulled.json new file mode 100644 index 0000000000..fff376b14f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-fileuploader-hidden-item-texttemplate-not-nulled.json @@ -0,0 +1 @@ +{"area": "mdl/backend/widgetobj", "date": "2026-10-15", "symptom": "File Uploader `allowedfileformat` left at the default `configMode: 'simple'` passes `mxcli check` and exec, then `mx check` reports CE0463 \"The definition of this widget has changed\" on the uploader; `configMode: 'advanced'` on every item avoids it", "cause": "The editorConfig hides an item's `typeFormatDescription` (plus mimeType/extensions) per ITEM via `hideNestedPropertiesIn(..., \"allowedFileFormats\", index, [...])` while that item's configMode is \"simple\". The extractor lifted the rule (ListPropertyKey allowedFileFormats, item-scoped condition), but `ApplyVisibilityRules` only walked top-level properties, so the hidden required TextTemplate kept the populated ClientTemplate the item builder fills it with (#891). Studio Pro wants null there (#574 one level down)", "file": "`mdl/backend/widgetobj/builder.go` (`ApplyVisibilityRules` → new `applyItemVisibilityRules` / `nullHiddenItemTemplates`)", "insight": "Without the real .mpk, a faithful one can be built: sparse-clone mendix/web-widgets `packages/pluggableWidgets/file-uploader-web`, bundle `src/*.editorConfig.ts` with bun (alias `@mendix/pluggable-widgets-tools` to the npm package's `dist/utils/PageEditorUtils.js`), terser it, and rewrite bun's `module.exports=__toCommonJS(...)` to plain `exports.getProperties=...` — with `module.exports` mxbuild silently ignores the editorConfig (CE0642 'Associated images is required' was the tell: hidden properties were being required-checked). `mx update-widgets` on a copy then diffed by PropertyKey isolated the single slot (`typeFormatDescription.TextTemplate` → null). The same fix also cleared the long-standing CE0463 on doctype-tests/32 Accordion `acc2` (groups with HeaderRenderMode custom hide the required `headerText`) — a nested-rule class, so check every widget with object-list TextTemplate rules (`mxcli widget describe` lists them) before and after; DataGrid columns' nested rules agree with emptyClientTemplateRules so 31 stays at 0. Maps map1/map2 CE0463 in fixture 32 are a separate, still-open cause", "refs": ["mendixlabs/mxcli#1198", "#574", "#891"], "ce": ["CE0463"]} diff --git a/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-partial-revoke-several-roles-matches-no-rule-1501.json b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-partial-revoke-several-roles-matches-no-rule-1501.json new file mode 100644 index 0000000000..ce56c64099 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-backend/2026-10-15-partial-revoke-several-roles-matches-no-rule-1501.json @@ -0,0 +1 @@ +{"area": "mdl/backend", "date": "2026-10-15", "symptom": "`revoke write (NewAttr) on entity M.E from M.RoleA, M.RoleB, M.RoleC` printed \"Unchanged entity access: M.E (M.RoleA, M.RoleB, M.RoleC) — nothing to revoke\", exited 0, and `show access` still listed NewAttr: ReadWrite in each rule; check and mxbuild clean (mendixlabs/mxcli#1501).", "cause": "The rules were granted one role each, so each stored a single-role ModuleRoles list. RevokeEntityMemberAccess only narrowed a rule whose role set EQUALLED the statement's ({A,B,C}); none did, 0 rules modified, reported as already revoked. RemoveEntityAccessRule (full revoke) had always matched per role, so only the partial form was affected.", "file": "mdl/backend/modelsdk/domainmodel_security_write.go (RevokeEntityMemberAccess: narrow every rule all of whose roles are named; refuse, before writing, a rule shared with an unnamed role)", "insight": "The reporter's hypothesis (write comes from the rule's default member access, no stored entry to narrow) was wrong, and one control settled it: the same revoke naming ONE role narrowed NewAttr on the same project, so the entries existed. When a report pins a multi-target statement, re-run it with a single target before reading the member logic — the variable that changed was the role list, not the member. Grant keys a rule on its exact role set (+ XPath) because that is identity for an upsert; revoke is a filter, and copying grant's key into it made a three-role statement match nothing. A rule shared with a role the statement does not name must be refused, not narrowed: narrowing it would silently revoke the unnamed role's right too. Measured on pedapp (11.13): old binary prints the Unchanged notice with ReadWrite x3; fixed binary narrows all three to ReadOnly, a rerun prints the honest Unchanged notice, and `mx check` reports the same 1002 pre-existing fixture errors as the unfixed copy, none on the entity.", "refs": ["mdl/backend/modelsdk/revoke_multi_role_1501_test.go", "mdl-examples/bug-tests/1501-revoke-write-from-several-roles.mdl", "mendixlabs/mxcli#1225"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-published-odata-key-missing-or-not-unique.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-published-odata-key-missing-or-not-unique.json new file mode 100644 index 0000000000..0836514fd5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-12-published-odata-key-missing-or-not-unique.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-12","symptom":"Published OData entity with no KEY, or with a single KEY on an attribute without a unique rule, passes `mxcli check` and `exec`; `mx check` reports CE6585 \"Published entity 'ModA.EntA' must have a key defined.\" / CE6624 \"Add a unique validation rule to attribute 'Name' of entity 'ModA.EntA' to be able to use it as the key.\"","cause":"No check-time rule existed. The KEY requirement was known (MDL-ODATA02's suggestion quotes CE6585, the quick reference names CE6624) but only as prose","file":"`mdl/executor/validate_odata_keys.go` (new `ValidateODataKeys`, MDL-ODATA08/09, wired in `validate_program.go` so exec's preflight refuses too); `validate_import_mapping_find.go` `entityFacts` gains `view` + `uniqueAttrs` (script first, project second)","insight":"**Measure the boundary before writing the predicate: the obvious rule ('every KEY attribute must be unique') is a false refusal.** mx 11.6.6, one service per case in one app: no KEY -> CE6585 for persistent, non-persistent AND view entities, also `expose (*)` and no expose clause; single KEY on a persistable entity without a unique VALIDATION RULE -> CE6624 on OData3 and OData4 alike, and `not null` or an index does not count; composite KEY with no unique rule -> clean (OData4; OData3 is CE7238, a different rule); single KEY on a non-persistent or view entity -> clean. **A CE1613 in the same app hid every key error**: an inherited key attribute is written qualified with the child entity (CE1613), and with it present mxbuild reported none of the CE6585s the other services had, so measure each case without unrelated errors in the app. The rule stays silent on inherited attributes rather than guess. Two committed bug-tests (595 `expose (*)`, 1357's keyless intermediate statement) were themselves CE6585 and had to be keyed. Guard `TestODataKeyRules_*` (revert control: 9 script cases + the project case fail); repro `mdl-examples/bug-tests/1426-odata-key-requires-unique-rule.fail.mdl`","refs":["mendixlabs/mxcli#1426","mendixlabs/mxcli#1423"],"ce":["CE6585","CE6624"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-entity-access-call-mismatch-ce0114.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-entity-access-call-mismatch-ce0114.json new file mode 100644 index 0000000000..521f0a1fb3 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-entity-access-call-mismatch-ce0114.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-13", "symptom": "Mendix 11.14.0, MPR v2: `@applyentityaccess(false)` on `create or modify microflow MyModule.DS_Tile_Count` whose body is `$List = call microflow MyModule.SUB_Scoped_List();` (an `@applyentityaccess(true)` microflow, in the project or created earlier in the script) passed `check -p --references` with \"no flow change exec would refuse … no used flow left without access\" and \"Check passed!\"; mxbuild then reported \"CE0114 A microflow that does not apply entity access can only call microflows that also do not apply entity access.\"", "cause": "No check modelled the rule: it is a consistency rule between two documents' ApplyEntityAccess, not a reference, so reference validation could not see it", "file": "`mdl/executor/validate_entity_access_calls.go` (CheckEntityAccessCalls, MDL-SEC22), wired into the project tier of `cmd/mxcli/cmd_check.go`", "insight": "Measure the direction and the call shapes before modelling — the message names only microflows, but a DECISION on a rule that applies entity access is the same CE0114 with \"rules\" (stored as Microflows$RuleCall.Microflow, the same key as Microflows$MicroflowCall). Measured on mxbuild 11.13.0 and 11.14.0, fresh projects: off->on call and off->rule-on decision -> CE0114; on->off, on->on, off->off, nanoflow->on, EXCLUDED off->on -> nothing; Off and Prototype security alike, so unlike CE0106 it is always an error. A decision is a rule call only if the name is a rule when exec builds the caller (IsRule at build time), so the simulation resolves rule-ness in statement order but microflow callees at the end of the script. An absent @applyentityaccess on create or modify carries the stored value (carriedApplyEntityAccess) — the simulation must use the same function or it false-flags every rewrite of a stored caller that applies entity access. Check-only prediction (exec does not refuse), so not language-version gated, the MDL-SEC21 precedent. Two-variant mxbuild 11.14.0 run on a fresh project with the callee stored: issue script -> check -p MDL-SEC22 exit 1, exec anyway -> docker check 1x CE0114; caller with @applyentityaccess(true) -> check passes, docker check 0 errors. Revert-proof: gate stubbed -> 6 positive tests fail with 'got 0'; callee-setting test removed -> the off->off control fails", "ce": ["CE0114"], "rules": ["MDL-SEC22"], "refs": ["mendixlabs/mxcli#1500"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-protected-module-hidden-refs-ce1406-ce5400.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-protected-module-hidden-refs-ce1406-ce5400.json new file mode 100644 index 0000000000..8f7a0effc8 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-13-protected-module-hidden-refs-ce1406-ce5400.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-13", "symptom": "in a Protected (add-on) module, a navigation home page / menu item / call microflow / parameter type pointing at a Hidden element, and an `export level api` microflow whose parameter or return type is a Hidden entity of that module, passed check -p --references and exec, then mxbuild refused with CE1406 \"... because project level documents|app modules cannot reference hidden elements in add-on modules\" and CE5400 \"The export level of entity 'M.E' should be 'Usable' because parameter 'p' exposes it\"; describe and show modules never said the module was Protected", "cause": "nothing read Projects$ModuleSettings.ExportLevel for validation, and entityFromGen dropped the entity's ExportLevel, so no check could know what is Hidden", "file": "`mdl/executor/validate_protected_module_refs.go` (`validateProtectedModuleRefs`, `moduleProtections`), `mdl/backend/modelsdk/domainmodel.go` (`entityFromGen`), `cmd_modules.go`/`cmd_entities_describe.go` (comments)", "insight": "Measure with mxbuild before trusting the issue's expectation: a PAGE in a Protected module is refused even with ExportLevel API stored on it (11.13.0 and 11.14.0, navigation and show-page alike), so `export level api` on pages would not have helped — an API microflow in the module that shows the page is the measured way in. CE1406 masks CE5400 (mxbuild reports the CE5400 only once the CE1406 is gone), so a one-shot mx check undercounts; measure each rule in its own copy. A module can be made Protected for measurement with UpdateModuleSettings (ExportLevel 'Protected') — no Studio Pro needed; the scratch helper set it and element ExportLevels by raw BSON", "refs": ["mendixlabs/mxcli#1498"], "ce": ["CE1406", "CE5400"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json new file mode 100644 index 0000000000..6b842e8e80 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-check-not-version-aware.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-15", "symptom": "`mxcli check script.mdl -p app.mpr` printed \"Check passed!\" for a script that `mxcli exec` on the same project then refused for the project's Mendix version, after running the statements before the refused one (on a 9.24 project: `create view entity` -> \"create view entity requires Mendix 10.18.0+\"; the issue's own StableId/CaseValues refusal was the write-time property floor, already cured by #1410)", "cause": "Every version gate exec applies lived inline in its handler as a checkFeature(ctx, area, name, …) call — about two dozen across 17 files, several conditional on the statement's content (send email in the body, a page with parameters, SupportsGraphQL, a timer event sub-process). check -p and exec's pre-flight never run a handler, so neither could see a gate: the project version was consulted only when the handler ran.", "file": "`mdl/executor/version_gates.go` (statementVersionGates: the one statement-to-feature table; checkVersionGates for the handlers; Executor.CheckVersionGates -> MDL-VERSION01 for `cmd/mxcli/cmd_check.go` and `cmd/mxcli/exec_preflight.go`); the handlers in cmd_*.go now call checkVersionGates(ctx, stmt)", "insight": "A refusal only a handler can raise is a check/exec gap by construction (duplicate-resolver-drift, check versus exec): move the decision into a pure function of the statement that the handler and the pre-flight both call, rather than giving check a second copy. TestCheckFeatureIsOnlyCalledFromTheGateTable fails on any new direct checkFeature call, so a future gate cannot bypass the table. Two kinds stay exec-only: gates that depend on stored state (drop/alter of a message definition document that is not there) and the write-time property floor (canon.StripUndeclaredProperties), which needs the encoded BSON and is cured by the writer emitting the version's shape (#1410), not by a check rule. Fixture for any Mendix 9 check: modelsdk/mpr/testdata/v1-project/App.mpr is 9.24.30.", "refs": ["mendixlabs/mxcli#1434", "mendixlabs/mxcli#1410"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-association-no-object-ce7007.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-association-no-object-ce7007.json new file mode 100644 index 0000000000..d73206c42c --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-association-no-object-ce7007.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-15","symptom":"`combobox x (Association: M.Asset_Customer, DataSource: database M.Customer, CaptionAttribute: CustomerName)` replacing a widget at the top of a snippet with `$Asset: M.Asset` passed check and exec; mx check 11.6.6: [CE7007] \"Selected value is not valid for entity 'Asset_Customer/Customer'. Please select the value again.\" Wrapping it in `dataview (DataSource: $Asset)` cleared it","cause":"The association mode writes `attributeAssociation` with no SourceVariable, which means \"the enclosing object\", and outside every data container there is none. The engine's Association mapping fell back from the (empty) outer entity to `pageBuilder.entityContext`, which the DataSource mapping had already moved to the OPTION entity, so nothing ever saw that the outer context was empty. validateInputBindingContext judged the combo box in the context its own option list creates (argContextForOwnAction), and bindingsWithoutScope skips any widget with a DataSource, so check missed it on both CREATE and ALTER","file":"`mdl/executor/combobox_association_object.go` (comboBoxAssociationNeedingObject, shared by all three callers), `widget_engine.go` (Association case, `inBuild`), `validate_widgets.go` (MDL-WIDGET46), `validate_alter_unscoped.go` (unscopedInsertion)","insight":"Mendix CAN express it, but the stored form differs: Studio Pro stores the binding with SourceVariable = the snippet or page parameter. MDL already writes that from `Attribute: $Asset.M.Asset_Customer`, which gives 0 errors on 11.6.6 and is what describe prints. So the bare spelling is refused and the message names the working one. mxcli does not pick a parameter for the author, because a snippet can have several of the same entity. A widget with its own data source is judged in the context it creates, and that is right for its attributes but wrong for the property that names something on the CONTAINING object. Look for every such property, i.e. every mapping that reads `outerEntityContext`. `Association: $Asset.…` is refused by MDL-WIDGET42 (it parses as an expression), so suggest the `Attribute:` spelling only","refs":["mendixlabs/mxcli#1420"],"ce":["CE7007"],"test":"mdl/executor/combobox_association_object_test.go TestExec1420_*, TestCheck1420_*; mdl-examples/bug-tests/1420-combobox-association-snippet-param{,.fail}.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-attribute-association-check-passes-exec-refuses.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-attribute-association-check-passes-exec-refuses.json new file mode 100644 index 0000000000..4d682aea3f --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-combobox-attribute-association-check-passes-exec-refuses.json @@ -0,0 +1 @@ +{"area":"mdl/executor","date":"2026-10-15","symptom":"`alter snippet M.S { replace with { combobox x (Attribute: $Asset.Asset_Customer, CaptionAttribute: …) } }` — check: \"Check passed!\"; exec: \"property attributeEnumeration binds Asset_Customer, which is an association … not an attribute\". The same combo box in CREATE SNIPPET, and `Attribute: Asset_Customer` inside a data view, passed check too","cause":"exec's #830 refusal (`rejectAssociationAsAttribute`) lives in the widget engine's Attribute mapping; no check pass ever called it. The pluggable attribute-scope walk (#647) skips any value containing `.`/`$` (`misboundAttributeError`), and ALTER's INSERT/REPLACE widgets were never scope-checked at all","file":"`mdl/executor/validate_assoc_as_attribute.go` (associationAsAttributeError, alterAssociationAsAttribute), `validate_widget_attribute_scope.go` (checkWidget), `validate_alter_unscoped.go`, `cmd_pages_builder_input.go` (associationNamedAsAttribute, rejectAssociationAsAttributeOn)","insight":"exec was the right side: with no `DataSource:` a combo box is in enumeration mode and `attributeEnumeration` cannot hold a reference. check now calls exec's function with exec's entity (the parameter's for `$P.Name`, the #647 binding scope otherwise; for ALTER the stored document's ParamScope + alterEntityContext), so the two cannot drift. The combo box gets its own remedy — the generic one pointed at a grid column/drop-down filter — naming the association mode that builds: `Attribute: $Asset.M.Asset_Customer, DataSource: …` (0 errors on mx 11.6.6; `Association: $Asset.…` is refused by MDL-WIDGET42, so do not suggest that spelling). A refusal that lives only in a builder is a check gap by construction: grep each `NewValidation` in widget_engine.go for a check-time caller","refs":["mendixlabs/mxcli#1440","#830"],"ce":["CE1613"],"test":"mdl/executor/validate_assoc_as_attribute_test.go TestCheck1440_*; mdl-examples/bug-tests/1440-combobox-attribute-association-check.mdl"} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-free-annotation-crlf-vs-lf-refused-splice.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-free-annotation-crlf-vs-lf-refused-splice.json new file mode 100644 index 0000000000..a67b254149 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-free-annotation-crlf-vs-lf-refused-splice.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-15", "symptom": "`create or modify microflow` under `mdl 1;` of a flow whose multi-line free annotation had its CRLF line breaks turned into LF (an editor saving describe's output) was refused: \"the free annotations change; the splice edits activities only\" \u2014 on every run, and the refusal did not say which note. Restoring the CR bytes made it splice (mendixlabs/mxcli#1505). A note on an activity re-spliced that activity for the same reason", "cause": "declaredMatches compared MicroflowAnnotation.Text byte for byte. Studio Pro stores a note's line breaks as CRLF and describe prints them raw, so the stored side has CR bytes a script saved by an LF editor lacks \u2014 invisible on the canvas, a difference to the matcher", "file": "`mdl/executor/flow_declared_match.go` (matchValue: note Text compared via lfLineBreaks), `mdl/executor/cmd_flow_modify.go` (freeNoteChange names the differing note in the refusal); tests `mdl/executor/flow_declared_match_test.go` (TestDeclaredMatches_NoteLineEndingsAreNotADifference, TestFreeNoteChange_NamesTheNote), `mdl/roundtrip/flow_modify_notes_test.go` (TestSpliceRerun_FreeNoteLineEndings, -tags integration); bug-test `mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl` (marked -text in .gitattributes, or git strips the CRs that ARE the case)", "insight": "**Describe output that holds raw CR bytes does not survive a text editor or git's eol=lf**, so any matcher comparing printed text against stored text must treat line-break spelling as layout, not content. Normalising only in the comparison is enough: a matched note is left as stored, so its CRLF bytes are kept and the run writes nothing. Plausible wrong turn: normalising describe to print LF \u2014 that makes describe's output differ from the stored unit and re-splices every Studio Pro note on first run. A refusal that quotes the differing value with strconv.Quote shows an invisible-character difference for what it is (`\\r\\n` vs `\\n`); the bare 'the free annotations change' left the reporter to bisect bytes by hand", "refs": ["mendixlabs/mxcli#1505"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-published-odata-inherited-member-ce1613.json b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-published-odata-inherited-member-ce1613.json new file mode 100644 index 0000000000..24d54f27e5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-executor/2026-10-15-published-odata-inherited-member-ce1613.json @@ -0,0 +1 @@ +{"area": "mdl/executor", "date": "2026-10-15", "symptom": "publishing a specialization and exposing a member its generalization declares passed check and exec, then mx check reported CE1613 \"The selected attribute 'M.Sub.Code' no longer exists\" — for an inherited attribute and for an inherited association alike", "cause": "lookupEntityMembers read only the published entity's own attributes and the associations it is directly a side of, so an inherited member fell through to the bare-name attribute fallback and publishedMemberToGen's qualifyMemberName prefixed the published entity", "file": "`mdl/executor/cmd_odata.go` (`lookupEntityMembers`, `addInheritedAttributes`, `addInheritedAssociations`, `astEntityDefToModel`)", "insight": "Mendix resolves a member by the entity that DECLARES it, not the one it is used through — the same rule as #1335's cross association. Any member lookup keyed on 'the entity itself' needs the generalization chain (generalizationChain already exists in validate_member_refs.go). The inherited attribute also lost its EdmType, which is CE5016 from 11.12 — a second error hiding behind the first. Proved on mx 11.6.6: 3 CE1613 before, 0 after; describe round-trips because it prints bareMemberName", "refs": []} diff --git a/.claude/skills/fix-issue/findings/modelsdk/2026-10-15-datagrid2-10x-augment-bare-key-and-exemplar-shift.json b/.claude/skills/fix-issue/findings/modelsdk/2026-10-15-datagrid2-10x-augment-bare-key-and-exemplar-shift.json new file mode 100644 index 0000000000..314332996f --- /dev/null +++ b/.claude/skills/fix-issue/findings/modelsdk/2026-10-15-datagrid2-10x-augment-bare-key-and-exemplar-shift.json @@ -0,0 +1 @@ +{"area": "modelsdk/widgets", "date": "2026-10-15", "symptom": "A Data grid 2 written by `create page` to a blank Mendix 10.24.0 project fails `mx check` with CE0463 \"The definition of this widget has changed\" until `mxcli fix widgets` runs; the same script on 11.x (and on 10.24.24) is clean. Studio Pro's own template grids in the project do not fail", "cause": "10.24.0 ships Data Widgets 2.30.3 and the embedded template is 3.4-shaped, so `AugmentTemplate` rebuilds the definition — and got three things wrong, each a real diff against `mx update-widgets` (the Object was identical): (1) `mpkPropDefsByKey` / `syncDefinitionAttrs` indexed PropertyDefs by BARE key across nesting levels, and 2.30.3 declares `filter` twice (column: widgets; filterList item: attribute), so the column's filter got the item's Type/Caption/AllowedTypes/DataSource/Required; (2) exemplar indices were taken BEFORE stale properties were removed, so the new `filterList` object list was cloned from whatever shifted into the Object exemplar's slot (an Integer) and had no ObjectType — same in the nested pass; (3) the mpk parser never read `` / `selectableObjects`, so `filterAssociation` had none and `filterAssociationOptionLabelAttr` kept its cloned exemplar's [Reference, ReferenceSet]. A fourth, masked by the grid failing first: template Translations kept where the package ships none (drop-down filter `emptyOptionCaption`)", "file": "`modelsdk/widgets/augment.go` (`scopedKey`/`walkPropertyTypes` used by all reconcile passes, `topLevelExemplars` after stale removal, nested re-index, AssociationTypes/SelectableObjectsProperty/Translations reconcile), `modelsdk/widgets/mpk/mpk.go` (`AssociationTypes`, `SelectableObjects`)", "insight": "Reproduce on the EXACT minor the reporter used: 10.24.24 ships a different Data Widgets and showed no grid CE0463 on either v0.26.0 or main, which nearly closed this as not-reproducible. Then the Type diff (flatten keyed by PropertyKey PATH, not index) against `mx update-widgets` on a copy bounded it to ~40 paths in three clusters; patching one cluster at a time showed none alone clears CE0463 — CE0463 compares the whole widget. A failing outer widget MASKS CE0463 in widgets nested in it (the drop-down filters surfaced only after the grids were fixed), so re-measure after every fix. Any augmentation map keyed by bare PropertyKey is suspect: keys repeat across object lists (`filter`, `caption`, `attribute`). Measured: doctype-tests/31 on 10.24.0 7 -> 0 CE0463, on 10.24.24 2 -> 0, on 11.6.6 0 -> 0; doctype-tests/32 on 11.6.6 3 -> 2 (Maps map1 cleared, map2 and Accordion acc2 remain). The sibling `sdk/widgets` copy was not changed: no production code imports it", "refs": ["mendixlabs/mxcli#1424", "#600", "#716", "#759"], "ce": ["CE0463"]} diff --git a/.claude/skills/fix-issue/findings/other/2026-10-15-postcss-selector-parser-quadratic-vega-widget.json b/.claude/skills/fix-issue/findings/other/2026-10-15-postcss-selector-parser-quadratic-vega-widget.json new file mode 100644 index 0000000000..e28a774abc --- /dev/null +++ b/.claude/skills/fix-issue/findings/other/2026-10-15-postcss-selector-parser-quadratic-vega-widget.json @@ -0,0 +1 @@ +{"area": "skill-packs/npm", "date": "2026-10-15", "symptom": "Dependabot alert ako/mxcli#21: \"postcss-selector-parser (npm) Affected versions < 7.1.6, Patched version 7.1.6\" (GHSA-rj75-hqrm-r3gf, quadratic parse of flat selectors such as .a.a.a...) in .claude/skills/packs/mendix-vega-charts/widget/package-lock.json", "cause": "The widget lock carried three vulnerable copies: 7.1.5 nested under postcss-modules-local-by-default and postcss-modules-scope, and 6.1.4 hoisted for cssnano-preset-default 5 (postcss-calc, postcss-merge-rules, postcss-minify-selectors, postcss-unique-selectors, stylehacks), all via @mendix/pluggable-widgets-tools 11.12.1 -> rollup-plugin-postcss 4.0.2. The cssnano 5 plugins declare ^6.x, and no 6.x release carries the fix.", "file": ".claude/skills/packs/mendix-vega-charts/widget/package.json (overrides: postcss-selector-parser ^7.1.6) + package-lock.json", "insight": "Two dead ends to skip. (1) `npm audit fix` cannot clear it and `--force` proposes @mendix/pluggable-widgets-tools 8.18.4, a three-major DOWNGRADE of the build toolchain; a dependabot PR cannot help either, because the 6.x copy has no patched version in its range. An npm `overrides` entry is the fix. (2) Forcing 7.x under cssnano 5's ^6 range looks risky, but 7.0.0's only breaking change is safe insertion during iteration. Measured rather than assumed: the widget built from the overridden lock is byte-identical to the baseline (all five dist files, cmp), and cssnano-preset-default over a corpus of merge, unique, calc, stylehacks, namespace and :is/:where selectors produced identical CSS under 6.1.4 and 7.1.6. The vulnerable shape is real in the old copy: a flat 20000-class selector took 468 ms on 6.1.4 and 54 ms on 7.1.6. Reachability is build-time only, on CSS from the pack itself, so this is hygiene, not an exploitable path. Neither govulncheck (Go only) nor bun audit on vscode-mdl sees this lock. To find which manifest a numbered alert means when the alerts API returns 403 to the session token, run `npm audit --package-lock-only` per lockfile; GitHub alerts cover every lockfile in the repo, not just the directories listed in .github/dependabot.yml.", "refs": ["https://github.com/ako/mxcli/security/dependabot/21", "https://github.com/advisories/GHSA-rj75-hqrm-r3gf"]} diff --git a/.claude/skills/mendix/alter-page/SKILL.md b/.claude/skills/mendix/alter-page/SKILL.md index e08426e560..3af0eaa3dc 100644 --- a/.claude/skills/mendix/alter-page/SKILL.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -13,14 +13,14 @@ description: "Modify an existing page or snippet's widget tree in place with ALT | 28-31 | Overview | | 32-56 | When to Use | | 57-107 | Syntax | -| 108-472 | Operations | -| 473-551 | Examples | -| 552-642 | DataGrid 2 columns: how to address them, and what you can set | -| 643-654 | Common Mistakes | -| 655-676 | Limitations — prefer binding at page creation (ledger finding #45) | -| 677-684 | Validation Checklist | -| 685-694 | Related Commands | -| 695-699 | Related Skills | +| 108-473 | Operations | +| 474-552 | Examples | +| 553-643 | DataGrid 2 columns: how to address them, and what you can set | +| 644-655 | Common Mistakes | +| 656-677 | Limitations — prefer binding at page creation (ledger finding #45) | +| 678-685 | Validation Checklist | +| 686-695 | Related Commands | +| 696-700 | Related Skills | # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets @@ -208,6 +208,7 @@ so a silent write would build cleanly and then fail to open. | `''` | Pluggable widgets — any **action-typed** property (File Uploader `createFileAction`, DataGrid 2 `onSelectionChange`, …) | Any `create page` action expression | `set ('createFileAction': call microflow M.ACT_Create) on fileUploader1` — refused, naming the widget's action slots, if the key is not action-typed | | `caption` | ACTIONBUTTON, LINKBUTTON | String | `set (caption: 'Submit') on btnSave` | | `content` | DYNAMICTEXT | String | `set (content: 'New Heading') on txtTitle` | +| `RenderMode` | DYNAMICTEXT | Text, Paragraph, H1–H6 (any case; another value is refused, naming the set) | `set (RenderMode: H2) on txtTitle` | | `label` | TEXTBOX, TEXTAREA, DATEPICKER, COMBOBOX, CHECKBOX, RADIOBUTTONS | String | `set (label: 'full Name') on txtName` | | `buttonstyle` | ACTIONBUTTON, LINKBUTTON | Primary, Default, Success, Danger, Warning, Info | `set (buttonstyle: danger) on btnDelete` | | `class` | Any widget | CSS class string | `set (class: 'card mx-2') on container1` | diff --git a/.claude/skills/mendix/check-syntax/SKILL.md b/.claude/skills/mendix/check-syntax/SKILL.md index 1654f97dd6..2668849e38 100644 --- a/.claude/skills/mendix/check-syntax/SKILL.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -13,13 +13,13 @@ description: "Validate MDL with `mxcli check` before presenting or executing it, | 29-35 | When to Use This Skill | | 36-286 | `exec` refuses what `check` rejects | | 287-422 | Pre-Flight Validation Checklist | -| 423-473 | Two rules that only real validation used to catch | -| 474-520 | Validation Workflow | -| 521-542 | Script Execution Behavior | -| 543-603 | Script Organization | -| 604-656 | Troubleshooting Parse Errors | -| 657-714 | Studio Pro MCP — Verification Only | -| 715-720 | Related Skills | +| 423-458 | Two rules that only real validation used to catch | +| 459-505 | Validation Workflow | +| 506-527 | Script Execution Behavior | +| 528-588 | Script Organization | +| 589-641 | Troubleshooting Parse Errors | +| 642-699 | Studio Pro MCP — Verification Only | +| 700-705 | Related Skills | # MDL Syntax Validation Skill @@ -62,7 +62,7 @@ pages, microflows and icons — against that project. It reaches inside stored documents where a name can only be answered there: an `ALTER PAGE … SET` is dry-run against the page it edits, so a widget the page does not have, or a property the stored widget does not declare, is reported here rather than -stopping the script partway through `exec`. What else it resolves, and what a pass still does not prove: [reference/references-resolved.md](reference/references-resolved.md). +stopping the script partway through `exec`. What else it resolves (including the project version's gates, MDL-VERSION01), and what a pass still does not prove: [reference/references-resolved.md](reference/references-resolved.md). ```bash mxcli check script.mdl # syntax + model-free rules @@ -443,32 +443,17 @@ are in. which is the usual shape — it matches the segment's leading name, not the whole segment. -### A used flow left without access — `MDL-SEC21` (CE0106) +### Cross-document flow rules — `MDL-SEC21` (CE0106), `MDL-SEC22` (CE0114) -With a project (`check -p`), `check` simulates the script's creates, drops, -grants and revokes and reports a microflow or nanoflow the script leaves with -**no allowed role** while something that needs one names it. MxBuild's error: - -> CE0106 "At least one allowed role must be selected if the microflow is used -> from navigation, a page, a nanoflow or a published service." - -Measured on Mendix 11.14: a page button or data source, a snippet, a navigation -or menu-document item, or a nanoflow call needs a role (even from an unused -snippet, menu document or nanoflow). A **published REST operation does not**, -nor a microflow called only from another microflow, nor an excluded page. It is -an **error at security level Prototype or Production** and a warning at Off, -where MxBuild does not check it. - -The usual cause is **drop + create in separate runs**: a create in a later run -is a *new* flow, and a new flow in a module that has its own module roles gets no -access. Within one run, and with `create or modify`, the stored roles are kept. -Fix: `grant execute on microflow M.Flow to M.Role;` in the same script, or rebuild -with `create or modify microflow` instead of dropping. Only what the script -*changes* is reported — a project that already has CE0106 does not fail an -unrelated script; `mxcli docker check` shows those. +With a project, `check` also reports a microflow or nanoflow the script leaves +with **no allowed role** while a page, snippet, nanoflow, menu or navigation uses +it (MDL-SEC21 — usually drop + create in separate runs), and a microflow that does +**not** apply entity access calling a microflow or rule that **does** (MDL-SEC22; +the reverse direction is fine). What triggers each, as measured, and the fixes: +[reference/cross-document-flow-rules.md](reference/cross-document-flow-rules.md). **`check` is still necessary, not sufficient.** Run `mx check` (or -`mxcli docker check`) after every `exec`; these two rules narrow the gap, they do +`mxcli docker check`) after every `exec`; these rules narrow the gap, they do not close it. ## Validation Workflow diff --git a/.claude/skills/mendix/check-syntax/reference/cross-document-flow-rules.md b/.claude/skills/mendix/check-syntax/reference/cross-document-flow-rules.md new file mode 100644 index 0000000000..4fa3247d69 --- /dev/null +++ b/.claude/skills/mendix/check-syntax/reference/cross-document-flow-rules.md @@ -0,0 +1,45 @@ +# Cross-document flow rules `check -p` predicts + +Two rules between documents that only a project can answer: whether a used flow +has an allowed role, and whether a call crosses "apply entity access". Both run +with `check -p` and simulate the script against what the project stores. + +## A used flow left without access — `MDL-SEC21` (CE0106) + +With a project (`check -p`), `check` simulates the script's creates, drops, +grants and revokes and reports a microflow or nanoflow the script leaves with +**no allowed role** while something that needs one names it. MxBuild's error: + +> CE0106 "At least one allowed role must be selected if the microflow is used +> from navigation, a page, a nanoflow or a published service." + +Measured on Mendix 11.14: a page button or data source, a snippet, a navigation +or menu-document item, or a nanoflow call needs a role (even from an unused +snippet, menu document or nanoflow). A **published REST operation does not**, +nor a microflow called only from another microflow, nor an excluded page. It is +an **error at security level Prototype or Production** and a warning at Off, +where MxBuild does not check it. + +The usual cause is **drop + create in separate runs**: a create in a later run +is a *new* flow, and a new flow in a module that has its own module roles gets no +access. Within one run, and with `create or modify`, the stored roles are kept. +Fix: `grant execute on microflow M.Flow to M.Role;` in the same script, or rebuild +with `create or modify microflow` instead of dropping. Only what the script +*changes* is reported — a project that already has CE0106 does not fail an +unrelated script; `mxcli docker check` shows those. + +## A call across entity access — `MDL-SEC22` (CE0114) + +With a project (`check -p`), a microflow that does **not** apply entity access +calling a microflow (or deciding on a rule) that **does** is an error. MxBuild: + +> CE0114 "A microflow that does not apply entity access can only call +> microflows that also do not apply entity access." (or "… rules …") + +Only that direction fails. Measured on Mendix 11.13 and 11.14: on -> off, on -> on +and off -> off build clean, as do a nanoflow caller (it has no such setting) and an +excluded caller, at every security level. The callee may be in the project or +created earlier in the script; an absent `@applyentityaccess` on `create or modify` +keeps the stored setting. A callee neither holds is not judged. Fix: put +`@applyentityaccess` on the caller, or call a flow that does not apply it — turning +it off on a shared callee widens what it may read and write for every caller. diff --git a/.claude/skills/mendix/check-syntax/reference/references-resolved.md b/.claude/skills/mendix/check-syntax/reference/references-resolved.md index af8170dca7..e454edd395 100644 --- a/.claude/skills/mendix/check-syntax/reference/references-resolved.md +++ b/.claude/skills/mendix/check-syntax/reference/references-resolved.md @@ -12,6 +12,22 @@ Beyond the page, microflow, entity and icon names a statement names directly, - `Module.Enum.Key` in an expression; - a view entity's FROM / JOIN entities. +## The project's Mendix version + +`check -p` applies the version gates `exec` refuses with — the same +statement-to-feature table, the floors in `sdk/versions/*.yaml` (`show features`) +— as **MDL-VERSION01** (mendixlabs/mxcli#1434): + +``` +statement 5: create view entity requires Mendix 10.18.0+ (project is 9.24.30.51827) [MDL-VERSION01] +``` + +`exec` reports the same line in its pre-flight and writes nothing. Without a +project there is no version, so nothing is reported. Still exec-only: a gate that +depends on what the project stores (a `drop`/`alter message definition` of a +document that is not there), and the write-time property floor (`this Mendix X +project cannot store …`), which needs the encoded document. + ## Not yet resolved A passing check does not prove these; `mxcli docker check` is still the gate: diff --git a/.claude/skills/mendix/manage-navigation/SKILL.md b/.claude/skills/mendix/manage-navigation/SKILL.md index 5e2df9e0d2..dc1794cb0d 100644 --- a/.claude/skills/mendix/manage-navigation/SKILL.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -18,10 +18,10 @@ description: "Inspect and change navigation profiles in MDL — home pages, menu | 381-402 | Catalog Queries | | 403-452 | Common Patterns | | 453-504 | Menu Documents (standalone, reusable) | -| 505-555 | Offline Profiles | -| 556-569 | Checklist | -| 570-573 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | -| 574-576 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | +| 505-575 | Offline Profiles | +| 576-590 | Checklist | +| 591-594 | Offline synchronization (`CREATE NAVIGATION … SYNC (…)`) | +| 595-597 | Menu documents (CREATE OR MODIFY/DESCRIBE/DROP MENU) | # Navigation Management Skill diff --git a/.claude/skills/mendix/manage-security/SKILL.md b/.claude/skills/mendix/manage-security/SKILL.md index 5edc79ce6d..ccc9d690ba 100644 --- a/.claude/skills/mendix/manage-security/SKILL.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -13,11 +13,11 @@ description: "Configure Mendix security in MDL — module and user roles, access | 27-35 | When to Use This Skill | | 36-43 | Security Concepts | | 44-120 | The System-module ceiling — decide the role model around this FIRST | -| 121-546 | Syntax Reference | -| 547-561 | Starlark Lint Rule APIs | -| 562-614 | Common Workflow: Setting Up Module Security | -| 615-624 | Common Mistakes | -| 625-634 | Validation | +| 121-552 | Syntax Reference | +| 553-567 | Starlark Lint Rule APIs | +| 568-620 | Common Workflow: Setting Up Module Security | +| 621-630 | Common Mistakes | +| 631-640 | Validation | # Security Management Skill @@ -331,6 +331,12 @@ revoke write (Email) on entity MyModule.Customer from MyModule.User; revoke delete on entity MyModule.Customer from MyModule.User; ``` +A partial revoke naming several roles narrows **every rule all of whose roles it +names** — one rule per role granted separately, or one rule granted to them +together. A rule that also grants a role the revoke does **not** name is refused +(narrowing it would revoke that role's right too): revoke from all of its roles, or +grant the roles in separate rules first. + #### Members added later A rule also carries a default for members added **after** it was written, and MDL diff --git a/.claude/skills/mendix/odata-data-sharing/SKILL.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md index bca05e1182..a814e88f36 100644 --- a/.claude/skills/mendix/odata-data-sharing/SKILL.md +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -19,8 +19,8 @@ description: "Share data between Mendix apps over OData — published services, | 192-224 | API Versioning | | 225-270 | Folder Organization | | 271-282 | Module Organization Conventions | -| 283-303 | Checklist | -| 304-331 | Exploration Commands | +| 283-304 | Checklist | +| 305-332 | Exploration Commands | # OData Data Sharing Between Mendix Apps @@ -284,7 +284,8 @@ This keeps the API contract separate from the domain logic, and the consumer sep Before publishing: - [ ] View entities expose only the fields consumers need (no internal IDs unless needed for writes) -- [ ] View entity has at least one `key` field for OData identity +- [ ] Every published entity has at least one `KEY` member — none is CE6585 (`check`: MDL-ODATA08) +- [ ] A single `KEY` on a **persistent** entity is an attribute declared `unique` — a unique validation rule; `not null` or an index does not count, on OData3 or OData4 (CE6624, MDL-ODATA09). A composite key (OData4 only) and a key on a view or non-persistent entity need no rule - [ ] Module role created and granted on view entities (READ, optionally WRITE) - [ ] OData service has AUTHENTICATION set (Basic, Session, or Microflow) - [ ] GRANT ACCESS ON PUBLISHED ODATA SERVICE to the API module role diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 4abf4a06b3..626ca0859d 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -19,20 +19,20 @@ description: "The warm Docker-free dev loop, `mxcli run --local` — model chang | 214-238 | Editing themes (SCSS) — rebuild, never clear caches | | 239-265 | `--watch` waits for a write to finish before it builds | | 266-294 | "My edit didn't show up" — stale process, not stale cache | -| 295-322 | Flags | -| 323-376 | Metrics and OpenTelemetry | -| 377-434 | Pages render in the browser | -| 435-452 | Pixel-perfect page loop | -| 453-473 | Debugging a server-side error | -| 474-494 | "Sign in failed" that is not about the password | -| 495-527 | The same licence also stops the app after a few hours | -| 528-607 | External browser preview (`--hub`) | -| 608-616 | Validation checklist | -| 617-656 | Constant values come from a configuration | -| 657-660 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | -| 661-664 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | -| 665-668 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | -| 669-671 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | +| 295-323 | Flags | +| 324-377 | Metrics and OpenTelemetry | +| 378-435 | Pages render in the browser | +| 436-453 | Pixel-perfect page loop | +| 454-474 | Debugging a server-side error | +| 475-495 | "Sign in failed" that is not about the password | +| 496-528 | The same licence also stops the app after a few hours | +| 529-608 | External browser preview (`--hub`) | +| 609-617 | Validation checklist | +| 618-657 | Constant values come from a configuration | +| 658-661 | Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`) | +| 662-665 | External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`) | +| 666-669 | Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub) | +| 670-672 | Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`) | # Warm Local Dev Loop — `mxcli run --local` @@ -304,6 +304,7 @@ run's process session), and `run status` reports them. Do **not** | `--web-client-timeout` | `$MXCLI_WEB_CLIENT_TIMEOUT`, else `5m` | Limit for one web client bundle build; on timeout the tail of `deployment/log/web-client-build.log` is printed | | `--ensure-db` | off | Provision local Postgres + app database if missing | | `--app-port` / `--admin-port` / `--serve-port` | 8080 / 8090 / 6543 | Ports | +| `--listen-addr` | `127.0.0.1` | IP the app binds to. `0.0.0.0` (or `::`) makes it reachable from other machines; the network URLs are printed once it runs. If the app builds absolute URLs (SSO redirects, email links), pair it with `--runtime-setting ApplicationRootUrl=http://:/` (or a real host in the configuration): the runtime does not derive one from the bind address — a warning says so. The M2EE admin API always stays on loopback | | `--db-host` / `--db-name` / `--db-user` / `--db-password` | 127.0.0.1:5432 / derived / mendix / mendix | Database; bracket IPv6 endpoints (`[::1]:5432`) | | `--db-type` | `postgresql` | `hsqldb` runs on the runtime's built-in file database: no database server, no `--ensure-db`, works offline. Data lives in `/deployment/data/database/hsqldb/`. Refused together with `--db-host`/`--db-user`/`--db-password`/`--ensure-db`. Local development only | | `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) | diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md index e45c218d5f..8a4807329b 100644 --- a/.claude/skills/mendix/write-microflows/reference/pitfalls.md +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -613,6 +613,11 @@ It is a **security** setting and it only ever narrows, so the rules mirror perfectly happy. Measured across 342 microflows in 4 projects: every microflow storing the flag came back without it, and nothing anywhere reported it. - **Turning it off is explicit**: `@applyentityaccess(false)`. +- **A flow without it cannot call a flow with it.** A microflow that does not apply + entity access may only call microflows — and decide on rules — that do not apply + it either: mxbuild CE0114, `check -p` MDL-SEC22. The reverse (on calling off) is + fine. Fix the caller, not the callee: turning it off on a shared sub-flow widens + it for everyone. - **Not available on a nanoflow.** A nanoflow runs in the client and Mendix stores no such property. Writing it there is **MDL059**, not a silent no-op — the same rule that catches `@applyentityacces` and any other annotation the @@ -637,7 +642,16 @@ end; parameters (...)` names the parameters passed as query arguments; `drop url` removes both. - **`export level api | hidden`** decides whether the microflow is part of the - module's public surface when the module is exported. + module's public surface when the module is exported. It bites in a + **Protected (add-on) module** — `describe module` prints + `-- export level: Protected …` and `show modules` says `Protected AddOn`: + another module or the navigation referencing a Hidden microflow, entity or + any page there is **CE1406**, and an `api` microflow whose parameter or return + type is a Hidden entity of that module is **CE5400**. `check -p --references` + reports both (mendixlabs/mxcli#1498). A page there cannot be made usable + (mxbuild refuses it even when stored as API) — reach it through an `api` + microflow in the module that shows it. An entity's export level has no MDL + syntax (`describe entity` prints it as a comment); set it in Studio Pro. - **`disallow concurrent execution`** takes `error message 'text'` or `error microflow Mod.Name`; `allow concurrent execution` is the default. diff --git a/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json b/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json index 6a81d223a8..d81b0dae80 100644 --- a/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json +++ b/.claude/skills/packs/mendix-vega-charts/widget/package-lock.json @@ -2586,13 +2586,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2621,29 +2614,6 @@ "node": ">= 4" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7773,14 +7743,11 @@ "license": "MIT" }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", @@ -8675,9 +8642,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "dev": true, "license": "MIT" }, @@ -10944,9 +10911,9 @@ "peer": true }, "node_modules/flow-estree": { - "version": "0.328.0", - "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.328.0.tgz", - "integrity": "sha512-uMB3dC4nfZYn+dd7/PYkyAK1mqBR4TJ4TWRdOjubpb/ObrLvPFdFSmMTq55fqttCCTygQ4NtD1Zdd+wcjC5t0g==", + "version": "0.331.0", + "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.331.0.tgz", + "integrity": "sha512-FVLYkSL/ITb/QXBEQvNWPjPooRGswuUtGOwrH+puSlMDneNkxy640+FZsk/TfKL+b7Wbw/Fg9FTUv2bMQDpj2w==", "dev": true, "license": "MIT", "engines": { @@ -10954,13 +10921,13 @@ } }, "node_modules/flow-parser": { - "version": "0.328.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.328.0.tgz", - "integrity": "sha512-F+Ik2Of7ndl2FyPmZ77NrhbZp9lYjIsreDXeq4fo0sjxa3SIFv48QAszBqvPeMMzmw7JzqySJUxGR8CSD7EdXw==", + "version": "0.331.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.331.0.tgz", + "integrity": "sha512-vEZcHIlnKeN8JSVtYFczj8sWJfHIJOkz+YG00O/oul/Bete+t1ZhTi+O6JRRLFYvGW+yJkZ0YgL16iStnbkUqQ==", "dev": true, "license": "MIT", "dependencies": { - "flow-estree": "0.328.0" + "flow-estree": "0.331.0" }, "engines": { "node": ">=0.4.0" @@ -11655,23 +11622,6 @@ "node": ">= 4" } }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, "node_modules/immutable": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", @@ -15844,14 +15794,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -16484,9 +16443,9 @@ } }, "node_modules/metro": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.87.0.tgz", - "integrity": "sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.87.1.tgz", + "integrity": "sha512-1oyLU9elM7hPAsKIqKooEekwxiuWr27+MZBhUROPhzcbXhMDxK64GPF/hCXwBZVp1F89ODavCrBmJtLx9WtBoQ==", "dev": true, "license": "MIT", "peer": true, @@ -16499,30 +16458,28 @@ "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", - "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", + "flow-parser": "0.331.0", "graceful-fs": "^4.2.4", - "hermes-parser": "0.36.1", - "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.87.0", - "metro-cache": "0.87.0", - "metro-cache-key": "0.87.0", - "metro-config": "0.87.0", - "metro-core": "0.87.0", - "metro-file-map": "0.87.0", - "metro-resolver": "0.87.0", - "metro-runtime": "0.87.0", - "metro-source-map": "0.87.0", - "metro-symbolicate": "0.87.0", - "metro-transform-plugins": "0.87.0", - "metro-transform-worker": "0.87.0", + "metro-babel-transformer": "0.87.1", + "metro-cache": "0.87.1", + "metro-cache-key": "0.87.1", + "metro-config": "0.87.1", + "metro-core": "0.87.1", + "metro-file-map": "0.87.1", + "metro-resolver": "0.87.1", + "metro-runtime": "0.87.1", + "metro-source-map": "0.87.1", + "metro-symbolicate": "0.87.1", + "metro-transform-plugins": "0.87.1", + "metro-transform-worker": "0.87.1", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", @@ -16539,46 +16496,27 @@ } }, "node_modules/metro-babel-transformer": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.87.0.tgz", - "integrity": "sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.87.1.tgz", + "integrity": "sha512-orvRIGpb0yK1yMbk5YP3dhVKjmoxUkRB9fdltGJxNppEqus/1tFpNuf9KnGFR3Qjg0izoUrJ4z6WgoBvdkXPrA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.36.1", - "metro-cache-key": "0.87.0", + "flow-parser": "0.331.0", + "metro-cache-key": "0.87.1", "nullthrows": "^1.1.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } }, - "node_modules/metro-babel-transformer/node_modules/hermes-estree": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", - "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/metro-babel-transformer/node_modules/hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", - "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-estree": "0.36.1" - } - }, "node_modules/metro-cache": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.87.0.tgz", - "integrity": "sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.87.1.tgz", + "integrity": "sha512-juNPaj0Xi5tkLp8imXqvbSnEIijwKG36m7OiFFVJMqEkiURwAjkMFVek3/xpix1+LVQqO7kjVp3PvuvIM61mKQ==", "dev": true, "license": "MIT", "peer": true, @@ -16586,16 +16524,16 @@ "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.87.0" + "metro-core": "0.87.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } }, "node_modules/metro-cache-key": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.87.0.tgz", - "integrity": "sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.87.1.tgz", + "integrity": "sha512-scqVVPMA2c+RVO12I3wyMDp5xCkRPLsyrcG4Qc3xfU5JR0EW/0sz2tjty+5UjMUtG09uiM1KYQ8HjWg7OQXHtw==", "dev": true, "license": "MIT", "peer": true, @@ -16607,9 +16545,9 @@ } }, "node_modules/metro-config": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.87.0.tgz", - "integrity": "sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.87.1.tgz", + "integrity": "sha512-NmkDlc/qZAdo5gTkVTuO2AKE/khemRKRH5IA+SKfqUBJigI5GqZ5TOXhmEokhlkAqzjt8dyzKhzI6FHvXoXaTQ==", "dev": true, "license": "MIT", "peer": true, @@ -16617,10 +16555,10 @@ "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.87.0", - "metro-cache": "0.87.0", - "metro-core": "0.87.0", - "metro-runtime": "0.87.0" + "metro": "0.87.1", + "metro-cache": "0.87.1", + "metro-core": "0.87.1", + "metro-runtime": "0.87.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" @@ -16739,25 +16677,25 @@ "peer": true }, "node_modules/metro-core": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.87.0.tgz", - "integrity": "sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.87.1.tgz", + "integrity": "sha512-xizzky/4+c/Mkznege8OW05j7bSdM39VPoEps61Se4+hf32UQujmbvLvIE9+UnP6kNEPVomk0Fw8NsBK9iRQmw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.87.0" + "metro-resolver": "0.87.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } }, "node_modules/metro-file-map": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.87.0.tgz", - "integrity": "sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.87.1.tgz", + "integrity": "sha512-9sPuNCojC3pS4vj+ZPY7FXmHGpdJeckXuKXQrDAfJvZc60AF8owRFTkI8XdwlwrmPIB2w++KraK9FMEXvC4wJw==", "dev": true, "license": "MIT", "peer": true, @@ -16769,8 +16707,7 @@ "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" + "nullthrows": "^1.1.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" @@ -16811,9 +16748,9 @@ } }, "node_modules/metro-minify-terser": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.87.0.tgz", - "integrity": "sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.87.1.tgz", + "integrity": "sha512-YK4k1oO1wV48hfE+Nbw2rJCnAgeabzrgT/xH5ha5n9gbK4p3HYFC898cERUiyTl9vRh58CNTiXRc88f7y6PfKg==", "dev": true, "license": "MIT", "peer": true, @@ -16826,9 +16763,9 @@ } }, "node_modules/metro-resolver": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.87.0.tgz", - "integrity": "sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.87.1.tgz", + "integrity": "sha512-FXH/brY69wT6sPxaSW8BhMmDGD/6irmBr/cL4rUi+VWwqR3pbvdcfUOfyjcHucgQ7pe5IW3xJY21eHKLVKNumQ==", "dev": true, "license": "MIT", "peer": true, @@ -16840,9 +16777,9 @@ } }, "node_modules/metro-runtime": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.87.0.tgz", - "integrity": "sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.87.1.tgz", + "integrity": "sha512-kjeuSvInsM6OzBjkCQsWxnkrKZFMKtnkCl38oXWRI9lDGs2L9hGs9Tak9sQYJ9h3gtDujneB7wZio+irgqJKDw==", "dev": true, "license": "MIT", "peer": true, @@ -16855,9 +16792,9 @@ } }, "node_modules/metro-source-map": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.87.0.tgz", - "integrity": "sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.87.1.tgz", + "integrity": "sha512-Bqpm0PBGdy53pigIJb4HRF7QxD2pUOdgSmVh/4AWbP635kSCv+Y6ltVxUZgSnzS/QbSmzyCGijmRPlXMj2/qHw==", "dev": true, "license": "MIT", "peer": true, @@ -16866,9 +16803,9 @@ "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.87.0", + "metro-symbolicate": "0.87.1", "nullthrows": "^1.1.1", - "ob1": "0.87.0", + "ob1": "0.87.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, @@ -16877,16 +16814,16 @@ } }, "node_modules/metro-symbolicate": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.87.0.tgz", - "integrity": "sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.87.1.tgz", + "integrity": "sha512-rcGvwabrg0lbXGRGRKl14PY3My3+dkno2jZYhdJEuH2DmjTtTkV5bYwgto4JtV/Lbe+sXE6GO1UGg654430uow==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.87.0", + "metro-source-map": "0.87.1", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" @@ -16899,9 +16836,9 @@ } }, "node_modules/metro-transform-plugins": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.87.0.tgz", - "integrity": "sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.87.1.tgz", + "integrity": "sha512-qMiJ/x+VfqumFJJSefKS6nj8uojzC29b2/aK8vlMuFABIqGjDucICEOXd2Yt6k/XMFAgAcaJvShUOT0HLyYaPw==", "dev": true, "license": "MIT", "peer": true, @@ -16918,9 +16855,9 @@ } }, "node_modules/metro-transform-worker": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.87.0.tgz", - "integrity": "sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.87.1.tgz", + "integrity": "sha512-VOzs3OV405FuOLYdAhqA2wsn1F9lvueDVxMDLvCKaQ44U+yknIqphJ9eMjjsGwLDZiJq9sW3DMLhjD27JxQgqQ==", "dev": true, "license": "MIT", "peer": true, @@ -16930,46 +16867,19 @@ "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", - "metro": "0.87.0", - "metro-babel-transformer": "0.87.0", - "metro-cache": "0.87.0", - "metro-cache-key": "0.87.0", - "metro-minify-terser": "0.87.0", - "metro-source-map": "0.87.0", - "metro-transform-plugins": "0.87.0", + "metro": "0.87.1", + "metro-babel-transformer": "0.87.1", + "metro-cache": "0.87.1", + "metro-cache-key": "0.87.1", + "metro-minify-terser": "0.87.1", + "metro-source-map": "0.87.1", + "metro-transform-plugins": "0.87.1", "nullthrows": "^1.1.1" }, "engines": { "node": "^22.13.0 || ^24.3.0 || >= 26.0.0" } }, - "node_modules/metro/node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/metro/node_modules/hermes-estree": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", - "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/metro/node_modules/hermes-parser": { - "version": "0.36.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", - "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "hermes-estree": "0.36.1" - } - }, "node_modules/metro/node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -17380,9 +17290,9 @@ "license": "MIT" }, "node_modules/ob1": { - "version": "0.87.0", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.87.0.tgz", - "integrity": "sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA==", + "version": "0.87.1", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.87.1.tgz", + "integrity": "sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==", "dev": true, "license": "MIT", "peer": true, @@ -18232,20 +18142,6 @@ "postcss": "^8.1.0" } }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-scope": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", @@ -18262,20 +18158,6 @@ "postcss": "^8.1.0" } }, - "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", - "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-values": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", @@ -18486,9 +18368,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -18780,17 +18662,6 @@ ], "license": "MIT" }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "~2.0.3" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -20494,13 +20365,6 @@ "spdx-ranges": "^2.0.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", diff --git a/.claude/skills/packs/mendix-vega-charts/widget/package.json b/.claude/skills/packs/mendix-vega-charts/widget/package.json index a8e4d98bc9..66c3d5472e 100644 --- a/.claude/skills/packs/mendix-vega-charts/widget/package.json +++ b/.claude/skills/packs/mendix-vega-charts/widget/package.json @@ -24,5 +24,11 @@ "devDependencies": { "@mendix/pluggable-widgets-tools": "11.12.1", "@types/big.js": "^6.2.2" + }, + "overrides": { + "postcss-selector-parser": "^7.1.6", + "@istanbuljs/load-nyc-config": { + "js-yaml": "^4.1.0" + } } } diff --git a/.gitattributes b/.gitattributes index cbc73e89a7..63c29cd081 100644 --- a/.gitattributes +++ b/.gitattributes @@ -79,3 +79,6 @@ docs-wiki/SYNC_LOG.md merge=union # syncs collide on one line. The prose that used to surround it stays in # maintain-wiki.md, where union would be a hazard rather than a help. .claude/skills/maintain-wiki/pages.md merge=union + +# Holds raw CR bytes on purpose: the case is a CRLF note vs an LF one (#1505). +mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl -text diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f7011b93..0798c1db7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Headline: **`exec` is all or nothing: when a statement fails at write time, the ### Added +- **`mxcli run --local --listen-addr` — reach a local app from another machine** (mendixlabs/mxcli#1248) — the app always bound `127.0.0.1`, so a phone or a second laptop on the LAN could not open it. `--listen-addr 0.0.0.0` (or `::`) binds every interface and prints the URLs the app is reachable at; the default stays loopback. The M2EE admin API is unaffected and stays on `127.0.0.1` whatever the flag says — before, the one option set both binds, so widening the app would have put the admin API on the network too. mxcli itself still dials the app on loopback under a wildcard (health check, screenshots, "App is running at"), and brackets an IPv6 address. The value must be an IP address and is refused before the build otherwise. The runtime does not derive an application root URL from the bind address (measured on 11.12.1: unset stays `null` under `0.0.0.0`), so a wildcard bind with none warns at boot and names the `--runtime-setting ApplicationRootUrl=…` to pass. - **`mxcli search --fail-on-match` turns the search into a gate** — a pipeline that must refuse a change when a forbidden word turns up in the project's text had no exit status to read: `search` exited 0 whether it found something or not. With the flag it exits 1 when there is at least one hit and 0 when there is none, printing the results as before (a JSON array under `--json`). A search that cannot run, such as an unbalanced `"` or an unknown project, exits 2 so that 1 always means a match; the `SEARCH` statement still answers an unrunnable query with "No matches found.". Without the flag a hit exits 0 as before. Rules: docs-site "Full-Text Search". - **`filemanager` — the classic file manager (`Forms$FileManager`) in MDL** (mendixlabs/mxcli#151) — `filemanager fileManager1 (Label: 'File', Type: Upload, MaxFileSize: 10, AllowedExtensions: '.xlsx', ShowFileInBrowser: false)` inside a data view on a `System.FileDocument` specialization. Before, there was no way to author one: `pluggablewidget 'Forms$FileManager'` had no definition, the bare keyword was refused as MDL-WIDGET25, and `describe page` printed a stored one as a NOT re-executable comment, so describe → exec dropped it. Omitted properties take Studio Pro's defaults (`Both`, 5 MB, any file, false); the written BSON has the property set of a Studio Pro-authored file manager. `alter page … set` changes all four properties. A file manager in no data container is MDL-WIDGET45 at `check`, and one in a data view on an entity that is not a FileDocument is refused by `exec` — both were mxbuild's CE6810 after a clean `exec`. - **A module's domain model as SVG, in the companion and on the command line** — `mxcli describe --format svg domainmodel ` writes a finished SVG: entities at the positions the model stores, coloured by kind, with attributes, associations, multiplicities and generalizations. Boxes are sized to fit every name, pulling the stored layout apart just enough instead of clipping. In the Claude Code companion, selecting a module's Domain Model node in the desktop app or VS Code shows it at actual size, with pan, Fit and a full-width view; the terminal lists the entities as before. @@ -43,6 +44,7 @@ Headline: **`exec` is all or nothing: when a statement fails at write time, the ### Fixed +- **`check -p` reports a microflow without entity access calling one with it — MDL-SEC22 (CE0114)** (mendixlabs/mxcli#1500) — `@applyentityaccess(false)` on a microflow that calls an `@applyentityaccess(true)` one passed `check -p --references` ("Check passed!") and mxbuild refused it: "A microflow that does not apply entity access can only call microflows that also do not apply entity access." It is now an error at check time, naming both settings and where each came from (the project, the script, or a new flow's default). Measured on Mendix 11.13 and 11.14: a decision on a rule that applies entity access is the same CE ("… rules …"); the reverse direction, on -> on, off -> off, a nanoflow caller and an excluded caller all build, at every security level, and are not reported. The callee may be in the project or created earlier in the script; one neither holds is not judged, and a project that already has the defect does not fail an unrelated script. - **`mxcli search` and `SEARCH` find words that appear only in an element's documentation** (mendixlabs/mxcli#1477) — the strings index held captions, messages, URLs and the documentation of microflows and workflows, but not the documentation of entities, attributes, associations, enumerations, constants, pages and the other documents whose catalog tables carry it, so a word present only there answered "No matches found." (measured: a word in a constant's documentation was found by `select … from catalog.constants where Description like …` and not by `search`). The index now also holds those: each hit names the element (`ObjectType` `ENTITY`, `ATTRIBUTE`, `CONSTANT`, `PAGE`, …; an attribute is `Module.Entity.Attribute`) and the field (`StringContext` `documentation`), the labels microflow documentation already used, plus the parameters of microflows and Java actions and a module's domain model documentation (`domain_model_documentation`). A module's own rows carry the module's name in `ModuleName`, not the empty string the `objects` view holds for a module. They are read from the tables the catalog builder has just filled, not from a second walk of the model; building a catalog of a stock 11.12 app takes the same time (0.5s) and the strings table grows by about 50 rows. Enumeration values are not indexed (Mendix has no documentation field on them), nor is the summary text of a remote service's contract entities. The catalog schema version is bumped to 25, so a cached catalog is rebuilt once. Rules: docs-site "Full-Text Search". - **`search` finds a microflow's log message** (mendixlabs/mxcli#1477) — only the log node name was indexed. A log message is a plain-text `Microflows$StringTemplate`, not a `Texts$Text`, so the walk that finds every translatable text never saw it, and a word present only in a log message was not found. It is now a `log_message` row beside the `log_node` one (activities inside a loop are not reached, as before for the node name). - **`check` refuses a widget datasource constraint that reaches two hops off a variable** (mendixlabs/mxcli#1466) — `database … where [Name = $currentObject/Mod.Assoc/Name]` on a combobox, list view, data grid 2 or gallery passed `check` and `exec`, then failed the build with CE0161. MDL055 covered only microflow retrieves; it now covers page datasources too. One hop (`[Mod.A = $currentObject/Mod.B]`), the context object's own attribute and the inverted form all build at 0 errors on 11.13.0 and 11.14.0 and stay accepted. diff --git a/cmd/mxcli/check_version_gates_test.go b/cmd/mxcli/check_version_gates_test.go new file mode 100644 index 0000000000..c8a20d7074 --- /dev/null +++ b/cmd/mxcli/check_version_gates_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/deprecation" + "github.com/mendixlabs/mxcli/mdl/executor" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +// The issue's own script (mendixlabs/mxcli#1434): a module and flows on a +// Mendix 9.24 project. The decision exercises the two properties the reported +// refusal named — Microflow.StableId (10.2+) and SequenceFlow.CaseValues +// (10.15+) — which the flow writers no longer emit below those versions (#1410). +const issue1434Flows = `create module R1434; +create entity R1434.Item ( "Count": integer ); +create microflow R1434.MF_Empty () begin return; end; +create microflow R1434.MF_Decide ($Item: R1434.Item) returns boolean +begin + declare $Result boolean = false; + if $Item/"Count" > 10 then + set $Result = true; + end if; + return $Result; +end; +` + +// The same script plus a statement the project's version cannot store: exec's +// handler refuses `create view entity` below 10.18. +const issue1434Gated = issue1434Flows + `create view entity R1434.ItemView (Name: String(200)) as select cast('a' as String) as Name; +` + +// copyV9Project copies the Mendix 9.24 v1 fixture the modelsdk/mpr tests use. +func copyV9Project(t *testing.T) (dir, mpr string) { + t.Helper() + src := filepath.Join("..", "..", "modelsdk", "mpr", "testdata", "v1-project", "App.mpr") + raw, err := os.ReadFile(src) + if err != nil { + t.Skipf("Mendix 9 fixture not found: %v", err) + } + dir = t.TempDir() + mpr = filepath.Join(dir, "App.mpr") + if err := os.WriteFile(mpr, raw, 0o644); err != nil { + t.Fatal(err) + } + return dir, mpr +} + +// mendixlabs/mxcli#1434: `mxcli check -p` printed "Check passed!" for a script +// that exec, on the same project, refused for the project's Mendix version — +// after the statements before the refused one had run. check -p now applies +// the gates exec's handlers apply (executor.CheckVersionGates), from the same +// statement-to-feature table. +// +// Controls: the gate-free script still passes, and check leaves the project +// as it was. +func TestCheck_RefusesWhatTheProjectVersionCannotStore(t *testing.T) { + dir, mpr := copyV9Project(t) + _ = checkCmd.InheritedFlags() + _ = rootCmd.PersistentFlags().Set("project", mpr) + defer func() { + _ = rootCmd.PersistentFlags().Set("project", "") + rootCmd.PersistentFlags().Lookup("project").Changed = false + }() + if f := checkCmd.Flags().Lookup("format"); f != nil { + _ = f.Value.Set(f.DefValue) + } + before := treeDigest(t, dir) + scripts := t.TempDir() + + var code int + gated := writeScript(t, scripts, "gated.mdl", issue1434Gated) + out := captureStd(t, func() { code = runCheckFile(checkCmd, gated) }) + if code == 0 || strings.Contains(out, "Check passed!") { + t.Errorf("check passed a statement the 9.24 project cannot store (exit %d):\n%s", code, out) + } + for _, want := range []string{"statement 5: create view entity requires Mendix 10.18.0+", "MDL-VERSION01"} { + if !strings.Contains(out, want) { + t.Errorf("check output must name %q:\n%s", want, out) + } + } + + // Control: the flows alone are storable on 9.24 and pass. + flows := writeScript(t, scripts, "flows.mdl", issue1434Flows) + out = captureStd(t, func() { code = runCheckFile(checkCmd, flows) }) + if code != 0 || strings.Contains(out, "MDL-VERSION01") { + t.Errorf("control: a script the 9.24 project can store failed its check (exit %d):\n%s", code, out) + } + + if n := countDiff(before, treeDigest(t, dir)); n > 0 { + t.Errorf("check wrote to the project: %d file(s) changed", n) + } +} + +// The other half of #1434: exec agrees with check on the same project. The +// gated script is refused by the pre-flight before anything is written (it used +// to run statements 1-4 and fail on the fifth); the flows alone — the +// statements the reported StableId / CaseValues refusal came from — pass the +// pre-flight and are written. +func TestExecPreflight_AgreesWithCheckOnVersionGates(t *testing.T) { + _, mpr := copyV9Project(t) + exec := executor.New(io.Discard) + exec.SetBackendFactory(func() backend.FullBackend { return modelsdkbackend.New() }) + if err := exec.Execute(&ast.ConnectStmt{Path: mpr}); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = exec.Execute(&ast.DisconnectStmt{}) }) + + build := func(script string) *ast.Program { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatal(errs[0]) + } + return prog + } + preflight := func(prog *ast.Program) (string, string) { + var out bytes.Buffer + refusal := execPreflight(exec, prog, mpr, "s.mdl", false, false, false, deprecation.Warn, &out, false) + return refusal, out.String() + } + + refusal, out := preflight(build(issue1434Gated)) + if refusal == "" || !strings.Contains(refusal, "Nothing was written") { + t.Errorf("want the pre-flight to refuse the version-gated script, got %q\n%s", refusal, out) + } + if !strings.Contains(out, "create view entity requires Mendix 10.18.0+") { + t.Errorf("pre-flight output must name the gate:\n%s", out) + } + + prog := build(issue1434Flows) + if refusal, out := preflight(prog); refusal != "" { + t.Fatalf("control: the flows were refused by the pre-flight: %s\n%s", refusal, out) + } + if err := exec.ExecuteProgram(prog); err != nil { + t.Errorf("exec refused the flows check passes on Mendix 9.24: %v", err) + } +} diff --git a/cmd/mxcli/cmd_check.go b/cmd/mxcli/cmd_check.go index 095b4ce21f..2eca2946a0 100644 --- a/cmd/mxcli/cmd_check.go +++ b/cmd/mxcli/cmd_check.go @@ -73,6 +73,11 @@ return in an inserted fragment, ...) is refused by exec under "mdl 1;", and check reports it as an MDL-V1-REBUILD error with exec's message. Under mdl 0 exec rebuilds the whole flow instead, and check reports the MDL-V1-REBUILD warning exec prints. An "alter microflow|nanoflow" exec would refuse is MDL090. + +Given a project it also applies exec's version gates: a statement the project's +Mendix version cannot store (create view entity below 10.18, send email below +11.13, ...) is MDL-VERSION01, with the message exec refuses it with. See +"show features" for the floors. This is the verdict "mxcli diff" reports as "Refused:", computed by the same code. A statement on a flow an earlier statement of the script changes, or whose flow does not build until an earlier statement has run, is not predicted. @@ -324,6 +329,11 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { // already stored that way: re-applying it changes nothing. violations = executor.SettleStoredVoidReturnValues(violations, prog, executor.NewStoredVoidReturns(b)) violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) + // MDL-VERSION01: a statement the project's Mendix version cannot + // store. The gates are the ones exec's handlers refuse with, from + // one table (executor.statementVersionGates), so check -p no + // longer passes a script exec refuses for the version (#1434). + violations = append(violations, exec.CheckVersionGates(prog)...) } } violations = executor.ApplyDeprecationPolicy(violations, depPolicy) @@ -458,6 +468,11 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { // reported shape was drop + create in separate runs, which loses the // roles that `create or modify` keeps. projectViolations = append(projectViolations, exec.CheckFlowAccess(prog)...) + // MDL-SEC22 (MxBuild CE0114): a microflow that does not apply entity + // access calling a microflow or rule that does (mendixlabs/mxcli#1500). + // A consistency rule between two documents, not a reference, so the + // reference check above cannot see it. + projectViolations = append(projectViolations, exec.CheckEntityAccessCalls(prog)...) // MDL-I18N01 (MxBuild CE4899): a script that changes the default // language leaves every required caption written in the old one empty // in the new one (ako/mxcli#944). @@ -481,7 +496,7 @@ func runCheckFileAgainst(cmd *cobra.Command, filePath, against string) int { if len(flowReport.Unpredicted) > 0 { flows = "no predicted flow change exec would refuse" } - fmt.Printf("✓ Expression types OK, no unstated member drops, %s, no used flow left without access\n", flows) + fmt.Printf("✓ Expression types OK, no unstated member drops, %s, no used flow left without access, no entity-access call mismatch\n", flows) } // A flow change whose verdict depends on what earlier statements // create is not predicted; say so rather than let the line above diff --git a/cmd/mxcli/cmd_new.go b/cmd/mxcli/cmd_new.go index 6dd0468c59..0c6768dfd7 100644 --- a/cmd/mxcli/cmd_new.go +++ b/cmd/mxcli/cmd_new.go @@ -3,6 +3,7 @@ package main import ( + "errors" "fmt" "io" "os" @@ -27,7 +28,8 @@ This command performs the following steps: 3. Applies mxcli's default styling (--theme, see 'mxcli theme list') 4. Creates a layout the project owns and moves its pages onto it (--layout none) 5. Initializes AI tooling and devcontainer configuration (mxcli init) - 6. Runs one build so generated sources are settled (--skip-build to skip) + 6. Runs one build so generated sources are settled (--skip-build to skip); + if mxbuild reports model errors, the command exits non-zero 7. Links this mxcli into the project (or downloads a Linux build on macOS/Windows) Step 4 exists because Atlas's layouts live in a Marketplace module, and Mendix's @@ -284,15 +286,17 @@ Examples: // changes nobody wrote (mxcli-todo #7). Doing it here puts the settled form // in the first commit. Best-effort: this is a nicety, not a precondition // for a usable project, so a missing JDK or a build failure is a warning. + // + // The one exception is a build mxbuild refused for MODEL errors: that + // project does not build, so the command finishes its steps (the project + // stays on disk to inspect) and then exits non-zero (mendixlabs/mxcli#1433). + buildFailed := false if skipBuild { fmt.Printf("\nStep 6/7: Skipped first build (--skip-build)\n") } else { fmt.Printf("\nStep 6/7: Running the first build (settles generated sources)...\n") if err := docker.SettleGeneratedSources(mprPath, mxPath, mendixVersion, os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, " Warning: could not run the first build: %v\n", err) - fmt.Fprintln(os.Stderr, " The project is usable. Note that the first build will rewrite the") - fmt.Fprintln(os.Stderr, " generated action stubs under javascriptsource/ and javasource/ —") - fmt.Fprintln(os.Stderr, " that diff is build output, so commit it and move on.") + buildFailed = reportFirstBuildFailure(err, os.Stderr) } } @@ -305,6 +309,13 @@ Examples: return downloadMxcliBinary("mendixlabs/mxcli", tag, "linux", "amd64", outPath, w) }, os.Stdout, os.Stderr) + if buildFailed { + fmt.Fprintf(os.Stderr, "\n✗ Project '%s' was created at %s, but it does not build (see the errors above).\n", + appName, absDir) + fmt.Fprintf(os.Stderr, " Re-check it with: mxcli docker check -p %s\n", mprPath) + os.Exit(1) + } + fmt.Printf("\n✓ Project '%s' created at %s\n", appName, absDir) fmt.Println("\nNext steps:") fmt.Println(" 1. Open the project folder in VS Code") @@ -313,6 +324,27 @@ Examples: }, } +// reportFirstBuildFailure reports a failed first build and says whether it is +// fatal to `mxcli new`. +// +// It is fatal when mxbuild ran and refused the model: the project does not +// build, and printing "Project created" with exit 0 over that is how a broken +// scaffold reached a user unnoticed (mendixlabs/mxcli#1433). Any other failure +// — no JDK, no mxbuild, a killed build — says nothing about the project, so it +// stays a warning. +func reportFirstBuildFailure(err error, errOut io.Writer) bool { + var merr *docker.ModelErrorsError + if errors.As(err, &merr) { + fmt.Fprintf(errOut, " Error: the project does not build — %v\n", merr) + return true + } + fmt.Fprintf(errOut, " Warning: could not run the first build: %v\n", err) + fmt.Fprintln(errOut, " The project is usable. Note that the first build will rewrite the") + fmt.Fprintln(errOut, " generated action stubs under javascriptsource/ and javasource/ —") + fmt.Fprintln(errOut, " that diff is build output, so commit it and move on.") + return false +} + // devcontainerReleaseTag is the release whose Linux binary matches this mxcli, // or "" when there is none. ldflagsVersion is main.Version: empty for a plain // `go build` (which then reports 0.1.0 — not a release), and a bare commit hash diff --git a/cmd/mxcli/cmd_new_layout.go b/cmd/mxcli/cmd_new_layout.go index 820cfce67d..4202e4035f 100644 --- a/cmd/mxcli/cmd_new_layout.go +++ b/cmd/mxcli/cmd_new_layout.go @@ -45,6 +45,12 @@ const scaffoldLayoutName = "App_Default" // design property rather than in the class. Without them the menu bar and the // language selector stack on top of each other in the corner — measured in a // browser, which is the only place that shows up. mx check is silent about it. +// They are Atlas 4's, though, and Atlas is versioned with the project: Mendix +// 10.24's template ships Atlas Core 3.17, which declares none of them, and +// mxbuild refuses each one with CE6083 (mendixlabs/mxcli#1433). So only the +// ones the project's theme declares are written — on Atlas 3 that is none, and +// none are needed, because its `.topbar-content` rule is already +// `display: flex; align-items: center`. // // It is MDL rather than Go so the project contains something the user could have // written — `describe layout` gives this back, and `create or replace layout` @@ -56,13 +62,10 @@ const scaffoldLayoutMDL = `create or replace layout {{.Module}}.{{.Layout}} ( scrollcontainer layoutContainer { region top (class: 'region-topbar') { container topbarContent ( - class: 'topbar-content', - designproperties: ( - 'Flex container': 'Horizontal (row)', - 'Align items Y': 'Center', - 'Disable row wrap': on, - 'Grow / shrink (self)': 'Fill container (only grow)' - ) + class: 'topbar-content'{{if .DesignProperties}}, + designproperties: ({{range $i, $p := .DesignProperties}}{{if $i}},{{end}} + '{{$p.Key}}': {{if $p.Toggle}}on{{else}}'{{$p.Value}}'{{end}}{{end}} + ){{end}} ) { menubar mainMenu (profile: '{{.Profile}}') snippetcall languageSelector (snippet: Atlas_Core.LanguageSelectorWidget) @@ -79,10 +82,41 @@ alter pages in {{$.Module}} set layout = {{$.Module}}.{{$.Layout}} {{end}}` type scaffoldLayoutData struct { - Module string - Layout string - Profile string - RepointFrom []string + Module string + Layout string + Profile string + RepointFrom []string + DesignProperties []scaffoldDesignProp +} + +// scaffoldDesignProp is one design property on the topbar container. Toggle +// properties are written as a bare `on`, the rest as a quoted option name. +type scaffoldDesignProp struct { + Key string + Value string + Toggle bool +} + +// topbarDesignProps is what the topbar container asks for on Atlas 4. Each is +// written only where the project's theme declares it (scaffoldDesignProps). +var topbarDesignProps = []scaffoldDesignProp{ + {Key: "Flex container", Value: "Horizontal (row)"}, + {Key: "Align items Y", Value: "Center"}, + {Key: "Disable row wrap", Value: "on", Toggle: true}, + {Key: "Grow / shrink (self)", Value: "Fill container (only grow)"}, +} + +// scaffoldDesignProps keeps the topbar properties reg declares for a container. +// A nil registry (no readable themesource) keeps none: mxbuild checks every +// design property against the theme, so there is nothing safe to write. +func scaffoldDesignProps(reg *executor.ThemeRegistry) []scaffoldDesignProp { + var out []scaffoldDesignProp + for _, p := range topbarDesignProps { + if executor.ThemeDeclaresDesignProperty(reg, "DivContainer", p.Key, p.Value) { + out = append(out, p) + } + } + return out } // scaffoldProjectLayout creates the project-owned layout and moves the project's @@ -98,20 +132,31 @@ func scaffoldProjectLayout(projectPath string, out io.Writer) (string, error) { return "", err } + script, err := renderScaffoldScript(target, executor.LoadThemeRegistryForProject(projectPath)) + if err != nil { + return "", err + } + if err := runMDL(projectPath, script, out); err != nil { + return "", err + } + return target.Module, nil +} + +// renderScaffoldScript writes the scaffold MDL for target, with only the design +// properties the project's theme (reg) declares. +func renderScaffoldScript(target scaffoldTargetInfo, reg *executor.ThemeRegistry) (string, error) { var script strings.Builder tmpl := template.Must(template.New("layout").Parse(scaffoldLayoutMDL)) if err := tmpl.Execute(&script, scaffoldLayoutData{ - Module: target.Module, - Layout: scaffoldLayoutName, - Profile: target.Profile, - RepointFrom: target.RepointFrom, + Module: target.Module, + Layout: scaffoldLayoutName, + Profile: target.Profile, + RepointFrom: target.RepointFrom, + DesignProperties: scaffoldDesignProps(reg), }); err != nil { return "", err } - if err := runMDL(projectPath, script.String(), out); err != nil { - return "", err - } - return target.Module, nil + return script.String(), nil } // scaffoldTargetInfo is what the project has to be read for before the script diff --git a/cmd/mxcli/cmd_new_layout_test.go b/cmd/mxcli/cmd_new_layout_test.go index 825699665c..6f3719b451 100644 --- a/cmd/mxcli/cmd_new_layout_test.go +++ b/cmd/mxcli/cmd_new_layout_test.go @@ -3,10 +3,13 @@ package main import ( + "encoding/json" "strings" "testing" "text/template" + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/executor" "github.com/mendixlabs/mxcli/mdl/visitor" ) @@ -109,3 +112,119 @@ func TestScaffoldLayoutMDL_CarriesTheAtlasLayoutClass(t *testing.T) { t.Errorf("the scaffold must carry the layout-atlas class:\n%s", script) } } + +// Trimmed from themesource/atlas_core/web/design-properties.json of a project +// made by `mx create-project` on Mendix 10.24.0 (Atlas Core 3.17): no flex group +// at all. Its .topbar-content rule is already `display: flex; align-items: +// center`, which is why the scaffold needs no design property there. +const atlas3DesignPropsJSON = `{ + "Widget": [{"name": "Spacing", "type": "Spacing"}, + {"name": "Align self", "type": "ToggleButtonGroup", "options": [{"name": "Left", "class": "pull-left"}, {"name": "Right", "class": "pull-right"}]}, + {"name": "Hide on", "type": "ToggleButtonGroup", "options": [{"name": "Phone", "class": "hide-phone"}, {"name": "Tablet", "class": "hide-tablet"}, {"name": "Desktop", "class": "hide-desktop"}]}], + "DivContainer": [{"name": "Align content", "type": "Dropdown", "options": [{"name": "Left align as a row", "class": "row-left"}, {"name": "Center align as a row", "class": "row-center"}]}, + {"name": "Shade", "type": "ToggleButtonGroup", "options": [{"name": "Light", "class": "background-light"}, {"name": "Dark", "class": "background-dark"}]}] +}` + +// The flex group as Mendix 11.6.6's create-project ships it (Atlas Core 4). +const atlas4DesignPropsJSON = `{ + "Widget": [{"name": "Spacing", "type": "Spacing"}], + "DivContainer": [ + {"name": "Flex container", "type": "ToggleButtonGroup", "options": [{"name": "Horizontal (row)", "class": "flex-row"}, {"name": "Vertical (column)", "class": "flex-column"}]}, + {"name": "Align items Y", "type": "ToggleButtonGroup", "options": [{"name": "Top", "class": "align-y-top"}, {"name": "Center", "class": "align-y-center"}]}, + {"name": "Grow / shrink (self)", "type": "Dropdown", "options": [{"name": "Fill container (only grow)"}, {"name": "Hug content (only shrink)"}]}, + {"name": "Disable row wrap", "type": "Toggle", "class": "flex-nowrap"}] +}` + +func themeRegistryFrom(t *testing.T, js string) *executor.ThemeRegistry { + t.Helper() + reg := &executor.ThemeRegistry{} + if err := json.Unmarshal([]byte(js), ®.WidgetProperties); err != nil { + t.Fatal(err) + } + return reg +} + +// scaffoldDesignPropViolations parses the script and asks the validator that +// `mxcli check -p` runs on pages whether the layout's design properties exist +// in reg — an oracle independent of the filter under test. +func scaffoldDesignPropViolations(t *testing.T, script string, reg *executor.ThemeRegistry) []string { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("the scaffold script does not parse: %v\n---\n%s", errs[0], script) + } + var out []string + for _, stmt := range prog.Statements { + l, ok := stmt.(*ast.CreateLayoutStmt) + if !ok { + continue + } + // The validator walks pages and snippets; a layout's widget tree has + // the same shape. + asSnippet := &ast.CreateSnippetStmtV3{Name: l.Name, Widgets: l.Widgets} + for _, v := range executor.ValidateDesignPropertiesForStatement(asSnippet, reg) { + out = append(out, v.Message) + } + } + return out +} + +// mendixlabs/mxcli#1433: on Mendix 10.24 the scaffold wrote Atlas 4's flex +// design properties into a project whose Atlas Core 3.17 declares none of them. +// mx check reported CE6083 "Design property Flex container is not supported by +// your theme" four times, so the first build of `mxcli new` failed. +func TestScaffoldScript_OmitsDesignPropertiesTheThemeDoesNotDeclare(t *testing.T) { + reg := themeRegistryFrom(t, atlas3DesignPropsJSON) + script, err := renderScaffoldScript(scaffoldTargetInfo{ + Module: "MyFirstModule", Profile: "Responsive", + RepointFrom: []string{"Atlas_Core.Atlas_TopBar"}, + }, reg) + if err != nil { + t.Fatal(err) + } + if v := scaffoldDesignPropViolations(t, script, reg); len(v) > 0 { + t.Errorf("the scaffold sets design properties the project's theme does not declare "+ + "(mxbuild: CE6083):\n %s\n---\n%s", strings.Join(v, "\n "), script) + } + if strings.Contains(script, "designproperties") { + t.Errorf("an Atlas 3 theme declares none of the topbar's properties, so none may be written:\n%s", script) + } +} + +// The other direction: on Atlas 4 (Mendix 11) the flex properties are what keep +// the menu and the language selector on one row, so they must survive the filter. +func TestScaffoldScript_KeepsTheFlexPropertiesWhereTheThemeDeclaresThem(t *testing.T) { + reg := themeRegistryFrom(t, atlas4DesignPropsJSON) + script, err := renderScaffoldScript(scaffoldTargetInfo{Module: "M", Profile: "Responsive"}, reg) + if err != nil { + t.Fatal(err) + } + if v := scaffoldDesignPropViolations(t, script, reg); len(v) > 0 { + t.Errorf("unexpected violations:\n %s", strings.Join(v, "\n ")) + } + for _, want := range []string{ + "'Flex container': 'Horizontal (row)'", + "'Align items Y': 'Center'", + "'Disable row wrap': on", + "'Grow / shrink (self)': 'Fill container (only grow)'", + } { + if !strings.Contains(script, want) { + t.Errorf("missing %s:\n%s", want, script) + } + } +} + +// A project with no readable theme metadata gets no design properties: mxbuild +// checks every one against the theme, so there is nothing safe to write. +func TestScaffoldScript_NoThemeNoDesignProperties(t *testing.T) { + script, err := renderScaffoldScript(scaffoldTargetInfo{Module: "M", Profile: "Responsive"}, nil) + if err != nil { + t.Fatal(err) + } + if strings.Contains(script, "designproperties") { + t.Errorf("wrote design properties with no theme to check them against:\n%s", script) + } + if _, errs := visitor.Build(script); len(errs) > 0 { + t.Fatalf("does not parse: %v\n%s", errs[0], script) + } +} diff --git a/cmd/mxcli/cmd_new_test.go b/cmd/mxcli/cmd_new_test.go index 1f600af93f..4d059cecfa 100644 --- a/cmd/mxcli/cmd_new_test.go +++ b/cmd/mxcli/cmd_new_test.go @@ -5,6 +5,7 @@ package main import ( "bytes" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -12,6 +13,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/mendixlabs/mxcli/cmd/mxcli/docker" ) // TestDownloadMxcliBinary_HTTP404ReturnsError verifies that a 404 from the @@ -134,3 +137,29 @@ func TestDevcontainerReleaseTag(t *testing.T) { } } } + +// mendixlabs/mxcli#1433: the first build failing because the model has errors +// means `mxcli new` handed back a project that does not build. That used to be +// printed as a warning followed by "Project created" and exit 0, so a script +// could not tell. It is fatal now; any other first-build failure (no JDK, a +// killed build) still only warns, because the project itself may be fine. +func TestReportFirstBuildFailure(t *testing.T) { + modelErr := fmt.Errorf("wrapped: %w", &docker.ModelErrorsError{ + Errors: []string{"ERROR at MyFirstModule, Layout 'App_Default': Design property Flex container is not supported by your theme."}, + }) + var buf bytes.Buffer + if !reportFirstBuildFailure(modelErr, &buf) { + t.Errorf("a build refused for model errors must be fatal") + } + if !strings.Contains(buf.String(), "Flex container") || !strings.Contains(buf.String(), "does not build") { + t.Errorf("the message must say the project does not build and why, got:\n%s", buf.String()) + } + + buf.Reset() + if reportFirstBuildFailure(errors.New("no JDK 21 available"), &buf) { + t.Errorf("an environment failure must stay a warning") + } + if !strings.Contains(buf.String(), "Warning") { + t.Errorf("an environment failure must still be reported, got:\n%s", buf.String()) + } +} diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index adb2049389..481e13fd06 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -157,6 +157,14 @@ Examples: watch, _ := cmd.Flags().GetBool("watch") setupOnly, _ := cmd.Flags().GetBool("setup") + // Refused here, before --detach and before the build, rather than as a + // bind failure in the runtime log minutes later. + listenAddr, _ := cmd.Flags().GetString("listen-addr") + if err := docker.ValidateListenAddr(listenAddr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + // --detach: start this same command as a background service and return // once its app is serving, or its boot failed. The child is told it is // the child, so it runs the loop rather than detaching again. @@ -244,6 +252,7 @@ Examples: AppPort: appPort, AdminPort: adminPort, ServePort: servePort, + ListenAddr: listenAddr, MxBuildPath: mxbuildPath, Watch: watch, WebClientTimeout: webClientTimeout, @@ -379,6 +388,7 @@ func init() { runCmd.Flags().Bool("setup", false, "Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook") runCmd.Flags().Int("app-port", 0, "HTTP port for the app (default 8080)") runCmd.Flags().Int("admin-port", 0, "M2EE admin API port (default 8090)") + runCmd.Flags().String("listen-addr", "", "Address the app binds to (default 127.0.0.1, loopback only). Must be an IP address. Set 0.0.0.0 (or :: for IPv6 too) to reach the app from other machines on the network; the URLs it is reachable at are printed once it runs. If the app builds absolute URLs (SSO redirects, links in emails), pair it with a reachable root URL — an ApplicationRootUrl naming a real host in the model, or --runtime-setting ApplicationRootUrl=http://:/ — because the runtime does not derive one from this address (a warning says so). The M2EE admin API is unaffected and always stays on 127.0.0.1.") runCmd.Flags().Int("serve-port", 0, "mxbuild --serve port (default 6543)") runCmd.Flags().String("mxbuild-path", "", "Path to the mxbuild to build with, overriding resolution (Studio Pro's bundled mxbuild on macOS/Windows, the cached CDN download on Linux)") runCmd.Flags().String("db-type", "", "Database type for a local run: postgresql (default) or hsqldb (the runtime's built-in file database — no server, no --db-host/--db-user/--db-password, data under /deployment/data/database/hsqldb/)") diff --git a/cmd/mxcli/docker/listenaddr.go b/cmd/mxcli/docker/listenaddr.go new file mode 100644 index 0000000000..a3f888eff3 --- /dev/null +++ b/cmd/mxcli/docker/listenaddr.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "fmt" + "net" + "strconv" +) + +// ValidateListenAddr refuses a --listen-addr the runtime could not bind to, +// before anything is built or booted. The value goes to the runtime verbatim +// (runtime_listen_addresses), so a typo used to surface only after the build and +// the JVM start, as a bind failure in the runtime log. Empty means the default. +func ValidateListenAddr(addr string) error { + if addr == "" { + return nil + } + if net.ParseIP(addr) == nil { + return fmt.Errorf("invalid --listen-addr %q: want an IP address — 127.0.0.1 (loopback, the default), 0.0.0.0 (all IPv4 interfaces), :: (all interfaces) or one interface's address; no host name, port or brackets", addr) + } + return nil +} + +// isWildcardListenAddr reports whether addr binds every interface (0.0.0.0, ::). +func isWildcardListenAddr(addr string) bool { + ip := net.ParseIP(addr) + return ip != nil && ip.IsUnspecified() +} + +// appDialHost is the host mxcli connects to the app on, given the address the +// app binds to. A wildcard is a bind address, not a destination: dialling +// 0.0.0.0 only works by accident on some platforms, and `::` in a URL without +// brackets does not parse. So a wildcard dials loopback of the same family; any +// other address is dialled as is (an app bound to one interface's address is not +// on loopback at all). +func appDialHost(listen string) string { + ip := net.ParseIP(listen) + if ip == nil || !ip.IsUnspecified() { + return listen + } + if ip.To4() != nil { + return "127.0.0.1" + } + return "::1" +} + +// appURLFor is the base URL of an app bound to listen:port, as mxcli dials it. +// net.JoinHostPort brackets an IPv6 host. +func appURLFor(listen string, port int) string { + return "http://" + net.JoinHostPort(appDialHost(listen), strconv.Itoa(port)) + "/" +} + +// networkAppURLs lists the URLs another machine can reach a wildcard-bound app +// at: one per non-loopback, non-link-local address of this host, of the family +// the wildcard covers (0.0.0.0 is IPv4 only; :: is both). Nil for a non-wildcard +// bind, whose AppURL already names the one address it serves on. +func networkAppURLs(listen string, port int, addrs []net.Addr) []string { + bind := net.ParseIP(listen) + if bind == nil || !bind.IsUnspecified() { + return nil + } + v4Only := bind.To4() != nil + var out []string + for _, a := range addrs { + ipn, ok := a.(*net.IPNet) + if !ok { + continue + } + ip := ipn.IP + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { + continue + } + if v4Only && ip.To4() == nil { + continue + } + out = append(out, "http://"+net.JoinHostPort(ip.String(), strconv.Itoa(port))+"/") + } + return out +} diff --git a/cmd/mxcli/docker/listenaddr_test.go b/cmd/mxcli/docker/listenaddr_test.go new file mode 100644 index 0000000000..2696ce52a6 --- /dev/null +++ b/cmd/mxcli/docker/listenaddr_test.go @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 + +package docker + +import ( + "bytes" + "net" + "net/url" + "strings" + "testing" +) + +// applyDefaults is where the empty-means-loopback default actually lives, so a +// test that skips it would assert a shape the runtime never sees. +func TestApplyDefaultsPinsListenAddr(t *testing.T) { + var o LocalRuntimeOptions + o.applyDefaults() + if o.ListenAddr != "127.0.0.1" { + t.Errorf("applyDefaults left ListenAddr = %q, want 127.0.0.1", o.ListenAddr) + } + + var w LocalRuntimeOptions + w.ListenAddr = "0.0.0.0" + w.applyDefaults() + if w.ListenAddr != "0.0.0.0" { + t.Errorf("applyDefaults overwrote an explicit ListenAddr: %q", w.ListenAddr) + } +} + +// The M2EE admin API is a privileged surface and must never be widened onto the +// network by --listen-addr: whatever the app binds to, the admin bind stays on +// loopback. This is the value that reaches both the runtime's +// M2EE_ADMIN_LISTEN_ADDRESSES and mxcli's own client Host (both use +// m2eeAdminListenAddr), so a regression here is silent otherwise — the app +// comes up and only the admin API is on the network. The want string is +// hard-coded, not read from the constant, so changing the constant fails here. +func TestLocalRuntimeEnvAdminBindIsAlwaysLoopback(t *testing.T) { + const want = "M2EE_ADMIN_LISTEN_ADDRESSES=127.0.0.1" + for _, listen := range []string{"", "127.0.0.1", "0.0.0.0", "::", "192.168.2.35"} { + var o LocalRuntimeOptions + o.AdminPass = "pw" + o.ListenAddr = listen + o.applyDefaults() + env := localRuntimeEnv(o) + found := false + for _, kv := range env { + if kv == want { + found = true + } + if strings.HasPrefix(kv, "M2EE_ADMIN_LISTEN_ADDRESSES=") && kv != want { + t.Errorf("ListenAddr %q: admin bind leaked %q, want %q", listen, kv, want) + } + } + if !found { + t.Errorf("ListenAddr %q: env is missing %q", listen, want) + } + } +} + +// The app's own bind is the appContainerParams value and is unaffected by the +// admin bind: a caller asking for 0.0.0.0 must get 0.0.0.0, or the flag would +// silently do nothing. +func TestAppContainerParamsKeepsWildcard(t *testing.T) { + for _, listen := range []string{"", "0.0.0.0", "192.168.2.35"} { + var o LocalRuntimeOptions + o.AppPort = 8095 + o.ListenAddr = listen + o.applyDefaults() + p := appContainerParams(o) + got, ok := p["runtime_listen_addresses"].(string) + if !ok { + t.Fatalf("ListenAddr %q: runtime_listen_addresses is %T, want string", listen, p["runtime_listen_addresses"]) + } + want := listen + if want == "" { + want = "127.0.0.1" + } + if got != want { + t.Errorf("runtime_listen_addresses for %q = %q, want %q", listen, got, want) + } + if got := p["runtime_port"]; got != 8095 { + t.Errorf("runtime_port = %v, want 8095", got) + } + } +} + +// AppURL is what mxcli dials (health check, screenshots, "App is running at"), +// so it must be a destination: a wildcard bind maps to loopback, and an IPv6 +// host is bracketed — `http://:::8080/` does not parse. +func TestAppURLIsDialable(t *testing.T) { + cases := map[string]string{ + "": "http://127.0.0.1:8080/", + "127.0.0.1": "http://127.0.0.1:8080/", + "0.0.0.0": "http://127.0.0.1:8080/", + "::": "http://[::1]:8080/", + "::1": "http://[::1]:8080/", + "192.168.2.35": "http://192.168.2.35:8080/", + "fe80::1": "http://[fe80::1]:8080/", + } + for listen, want := range cases { + o := LocalRuntimeOptions{ListenAddr: listen} + o.applyDefaults() + rt := &LocalRuntime{opts: o} + got := rt.AppURL() + if got != want { + t.Errorf("ListenAddr %q: AppURL = %q, want %q", listen, got, want) + } + u, err := url.Parse(got) + if err != nil || u.Port() != "8080" { + t.Errorf("ListenAddr %q: AppURL %q does not parse to port 8080 (err %v)", listen, got, err) + } + } +} + +func TestValidateListenAddr(t *testing.T) { + for _, ok := range []string{"", "127.0.0.1", "0.0.0.0", "::", "::1", "192.168.2.35"} { + if err := ValidateListenAddr(ok); err != nil { + t.Errorf("ValidateListenAddr(%q) = %v, want nil", ok, err) + } + } + for _, bad := range []string{"localhost", "0.0.0.0:8080", "[::]", "*", "192.168.2", " 0.0.0.0"} { + if err := ValidateListenAddr(bad); err == nil { + t.Errorf("ValidateListenAddr(%q) = nil, want an error", bad) + } + } +} + +func TestNetworkAppURLs(t *testing.T) { + cidr := func(s string) net.Addr { + ip, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatal(err) + } + n.IP = ip + return n + } + addrs := []net.Addr{ + cidr("127.0.0.1/8"), cidr("::1/128"), cidr("fe80::1/64"), + cidr("192.168.2.35/24"), cidr("2001:db8::5/64"), + } + check := func(listen string, want []string) { + t.Helper() + got := networkAppURLs(listen, 8080, addrs) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("networkAppURLs(%q) = %v, want %v", listen, got, want) + } + } + check("0.0.0.0", []string{"http://192.168.2.35:8080/"}) + check("::", []string{"http://192.168.2.35:8080/", "http://[2001:db8::5]:8080/"}) + check("127.0.0.1", nil) + check("192.168.2.35", nil) +} + +// The root-URL warning fires only for a wildcard bind that nothing gives a +// reachable root URL: not for loopback, not when the model or hub supplied +// one, not when --runtime-setting did. +func TestWarnWildcardWithoutRootURL(t *testing.T) { + cases := []struct { + listen, root string + settings map[string]any + warn bool + }{ + {"0.0.0.0", "", nil, true}, + {"::", "", nil, true}, + {"127.0.0.1", "", nil, false}, + {"192.168.2.35", "", nil, false}, + {"0.0.0.0", "http://dev.box:8080/", nil, false}, + {"0.0.0.0", "", map[string]any{"ApplicationRootUrl": "http://dev.box:8080/"}, false}, + } + for _, c := range cases { + var b bytes.Buffer + warnWildcardWithoutRootURL(&b, c.listen, 8080, c.root, c.settings) + if got := b.Len() > 0; got != c.warn { + t.Errorf("listen %q root %q settings %v: warned=%v, want %v (%q)", c.listen, c.root, c.settings, got, c.warn, b.String()) + } + } +} diff --git a/cmd/mxcli/docker/localapp.go b/cmd/mxcli/docker/localapp.go index 02d3538e33..4e2f323787 100644 --- a/cmd/mxcli/docker/localapp.go +++ b/cmd/mxcli/docker/localapp.go @@ -20,6 +20,10 @@ type LocalAppOptions struct { AppPort int AdminPort int ServePort int + // ListenAddr is the address the app binds to. Empty means 127.0.0.1 + // (loopback only); set 0.0.0.0 to reach the app from other machines on the + // network. The M2EE admin API is unaffected and always stays on loopback. + ListenAddr string // AdminPass is the M2EE admin password (defaults to the local-run password). AdminPass string // MxBuildPath overrides mxbuild resolution (optional), as --mxbuild-path does @@ -69,6 +73,7 @@ func (o LocalAppOptions) runtimeOptions(installPath string) LocalRuntimeOptions JavaMajor: javaMajor, AppPort: o.AppPort, AdminPort: o.AdminPort, + ListenAddr: o.ListenAddr, AdminPass: o.AdminPass, DB: o.DB, RuntimeLogPath: o.RuntimeLogPath, diff --git a/cmd/mxcli/docker/localboot.go b/cmd/mxcli/docker/localboot.go index 673b2f1f4a..3abf310e18 100644 --- a/cmd/mxcli/docker/localboot.go +++ b/cmd/mxcli/docker/localboot.go @@ -72,7 +72,9 @@ type LocalRuntimeOptions struct { AppPort int // AdminPass is the M2EE admin password (required). AdminPass string - // ListenAddr binds both the admin API and the app (default 127.0.0.1). + // ListenAddr is the address the app binds to (default 127.0.0.1). It does + // NOT affect the M2EE admin API, which is always loopback (see + // m2eeAdminListenAddr). ListenAddr string // DTAPMode is D/A/T/P (default "D"). DTAPMode string @@ -260,6 +262,15 @@ func (o *LocalRuntimeOptions) jvmArgs() []string { } } +// m2eeAdminListenAddr is the address the M2EE admin API binds to, and the +// address mxcli talks to it on. Always loopback, deliberately independent of +// LocalRuntimeOptions.ListenAddr: the admin API is a privileged surface (it +// boots the app, rewrites configuration, runs actions) and widening the app +// bind must not widen it onto the network as a side effect. A wildcard would +// not be a valid client destination anyway — 0.0.0.0 is a bind address, not a +// host, so a client dialling it is not portable. +const m2eeAdminListenAddr = "127.0.0.1" + // localRuntimeEnv builds the environment for the runtime JVM, layered on the // current process environment. PrepareMxCommand later adds the FreeType fix. // o.Env is appended last so a caller-supplied value wins over both the inherited @@ -268,7 +279,7 @@ func localRuntimeEnv(o LocalRuntimeOptions) []string { env := append(os.Environ(), "M2EE_ADMIN_PASS="+o.AdminPass, fmt.Sprintf("M2EE_ADMIN_PORT=%d", o.AdminPort), - "M2EE_ADMIN_LISTEN_ADDRESSES="+o.ListenAddr, + "M2EE_ADMIN_LISTEN_ADDRESSES="+m2eeAdminListenAddr, "MX_INSTALL_PATH="+o.InstallPath, "MX_LOG_LEVEL=i", ) @@ -376,7 +387,8 @@ func runtimeConfigParams(o LocalRuntimeOptions, constants map[string]string) map "MicroflowConstants": constants, } // Only set ApplicationRootUrl when the app is served behind an external URL; - // on a plain local run the runtime defaults it from the listen address. + // on a plain local run it stays unset (the runtime does not derive one from + // the listen address; see warnWildcardWithoutRootURL). if o.ApplicationRootUrl != "" { params["ApplicationRootUrl"] = o.ApplicationRootUrl } @@ -512,7 +524,9 @@ func StartLocalRuntime(opts LocalRuntimeOptions) (*LocalRuntime, error) { rt := &LocalRuntime{ opts: opts, m2ee: M2EEOptions{ - Host: opts.ListenAddr, + // The admin API is always loopback, independent of the app bind: + // see m2eeAdminListenAddr. + Host: m2eeAdminListenAddr, Port: opts.AdminPort, Token: opts.AdminPass, Direct: true, @@ -699,9 +713,11 @@ func (rt *LocalRuntime) Restart() error { return rt.spawnAndConfigure() } -// AppURL is the base URL the app serves on. +// AppURL is the base URL mxcli reaches the app on — health checks, screenshots, +// the "app is running" line. It is a destination, not the bind: under a +// wildcard --listen-addr it names loopback (see appDialHost). func (rt *LocalRuntime) AppURL() string { - return fmt.Sprintf("http://%s:%d/", rt.opts.ListenAddr, rt.opts.AppPort) + return appURLFor(rt.opts.ListenAddr, rt.opts.AppPort) } // HealthOK reports whether the app answers an HTTP request (any status < 500). diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index b3d8166761..22f315f308 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -62,6 +62,13 @@ type LocalRunOptions struct { AppPort int AdminPort int ServePort int + // ListenAddr is the address the app binds to. Empty means 127.0.0.1, which + // is loopback-only on purpose. Set it to 0.0.0.0 to let the app be reached + // from other machines on the network; pair that with a reachable + // ApplicationRootUrl (--runtime-setting) if the app builds absolute URLs, + // because the runtime does not derive one (see warnWildcardWithoutRootURL). + // The M2EE admin API is unaffected and stays on loopback. + ListenAddr string // AdminPass is the M2EE admin password (default is a fixed local-dev value). AdminPass string // DB is the Postgres the runtime connects to (devcontainer defaults applied). @@ -343,6 +350,28 @@ func applicationRootURLFrom(settings *model.ProjectSettings) (rootURL, configNam return first, firstName } +// warnWildcardWithoutRootURL warns when the app binds every interface but +// nothing gives the runtime the URL it is reached at. The runtime does NOT +// derive one from the bind address: measured on 11.12.1, +// Core.getConfiguration().getApplicationRootUrl() is null after a boot with no +// ApplicationRootUrl, under 127.0.0.1 and 0.0.0.0 alike, and is the passed value +// under --runtime-setting. So anything that builds an absolute URL from it +// (OIDC/SAML redirect URIs, links in emails) has no host another machine can +// use, while the app itself answers fine — the client uses relative URLs. A +// warning rather than a refusal: plenty of apps build no absolute URL at all. +func warnWildcardWithoutRootURL(stderr io.Writer, listen string, port int, appRootURL string, settings map[string]any) { + if !isWildcardListenAddr(listen) || appRootURL != "" { + return + } + if _, ok := settings["ApplicationRootUrl"]; ok { + return + } + fmt.Fprintf(stderr, "Warning: --listen-addr %s with no application root URL — the runtime does not derive one, "+ + "so absolute URLs the app builds from it (SSO redirects, links in emails) have no host another machine can open. "+ + "Pass --runtime-setting ApplicationRootUrl=http://:%d/ or set one naming a real host in the configuration.\n", + listen, port) +} + // customHostRootURL reports whether an ApplicationRootUrl names a host worth // telling the runtime about. // @@ -350,8 +379,7 @@ func applicationRootURLFrom(settings *model.ProjectSettings) (rootURL, configNam // mean "chosen". Passing that back would be a behaviour change for every // existing project — and an actively wrong one under --app-port, where the // stock value names a port the app is not serving on. A loopback host also adds -// nothing: with no ApplicationRootUrl the runtime derives one from the listen -// address, which is the same thing. So only a real host name — the case this +// nothing another machine could use. So only a real host name — the case this // exists for, giving each app in a solution its own name — is honoured. func customHostRootURL(rootURL string) bool { if rootURL == "" { @@ -788,6 +816,7 @@ func RunLocal(opts LocalRunOptions) error { if err != nil { return err } + warnWildcardWithoutRootURL(stderr, opts.ListenAddr, opts.AppPort, appRootURL, runtimeSettings) traceService := opts.TraceService if opts.Trace && traceService == "" { traceService = strings.TrimSuffix(filepath.Base(opts.ProjectPath), filepath.Ext(opts.ProjectPath)) @@ -800,6 +829,7 @@ func RunLocal(opts LocalRunOptions) error { JavaMajor: javaMajor, AppPort: opts.AppPort, AdminPort: opts.AdminPort, + ListenAddr: opts.ListenAddr, AdminPass: opts.AdminPass, ApplicationRootUrl: appRootURL, DB: opts.DB, @@ -845,6 +875,11 @@ func RunLocal(opts LocalRunOptions) error { }) fmt.Fprintf(w, "\nApp is running at %s\n", rt.AppURL()) + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, u := range networkAppURLs(opts.ListenAddr, opts.AppPort, addrs) { + fmt.Fprintf(w, " from the network: %s\n", u) + } + } // The local runtime boots with the live-preview dev flags (see // LocalRuntimeOptions.jvmArgs), so `mxcli oql` can query it directly — and it // now defaults to the local admin password, so no M2EE_ADMIN_PASS is needed diff --git a/cmd/mxcli/docker/settle.go b/cmd/mxcli/docker/settle.go index fa104e8816..82498c055e 100644 --- a/cmd/mxcli/docker/settle.go +++ b/cmd/mxcli/docker/settle.go @@ -60,12 +60,44 @@ func SettleGeneratedSources(projectPath, mxPath, version string, w io.Writer) er if errors.As(err, &werr) { return err // already carries the tail and the java home } - return fmt.Errorf("mxbuild failed: %w\n%s", err, lastLines(out.String(), 20)) + return classifySettleFailure(err, out.String()) } fmt.Fprintln(w, " Generated sources are in their post-build form.") return nil } +// ModelErrorsError is a build mxbuild refused because the MODEL has errors — +// as opposed to one that could not run (no JDK, a killed JVM). The difference +// matters to `mxcli new`: the first means the project it just created does not +// build, which is a failed scaffold, not a warning (mendixlabs/mxcli#1433). +type ModelErrorsError struct { + // Errors are mxbuild's "ERROR at : " lines. + Errors []string +} + +func (e *ModelErrorsError) Error() string { + return fmt.Sprintf("mxbuild refused the project: %d model error(s):\n %s", + len(e.Errors), strings.Join(e.Errors, "\n ")) +} + +// classifySettleFailure turns a failed settle build into an error, typed as a +// *ModelErrorsError when mxbuild's output lists model errors. mxbuild prints +// each as "ERROR at : " after "The project cannot be +// deployed, because it contains errors." (measured on 10.24.0). +func classifySettleFailure(runErr error, output string) error { + var modelErrs []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ERROR at ") { + modelErrs = append(modelErrs, line) + } + } + if len(modelErrs) > 0 { + return &ModelErrorsError{Errors: modelErrs} + } + return fmt.Errorf("mxbuild failed: %w\n%s", runErr, lastLines(output, 20)) +} + // resolveMxBuildForSettle finds mxbuild beside the resolved mx binary (the // Studio Pro / CDN layout puts them in the same directory), falling back to the // version's download cache. diff --git a/cmd/mxcli/docker/settle_test.go b/cmd/mxcli/docker/settle_test.go index e98c0c9783..a4523b8255 100644 --- a/cmd/mxcli/docker/settle_test.go +++ b/cmd/mxcli/docker/settle_test.go @@ -3,6 +3,7 @@ package docker import ( + "errors" "os" "path/filepath" "runtime" @@ -82,3 +83,46 @@ func TestSettleGeneratedSources_NoMxBuild(t *testing.T) { t.Errorf("error should name what is missing, got: %v", err) } } + +// The tail of the settle build mxbuild 10.24.0 printed for a `mxcli new` project +// whose scaffold layout named Atlas 4 design properties (mendixlabs/mxcli#1433). +const mxbuildModelErrorsOutput = `Executing step 'Check prerequisites' +Checking for errors... +Executing step 'Clean up' +Cleaning up... +ERROR: The project cannot be deployed, because it contains errors. +ERROR at MyFirstModule, Layout 'App_Default', Container 'topbarContent': Design property Flex container is not supported by your theme. Right-click to see more options. +ERROR at MyFirstModule, Layout 'App_Default', Container 'topbarContent': Design property Align items Y is not supported by your theme. Right-click to see more options. +BUILD FAILED +` + +// mendixlabs/mxcli#1433: a settle build that fails because the MODEL has errors +// is not the same failure as a missing JDK. It means the project mxcli just +// created does not build, and the caller has to be able to tell, so it is a +// typed error carrying the model errors rather than a flat string. +func TestClassifySettleFailure_ModelErrors(t *testing.T) { + err := classifySettleFailure(errors.New("exit status 3"), mxbuildModelErrorsOutput) + var merr *ModelErrorsError + if !errors.As(err, &merr) { + t.Fatalf("a build refused for model errors must be a *ModelErrorsError, got %T: %v", err, err) + } + if len(merr.Errors) != 2 { + t.Errorf("want the 2 model errors, got %d: %q", len(merr.Errors), merr.Errors) + } + if !strings.Contains(err.Error(), "Flex container") { + t.Errorf("the error must name what is wrong, got: %v", err) + } +} + +// Anything else (a JVM crash, a missing tool) stays an ordinary error: the +// project may well be fine. +func TestClassifySettleFailure_OtherFailure(t *testing.T) { + err := classifySettleFailure(errors.New("exit status 1"), "Error: could not start the JVM\n") + var merr *ModelErrorsError + if errors.As(err, &merr) { + t.Fatalf("a non-model failure was classified as model errors: %v", err) + } + if err == nil || !strings.Contains(err.Error(), "could not start the JVM") { + t.Errorf("the error must carry the output tail, got: %v", err) + } +} diff --git a/cmd/mxcli/exec_preflight.go b/cmd/mxcli/exec_preflight.go index 68dfda7189..5d3eac4327 100644 --- a/cmd/mxcli/exec_preflight.go +++ b/cmd/mxcli/exec_preflight.go @@ -44,6 +44,10 @@ func execPreflight(exec *executor.Executor, prog *ast.Program, projectPath, scri // project for MDL-WORKFLOW10 (ako/mxcli#943). violations = append(violations, executor.StoredTaskClaimViolations(prog, b)...) } + // A statement the project's Mendix version cannot store + // (MDL-VERSION01): the gate its handler applies when reached, + // after the statements before it (mendixlabs/mxcli#1434). + violations = append(violations, exec.CheckVersionGates(prog)...) } printPreflightViolations(violations, script, showInfo, w, color) if summary := linter.Summarize(violations); summary.Errors > 0 { diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 5bc2661c7a..2ad1840c1e 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -311,7 +311,7 @@ LIST IMPACT OF htmlelement; "drop template", "insert template", "list view template", "add parameter", "drop parameter", "page parameter", "add parameters", }, - Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match; or a\n-- navigation list item: nav item(widgetInIt), nav item('Text'), nav item@n. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", + Syntax: "ALTER PAGE Module.Name { -- the generic ALTER: set / insert / replace / drop\n SET (property: value) ON widgetName; -- widget property names: any casing\n SET ('Row size': 'Small') ON lvOrders; -- an Atlas DESIGN property of that widget's\n -- type; quoted and case-sensitive.\n -- `list design properties for ` lists\n -- them. ON/OFF for a toggle, where OFF\n -- REMOVES the entry.\n -- A multi-select ('Hide on') or compound\n -- ('Spacing') one needs the inline\n -- DesignProperties: (...) form, because a\n -- SET assignment carries one value.\n SET (Action: CALL MICROFLOW Module.MF) ON btnSave; -- any CREATE PAGE action form\n SET ('createFileAction': CALL MICROFLOW Module.MF) ON fileUploader1;\n -- a pluggable widget's NAMED action slot,\n -- by the widget's own key; refused on a\n -- key that is not action-typed\n SET (DataSource: $Param) ON dvOrder; -- parameter/microflow/nanoflow/selection;\n -- DATABASE and association are REPLACE-only,\n -- and a data view takes no database source\n SET (RenderMode: H2) ON txtTitle; -- dynamic text: Text | Paragraph | H1..H6\n SET (prop1: val1, prop2: val2) ON widgetName;\n SET (Title: 'New Title'); -- page-level (case-sensitive): no ON\n SET (Documentation: 'What this page is for.');\n SET (Class: 'css-class'); -- page-level CSS class / style\n SET (Style: 'css: rule');\n SET (PopupWidth: 800, PopupHeight: 480, PopupResizable: true); -- page-level pop-up\n INSERT AFTER widgetName { };\n INSERT BEFORE widgetName { };\n INSERT INTO containerName { };\n DROP name1, name2;\n DROP TEMPLATE FOR Module.Specialization IN listViewName;\n REPLACE widgetName WITH { };\n ADD PARAMETERS $Order: Module.Order; -- or a primitive: $Count: Integer.\n -- A page with a Url needs a {Order}\n -- segment: SET (Url: '…/{Order}') in the\n -- same statement (CE5601 otherwise).\n -- A snippet takes entities only (MDL087).\n DROP PARAMETERS $Order; -- refused while a data source or an\n -- expression on the page still uses it\n ADD VARIABLES $show: Boolean = true;\n DROP VARIABLES $show;\n};\n-- A target is a widget name, or a DataGrid 2 column by what it shows:\n-- grid column(Attr), grid column('Caption'), @n when two match; or a\n-- navigation list item: nav item(widgetInIt), nav item('Text'), nav item@n. The old spellings `SET p = v`,\n-- `SET p: v` (no parentheses) and `DROP WIDGET a` still run and warn\n-- (MDL-DEPR101..103).\n\n-- The BULK form: one design property on every widget of a TYPE.\nALTER PAGES [IN Module]\n SET 'Compact' = ON, 'Striped' = ON\n WHERE WIDGETTYPE = datagrid -- the MDL keyword, which resolves to\n -- exactly one widget id. A full id in\n -- quotes works too. NOT a name: a widget\n -- name is unique only within its page.\n [DRY RUN]; -- run this FIRST. It reports the matches\n -- against a discardable copy and writes\n -- nothing.", 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 txtUnused;\n};", SeeAlso: []string{"page.create", "page.show", "snippet.alter"}, }) diff --git a/docs-site/src/appendixes/error-messages.md b/docs-site/src/appendixes/error-messages.md index 349a17e29d..12822f64d3 100644 --- a/docs-site/src/appendixes/error-messages.md +++ b/docs-site/src/appendixes/error-messages.md @@ -175,6 +175,21 @@ level Production. This script creates it as a NEW microflow, ... [MDL-SEC21] It is an **error at security level Prototype or Production** (the stored level, or the one the script sets) and a warning at Off, where MxBuild does not check it. Only what the script changes is reported: a flow that already had the problem before the script, and that the script does not touch, is left to `mxcli docker check`. +### MDL-SEC22: A call across entity access (CE0114) + +``` +✗ microflow MyModule.DS_Tile_Count does not apply entity access (@applyentityaccess(false) +in this script) but calls microflow MyModule.SUB_Scoped_List, which does +(@applyentityaccess(true) as stored) — MxBuild reports CE0114 "A microflow that does not +apply entity access can only call microflows that also do not apply entity access." [MDL-SEC22] +``` + +**Cause:** With a project (`check -p`), a microflow without "Apply entity access" calls a microflow — or decides on a rule (`if Mod.Rule(…)`) — that has it. Measured on Mendix 11.13 and 11.14, MxBuild refuses only that direction, at every security level; on -> off, on -> on, off -> off, a nanoflow caller and an excluded caller all build. Both settings are read from the project and simulated through the script (`@applyentityaccess`, `@applyentityaccess(false)`, and an absent annotation on `create or modify` keeping what is stored). + +**Solution:** Put `@applyentityaccess` on the caller, or call a flow that does not apply entity access. Turning it off on the callee also builds, but widens what that flow may read and write for every caller. + +Only what the script changes is reported, and a callee neither the project nor the script holds is not judged. + ### MDL-I18N01: A tab page caption without the default language (CE4899) ``` diff --git a/docs-site/src/language/alter-page.md b/docs-site/src/language/alter-page.md index c4c1c7ab05..73a8187390 100644 --- a/docs-site/src/language/alter-page.md +++ b/docs-site/src/language/alter-page.md @@ -41,6 +41,7 @@ ALTER PAGE Module.EditPage { |----------|-------------|---------| | `Caption` | Button/link caption | `SET (Caption: 'Submit') ON btnSave` | | `Label` | Input field label | `SET (Label: 'Full Name') ON txtName` | +| `RenderMode` | Dynamic text rendering: `Text`, `Paragraph`, `H1`–`H6` | `SET (RenderMode: H2) ON txtTitle` | | `Placeholder` | Input hint text (text box, text area, date picker) | `SET (Placeholder: 'Enter your name') ON txtName` | | `Password` | Mask a text box's input | `SET (Password: true) ON txtPin` | | `Validation` | A text box's validation expression | `SET (Validation: '$value != empty') ON txtName` | diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index a4c1f62e1d..2f6d6a71b7 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -131,6 +131,7 @@ would make Studio Pro 11.13 fail to open it — a branch with no upstream, or | `--app-port` | 8080 | App HTTP port | | `--admin-port` | 8090 | M2EE admin API port | | `--serve-port` | 6543 | `mxbuild --serve` port | +| `--listen-addr` | `127.0.0.1` | IP address the app binds to (not a host name). `0.0.0.0` binds all IPv4 interfaces, `::` all interfaces, so other machines on the network can open the app; the URLs it is reachable at are printed after boot. The runtime does not derive an application root URL from this address, so if the app builds absolute URLs (SSO redirects, links in emails) pass `--runtime-setting ApplicationRootUrl=http://:/` or set a real host in the configuration (a warning says so). The M2EE admin API is unaffected and always binds `127.0.0.1` | | `--mxbuild-path` | resolved for this host | The mxbuild to build with, overriding resolution (Studio Pro on macOS/Windows, the cached CDN download on Linux). `MXCLI_MXBUILD_PATH` sets the same override from the environment; the flag wins | | `--db-host` | 127.0.0.1:5432 | Database `host:port`; bracket IPv6 endpoints (`[::1]:5432`) | | `--db-name` | derived from project | Database name | diff --git a/docs-site/src/tutorial/validation.md b/docs-site/src/tutorial/validation.md index 57c7e0ccd7..61bdd407f1 100644 --- a/docs-site/src/tutorial/validation.md +++ b/docs-site/src/tutorial/validation.md @@ -44,6 +44,7 @@ This catches everything Level 1 catches, plus: - 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 +- A statement the project's Mendix version cannot store (`MDL-VERSION01`, e.g. `create view entity` below 10.18) — the same version gates `exec` refuses with 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. diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 458730a6cc..0aba7bb301 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -1714,6 +1714,7 @@ This is the generic ALTER — `alter Module.Name { set (Key: value) on { +-- if (p.configMode === "simple") +-- hideNestedPropertiesIn(properties, values, "allowedFileFormats", index, +-- ["mimeType", "extensions", "typeFormatDescription"]); +-- else +-- hideNestedPropertiesIn(..., ["predefinedType"]); +-- }); +-- +-- A hidden TextTemplate must be stored as null (#574). mxcli applied that rule +-- to top-level properties only, so the required `typeFormatDescription` kept +-- the populated ClientTemplate the item builder gives it, and Studio Pro +-- rejected the widget. `mx update-widgets` on a copy changed exactly that slot +-- to null. Fixed by applying nested (object-list) visibility rules per item. +-- +-- The same fix clears the CE0463 on an Accordion group with +-- `HeaderRenderMode: 'custom'` (its `headerText` is hidden there) — +-- doctype-tests/32 `acc2`. +-- +-- Verify (needs File Uploader installed in widgets/): +-- mxcli -p app.mpr exec 1198-fileuploader-simple-allowedfileformat.mdl +-- mxcli docker check -p app.mpr --no-update-widgets -> 0 CE0463 +-- mxcli -p app.mpr -c "describe page BT1198.P_Simple" -> round-trips + +create module BT1198; +create module role BT1198.User; + +create persistent entity BT1198.UploadCtx ( Label: string(100) ); +create persistent entity BT1198.MyFile extends System.FileDocument (); +create association BT1198.MyFile_UploadCtx from BT1198.MyFile to BT1198.UploadCtx; +create nanoflow BT1198.NF_CreateFile ( $Ctx: BT1198.UploadCtx ) begin end; + +-- The failing case: configMode left at its default, 'simple'. +create or modify page BT1198.P_Simple (Title: 'Simple', Layout: Atlas_Core.Atlas_Default, + Params: ( $Ctx: BT1198.UploadCtx )) +{ + DATAVIEW dv (DataSource: $Ctx) { + fileuploader fu ( + uploadMode: 'files', + associatedFiles: association BT1198.MyFile_UploadCtx, + createFileAction: nanoflow BT1198.NF_CreateFile(Ctx = $Ctx) + ) { + allowedfileformat f1 (predefinedType: 'pdfFile') + } + } +}; + +-- The control: advanced mode always worked, and must keep its description. +create or modify page BT1198.P_Advanced (Title: 'Advanced', Layout: Atlas_Core.Atlas_Default, + Params: ( $Ctx: BT1198.UploadCtx )) +{ + DATAVIEW dv (DataSource: $Ctx) { + fileuploader fu ( + uploadMode: 'files', + associatedFiles: association BT1198.MyFile_UploadCtx, + createFileAction: nanoflow BT1198.NF_CreateFile(Ctx = $Ctx) + ) { + allowedfileformat f1 (configMode: 'advanced', mimeType: 'text/csv', extensions: '.csv', typeFormatDescription: 'CSV files') + } + } +}; + +grant view on page BT1198.P_Simple to BT1198.User; +grant view on page BT1198.P_Advanced to BT1198.User; diff --git a/mdl-examples/bug-tests/1357-odata-key-canbeempty-carry.mdl b/mdl-examples/bug-tests/1357-odata-key-canbeempty-carry.mdl index 2457fa768b..e290b072a7 100644 --- a/mdl-examples/bug-tests/1357-odata-key-canbeempty-carry.mdl +++ b/mdl-examples/bug-tests/1357-odata-key-canbeempty-carry.mdl @@ -9,7 +9,7 @@ create module MyModule; create persistent entity MyModule.Customer ( Name: String(200), Email: String(200) not null unique, - Phone: String(50) + Phone: String(50) unique ); create published odata service MyModule.CustomerAPI ( @@ -20,12 +20,13 @@ create published odata service MyModule.CustomerAPI ( ) { publish entity MyModule.Customer as 'Customers' ( ReadMode: SOURCE ) - expose (Name, Email, Phone); + expose (Name, Email, Phone (KEY)); }; -- Re-run with Email as the key. Email was stored with CanBeEmpty true as a --- non-key member; as a key it must be written false. (The first statement --- alone has no key and is CE6585 — it is the stored state, not the target.) +-- non-key member; as a key it must be written false. (The first statement is +-- keyed on Phone, not keyless: a published entity without a KEY is CE6585 and +-- check refuses it as MDL-ODATA08, mendixlabs/mxcli#1426.) create or modify published odata service MyModule.CustomerAPI ( Path: 'odata/customers/', Version: '1.0.0', diff --git a/mdl-examples/bug-tests/1420-combobox-association-snippet-param.fail.mdl b/mdl-examples/bug-tests/1420-combobox-association-snippet-param.fail.mdl new file mode 100644 index 0000000000..3857a692bd --- /dev/null +++ b/mdl-examples/bug-tests/1420-combobox-association-snippet-param.fail.mdl @@ -0,0 +1,27 @@ +-- mendixlabs/mxcli#1420: a combo box in association mode with no object for the +-- association to start from. +-- +-- NEGATIVE TEST (.fail.mdl) — this script is EXPECTED to fail `mxcli check`. +-- `make check-mdl` inverts the exit code for .fail.mdl files. +-- +-- Before the fix it passed check and exec, and mx check (11.6.6) reported +-- [CE7007] "Selected value is not valid for entity 'Asset_Customer/Customer'." +-- Expected now: MDL-WIDGET46 on combobox `x`, naming CE7007 and the spelling +-- that builds (`Attribute: $Asset.Bug1420F.Asset_Customer`, see +-- 1420-combobox-association-snippet-param.mdl). + +create module Bug1420F; + +create persistent entity Bug1420F.Customer ( CustomerName: String(200) ); +create persistent entity Bug1420F.Asset ( Code: String(100) ); +create association Bug1420F.Asset_Customer from Bug1420F.Asset to Bug1420F.Customer; + +create or modify snippet Bug1420F.SN_Asset +( Params: ( $Asset: Bug1420F.Asset ) ) +{ + combobox x ( + Association: Bug1420F.Asset_Customer, + DataSource: database from Bug1420F.Customer, + CaptionAttribute: CustomerName + ) +}; diff --git a/mdl-examples/bug-tests/1420-combobox-association-snippet-param.mdl b/mdl-examples/bug-tests/1420-combobox-association-snippet-param.mdl new file mode 100644 index 0000000000..590dba6976 --- /dev/null +++ b/mdl-examples/bug-tests/1420-combobox-association-snippet-param.mdl @@ -0,0 +1,49 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1420 — a combo box in association mode, bound to a snippet +-- parameter's reference, passed check and exec, then failed CE7007 +-- ============================================================================ +-- +-- Symptom (mxcli v0.26.0; reproduced on mx 11.6.6): +-- -- a snippet with $Asset: M.Asset, outside every data container: +-- combobox x (Association: M.Asset_Customer, DataSource: database M.Customer, +-- CaptionAttribute: CustomerName) +-- check: "Check passed!" exec: "Altered snippet M.S" +-- mx check: [CE7007] "Selected value is not valid for entity +-- 'Asset_Customer/Customer'. Please select the value again." +-- +-- The writer stored the association with no SourceVariable ("the enclosing +-- object"), and there is none. That spelling is now refused by check (no +-- project needed at a document root; with -p for ALTER) and by exec — see +-- 1420-combobox-association-snippet-param.fail.mdl. +-- +-- The two forms below are what builds. The first is what Studio Pro stores +-- for a parameter's reference: SourceVariable = the snippet parameter. +-- Verify: exec, then `mx check` (11.6.6) reports 0 errors, and +-- describe → exec reports "Unchanged snippet". +-- ============================================================================ + +create module Bug1420; + +create persistent entity Bug1420.Customer ( CustomerName: String(200) ); +create persistent entity Bug1420.Asset ( Code: String(100) ); +create association Bug1420.Asset_Customer from Bug1420.Asset to Bug1420.Customer; + +create or modify snippet Bug1420.SN_Asset +( Params: ( $Asset: Bug1420.Asset ) ) +{ + combobox cbParam ( + Attribute: $Asset.Bug1420.Asset_Customer, + DataSource: database from Bug1420.Customer, + CaptionAttribute: CustomerName + ) + dataview dvAsset (DataSource: $Asset) { + combobox cbInDataView ( + Association: Bug1420.Asset_Customer, + DataSource: database from Bug1420.Customer, + CaptionAttribute: CustomerName + ) + } +}; + +describe snippet Bug1420.SN_Asset; diff --git a/mdl-examples/bug-tests/1424-datagrid2-on-10x-package.mdl b/mdl-examples/bug-tests/1424-datagrid2-on-10x-package.mdl new file mode 100644 index 0000000000..4bfd0dbf27 --- /dev/null +++ b/mdl-examples/bug-tests/1424-datagrid2-on-10x-package.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- mendixlabs/mxcli#1424 — a Data grid 2 written by mxcli to a blank Mendix +-- 10.24.0 project failed `mx check` with CE0463 "The definition of this widget +-- has changed" until `mxcli fix widgets` ran. A blank 11.13.0 project was clean. +-- +-- 10.24.0 ships Data Widgets 2.30.3; the embedded template is 3.4-shaped, so +-- augmentation rebuilds the definition from the installed .mpk. Diffing mxcli's +-- widget Type against `mx update-widgets` output (the Object was identical) +-- showed three augmentation defects, each fixed in modelsdk/widgets: +-- +-- * a column's `filter` (widgets) took the `filterList` item's `filter` +-- (attribute) definition — PropertyDefs were indexed by bare key across +-- nesting levels; +-- * the new `filterList` object list had no ObjectType — exemplar indices +-- were taken before stale properties were removed, so it was cloned from an +-- Integer property; +-- * association definition fields (, selectableObjects) +-- were never parsed or reconciled. +-- +-- Plus the template's translations on properties the package ships none for +-- (the drop-down filter's emptyOptionCaption), which `update-widgets` drops. +-- +-- Verify on a blank 10.24.0 project (`mxcli new B --version 10.24.0`): +-- mxcli -p B.mpr exec 1424-datagrid2-on-10x-package.mdl +-- mxcli docker check -p B.mpr --no-update-widgets -> no CE0463 +-- Measured: before 1 CE0463 (gridA), after 0; doctype-tests/31 on 10.24.0 +-- 7 -> 0, on 10.24.24 2 -> 0, on 11.6.6 0 -> 0. + +create module BT1424; +create persistent entity BT1424.Customer (Name: string(200), Active: boolean default false); + +create page BT1424.P_Grid (Title: 'Grid', Layout: Atlas_Core.Atlas_Default) +{ + DATAGRID gridA (DataSource: DATABASE BT1424.Customer) { + COLUMN (Attribute: Name, Caption: 'Name') + } +}; + +-- A drop-down filter in a column: the emptyOptionCaption translation case. +create page BT1424.P_GridFilter (Title: 'Grid with filter', Layout: Atlas_Core.Atlas_Default) +{ + DATAGRID gridB (DataSource: DATABASE BT1424.Customer) { + COLUMN (Attribute: Name, Caption: 'Name') + COLUMN (Attribute: Active, Caption: 'Active') { + dropdownfilter ddfActive + } + } +}; diff --git a/mdl-examples/bug-tests/1426-odata-key-requires-unique-rule.fail.mdl b/mdl-examples/bug-tests/1426-odata-key-requires-unique-rule.fail.mdl new file mode 100644 index 0000000000..d4a74b7634 --- /dev/null +++ b/mdl-examples/bug-tests/1426-odata-key-requires-unique-rule.fail.mdl @@ -0,0 +1,66 @@ +mdl 1; + +/** + * mendixlabs/mxcli#1426: a published OData entity with no KEY, or with a KEY on + * an attribute without a unique rule, passed check and exec. mx check said + * [CE6585] "Published entity 'ModA.EntA' must have a key defined." + * [CE6624] "Add a unique validation rule to attribute 'Name' of entity + * 'ModA.EntA' to be able to use it as the key." + * + * This file is expected to FAIL `mxcli check` (.fail.mdl): MDL-ODATA08 on + * PubNoKey, MDL-ODATA09 on PubKeyNotUnique. exec refuses it too. + * + * Measured on mx 11.6.6 (OData3 and OData4 alike): only a unique VALIDATION RULE + * satisfies CE6624 — `not null` or an index does not. A composite key needs no + * rule (OData4 only, CE7238), and a non-persistent or view entity is exempt. + * The control is PubControl below, and 1423-odata-expose-quoted-member-name.mdl, + * whose key attribute is `unique` for this reason. + */ + +create module Bug1426; + +create persistent entity Bug1426.EntA ( + Name: string(200), + Code: string(50) +); + +create persistent entity Bug1426.EntB ( + Name: string(200) unique error message 'Name must be unique', + Code: string(50) +); + +-- CE6585: no member is KEY. +create published odata service Bug1426.PubNoKey ( + path: 'odata/bug1426nokey/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'Bug1426.NoKey' +) +{ + publish entity Bug1426.EntB as 'EntBs' (ReadMode: source) + expose (Name, Code); +}; + +-- CE6624: a single KEY on an attribute with no unique rule. +create published odata service Bug1426.PubKeyNotUnique ( + path: 'odata/bug1426notunique/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'Bug1426.NotUnique' +) +{ + publish entity Bug1426.EntA as 'EntAs' (ReadMode: source) + expose (Name (KEY), Code); +}; + +-- Control: the same key on a unique attribute builds clean. +create published odata service Bug1426.PubControl ( + path: 'odata/bug1426ok/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'Bug1426.Ok' +) +{ + publish entity Bug1426.EntB as 'EntBs' (ReadMode: source) + expose (Name (KEY), Code); +}; diff --git a/mdl-examples/bug-tests/1434-check-version-gates.mdl b/mdl-examples/bug-tests/1434-check-version-gates.mdl new file mode 100644 index 0000000000..bc0c9cc816 --- /dev/null +++ b/mdl-examples/bug-tests/1434-check-version-gates.mdl @@ -0,0 +1,38 @@ +mdl 1; +-- mendixlabs/mxcli#1434 — `mxcli check -p` passed a script that `mxcli exec` +-- then refused for the project's Mendix version, after the statements before +-- the refused one had run. check -p now applies exec's version gates (the same +-- statement-to-feature table, executor.statementVersionGates) as MDL-VERSION01, +-- and exec's pre-flight refuses the script before anything is written. +-- +-- Verify (on a copy of a Mendix 9.x project, e.g. modelsdk/mpr/testdata/v1-project): +-- mxcli check 1434-check-version-gates.mdl -p app.mpr +-- -> statement 5: create view entity requires Mendix 10.18.0+ ... [MDL-VERSION01] +-- mxcli exec 1434-check-version-gates.mdl -p app.mpr +-- -> the same line, "Refusing to execute ... Nothing was written." +-- On a 10.18+ project both pass. Without the view entity, both pass on 9.24: +-- the flows are what the reported StableId / CaseValues refusal came from (#1410). + +create module R1434; + +create entity R1434.Item ( + "Count": integer +); + +create microflow R1434.MF_Empty () +begin + return; +end; + +create microflow R1434.MF_Decide ($Item: R1434.Item) returns boolean +begin + declare $Result boolean = false; + if $Item/"Count" > 10 then + set $Result = true; + end if; + return $Result; +end; + +create view entity R1434.ItemView ( + Name: String(200) +) as select cast('a' as String) as Name; diff --git a/mdl-examples/bug-tests/1440-combobox-attribute-association-check.mdl b/mdl-examples/bug-tests/1440-combobox-attribute-association-check.mdl new file mode 100644 index 0000000000..bcf71bf9d9 --- /dev/null +++ b/mdl-examples/bug-tests/1440-combobox-attribute-association-check.mdl @@ -0,0 +1,45 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1440 — a combo box bound to an association through the +-- `Attribute:` key passed check, and exec refused it +-- ============================================================================ +-- +-- Symptom (mxcli v0.26.0): +-- alter snippet M.S { replace with { +-- combobox x (Attribute: $Asset.Asset_Customer, CaptionAttribute: …) +-- } }; +-- check: "Check passed!" +-- exec: "property attributeEnumeration binds Asset_Customer, which is an +-- association … not an attribute" +-- +-- exec is right: with no `DataSource:` the combo box is in ENUMERATION mode, +-- and its attribute property cannot hold a reference (#830). check now asks +-- exec's question with exec's function (rejectAssociationAsAttributeOn), for +-- CREATE and for an ALTER's INSERT / REPLACE, and needs `-p` to answer it: +-- mxcli check -p app.mpr +-- → "widget `x` property `attributeEnumeration` binds `Asset_Customer`, which +-- is an association (Bug1440.Asset → Bug1440.Customer) … To pick the +-- associated object, use the combo box's association mode …" +-- +-- The association mode below is the remedy the message names. Verify: exec, +-- then `mx check` (11.6.6) reports 0 errors, and describe → exec reports +-- "Unchanged snippet". +-- ============================================================================ + +create module Bug1440; + +create persistent entity Bug1440.Customer ( CustomerName: String(200) ); +create persistent entity Bug1440.Asset ( Code: String(100) ); +create association Bug1440.Asset_Customer from Bug1440.Asset to Bug1440.Customer; + +create or modify snippet Bug1440.SN_Asset +( Params: ( $Asset: Bug1440.Asset ) ) +{ + combobox x ( + Attribute: $Asset.Bug1440.Asset_Customer, + DataSource: database from Bug1440.Customer, + CaptionAttribute: CustomerName + ) +}; + +describe snippet Bug1440.SN_Asset; diff --git a/mdl-examples/bug-tests/1498-protected-module-export-levels.mdl b/mdl-examples/bug-tests/1498-protected-module-export-levels.mdl new file mode 100644 index 0000000000..c8263af164 --- /dev/null +++ b/mdl-examples/bug-tests/1498-protected-module-export-levels.mdl @@ -0,0 +1,53 @@ +mdl 1; +-- ============================================================================ +-- mendixlabs/mxcli#1498 — references into a Protected (add-on) module +-- ============================================================================ +-- +-- Reported: "Each time `check` passed, exec wrote, mxbuild refused, and the +-- gate rolled back." A module whose Projects$ModuleSettings says ExportLevel +-- 'Protected' hides each element not marked Usable (stored "API"): +-- +-- CE1406 "… is not allowed to use microflow 'MyAddOn.X' because app modules +-- cannot reference hidden elements in add-on modules." (navigation +-- says "project level documents") +-- CE5400 "The export level of entity 'MyAddOn.Request' should be 'Usable' +-- because parameter 'body' exposes it and has export level 'Usable'." +-- +-- This file is the shape that must PASS: the module it creates is an ordinary +-- (Source) module, which hides nothing, so every reference below is legal. MDL +-- cannot make a module Protected and `make check-mdl` runs without a project, +-- so the refused cases are Go tests against a Protected module: +-- mdl/executor/validate_protected_module_refs_test.go. +-- +-- Measured on blank Mendix 11.14.0 and 11.13.0 apps with the module set to +-- Protected: each refused case below fails with the CE code above; set back to +-- Source, the same model has 0 errors. A page in a Protected module is refused +-- even with export level API stored on it; an API microflow in the module that +-- shows the page is accepted. +-- ============================================================================ + +CREATE MODULE BugAddOn1498; + +CREATE OR MODIFY NON-PERSISTENT ENTITY BugAddOn1498.Request ( Payload: String(200) ); + +-- Usable microflow exposing an entity: CE5400 in a Protected module while the +-- entity is Hidden, legal here. +CREATE OR MODIFY MICROFLOW BugAddOn1498.API_Request_Post ($body: BugAddOn1498.Request) +RETURNS Boolean +EXPORT LEVEL api +BEGIN + RETURN true; +END; + +CREATE OR MODIFY MICROFLOW BugAddOn1498.Hidden_MF () +RETURNS Boolean +BEGIN + RETURN true; +END; + +-- A call from another module into a Hidden microflow: CE1406 in a Protected +-- module, legal here. +CREATE OR MODIFY MICROFLOW MyFirstModule.BugCaller1498 () +BEGIN + $r = CALL MICROFLOW BugAddOn1498.Hidden_MF(); +END; diff --git a/mdl-examples/bug-tests/1500-entity-access-call-mismatch.mdl b/mdl-examples/bug-tests/1500-entity-access-call-mismatch.mdl new file mode 100644 index 0000000000..15b90b20af --- /dev/null +++ b/mdl-examples/bug-tests/1500-entity-access-call-mismatch.mdl @@ -0,0 +1,69 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1500 - `check -p --references` passes a flow +-- without entity access that calls a flow with entity access; mxbuild +-- refuses it +-- ============================================================================ +-- +-- Symptom (before fix, Mendix 11.14.0): +-- `check -p --references` printed "no flow change exec would refuse … no +-- used flow left without access" and "Check passed!", and mxbuild then +-- reported: +-- CE0114 A microflow that does not apply entity access can only call +-- microflows that also do not apply entity access. +-- +-- After fix: +-- `check -p` reports MDL-SEC22 (error) on BugTest1500.DS_Tile_Count, naming +-- both settings and the CE0114 rule. Measured with mxbuild 11.13.0 and +-- 11.14.0: the same holds for a decision on a rule that applies entity +-- access ("can only call rules …"); the reverse direction, on -> on and +-- off -> off build clean, as do a nanoflow caller and an excluded caller. The +-- controls below are those clean shapes and are not reported. +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/1500-entity-access-call-mismatch.mdl -p app.mpr --references +-- Expect exactly one MDL-SEC22, on DS_Tile_Count. Exec it anyway and +-- `mxcli docker check` reports exactly one CE0114, at the same call. +-- ============================================================================ + +create module BugTest1500; +create persistent entity BugTest1500.Entity ( Name: String(100) ); + +@applyentityaccess(true) +create microflow BugTest1500.SUB_Scoped_List () returns List of BugTest1500.Entity +begin + retrieve $L from BugTest1500.Entity; + return $L; +end; + +@applyentityaccess(false) +create microflow BugTest1500.SUB_Unscoped_Count () returns Integer +begin + return 0; +end; + +-- The reported shape: off -> on. MDL-SEC22 / CE0114. +@applyentityaccess(false) +create or modify microflow BugTest1500.DS_Tile_Count () returns Integer +begin + $List = call microflow BugTest1500.SUB_Scoped_List(); + $n = count $List; + return $n; +end; + +-- Control: on -> off builds clean. +@applyentityaccess(true) +create microflow BugTest1500.DS_Scoped_Calls_Unscoped () returns Integer +begin + $n = call microflow BugTest1500.SUB_Unscoped_Count(); + return $n; +end; + +-- Control: on -> on builds clean. +@applyentityaccess(true) +create microflow BugTest1500.DS_Scoped_Calls_Scoped () returns Integer +begin + $List = call microflow BugTest1500.SUB_Scoped_List(); + $n = count $List; + return $n; +end; diff --git a/mdl-examples/bug-tests/1501-revoke-write-from-several-roles.mdl b/mdl-examples/bug-tests/1501-revoke-write-from-several-roles.mdl new file mode 100644 index 0000000000..f34fb1612e --- /dev/null +++ b/mdl-examples/bug-tests/1501-revoke-write-from-several-roles.mdl @@ -0,0 +1,55 @@ +mdl 1; +-- ============================================================================ +-- Bug #1501 — `revoke write (NewAttr) … from RoleA, RoleB, RoleC` said +-- "nothing to revoke", exited 0, and left NewAttr ReadWrite for all three roles. +-- +-- The rules were granted one role each, so each stores a single-role list. A +-- partial revoke only matched a rule whose role set EQUALLED the statement's, +-- {RoleA, RoleB, RoleC}, which no rule had. The member entries were there all +-- along — the same revoke from one role worked — so it was not the rule's +-- default member access. A partial revoke now narrows every rule all of whose +-- roles it names, as a full revoke always has, and refuses a rule shared with a +-- role it does not name rather than narrowing that role too. +-- +-- mxcli exec this-file -p app.mpr +-- before: "Unchanged entity access: Issue1501.Ticket (Issue1501.RoleA, +-- Issue1501.RoleB, Issue1501.RoleC) — nothing to revoke"; +-- `describe entity Issue1501.Ticket` still grants write (NewAttr) x3 +-- after: "Revoked partial access on Issue1501.Ticket from Issue1501.RoleA, +-- Issue1501.RoleB, Issue1501.RoleC"; NewAttr read-only in each rule +-- ============================================================================ + +create module Issue1501; +create module role Issue1501.RoleA; +create module role Issue1501.RoleB; +create module role Issue1501.RoleC; + +create enumeration Issue1501.Channel ( + Email 'Email', + Phone 'Phone' +); + +@position(100, 100) +create persistent entity Issue1501.Ticket ( + Title: string(200) +); + +create association Issue1501.Ticket_Owner + from Issue1501.Ticket + to System.User + type Reference; + +-- One rule per role, each `write *` and row-constrained — the reported shape. +grant create, delete, read *, write * on entity Issue1501.Ticket to Issue1501.RoleA + where [Issue1501.Ticket_Owner = '[%CurrentUser%]']; +grant create, delete, read *, write * on entity Issue1501.Ticket to Issue1501.RoleB + where [Issue1501.Ticket_Owner = '[%CurrentUser%]']; +grant create, delete, read *, write * on entity Issue1501.Ticket to Issue1501.RoleC + where [Issue1501.Ticket_Owner = '[%CurrentUser%]']; + +alter entity Issue1501.Ticket add attribute if not exists "NewAttr": enumeration(Issue1501.Channel); + +-- One statement for all three roles: the bug. +revoke write (NewAttr) on entity Issue1501.Ticket from Issue1501.RoleA, Issue1501.RoleB, Issue1501.RoleC; + +describe entity Issue1501.Ticket; diff --git a/mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl b/mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl new file mode 100644 index 0000000000..68d6e6bdff --- /dev/null +++ b/mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl @@ -0,0 +1,48 @@ +mdl 1; +-- ============================================================================ +-- Regression: mendixlabs/mxcli#1505 - a free annotation's line endings are +-- not a change to the flow +-- ============================================================================ +-- +-- Symptom: +-- describe prints a multi-line note with CRLF line breaks, as Studio Pro +-- stores it. Saved by an editor with LF, the same flow under mdl 1 was +-- refused: "the free annotations change; the splice edits activities only". +-- +-- After fix: +-- Note text is compared with every line break spelled LF. The second +-- create or modify below (LF) writes nothing, and the stored note keeps +-- its CRLF bytes. A real change to a free note is still refused, and the +-- refusal now names the note. +-- +-- This file holds raw CR bytes in the first statement's notes on purpose; an +-- editor that normalises line endings to LF will erase the case. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/1505-free-annotation-crlf-splice.mdl -p app.mpr +-- Expect the second statement to report no change, not a refusal. +-- ============================================================================ + +create module Bug1505; + +-- As Studio Pro stores it (CRLF). +create or modify microflow Bug1505.MF_NoteLineEndings () +begin + @annotation 'First line. +Second line.' + @position(200, 200) + @annotation 'On the log. +Two lines.' + log info node 'Bug1505' 'hello'; +end; + +-- As an editor saves it (LF): no change. +create or modify microflow Bug1505.MF_NoteLineEndings () +begin + @annotation 'First line. +Second line.' + @position(200, 200) + @annotation 'On the log. +Two lines.' + log info node 'Bug1505' 'hello'; +end; diff --git a/mdl-examples/bug-tests/595-published-odata-entitytypepointer.mdl b/mdl-examples/bug-tests/595-published-odata-entitytypepointer.mdl index 7f04b12b8c..33fc143733 100644 --- a/mdl-examples/bug-tests/595-published-odata-entitytypepointer.mdl +++ b/mdl-examples/bug-tests/595-published-odata-entitytypepointer.mdl @@ -18,7 +18,7 @@ create module bug595; create persistent entity bug595.Customer ( Name: string(200), - Email: string(200) + Email: string(200) unique ); create published odata service bug595.CustomerAPI ( @@ -35,5 +35,8 @@ create published odata service bug595.CustomerAPI ( UpdateMode: source, DeleteMode: not_supported ) - expose (*); + -- Not `expose (*)`: that publishes no KEY, which is CE6585 and refused by + -- check as MDL-ODATA08 (mendixlabs/mxcli#1426). A single key needs a unique + -- rule on its attribute (CE6624). + expose (Name, Email (KEY)); }; diff --git a/mdl-examples/bug-tests/alter-page-set-rendermode-dynamictext.mdl b/mdl-examples/bug-tests/alter-page-set-rendermode-dynamictext.mdl new file mode 100644 index 0000000000..e451ed2a13 --- /dev/null +++ b/mdl-examples/bug-tests/alter-page-set-rendermode-dynamictext.mdl @@ -0,0 +1,66 @@ +mdl 1; +-- ============================================================================ +-- ALTER PAGE / ALTER SNIPPET: SET RenderMode on a dynamic text +-- ============================================================================ +-- +-- Reported: +-- +-- alter page MyModule.Comparison_Page { set (RenderMode: H2) on compTitle } +-- -> failed to set RenderMode on compTitle: property "RenderMode" not found +-- (widget has no pluggable Object) +-- +-- (later builds word the same refusal as "not a property of this built-in +-- widget … use alter styling", which is no more true: RenderMode is not a +-- design property either). Upstream: mendixlabs/mxcli#1228, fix ported from +-- mendixlabs/mxcli#1229. +-- +-- `create page … dynamictext x (RenderMode: H1)` and `replace x with { … }` +-- both accept it, because RenderMode is a first-class Forms$DynamicText +-- property. The ALTER SET switch in setRawWidgetPropertyMut simply had no case +-- for it, so it fell through to the pluggable-property setter. +-- +-- Values: Text, Paragraph, H1..H6 (matched case-insensitively, stored in the +-- canonical spelling). Anything else is refused by both `check -p` and `exec` +-- — e.g. `set (RenderMode: H7) on title` → +-- invalid RenderMode "H7" for dynamic text "title": expected one of Text, +-- Paragraph, H1, H2, H3, H4, H5, H6 +-- +-- Measured on a copy of a real Mendix 11.14.0 app: exec → "Altered page", +-- describe shows RenderMode: H2, mx check → 0 errors; exactly one .mxunit +-- changed. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/alter-page-set-rendermode-dynamictext.mdl -p app.mpr +-- ============================================================================ + +create entity MyFirstModule.RmRow ( Name: String ); + +create or modify page MyFirstModule.P_SetRenderMode +( + Title: 'Set RenderMode', + Layout: Atlas_Core.Atlas_Default, + Params: ( $Row: MyFirstModule.RmRow ) +) +{ + dataview dv (datasource: $Row) { + dynamictext title (content: 'Title {1}', contentparams: ({1} = Name), rendermode: H1) + dynamictext body (content: 'Body') + } +}; + +alter page MyFirstModule.P_SetRenderMode { + set (RenderMode: H2) on title; + set (rendermode: paragraph) on body; +}; + +create or modify snippet MyFirstModule.S_SetRenderMode +{ + dynamictext snipTitle (content: 'Snippet title', rendermode: H3) +}; + +alter snippet MyFirstModule.S_SetRenderMode { + set (RenderMode: H4) on snipTitle; +}; + +describe page MyFirstModule.P_SetRenderMode; +describe snippet MyFirstModule.S_SetRenderMode; diff --git a/mdl-examples/bug-tests/published-odata-inherited-member.mdl b/mdl-examples/bug-tests/published-odata-inherited-member.mdl new file mode 100644 index 0000000000..7f2064bd79 --- /dev/null +++ b/mdl-examples/bug-tests/published-odata-inherited-member.mdl @@ -0,0 +1,40 @@ +mdl 1; + +/** + * Publishing a specialization and exposing a member its generalization + * declares wrote the member under the specialization's name. check and exec + * were clean; mx check (11.6.6) reported + * [CE1613] "The selected attribute 'OdInh.Sub.Code' no longer exists." + * [CE1613] "The selected attribute 'OdInh.Sub.Base_Tag' no longer exists." + * Mendix resolves an inherited attribute by the entity that declares it + * (OdInh.Base.Code), and an inherited association is an association end. + * After the fix: mx check 0 errors; describe prints the bare names below. + */ + +create module OdInh; + +create persistent entity OdInh.Base ( + Code: string(50) unique error message 'Code must be unique', + Label: string(100) +); + +create persistent entity OdInh.Sub extends OdInh.Base ( + Extra: string(100) +); + +create persistent entity OdInh.Tag ( + TagCode: string(50) unique error message 'TagCode must be unique' +); + +create association OdInh.Base_Tag from OdInh.Base to OdInh.Tag; + +create published odata service OdInh.Svc ( + path: 'odata/inh/', version: '1.0.0', ODataVersion: OData4, + namespace: 'OdInh' +) +{ + publish entity OdInh.Sub as 'Subs' (ReadMode: source) + expose (Code (KEY), Label, Extra, Base_Tag); + publish entity OdInh.Tag as 'Tags' (ReadMode: source) + expose (TagCode (KEY)); +}; diff --git a/mdl/backend/modelsdk/domainmodel.go b/mdl/backend/modelsdk/domainmodel.go index 6a601a663c..2c453553b6 100644 --- a/mdl/backend/modelsdk/domainmodel.go +++ b/mdl/backend/modelsdk/domainmodel.go @@ -182,6 +182,7 @@ func entityFromGen(e *genDm.Entity) *domainmodel.Entity { out := &domainmodel.Entity{ Name: e.Name(), Documentation: e.Documentation(), + ExportLevel: e.ExportLevel(), Persistable: true, // default; NoGeneralization overrides below } out.ID = model.ID(e.ID()) diff --git a/mdl/backend/modelsdk/domainmodel_security_write.go b/mdl/backend/modelsdk/domainmodel_security_write.go index 566eabee8b..067d665767 100644 --- a/mdl/backend/modelsdk/domainmodel_security_write.go +++ b/mdl/backend/modelsdk/domainmodel_security_write.go @@ -142,14 +142,44 @@ func (b *Backend) RevokeEntityMemberAccess(unitID model.ID, entityName string, r return 0, fmt.Errorf("RevokeEntityMemberAccess: entity not found: %s", entityName) } - readSet := toStringSet(rev.RevokeReadMembers) - writeSet := toStringSet(rev.RevokeWriteMembers) - modified := 0 + // The rules to narrow are those every role of which the statement names — + // per role, as RemoveEntityAccessRule has always matched. Requiring the + // rule's role set to EQUAL the statement's matched nothing when the rules + // were granted one role each and revoked in one statement, and 0 modified + // read as "nothing to revoke" while each rule kept the right + // (mendixlabs/mxcli#1501). A rule shared with a role the statement does not + // name cannot be narrowed without narrowing that role too, so it is refused + // before anything is written. + named := toStringSet(roleNames) + var targets []*genDm.AccessRule for _, el := range ent.AccessRulesItems() { rule, ok := el.(*genDm.AccessRule) - if !ok || !sameStringSet(rule.ModuleRolesQualifiedNames(), roleNames) { + if !ok { + continue + } + var in, out []string + for _, r := range rule.ModuleRolesQualifiedNames() { + if named[r] { + in = append(in, r) + } else { + out = append(out, r) + } + } + switch { + case len(in) == 0: continue + case len(out) > 0: + return 0, fmt.Errorf("the access rule on %s for %s also grants %s, which this revoke does not name; "+ + "revoke from all of the rule's roles, or grant the roles in separate rules first", + entityName, strings.Join(rule.ModuleRolesQualifiedNames(), ", "), strings.Join(out, ", ")) } + targets = append(targets, rule) + } + + readSet := toStringSet(rev.RevokeReadMembers) + writeSet := toStringSet(rev.RevokeWriteMembers) + modified := 0 + for _, rule := range targets { ruleMod := false if rev.RevokeCreate && rule.AllowCreate() { rule.SetAllowCreate(false) diff --git a/mdl/backend/modelsdk/entity_export_level_read_test.go b/mdl/backend/modelsdk/entity_export_level_read_test.go new file mode 100644 index 0000000000..bde5240ced --- /dev/null +++ b/mdl/backend/modelsdk/entity_export_level_read_test.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +package modelsdkbackend + +import ( + "testing" + + genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels" +) + +// mendixlabs/mxcli#1498: the check that a reference into a Protected (add-on) +// module names a Usable entity (CE1406, CE5400) needs the entity's stored +// export level, which the read dropped. Both members, so a read that returns a +// constant cannot pass. +func TestEntityFromGen_ReadsExportLevel(t *testing.T) { + for _, lvl := range []string{"API", "Hidden"} { + e := genDm.NewEntity() + e.SetName("Request") + e.SetExportLevel(lvl) + if got := entityFromGen(e).ExportLevel; got != lvl { + t.Errorf("stored ExportLevel %q read back as %q", lvl, got) + } + } +} diff --git a/mdl/backend/modelsdk/revoke_multi_role_1501_test.go b/mdl/backend/modelsdk/revoke_multi_role_1501_test.go new file mode 100644 index 0000000000..531fde03d8 --- /dev/null +++ b/mdl/backend/modelsdk/revoke_multi_role_1501_test.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1501: `revoke write (NewAttr) on entity M.E from M.RoleA, +// M.RoleB, M.RoleC` answered "Unchanged entity access: … — nothing to revoke", +// exit 0, while every rule still held NewAttr at ReadWrite. +// +// The rules were granted one role each (`grant … to M.RoleA where […]`, three +// times), so each stores a single-role ModuleRoles list. RevokeEntityMemberAccess +// only touched a rule whose role set EQUALLED the statement's, {A,B,C}, which no +// rule had — 0 rules modified, reported as already revoked. The member entries +// were there all along (the same revoke from one role narrows them), so the +// reporter's "default member access, no stored entry" hypothesis was not it. +// +// A full REVOKE (RemoveEntityAccessRule) has always worked per role. A partial +// one now matches the same way — every rule whose roles are all named — and +// refuses a rule it would have to narrow for a role the statement does not name. +package modelsdkbackend + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" +) + +// grantRoles stores a `read *, write *` rule on ZzCust for exactly roles, +// constrained by xpath, the shape `grant … read *, write * … to roles where […]` +// writes. +func grantRoles(t *testing.T, b *Backend, dmID model.ID, xpath string, roles ...string) { + t.Helper() + p := grantParams(dmID, "ReadWrite", xpath, nil) + p.RoleNames = roles + if err := b.AddEntityAccessRule(p); err != nil { + t.Fatalf("AddEntityAccessRule %v: %v", roles, err) + } +} + +// ruleRights returns the member rights of the stored rule whose roles are exactly roles. +func ruleRights(t *testing.T, b *Backend, modID model.ID, roles ...string) map[string]string { + t.Helper() + for _, r := range rulesOf(t, b, modID) { + if sameStringSet(r.ModuleRoleNames, roles) { + return rightsOf(r) + } + } + t.Fatalf("no rule for roles %v", roles) + return nil +} + +func revokeWritePhone() types.EntityAccessRevocation { + return types.EntityAccessRevocation{RevokeWriteMembers: []string{"MyFirstModule.ZzCust.Phone"}} +} + +const ownerXPath = "[MyFirstModule.ZzCust_Owner = '[%CurrentUser%]']" + +// The reported shape: one rule per role, one revoke naming all three. +func TestRevokeMember_NarrowsEachSingleRoleRuleTheStatementNames(t *testing.T) { + b, mod, dm := grantFixture(t) + roles := []string{"MyFirstModule.RoleA", "MyFirstModule.RoleB", "MyFirstModule.RoleC"} + for _, r := range roles { + grantRoles(t, b, dm.ID, ownerXPath, r) + } + + n, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", roles, revokeWritePhone()) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if n != 3 { + t.Errorf("rules modified = %d, want 3 — 0 is the reported \"nothing to revoke\"", n) + } + for _, r := range roles { + got := ruleRights(t, b, mod.ID, r) + if got["Phone"] != "ReadOnly" { + t.Errorf("%s: Phone = %q, want ReadOnly", r, got["Phone"]) + } + if got["Name"] != "ReadWrite" || got["Email"] != "ReadWrite" { + t.Errorf("%s: unnamed members changed: %v", r, got) + } + } +} + +// Control: a rule naming exactly the statement's roles still matches. +func TestRevokeMember_ExactRoleSetStillMatches(t *testing.T) { + b, mod, dm := grantFixture(t) + roles := []string{"MyFirstModule.RoleA", "MyFirstModule.RoleB"} + grantRoles(t, b, dm.ID, "", roles...) + + n, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", roles, revokeWritePhone()) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if n != 1 { + t.Errorf("rules modified = %d, want 1", n) + } + if got := ruleRights(t, b, mod.ID, roles...); got["Phone"] != "ReadOnly" { + t.Errorf("Phone = %q, want ReadOnly", got["Phone"]) + } +} + +// A rule of other roles only is not the statement's to narrow. +func TestRevokeMember_LeavesRulesOfUnnamedRoles(t *testing.T) { + b, mod, dm := grantFixture(t) + grantRoles(t, b, dm.ID, "", "MyFirstModule.RoleA") + grantRoles(t, b, dm.ID, ownerXPath, "MyFirstModule.RoleD") + + if _, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", []string{"MyFirstModule.RoleA"}, revokeWritePhone()); err != nil { + t.Fatalf("revoke: %v", err) + } + if got := ruleRights(t, b, mod.ID, "MyFirstModule.RoleD"); got["Phone"] != "ReadWrite" { + t.Errorf("RoleD: Phone = %q, want ReadWrite (not named by the revoke)", got["Phone"]) + } +} + +// A rule shared with a role the statement does not name cannot be narrowed +// without narrowing that role too. Refused, naming the rule, and nothing is +// written — not even the rules that could have been narrowed. +func TestRevokeMember_RefusesARuleSharedWithAnUnnamedRole(t *testing.T) { + b, mod, dm := grantFixture(t) + grantRoles(t, b, dm.ID, ownerXPath, "MyFirstModule.RoleA") + grantRoles(t, b, dm.ID, "", "MyFirstModule.RoleA", "MyFirstModule.RoleD") + + n, err := b.RevokeEntityMemberAccess(dm.ID, "ZzCust", []string{"MyFirstModule.RoleA"}, revokeWritePhone()) + if err == nil { + t.Fatalf("revoke succeeded (%d rules); want a refusal naming the shared rule", n) + } + if msg := err.Error(); !strings.Contains(msg, "MyFirstModule.RoleD") { + t.Errorf("error does not name the unnamed role: %v", err) + } + if got := ruleRights(t, b, mod.ID, "MyFirstModule.RoleA"); got["Phone"] != "ReadWrite" { + t.Errorf("RoleA-only rule was narrowed before the refusal: Phone = %q", got["Phone"]) + } + if got := ruleRights(t, b, mod.ID, "MyFirstModule.RoleA", "MyFirstModule.RoleD"); got["Phone"] != "ReadWrite" { + t.Errorf("shared rule was narrowed: Phone = %q", got["Phone"]) + } +} diff --git a/mdl/backend/pagemutator/dynamictext_rendermode_test.go b/mdl/backend/pagemutator/dynamictext_rendermode_test.go new file mode 100644 index 0000000000..2c5b365fa1 --- /dev/null +++ b/mdl/backend/pagemutator/dynamictext_rendermode_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pagemutator + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/bsonnav" +) + +// makeDynamicText builds a stored dynamic text widget the way Studio Pro writes +// it: a RenderMode string alongside the Content client template. +func makeDynamicText(name, renderMode string) bson.D { + return bson.D{ + {Key: "$Type", Value: "Forms$DynamicText"}, + {Key: "Name", Value: name}, + {Key: "NativeTextStyle", Value: "Text"}, + {Key: "RenderMode", Value: renderMode}, + } +} + +// `alter page P { set RenderMode = H1 on compTitle }` on a dynamic text failed +// with +// +// failed to set RenderMode on compTitle: property "RenderMode" not found (widget has no pluggable Object) +// +// while `create page … dynamictext x (…, RenderMode: H1)` and `replace x with +// { dynamictext x (…, RenderMode: H1) }` both accept it. RenderMode is a +// first-class Forms$DynamicText property, so the SET must write it in place. +func TestSetWidgetProperty_DynamicTextRenderMode(t *testing.T) { + cases := []struct{ in, want string }{ + {"H1", "H1"}, + {"H6", "H6"}, + {"h2", "H2"}, // property values arrive as typed — case-insensitive, like the name + {"Paragraph", "Paragraph"}, + {"text", "Text"}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + rawData := makeRawPage(makeDynamicText("compTitle", "Text")) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("compTitle", "RenderMode", tc.in); err != nil { + t.Fatalf("SetWidgetProperty(RenderMode=%s) failed: %v", tc.in, err) + } + got := bsonnav.DGetString(findBsonWidget(rawData, "compTitle").widget, "RenderMode") + if got != tc.want { + t.Errorf("RenderMode = %q, want %q", got, tc.want) + } + }) + } + + t.Run("lowercase property name", func(t *testing.T) { + rawData := makeRawPage(makeDynamicText("compTitle", "Text")) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + if err := m.SetWidgetProperty("compTitle", "rendermode", "H3"); err != nil { + t.Fatalf("SetWidgetProperty(rendermode) failed: %v", err) + } + if got := bsonnav.DGetString(findBsonWidget(rawData, "compTitle").widget, "RenderMode"); got != "H3" { + t.Errorf("RenderMode = %q, want H3", got) + } + }) +} + +// An invalid value is refused with the accepted list, and the stored value is +// left alone — writing an unknown enum member gives a document Studio Pro +// cannot open. +func TestSetWidgetProperty_DynamicTextRenderModeInvalid(t *testing.T) { + for _, bad := range []any{"H7", "Div", "", 1, true} { + rawData := makeRawPage(makeDynamicText("compTitle", "H1")) + m := &Mutator{rawData: rawData, widgetFinder: findBsonWidget} + err := m.SetWidgetProperty("compTitle", "RenderMode", bad) + if err == nil { + t.Fatalf("RenderMode = %v must be refused", bad) + } + msg := err.Error() + if strings.Contains(msg, "pluggable Object") { + t.Errorf("RenderMode = %v: the message describes mxcli internals: %q", bad, msg) + } + if !strings.Contains(msg, "Paragraph") || !strings.Contains(msg, "H6") { + t.Errorf("RenderMode = %v: the message does not list the accepted values: %q", bad, msg) + } + if got := bsonnav.DGetString(findBsonWidget(rawData, "compTitle").widget, "RenderMode"); got != "H1" { + t.Errorf("RenderMode = %v: stored value changed to %q on a refused write", bad, got) + } + } +} diff --git a/mdl/backend/pagemutator/mutator.go b/mdl/backend/pagemutator/mutator.go index 67124ea437..26a95a2a9f 100644 --- a/mdl/backend/pagemutator/mutator.go +++ b/mdl/backend/pagemutator/mutator.go @@ -3178,12 +3178,69 @@ func setRawWidgetPropertyMut(widget bson.D, propName string, value any) error { return err } return setPluggableWidgetPropertyMut(widget, propName, value) + case "rendermode": + // RenderMode is a first-class property of a dynamic text (Text, Paragraph, + // H1–H6). Other widgets that store a RenderMode (a container's Div/Section/…) + // or a pluggable widget with its own "renderMode" key keep the existing path. + if isDynamicTextWidget(widget) { + return setDynamicTextRenderModeMut(widget, value) + } + return setPluggableWidgetPropertyMut(widget, propName, value) default: // Try as pluggable widget property return setPluggableWidgetPropertyMut(widget, propName, value) } } +// dynamicTextRenderModes are the values a Forms$DynamicText's RenderMode takes — +// the same set CREATE PAGE writes from `dynamictext x (RenderMode: …)`. +var dynamicTextRenderModes = []pages.TextRenderMode{ + pages.TextRenderModeText, + pages.TextRenderModeParagraph, + pages.TextRenderModeH1, + pages.TextRenderModeH2, + pages.TextRenderModeH3, + pages.TextRenderModeH4, + pages.TextRenderModeH5, + pages.TextRenderModeH6, +} + +// isDynamicTextWidget reports whether a stored widget is a dynamic text. Stored +// documents carry Forms$DynamicText; Pages$DynamicText is the SDK spelling. +func isDynamicTextWidget(widget bson.D) bool { + switch bsonnav.DGetString(widget, "$Type") { + case "Forms$DynamicText", "Pages$DynamicText": + return true + } + return false +} + +// setDynamicTextRenderModeMut writes a dynamic text's RenderMode in place. The +// value is matched case-insensitively (MDL passes it as typed: `H1`, `h1`) and +// stored in the canonical spelling; anything outside the enumeration is refused +// before the document is touched — an unknown enum member is a document Studio +// Pro cannot open. +func setDynamicTextRenderModeMut(widget bson.D, value any) error { + s, _ := value.(string) + for _, mode := range dynamicTextRenderModes { + if s != "" && strings.EqualFold(s, string(mode)) { + if !bsonnav.DSet(widget, "RenderMode", string(mode)) { + // Studio Pro writes RenderMode on every dynamic text; a document + // without it is not one this setter should guess a position for. + return fmt.Errorf("dynamic text %q has no stored RenderMode property to set", + bsonnav.DGetString(widget, "Name")) + } + return nil + } + } + names := make([]string, len(dynamicTextRenderModes)) + for i, mode := range dynamicTextRenderModes { + names[i] = string(mode) + } + return fmt.Errorf("invalid RenderMode %q for dynamic text %q: expected one of %s", + fmt.Sprint(value), bsonnav.DGetString(widget, "Name"), strings.Join(names, ", ")) +} + // setLegacyImageRefMut writes a static image's Image or a dynamic image's // DefaultImage: the qualified name of an image-collection entry as a plain // string ("" when unset), the key and shape CREATE writes and DESCRIBE reads diff --git a/mdl/backend/widgetobj/builder.go b/mdl/backend/widgetobj/builder.go index 0a7bdd2a5a..a9b3d84958 100644 --- a/mdl/backend/widgetobj/builder.go +++ b/mdl/backend/widgetobj/builder.go @@ -865,6 +865,11 @@ func ApplyVisibilityRules(object bson.D, propertyTypeIDs map[string]pages.Proper hidden := make(map[string]bool, len(rules)) conditional := make([]string, 0, len(rules)) for _, rule := range rules { + if rule.Nested() { + // Its PropertyKey names a sub-property of a list ITEM, not of the + // widget — applied per item below. + continue + } entry, ok := propertyTypeIDs[rule.PropertyKey] if !ok || entry.ValueType != "TextTemplate" { continue @@ -900,9 +905,104 @@ func ApplyVisibilityRules(object bson.D, propertyTypeIDs map[string]pages.Proper return val }) } + return applyItemVisibilityRules(object, propertyTypeIDs, rules, values) +} + +// applyItemVisibilityRules is the #574 nulling one level down: a TextTemplate +// sub-property of an object-list ITEM that a nested rule hides for that item +// gets TextTemplate: null (mendixlabs/mxcli#1198). +// +// File Uploader hides an `allowedFileFormats` item's `typeFormatDescription` +// while that item's `configMode` is "simple". The sub-property is required, so +// the item builder fills it with the shipped translations (#891) — correct +// while it is visible, CE0463 once it is hidden: `mx update-widgets` on a copy +// changes exactly that slot to null and nothing else of substance. +// +// Each condition is resolved in its own scope (types.ConditionScopeItem → the +// item's values, otherwise the widget's), the same split MDL-WIDGET10 makes in +// validateWidgetItemVisibility. Only the hidden→null direction is applied here: +// an unset item TextTemplate is null by default (4ea402c2) and required ones +// are already filled, so there is no visible-but-null case to repair. +func applyItemVisibilityRules(object bson.D, propertyTypeIDs map[string]pages.PropertyTypeIDEntry, rules []types.WidgetVisibilityRule, widgetValues map[string]string) bson.D { + byList := map[string][]types.WidgetVisibilityRule{} + for _, rule := range rules { + if !rule.Nested() { + continue + } + entry, ok := propertyTypeIDs[rule.ListPropertyKey] + if !ok || len(entry.NestedPropertyIDs) == 0 { + continue + } + if sub, ok := entry.NestedPropertyIDs[rule.PropertyKey]; !ok || sub.ValueType != "TextTemplate" { + continue + } + byList[rule.ListPropertyKey] = append(byList[rule.ListPropertyKey], rule) + } + lists := make([]string, 0, len(byList)) + for k := range byList { + lists = append(lists, k) + } + sort.Strings(lists) + + for _, listKey := range lists { + nested := propertyTypeIDs[listKey].NestedPropertyIDs + listRules := byList[listKey] + object = updateWidgetPropertyValue(object, propertyTypeIDs, listKey, func(val bson.D) bson.D { + out := make(bson.D, 0, len(val)) + for _, elem := range val { + objects, ok := elem.Value.(bson.A) + if elem.Key != "Objects" || !ok { + out = append(out, elem) + continue + } + updated := make(bson.A, len(objects)) + for i, o := range objects { + item, ok := o.(bson.D) + if !ok { + updated[i] = o // the list marker + continue + } + updated[i] = nullHiddenItemTemplates(item, nested, listRules, widgetValues) + } + out = append(out, bson.E{Key: "Objects", Value: updated}) + } + return out + }) + } return object } +// nullHiddenItemTemplates nulls the TextTemplate of every sub-property of one +// object-list item that rules hide under that item's own configuration. +func nullHiddenItemTemplates(item bson.D, nested map[string]pages.PropertyTypeIDEntry, rules []types.WidgetVisibilityRule, widgetValues map[string]string) bson.D { + itemValues := primitiveValuesOf(item, nested) + hidden := map[string]bool{} + for _, rule := range rules { + fires, determinable := rule.Fires(func(c types.WidgetVisibilityCondition) (string, bool) { + values := widgetValues + if c.Scope == types.ConditionScopeItem { + values = itemValues + } + v, ok := values[c.PropertyKey] + return v, ok + }) + if determinable && fires { + hidden[rule.PropertyKey] = true + } + } + keys := make([]string, 0, len(hidden)) + for k := range hidden { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + item = updateWidgetPropertyValue(item, nested, key, func(val bson.D) bson.D { + return setBSONField(val, "TextTemplate", nil) + }) + } + return item +} + // bsonFieldIsNil reports whether a field is absent or explicitly nil. func bsonFieldIsNil(val bson.D, field string) bool { for _, elem := range val { diff --git a/mdl/backend/widgetobj/objectlist_item_visibility_test.go b/mdl/backend/widgetobj/objectlist_item_visibility_test.go new file mode 100644 index 0000000000..5844efeba7 --- /dev/null +++ b/mdl/backend/widgetobj/objectlist_item_visibility_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Issue mendixlabs/mxcli#1198: a File Uploader `allowedfileformat` item in the +// default `configMode: 'simple'` passed check and exec, then failed `mx check` +// with CE0463 "the definition of this widget has changed". +// +// The widget's editorConfig hides the item's `typeFormatDescription` +// TextTemplate in simple mode, per item: +// +// values.allowedFileFormats.forEach((p, index) => { +// if (p.configMode === "simple") +// hideNestedPropertiesIn(properties, values, "allowedFileFormats", index, +// ["mimeType", "extensions", "typeFormatDescription"]); +// ... +// +// The rule was extracted (ListPropertyKey "allowedFileFormats"), but +// ApplyVisibilityRules only looked at top-level properties, so the hidden +// item template kept the populated ClientTemplate the required-TextTemplate +// default (#891) gives it. `mx update-widgets` on a copy changes exactly that +// slot to null (11.6.6), which is the #574 rule one level down. +package widgetobj + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +const ( + fuListID = "a0000000-0000-0000-0000-000000000001" // object list "allowedFileFormats" + fuObjTypeID = "a0000000-0000-0000-0000-000000000002" + fuConfigModeID = "a0000000-0000-0000-0000-000000000003" // item enum "configMode" + fuDescID = "a0000000-0000-0000-0000-000000000004" // item TextTemplate "typeFormatDescription" + fuTopDescID = "a0000000-0000-0000-0000-000000000005" // top-level TextTemplate sharing the item key + fuModeID = "a0000000-0000-0000-0000-000000000006" // widget enum "uploadMode" + fuCaptionID = "a0000000-0000-0000-0000-000000000007" // item TextTemplate "caption" (widget-scoped rule) +) + +func fuPropertyTypeIDs() map[string]pages.PropertyTypeIDEntry { + return map[string]pages.PropertyTypeIDEntry{ + "uploadMode": {PropertyTypeID: fuModeID, ValueType: "Enumeration"}, + // A top-level property whose key collides with the item's: a nested rule + // must never be applied to it. + "typeFormatDescription": {PropertyTypeID: fuTopDescID, ValueType: "TextTemplate"}, + "allowedFileFormats": { + PropertyTypeID: fuListID, + ValueType: "Object", + ObjectTypeID: fuObjTypeID, + NestedKeyOrder: []string{"configMode", "typeFormatDescription", "caption"}, + NestedPropertyIDs: map[string]pages.PropertyTypeIDEntry{ + "configMode": {PropertyTypeID: fuConfigModeID, ValueType: "Enumeration", DefaultValue: "simple"}, + "typeFormatDescription": {PropertyTypeID: fuDescID, ValueType: "TextTemplate", Required: true}, + "caption": {PropertyTypeID: fuCaptionID, ValueType: "TextTemplate", Required: true}, + }, + }, + } +} + +func fuProp(id, primitive string, tt any, objects bson.A) bson.D { + val := bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetValue"}, + {Key: "PrimitiveValue", Value: primitive}, + {Key: "TextTemplate", Value: tt}, + } + if objects != nil { + val = append(val, bson.E{Key: "Objects", Value: objects}) + } + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetProperty"}, + {Key: "TypePointer", Value: types.UUIDToBlob(id)}, + {Key: "Value", Value: val}, + } +} + +func fuItem(configMode string) bson.D { + return bson.D{ + {Key: "$Type", Value: "CustomWidgets$WidgetObject"}, + {Key: "TypePointer", Value: types.UUIDToBlob(fuObjTypeID)}, + {Key: "Properties", Value: bson.A{ + int32(2), + fuProp(fuConfigModeID, configMode, nil, nil), + fuProp(fuDescID, "", BuildEmptyClientTemplate(), nil), + fuProp(fuCaptionID, "", BuildEmptyClientTemplate(), nil), + }}, + } +} + +func fuObject(uploadMode string, items ...bson.D) bson.D { + objects := bson.A{int32(2)} + for _, it := range items { + objects = append(objects, it) + } + return bson.D{{Key: "Properties", Value: bson.A{ + int32(2), + fuProp(fuModeID, uploadMode, nil, nil), + fuProp(fuTopDescID, "", BuildEmptyClientTemplate(), nil), + fuProp(fuListID, "", nil, objects), + }}} +} + +// itemTemplate returns the TextTemplate of the item sub-property propID in the +// n-th (0-based) item of the allowedFileFormats list. +func itemTemplate(t *testing.T, object bson.D, n int, propID string) any { + t.Helper() + for _, elem := range object { + if elem.Key != "Properties" { + continue + } + for _, p := range elem.Value.(bson.A) { + prop, ok := p.(bson.D) + if !ok || propertyTypePointerID(prop) != normalizeID(fuListID) { + continue + } + val := findField(t, prop, "Value").(bson.D) + objects := findField(t, val, "Objects").(bson.A) + item := objects[n+1].(bson.D) // [0] is the list marker + return templateOf(t, item, propID) + } + } + t.Fatalf("allowedFileFormats not found") + return nil +} + +func TestApplyVisibilityRules_ObjectListItems(t *testing.T) { + rules := []types.WidgetVisibilityRule{ + { + PropertyKey: "typeFormatDescription", + ListPropertyKey: "allowedFileFormats", + HiddenWhen: &types.WidgetVisibilityCondition{ + PropertyKey: "configMode", Operator: "eq", Value: "simple", Scope: types.ConditionScopeItem, + }, + }, + // A nested rule whose condition is about the WIDGET, not the item. + { + PropertyKey: "caption", + ListPropertyKey: "allowedFileFormats", + HiddenWhen: &types.WidgetVisibilityCondition{PropertyKey: "uploadMode", Operator: "ne", Value: "files"}, + }, + } + + got := ApplyVisibilityRules(fuObject("files", fuItem("simple"), fuItem("advanced")), fuPropertyTypeIDs(), rules) + + if tt := itemTemplate(t, got, 0, fuDescID); tt != nil { + t.Errorf("simple-mode item: typeFormatDescription TextTemplate = %v, want nil "+ + "(hidden when configMode = simple; a populated template is CE0463, #1198)", tt) + } + if tt := itemTemplate(t, got, 1, fuDescID); tt == nil { + t.Error("advanced-mode item: typeFormatDescription TextTemplate was nulled; it is visible there") + } + // The item-scoped condition is evaluated per ITEM, not against the widget. + if tt := templateOf(t, got, fuTopDescID); tt == nil { + t.Error("top-level typeFormatDescription was nulled by a NESTED rule; nested rules only address list items") + } + // Widget-scoped condition inside a nested rule: uploadMode is "files", so visible. + if tt := itemTemplate(t, got, 0, fuCaptionID); tt == nil { + t.Error("caption nulled although its widget-scoped condition (uploadMode ≠ files) does not hold") + } + + got = ApplyVisibilityRules(fuObject("images", fuItem("advanced")), fuPropertyTypeIDs(), rules) + if tt := itemTemplate(t, got, 0, fuCaptionID); tt != nil { + t.Error("caption kept its template although its widget-scoped condition (uploadMode ≠ files) holds") + } +} diff --git a/mdl/executor/cmd_agenteditor_models.go b/mdl/executor/cmd_agenteditor_models.go index f3ed685907..b1f6e8dfdf 100644 --- a/mdl/executor/cmd_agenteditor_models.go +++ b/mdl/executor/cmd_agenteditor_models.go @@ -140,9 +140,7 @@ func execCreateAgentEditorModel(ctx *ExecContext, s *ast.CreateModelStmt) error // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_model", - "create model", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_agenteditor_write.go b/mdl/executor/cmd_agenteditor_write.go index d26aa64453..b938dd04b0 100644 --- a/mdl/executor/cmd_agenteditor_write.go +++ b/mdl/executor/cmd_agenteditor_write.go @@ -26,9 +26,7 @@ func execCreateConsumedMCPService(ctx *ExecContext, s *ast.CreateConsumedMCPServ // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_consumed_mcp_service", - "create consumed mcp service", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -116,9 +114,7 @@ func execCreateKnowledgeBase(ctx *ExecContext, s *ast.CreateKnowledgeBaseStmt) e // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent_knowledge_base", - "create knowledge base", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -223,9 +219,7 @@ func execCreateAgent(ctx *ExecContext, s *ast.CreateAgentStmt) error { // module. Nothing downstream catches an older project: the documents are // custom blobs, so mxbuild does not validate them and the build stays green // while Studio Pro cannot open the result. - if err := checkFeature(ctx, "agent_documents", "agent", - "create agent", - "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_alter_workflow.go b/mdl/executor/cmd_alter_workflow.go index e7f932e655..2547287db4 100644 --- a/mdl/executor/cmd_alter_workflow.go +++ b/mdl/executor/cmd_alter_workflow.go @@ -23,28 +23,15 @@ func execAlterWorkflow(ctx *ExecContext, s *ast.AlterWorkflowStmt) error { return mdlerrors.NewNotConnectedWrite() } - // Version pre-check: workflows require Mendix 9.12+ - if err := checkFeature(ctx, "workflows", "basic", - "alter workflow", - "upgrade your project to Mendix 9.12+ to use workflows"); err != nil { + // Version pre-check: workflows require Mendix 9.12+, and the added + // activities need their own features (statementVersionGates). + if err := checkVersionGates(ctx, s); err != nil { return err } if vs := completionRuleViolations(alterWorkflowAddedActivities(s), workflowLocation(s.Name)); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } - if workflowUsesAgentTask(alterWorkflowAddedActivities(s)) { - if err := checkFeature(ctx, "workflows", "ai_agent_task", "call agent microflow", - "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"); err != nil { - return err - } - } - if workflowUsesNotificationEvents(alterWorkflowAddedActivities(s)) { - if err := checkFeature(ctx, "workflows", "notification_events", "notification activities and notification boundary events", - "these need Mendix 11.11 or later — use `wait for notification` on older projects"); err != nil { - return err - } - } // Same exec-side guard as CREATE WORKFLOW: ALTER had no reference validation // at all, so an inserted activity could name a microflow that exists nowhere diff --git a/mdl/executor/cmd_datatransformer.go b/mdl/executor/cmd_datatransformer.go index cd20343f5f..ebbde4951b 100644 --- a/mdl/executor/cmd_datatransformer.go +++ b/mdl/executor/cmd_datatransformer.go @@ -108,9 +108,7 @@ func execCreateDataTransformer(ctx *ExecContext, s *ast.CreateDataTransformerStm return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "data_transformer", - "create data transformer", - "upgrade your project to 11.9+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 3c4c3cd0f4..5607b407f5 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -942,9 +942,7 @@ func execCreateViewEntity(ctx *ExecContext, s *ast.CreateViewEntityStmt) error { } // Version pre-check - if err := checkFeature(ctx, "domain_model", "view_entities", - "create view entity", - "upgrade your project to 10.18+ or use a regular entity with a microflow data source"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index ea125aabf2..1f5f642c92 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -226,9 +226,21 @@ func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { } lang := describeDefaultLanguage(ctx) + protection := moduleProtection(ctx, string(module.ID)) for _, entity := range dm.Entities { if entity.Name == name.Name { + // In a Protected module an entity's export level decides whether + // other modules may use it (CE1406) and whether a Usable microflow + // may expose it (CE5400). MDL has no syntax for it, so it prints as + // a comment and a rewrite keeps the stored value (mendixlabs/mxcli#1498). + if protection != "" { + lvl := "Hidden" + if entity.ExportLevel == "API" { + lvl = "Usable" + } + fmt.Fprintf(ctx.Output, "-- export level: %s (%s is a protected module; set in Studio Pro)\n", lvl, module.Name) + } // Output JavaDoc documentation if present if entity.Documentation != "" { outputJavadocIndented(ctx.Output, entity.Documentation, "") diff --git a/mdl/executor/cmd_flow_modify.go b/mdl/executor/cmd_flow_modify.go index c28e57e33b..c63fd00600 100644 --- a/mdl/executor/cmd_flow_modify.go +++ b/mdl/executor/cmd_flow_modify.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "reflect" + "strconv" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -747,7 +748,7 @@ func diffFlowBody(a *alterFlowContext, declared, stored []ast.MicroflowStatement declared, declaredFree := withoutFreeNotes(declared) stored, storedFree := withoutFreeNotes(stored) if !declaredMatches(declaredFree, storedFree) { - return nil, nil, nil, cannotSplice("the free annotations change; the splice edits activities only") + return nil, nil, nil, cannotSplice("the free annotations change (%s); the splice edits activities only", freeNoteChange(declaredFree, storedFree)) } // @start is where the start event is drawn, which describe prints on the @@ -1342,6 +1343,33 @@ func keepStoredNotes(stored ast.MicroflowStatement, declared []ast.MicroflowStat return out, false, nil } +// freeNoteChange names the first free annotation that differs between the +// declared and the stored flow, so a refusal says which note to look at +// (mendixlabs/mxcli#1505). The text is quoted with escapes, which shows +// a difference in invisible characters for what it is. +func freeNoteChange(declared, stored []ast.MicroflowAnnotation) string { + quote := func(text string) string { + if r := []rune(text); len(r) > 40 { + text = string(r[:40]) + "…" + } + return strconv.Quote(text) + } + for i := 0; i < len(declared) || i < len(stored); i++ { + switch { + case i >= len(stored): + return "note " + quote(declared[i].Text) + " is not in the stored flow" + case i >= len(declared): + return "note " + quote(stored[i].Text) + " is not in the script" + case !declaredMatches(declared[i], stored[i]): + if declared[i].Text != stored[i].Text { + return "note " + quote(stored[i].Text) + " is now " + quote(declared[i].Text) + } + return "note " + quote(stored[i].Text) + " is drawn elsewhere" + } + } + return "their order differs" +} + // withoutFreeNotes returns the statements with their free annotations taken // off (as copies; the script's own statements are not modified, since the // rebuild may still need them), and the free annotations in order. diff --git a/mdl/executor/cmd_messagedefinition_documents.go b/mdl/executor/cmd_messagedefinition_documents.go index 78f0e3ba95..f80a3b93f3 100644 --- a/mdl/executor/cmd_messagedefinition_documents.go +++ b/mdl/executor/cmd_messagedefinition_documents.go @@ -51,13 +51,6 @@ func messageCollectionsRemovedError(ctx *ExecContext, statement string) error { "definition, and a mapping names one as Module.Name", statement, version)) } -// requireMessageDefinitionDocuments refuses the per-document form below 11.15. -func requireMessageDefinitionDocuments(ctx *ExecContext, statement string) error { - return checkFeature(ctx, "integration", "message_definition_document", statement, - "below Mendix 11.15 a message definition is an entry of a collection: "+ - "create message definition collection Module.Name { definition Name for Module.Entity { … } };") -} - // projectStoresMessageDocuments reports whether the connected project stores // message definitions as MessageDefinition2 documents. func projectStoresMessageDocuments(ctx *ExecContext) bool { @@ -123,7 +116,7 @@ func execCreateMessageDefinition(ctx *ExecContext, s *ast.CreateMessageDefinitio if !ctx.ConnectedForWrite() { return mdlerrors.NewNotConnectedWrite() } - if err := requireMessageDefinitionDocuments(ctx, "create message definition"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } existing := findMessageDefinitionDocument(ctx, s.Name.Module, s.Name.Name) diff --git a/mdl/executor/cmd_microflows_build.go b/mdl/executor/cmd_microflows_build.go index 9f73b6bb23..dd1d3e9bca 100644 --- a/mdl/executor/cmd_microflows_build.go +++ b/mdl/executor/cmd_microflows_build.go @@ -96,13 +96,9 @@ func buildMicroflowFromStmt(ctx *ExecContext, s *ast.CreateMicroflowStmt, opts b } } - // SEND EMAIL writes the 11.13+ shape (StringTemplate subject and bodies); - // an older project stores a different EmailMessage, or no such activity. - if bodyContains(s.Body, func(st ast.MicroflowStatement) bool { _, ok := st.(*ast.SendEmailStmt); return ok }) { - if err := checkFeature(ctx, "microflows", "send_email", "send email", - "use the Email Connector module, or upgrade the project to 11.13+"); err != nil { - return nil, err - } + // SEND EMAIL (11.13+) and a deep link URL (10.6+): statementVersionGates. + if err := checkVersionGates(ctx, s); err != nil { + return nil, err } // Find the module, and the folder, WITHOUT creating either on a dry run: @@ -684,16 +680,9 @@ func buildNanoflowFromStmt(ctx *ExecContext, s *ast.CreateNanoflowStmt, opts bui return nil, fmt.Errorf("%s", errMsg) } - // SYNCHRONIZE UNSYNCHRONIZED is the one mode with a floor inside Mendix 9: - // SynchronizationType.Unsynchronized was introduced in 9.4.0, while the - // activity and its other two modes go back further. Gate the mode, not the - // statement, so a 9.0-9.3 project keeps `synchronize all`. - if bodyUsesUnsynchronized(s.Body) { - if err := checkFeature(ctx, "microflows", "synchronize_unsynchronized", - "synchronize unsynchronized", - "use `synchronize all` or `synchronize $Var`, or upgrade the project to 9.4+"); err != nil { - return nil, err - } + // SYNCHRONIZE UNSYNCHRONIZED (9.4+): statementVersionGates. + if err := checkVersionGates(ctx, s); err != nil { + return nil, err } // Build flow graph from body statements diff --git a/mdl/executor/cmd_modules.go b/mdl/executor/cmd_modules.go index f54ef8b782..bfe5fe6e38 100644 --- a/mdl/executor/cmd_modules.go +++ b/mdl/executor/cmd_modules.go @@ -575,6 +575,7 @@ func listModules(ctx *ExecContext) error { extDb int } var rows []row + protections := moduleProtections(ctx) for _, m := range modules { // Determine source (AppStore with version or local) @@ -586,6 +587,14 @@ func listModules(ctx *ExecContext) error { source = "Marketplace" } } + if kind := protections[string(m.ID)]; kind != "" { + // mendixlabs/mxcli#1498: its Hidden elements cannot be referenced + // from other modules, which nothing else printed showed. + if source != "" { + source += ", " + } + source += "Protected " + kind + } r := row{ name: m.Name, @@ -649,6 +658,12 @@ func describeModule(ctx *ExecContext, moduleName string, withAll bool) error { fmt.Fprintf(ctx.Output, "/**\n * %s\n */\n", strings.ReplaceAll(dm.Documentation, "\n", "\n * ")) } fmt.Fprintf(ctx.Output, "create or modify module %s;\n", targetModule.Name) + // A Protected (add-on / solution) module hides every element not marked + // Usable from the rest of the app (CE1406). MDL has no syntax for the + // module's export level, so it prints as a comment (mendixlabs/mxcli#1498). + if kind := moduleProtection(ctx, string(targetModule.ID)); kind != "" { + fmt.Fprintf(ctx.Output, "-- export level: Protected (%s module) — set in Studio Pro; elements not marked Usable cannot be referenced from other modules\n", kind) + } // Module roles live in the module's own Security$ModuleSecurity unit rather // than in any document, so a sweep that describes every document in a module diff --git a/mdl/executor/cmd_modules_export_level_test.go b/mdl/executor/cmd_modules_export_level_test.go new file mode 100644 index 0000000000..9d00d8a0aa --- /dev/null +++ b/mdl/executor/cmd_modules_export_level_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "bytes" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1498: nothing mxcli printed said a module was Protected, nor +// what an entity's export level was, so the reporter found both by a raw read. +// MDL has no syntax for either, so they print as comments — the output still +// re-runs, and a rewrite keeps the stored values. Only a Protected module's +// output changes; Open is the control. +func TestDescribe_ProtectedModuleExportLevels(t *testing.T) { + ctx := protectedModuleFixture(t, nil) + buf := ctx.Output.(*bytes.Buffer) + + describe := func(f func() error) string { + t.Helper() + buf.Reset() + if err := f(); err != nil { + t.Fatal(err) + } + out := buf.String() + parseMDL(t, out) // still re-runnable + return out + } + + out := describe(func() error { return describeModule(ctx, "MyAddOn", false) }) + if !strings.Contains(out, "-- export level: Protected (AddOn module)") { + t.Errorf("describe module of a protected module does not say so:\n%s", out) + } + out = describe(func() error { return describeModule(ctx, "Open", false) }) + if strings.Contains(out, "export level") { + t.Errorf("describe module of an ordinary module mentions an export level:\n%s", out) + } + + for _, c := range []struct{ entity, want string }{ + {"Request", "-- export level: Hidden"}, + {"Pub", "-- export level: Usable"}, + } { + out = describe(func() error { return describeEntity(ctx, ast.QualifiedName{Module: "MyAddOn", Name: c.entity}) }) + if !strings.Contains(out, c.want) { + t.Errorf("describe entity MyAddOn.%s: want %q in\n%s", c.entity, c.want, out) + } + } + out = describe(func() error { return describeEntity(ctx, ast.QualifiedName{Module: "Open", Name: "E"}) }) + if strings.Contains(out, "export level") { + t.Errorf("describe entity in an ordinary module mentions an export level:\n%s", out) + } +} + +func TestShowModules_MarksProtectedModule(t *testing.T) { + ctx := protectedModuleFixture(t, nil) + buf := ctx.Output.(*bytes.Buffer) + if err := listModules(ctx); err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(buf.String(), "\n") { + switch { + case strings.Contains(line, "| MyAddOn "): + if !strings.Contains(line, "Protected AddOn") { + t.Errorf("MyAddOn row does not say Protected: %s", line) + } + case strings.Contains(line, "| Open "): + if strings.Contains(line, "Protected") { + t.Errorf("Open row says Protected: %s", line) + } + } + } +} diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 4fdf22362d..280dfb3c88 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -1488,13 +1488,8 @@ func createODataService(ctx *ExecContext, stmt *ast.CreateODataServiceStmt) erro "the default (Yes), or drop SupportsGraphQL.") } - if stmt.SupportsGraphQL { - if err := checkFeature(ctx, "integration", "odata_graphql", - "SupportsGraphQL on a published OData service", - "Publishing a service as GraphQL as well arrived in Studio Pro 10.14. "+ - "Remove SupportsGraphQL to publish OData only."); err != nil { - return err - } + if err := checkVersionGates(ctx, stmt); err != nil { + return err } module, err := findModule(ctx, stmt.Name.Module) @@ -2003,6 +1998,11 @@ func mendixAttrTypeToEdm(t domainmodel.AttributeType) string { type publishedAttrType struct { Edm string AsString bool + // DeclaredOn is the qualified name of the generalization that declares an + // inherited attribute, "" for one the published entity declares itself. + // Mendix resolves a published attribute by its declaring entity, so an + // inherited one written under the published entity is CE1613. + DeclaredOn string } // enumPublishedAsString reports whether a published attribute needs the @@ -2044,6 +2044,7 @@ func lookupEntityMembers(ctx *ExecContext, entityQN ast.QualifiedName) (map[stri } } } + addInheritedAttributes(ctx, entityQN, attrs) for _, a := range dm.Associations { parentQN := entityIDToQN[a.ParentID] childQN := entityIDToQN[a.ChildID] @@ -2068,9 +2069,98 @@ func lookupEntityMembers(ctx *ExecContext, entityQN ast.QualifiedName) (map[stri // Published from the TO side, the association lives in another module's // domain model, so the published entity's own one cannot find it. addCrossAssociationsTo(ctx, entityQN, assocs) + addInheritedAssociations(ctx, entityQN, assocs) return attrs, assocs } +// addInheritedAttributes adds the attributes entityQN inherits, nearest +// generalization first, each recorded with the entity that declares it. A name +// already present — the entity's own, or a nearer ancestor's — wins. +// Best-effort like lookupEntityMembers: an unreadable link stops the walk with +// what was found, and an attribute it misses falls back to the bare name. +func addInheritedAttributes(ctx *ExecContext, entityQN ast.QualifiedName, attrs map[string]publishedAttrType) { + b, ok := ctx.Backend.(entityLookupBackend) + if !ok { + return + } + chain, _ := generalizationChain(ctx, entityQN.String()) + for _, ancestorQN := range chain[min(1, len(chain)):] { + ancestor, ok := findEntityByQN(b, ancestorQN) + if !ok { + return + } + for _, a := range ancestor.Attributes { + if _, taken := attrs[a.Name]; taken { + continue + } + attrs[a.Name] = publishedAttrType{ + Edm: mendixAttrTypeToEdm(a.Type), + AsString: enumPublishedAsString(a.Type), + DeclaredOn: ancestorQN, + } + } + } +} + +// addInheritedAssociations adds the associations a generalization of entityQN +// takes part in — same-module ones from either side, cross-module ones from +// their FROM side — nearest generalization first. The published entity stands +// in for the ancestor on its side, so the caller's direction and multiplicity +// logic (which compares against the published entity) reads them as its own; +// QN stays the association's own name, which is what Mendix resolves. Without +// this an inherited association fell to the attribute fallback: CE1613 on a +// PublishedAttribute named after it. A name already present wins. +// +// Not covered: a cross-module association whose TO side is a generalization. +func addInheritedAssociations(ctx *ExecContext, entityQN ast.QualifiedName, assocs map[string]*assocMembership) { + b, ok := ctx.Backend.(entityLookupBackend) + if !ok { + return + } + self := entityQN.String() + chain, _ := generalizationChain(ctx, self) + for _, ancestorQN := range chain[min(1, len(chain)):] { + modName, _, _ := strings.Cut(ancestorQN, ".") + mod, err := b.GetModuleByName(modName) + if err != nil || mod == nil { + return + } + dm, err := b.GetDomainModel(mod.ID) + if err != nil || dm == nil { + return + } + qnByID := make(map[model.ID]string, len(dm.Entities)) + for _, e := range dm.Entities { + qnByID[e.ID] = modName + "." + e.Name + } + side := func(qn string) string { + if qn == ancestorQN { + return self + } + return qn + } + for _, a := range dm.Associations { + parentQN, childQN := qnByID[a.ParentID], qnByID[a.ChildID] + if parentQN != ancestorQN && childQN != ancestorQN { + continue + } + if _, taken := assocs[a.Name]; taken || parentQN == "" || childQN == "" { + continue + } + assocs[a.Name] = &assocMembership{QN: modName + "." + a.Name, ParentQN: side(parentQN), ChildQN: side(childQN), Type: a.Type} + } + for _, ca := range dm.CrossAssociations { + if qnByID[ca.ParentID] != ancestorQN { + continue + } + if _, taken := assocs[ca.Name]; taken { + continue + } + assocs[ca.Name] = &assocMembership{QN: modName + "." + ca.Name, ParentQN: self, ChildQN: ca.ChildRef, Type: ca.Type} + } + } +} + // addCrossAssociationsTo adds the cross-module associations whose TO entity is // entityQN. Best-effort like lookupEntityMembers: a backend failure leaves assocs // as it was. An association name already present (local to the module) wins. @@ -2165,6 +2255,12 @@ func astEntityDefToModel(ctx *ExecContext, def *ast.PublishedEntityDef) (*model. member.Kind = "attribute" member.EdmType = pub.Edm member.EnumerationAsString = pub.AsString + // Qualified here, not by the writer: it can only prefix the + // published entity, and an inherited attribute is resolved by the + // entity that declares it (CE1613 otherwise). + if pub.DeclaredOn != "" { + member.Name = pub.DeclaredOn + "." + m.Name + } } else if assoc := moduleAssocs[m.Name]; assoc != nil { member.Kind = "association" // Qualified here, not by the writer: it can only prefix the published diff --git a/mdl/executor/cmd_odata_publish_inherited_test.go b/mdl/executor/cmd_odata_publish_inherited_test.go new file mode 100644 index 0000000000..579a56cfe9 --- /dev/null +++ b/mdl/executor/cmd_odata_publish_inherited_test.go @@ -0,0 +1,139 @@ +// 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/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// Publishing a specialization and exposing an attribute it inherits wrote the +// member as the specialization's (Probe.Sub.Code). Mendix resolves an attribute +// by the entity that declares it, so mx check reported +// +// [CE1613] "The selected attribute 'Probe.Sub.Code' no longer exists." +// +// while check and exec were clean. lookupEntityMembers read only the published +// entity's own attributes, so an inherited one fell through to the bare-name +// fallback and the writer prefixed the published entity. It is now qualified by +// its declaring entity — two levels up included — and keeps its EDM type. +func TestPublishedODataService_InheritedAttributeIsQualifiedByDeclaringEntity(t *testing.T) { + mod := mkModule("Probe") + str := func(name string) *domainmodel.Attribute { + return &domainmodel.Attribute{BaseElement: model.BaseElement{ID: nextID("attr")}, Name: name, Type: &domainmodel.StringAttributeType{}} + } + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: nextID("dm")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: nextID("ent")}, Name: "Root", Persistable: true, + Attributes: []*domainmodel.Attribute{str("Origin")}}, + {BaseElement: model.BaseElement{ID: nextID("ent")}, Name: "Base", Persistable: true, + GeneralizationRef: "Probe.Root", + Attributes: []*domainmodel.Attribute{str("Code"), str("Label")}}, + {BaseElement: model.BaseElement{ID: nextID("ent")}, Name: "Sub", Persistable: true, + GeneralizationRef: "Probe.Base", + Attributes: []*domainmodel.Attribute{str("Extra")}}, + }, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + if name == "Probe" { + return mod, nil + } + return nil, nil + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + et, _ := astEntityDefToModel(ctx, &ast.PublishedEntityDef{ + Entity: ast.QualifiedName{Module: "Probe", Name: "Sub"}, + ExposedName: "Subs", + Members: []*ast.PublishedMemberDef{ + {Name: "Code", IsPartOfKey: true}, + {Name: "Label"}, + {Name: "Extra"}, + {Name: "Origin"}, + }, + }) + + want := map[string]string{ + "Code": "Probe.Base.Code", // inherited one level up + "Label": "Probe.Base.Label", // inherited one level up + "Extra": "Extra", // its own: the writer qualifies it by the published entity + "Origin": "Probe.Root.Origin", // inherited two levels up + } + if len(et.Members) != len(want) { + t.Fatalf("got %d members, want %d", len(et.Members), len(want)) + } + for _, m := range et.Members { + if m.Kind != "attribute" { + t.Errorf("%s: kind = %q, want attribute", m.ExposedName, m.Kind) + } + if got := m.Name; got != want[m.ExposedName] { + t.Errorf("%s: member name = %q, want %q — Mendix resolves an inherited attribute by its declaring entity (CE1613)", + m.ExposedName, got, want[m.ExposedName]) + } + if m.EdmType != "Edm.String" { + t.Errorf("%s: EdmType = %q, want Edm.String (an unread type is CE5016 from 11.12)", m.ExposedName, m.EdmType) + } + } +} + +// The association twin: an association the generalization takes part in is +// published from the specialization as an association end (not the attribute +// fallback, which named a PublishedAttribute after it — CE1613), with the +// association's own qualified name, the other side as target, and the +// specialization standing in for the generalization's side. +func TestPublishedODataService_InheritedAssociationIsAnAssociationEnd(t *testing.T) { + mod := mkModule("Probe") + baseID, subID, tagID := nextID("ent"), nextID("ent"), nextID("ent") + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: nextID("dm")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: baseID}, Name: "Base", Persistable: true}, + {BaseElement: model.BaseElement{ID: subID}, Name: "Sub", Persistable: true, GeneralizationRef: "Probe.Base"}, + {BaseElement: model.BaseElement{ID: tagID}, Name: "Tag", Persistable: true}, + }, + Associations: []*domainmodel.Association{ + // FROM Base TO Tag: a Reference, so to-one from the FROM side. + {BaseElement: model.BaseElement{ID: nextID("assoc")}, Name: "Base_Tag", ParentID: baseID, ChildID: tagID, Type: domainmodel.AssociationTypeReference}, + }, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(name string) (*model.Module, error) { + if name == "Probe" { + return mod, nil + } + return nil, nil + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + + et, _ := astEntityDefToModel(ctx, &ast.PublishedEntityDef{ + Entity: ast.QualifiedName{Module: "Probe", Name: "Sub"}, + Members: []*ast.PublishedMemberDef{{Name: "Base_Tag"}}, + }) + if len(et.Members) != 1 { + t.Fatalf("got %d members, want 1", len(et.Members)) + } + m := et.Members[0] + if m.Kind != "association" { + t.Fatalf("kind = %q, want association — the attribute fallback is CE1613", m.Kind) + } + if m.Name != "Probe.Base_Tag" || m.AssociationTargetEntity != "Probe.Tag" || m.IsMany { + t.Errorf("got name %q target %q isMany %v, want Probe.Base_Tag → Probe.Tag, to-one", + m.Name, m.AssociationTargetEntity, m.IsMany) + } +} diff --git a/mdl/executor/cmd_pages_builder_input.go b/mdl/executor/cmd_pages_builder_input.go index ba1b0af368..c77ab1b2b5 100644 --- a/mdl/executor/cmd_pages_builder_input.go +++ b/mdl/executor/cmd_pages_builder_input.go @@ -5,6 +5,7 @@ package executor import ( "strings" + "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/model" ) @@ -156,46 +157,92 @@ func (pb *pageBuilder) entityAttributeOwners() (owners map[string]map[string]boo // // where names the binding for the message (e.g. "column `colCustomer`"). func (pb *pageBuilder) rejectAssociationAsAttribute(name, entityContext, where string) error { + assocQN, fromEntity, toEntity, ok := pb.associationNamedAsAttribute(name, entityContext) + if !ok { + return nil + } + leaf := toEntity + if idx := strings.LastIndex(leaf, "."); idx >= 0 { + leaf = leaf[idx+1:] + } + return mdlerrors.NewValidationf( + "%s binds `%s`, which is an association (%s → %s), not an attribute — "+ + "Mendix cannot store a reference in an attribute-typed widget property and the build fails "+ + "CE1613 \"The selected attribute '%s.%s' no longer exists\". "+ + "To SHOW a value from the associated object, traverse the reference: `attribute: %s/` "+ + "(e.g. `%s/Name`). To FILTER the grid by the reference, use the drop-down filter's association mode: "+ + "`dropdownfilter f (Association: %s, datasource: database %s, CaptionAttribute: )`", + where, name, fromEntity, toEntity, entityContext, name, + name, name, assocQN, toEntity) +} + +// associationNamedAsAttribute reports whether name, bound where an attribute +// belongs against entityContext, is in fact an association reachable from that +// entity — and if so which one, and the entities at its two ends. It is the +// decision behind rejectAssociationAsAttribute, shared with the combo box's own +// wording of the refusal so the two cannot disagree on what they refuse. +func (pb *pageBuilder) associationNamedAsAttribute(name, entityContext string) (assocQN, fromEntity, toEntity string, ok bool) { // A path (`Assoc/Attr`) is the SUPPORTED form; a `$var` reference and an // empty binding are not ours to judge here. if name == "" || strings.ContainsAny(name, "/$") || entityContext == "" { - return nil + return "", "", "", false } // Deciding attribute-vs-association needs the model; without one (no backend // and nothing cached — a unit test building widgets in isolation) let the // binding through rather than guessing, exactly as declaringEntityFor does. if pb.backend == nil && (pb.execCache == nil || pb.execCache.domainModels == nil) { - return nil + return "", "", "", false } name = storedSystemMemberName(name) // A three-part name is already an explicit Module.Entity.Attribute. if strings.Count(name, ".") >= 2 { - return nil + return "", "", "", false } // An attribute wins: entity members share one namespace, so a name that // resolves as an attribute anywhere in the generalization chain is not an // association. - if _, ok := pb.declaringEntityFor(entityContext, name); ok { - return nil + if _, found := pb.declaringEntityFor(entityContext, name); found { + return "", "", "", false } - assocQN := pb.resolveAssociationPathIn(name, entityContext) - fromEntity, toEntity, ok := pb.associationEndpoints(assocQN) - if !ok || !pb.entityInChain(entityContext, fromEntity) { + assocQN = pb.resolveAssociationPathIn(name, entityContext) + fromEntity, toEntity, found := pb.associationEndpoints(assocQN) + if !found || !pb.entityInChain(entityContext, fromEntity) { + return "", "", "", false + } + return assocQN, fromEntity, toEntity, true +} + +// rejectAssociationAsAttributeOn is rejectAssociationAsAttribute for widget w, +// whose `Attribute:` was written `$param.name` when param is not "". +// +// A combo box gets its own remedy (mendixlabs/mxcli#1440). `combobox x +// (Attribute: $Asset.Asset_Customer, CaptionAttribute: …)` has no `DataSource:`, +// so it is the ENUMERATION mode and the association lands in +// `attributeEnumeration`; the generic advice — traverse the reference, or use a +// drop-down filter — is advice for a grid column. What the author wants is the +// association mode, which a `DataSource:` selects, and which binds a reference +// from a parameter as `Attribute: $Param.Module.Assoc` (what describe prints for +// Studio Pro's snippets; 0 errors on mx 11.6.6). +func (pb *pageBuilder) rejectAssociationAsAttributeOn(w *ast.WidgetV3, param, name, entityContext, where string) error { + if w == nil || !strings.EqualFold(w.Type, "combobox") { + return pb.rejectAssociationAsAttribute(name, entityContext, where) + } + assocQN, fromEntity, toEntity, ok := pb.associationNamedAsAttribute(name, entityContext) + if !ok { return nil } - leaf := toEntity - if idx := strings.LastIndex(leaf, "."); idx >= 0 { - leaf = leaf[idx+1:] + binding := assocQN + if param != "" { + binding = "$" + param + "." + assocQN } return mdlerrors.NewValidationf( - "%s binds `%s`, which is an association (%s → %s), not an attribute — "+ - "Mendix cannot store a reference in an attribute-typed widget property and the build fails "+ - "CE1613 \"The selected attribute '%s.%s' no longer exists\". "+ - "To SHOW a value from the associated object, traverse the reference: `attribute: %s/` "+ - "(e.g. `%s/Name`). To FILTER the grid by the reference, use the drop-down filter's association mode: "+ - "`dropdownfilter f (Association: %s, datasource: database %s, CaptionAttribute: )`", + "%s binds `%s`, which is an association (%s → %s), not an attribute — a combo box with "+ + "`Attribute:` and no `DataSource:` is in enumeration mode, whose attribute property cannot hold a "+ + "reference, and the build fails CE1613 \"The selected attribute '%s.%s' no longer exists\". "+ + "To pick the associated object, use the combo box's association mode, which a `DataSource:` "+ + "selects: `combobox %s (Attribute: %s, DataSource: database %s, CaptionAttribute: )`", where, name, fromEntity, toEntity, entityContext, name, - name, name, assocQN, toEntity) + w.Name, binding, toEntity, toEntity) } // entityInChain reports whether want is entityQN or one of its ancestors, so a diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index 386cecb182..b67c7b7e0c 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -27,12 +27,8 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { // which locked every 10.x project out of parameterised pages entirely // (mendixlabs/mxcli#1121). The gate stays because 9.0–9.3 genuinely has no // Pages$PageParameter; only the floor and the hint were wrong. - if len(s.Parameters) > 0 { - if err := checkFeature(ctx, "pages", "page_parameters", - "create page with parameters", - "pass data via a non-persistent entity or microflow parameter instead"); err != nil { - return err - } + if err := checkVersionGates(ctx, s); err != nil { + return err } // Find or auto-create module diff --git a/mdl/executor/cmd_published_rest.go b/mdl/executor/cmd_published_rest.go index 2917c0f4cd..160c854eca 100644 --- a/mdl/executor/cmd_published_rest.go +++ b/mdl/executor/cmd_published_rest.go @@ -542,9 +542,7 @@ func execCreatePublishedRestService(ctx *ExecContext, s *ast.CreatePublishedRest return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_service", - "create published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } @@ -881,9 +879,7 @@ func execAlterPublishedRestService(ctx *ExecContext, s *ast.AlterPublishedRestSe return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_alter", - "alter published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index 503d16db6f..5bc763ad1e 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -358,9 +358,7 @@ func createRestClient(ctx *ExecContext, stmt *ast.CreateRestClientStmt) error { } // Version pre-check: REST clients require 10.1+ - if err := checkFeature(ctx, "integration", "rest_client_basic", - "create rest client", - "upgrade your project to 10.1+"); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } @@ -807,9 +805,7 @@ func createRestClientFromSpec(ctx *ExecContext, stmt *ast.CreateRestClientStmt) return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "rest_client_basic", - "create rest client", - "upgrade your project to 10.1+"); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index 1183b24129..0226dcba12 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -1762,9 +1762,7 @@ func execGrantPublishedRestServiceAccess(ctx *ExecContext, s *ast.GrantPublished return mdlerrors.NewNotConnectedWrite() } - if err := checkFeature(ctx, "integration", "published_rest_grant_revoke", - "grant access on published rest service", - "upgrade your project to 10.0+"); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/cmd_settings_workflow_groups.go b/mdl/executor/cmd_settings_workflow_groups.go index a6eb75d915..de4dad8ee2 100644 --- a/mdl/executor/cmd_settings_workflow_groups.go +++ b/mdl/executor/cmd_settings_workflow_groups.go @@ -40,10 +40,7 @@ func alterSettingsWorkflowGroup(ctx *ExecContext, ps *model.ProjectSettings, stm if ps.Workflows == nil { return mdlerrors.NewNotFound("settings section", "workflows") } - if err := checkFeature(ctx, "workflows", "groups", - "alter settings workflows add group ''", - "Workflow groups (Settings$WorkflowGroup) were introduced in the 11.2 metamodel. "+ - "On an earlier version, model the buckets as your own entity and target user tasks with a microflow."); err != nil { + if err := checkVersionGates(ctx, stmt); err != nil { return err } if strings.TrimSpace(stmt.GroupName) == "" { diff --git a/mdl/executor/cmd_workflows_write.go b/mdl/executor/cmd_workflows_write.go index f42ff05ce0..01848e9e19 100644 --- a/mdl/executor/cmd_workflows_write.go +++ b/mdl/executor/cmd_workflows_write.go @@ -74,19 +74,7 @@ func execCreateWorkflow(ctx *ExecContext, s *ast.CreateWorkflowStmt) error { if vs := ValidateWorkflowEventTypes(s); len(vs) > 0 { return mdlerrors.NewValidationf("%s\n → %s", vs[0].Message, vs[0].Suggestion) } - if len(s.EventHandlers) > 0 { - if err := checkFeature(ctx, "workflows", "event_handlers", "on workflow events", - "remove the `on … workflow event` clauses, or upgrade the project"); err != nil { - return err - } - } - if workflowUsesAgentTask(workflowStatementActivities(s)) { - if err := checkFeature(ctx, "workflows", "ai_agent_task", "call agent microflow", - "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"); err != nil { - return err - } - } - if err := checkEventSubProcessFeatures(ctx, s); err != nil { + if err := checkVersionGates(ctx, s); err != nil { return err } diff --git a/mdl/executor/combobox_association_object.go b/mdl/executor/combobox_association_object.go new file mode 100644 index 0000000000..39868fc4b5 --- /dev/null +++ b/mdl/executor/combobox_association_object.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// A combo box in association mode edits a reference OF AN OBJECT, and the object +// has to come from somewhere (mendixlabs/mxcli#1420): +// +// -- in a snippet with parameter $Asset, outside every data container: +// combobox x (Association: M.Asset_Customer, DataSource: database M.Customer, +// CaptionAttribute: CustomerName) +// +// passed check and exec, and mx check 11.6.6 then reported +// +// [CE7007] "Selected value is not valid for entity 'Asset_Customer/Customer'. +// Please select the value again." at Combo box 'x' +// +// because the writer stores the association with no SourceVariable — "the +// enclosing object" — and there is none. Studio Pro stores a combo box over a +// parameter's reference with SourceVariable { SnippetParameter / PageParameter } +// (measured on TestApp's WorkflowCommons snippets, cmd_pages_parameter_binding.go), +// which MDL already writes from `Attribute: $Asset.M.Asset_Customer` — 0 errors +// on 11.6.6, and what describe prints for it. So the construct is expressible; +// the bare spelling just names no object. Which parameter the author meant is +// not mxcli's to guess (a snippet may have several of the same entity), so the +// bare form is refused, naming the spelling that builds. +// +// One decision, three callers, so check and exec cannot disagree about it: +// the widget engine at build time (no enclosing entity), the widget-tree walk at +// check time with no project (MDL-WIDGET46, document root known empty), and the +// ALTER insertion-point pass with a project (validate_alter_unscoped.go). + +// comboBoxAssociationNeedingObject returns the association binding of a combo +// box in association mode that names no object of its own to start from — so it +// starts at the enclosing data container's object, and fails CE7007 without +// one. ok is false for anything else: another widget, enumeration mode, no +// binding, or a binding that names its object (`$Param.Module.Assoc`). +func comboBoxAssociationNeedingObject(w *ast.WidgetV3) (key, binding string, ok bool) { + if w == nil || !strings.EqualFold(w.Type, "combobox") { + return "", "", false + } + // Association mode is selected by an option list, given generically or + // under the schema key (the def's hasDataSource condition). + if w.GetDataSource() == nil { + if _, named := lookupProperty(w.Properties, comboBoxOptionsDataSourceKey); !named { + return "", "", false + } + } + key, binding = "Association", w.GetStringProp("Association") + if binding == "" { + key, binding = "Attribute", w.GetAttribute() + } + if binding == "" || strings.HasPrefix(binding, "$") { + // `$Param.Module.Assoc` names its object; any other `$…` spelling is + // another rule's to judge. + return "", "", false + } + return key, binding, true +} + +// comboBoxAssociationObjectProblem is the refusal for one such combo box. +func comboBoxAssociationObjectProblem(w *ast.WidgetV3, key, binding string) string { + return fmt.Sprintf("combobox `%s`: `%s: %s` names no object for the association to start from — no data "+ + "view, list view, gallery or data grid with a known entity encloses the combo box, so it would be written "+ + "with no source object and mxbuild reports CE7007 \"Selected value is not valid for entity '…'\". Start it at the page "+ + "or snippet parameter that holds the object, as Studio Pro stores it — `Attribute: $Param.%s` — or place "+ + "the combo box inside `dataview dv (DataSource: $Param) { … }`", + w.Name, key, binding, binding) +} + +// validateComboBoxAssociationObject is the check-time rule with no project +// (MDL-WIDGET46): a combo box at a document root the walk knows to be empty. +// c is the context the widget SITS in — not the one its own option list +// creates, which says nothing about where the association starts. +func validateComboBoxAssociationObject(w *ast.WidgetV3, c pageArgContext, locationPrefix string) []linter.Violation { + if !c.known || c.present { + return nil + } + key, binding, ok := comboBoxAssociationNeedingObject(w) + if !ok { + return nil + } + return []linter.Violation{{ + RuleID: "MDL-WIDGET46", + Severity: linter.SeverityError, + Message: locationPrefix + ": " + comboBoxAssociationObjectProblem(w, key, binding), + Suggestion: "A combo box in association mode sets a reference of an object: bind it through the parameter (`Attribute: $Param.Module.Assoc`) or put it inside a data container over that object.", + }} +} + +// unanchoredComboBoxes lists the problems of the association-mode combo boxes +// among ws that would start at the insertion point's object, for an insertion +// point with none. A widget with a data source of its own supplies an object to +// its children, so they are not descended into. +func unanchoredComboBoxes(ws []*ast.WidgetV3) []string { + var out []string + for _, w := range ws { + if w == nil { + continue + } + if key, binding, ok := comboBoxAssociationNeedingObject(w); ok { + out = append(out, comboBoxAssociationObjectProblem(w, key, binding)) + continue + } + if w.GetDataSource() != nil { + continue + } + out = append(out, unanchoredComboBoxes(w.Children)...) + } + return out +} diff --git a/mdl/executor/combobox_association_object_test.go b/mdl/executor/combobox_association_object_test.go new file mode 100644 index 0000000000..35afb08f31 --- /dev/null +++ b/mdl/executor/combobox_association_object_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/backend/pagemutator" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1420: a combo box in association mode, replacing a widget of +// a snippet whose object comes from a snippet parameter, outside every data +// container — +// +// combobox x (Association: M.A, DataSource: database M.E, CaptionAttribute: Attr) +// +// passed check and exec, and mx check (reproduced on 11.6.6) then reported +// +// [CE7007] "Selected value is not valid for entity 'Asset_Customer/Customer'. +// Please select the value again." at Combo box 'x' +// +// The binding that builds names the parameter: `Attribute: $Asset.M.Asset_Customer` +// (SourceVariable = the snippet parameter, as Studio Pro stores it; 0 errors), +// and so does wrapping it in `dataview (DataSource: $Asset)`. + +const unanchoredComboBox = `combobox x (Association: M.Asset_Customer, DataSource: database M.Customer, CaptionAttribute: CustomerName)` + +// Exec: the widget engine refuses the association when the widget's Build +// started with no entity in scope. +func TestExec1420_ComboBoxAssociationWithoutObjectIsRefused(t *testing.T) { + reg := LoadWidgetRegistry("") + def, ok := reg.Get("COMBOBOX") + if !ok { + t.Fatal("no COMBOBOX definition in the built-in registry") + } + w := parseSnippetWidget(t, unanchoredComboBox) + var assoc PropertyMapping + e := &PluggableWidgetEngine{currentDef: def} + mappings, _, err := e.selectMappings(def, w) + if err != nil { + t.Fatalf("selectMappings: %v", err) + } + for _, m := range mappings { + if m.Source == "Association" { + assoc = m + } + } + if assoc.PropertyKey == "" { + t.Fatal("association mode has no Association mapping") + } + + resolve := func(outer string) error { + // The option list's DataSource mapping has already moved entityContext + // to M.Customer, as it does in Build. + e := &PluggableWidgetEngine{currentDef: def, pageBuilder: &pageBuilder{entityContext: "M.Customer"}, + outerEntityContext: outer, inBuild: true} + _, err := e.resolveMapping(assoc, w) + return err + } + err = resolve("") + if err == nil || !strings.Contains(err.Error(), "CE7007") || !strings.Contains(err.Error(), "$Param.M.Asset_Customer") { + t.Fatalf("no enclosing object: want a CE7007 refusal naming the parameter spelling, got %v", err) + } + // Control: inside a data container over M.Asset the same widget builds. + if err := resolve("M.Asset"); err != nil { + t.Errorf("inside a data container: unexpected refusal %v", err) + } +} + +// Check, no project: the widget-tree walk refuses it at a document root. +func TestCheck1420_ComboBoxAssociationAtDocumentRoot(t *testing.T) { + count := func(src string) (n int, msg string) { + for _, v := range ValidateWidgetProperties(parseMDL(t, src), "") { + if v.RuleID == "MDL-WIDGET46" && strings.Contains(v.Message, "CE7007") { + n++ + msg = v.Message + } + } + return + } + if n, msg := count(`create snippet M.S (Params: ($Asset: M.Asset)) { ` + unanchoredComboBox + ` };`); n != 1 { + t.Errorf("combo box at the snippet root: %d refusals, want 1", n) + } else if !strings.Contains(msg, "`Association: M.Asset_Customer`") { + t.Errorf("refusal does not quote the binding: %s", msg) + } + // Controls: the parameter spelling, a data view around it, and the + // enumeration mode are not this rule's. + for _, src := range []string{ + `create snippet M.S (Params: ($Asset: M.Asset)) { combobox x (Attribute: $Asset.M.Asset_Customer, DataSource: database M.Customer, CaptionAttribute: CustomerName) };`, + `create snippet M.S (Params: ($Asset: M.Asset)) { dataview dv (DataSource: $Asset) { ` + unanchoredComboBox + ` } };`, + `create snippet M.S (Params: ($Asset: M.Asset)) { dataview dv (DataSource: $Asset) { combobox x (Attribute: Status) } };`, + } { + if n, msg := count(src); n != 0 { + t.Errorf("%s:\n unexpected refusal %s", src, msg) + } + } +} + +// Check with a project: an ALTER's REPLACE at the top of a stored snippet — +// the reported shape — is refused against the stored insertion point. +func TestCheck1420_AlterReplaceAtSnippetRoot(t *testing.T) { + mod := mkModule("M") + snip := mkSnippet(mod.ID, "S") + h := mkHierarchy(mod) + withContainer(h, snip.ID, mod.ID) + stored := bson.D{ + {Key: "$Type", Value: "Forms$Snippet"}, + {Key: "Parameters", Value: bson.A{int32(2), bson.D{ + {Key: "$Type", Value: "Forms$SnippetParameter"}, + {Key: "Name", Value: "Asset"}, + {Key: "ParameterType", Value: bson.D{{Key: "$Type", Value: "DataTypes$ObjectType"}, {Key: "Entity", Value: "M.Asset"}}}, + }}}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{{Key: "$Type", Value: "Forms$DynamicText"}, {Key: "Name", Value: "placeholder"}}}}, + } + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(string) (*model.Module, error) { return mod, nil }, + ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snip}, nil }, + OpenPageForMutationFunc: func(unitID model.ID) (backend.PageMutator, error) { + return pagemutator.New(stored, unitID, &countingDeps{}), nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + check := func(widgets string) []error { + prog := parseMDL(t, "alter snippet M.S { replace placeholder with { "+widgets+" } };") + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateAlterUnscopedBindings(ctx, prog, sc) + } + errs := check(unanchoredComboBox) + if len(errs) != 1 || !strings.Contains(errs[0].Error(), "alter snippet M.S: combobox `x`") || + !strings.Contains(errs[0].Error(), "CE7007") { + t.Fatalf("want one CE7007 refusal for combobox x, got %v", errs) + } + for _, ok := range []string{ + `combobox x (Attribute: $Asset.M.Asset_Customer, DataSource: database M.Customer, CaptionAttribute: CustomerName)`, + `dataview dv (DataSource: $Asset) { ` + unanchoredComboBox + ` }`, + } { + if errs := check(ok); len(errs) != 0 { + t.Errorf("%s: unexpected refusal %v", ok, errs) + } + } +} + +func parseSnippetWidget(t *testing.T, widget string) *ast.WidgetV3 { + t.Helper() + prog := parseMDL(t, "create snippet M.S { "+widget+" };") + s, ok := prog.Statements[0].(*ast.CreateSnippetStmtV3) + if !ok || len(s.Widgets) != 1 { + t.Fatalf("got %T", prog.Statements[0]) + } + return s.Widgets[0] +} diff --git a/mdl/executor/flow_declared_match.go b/mdl/executor/flow_declared_match.go index 7cbddc3ea0..d0909ba580 100644 --- a/mdl/executor/flow_declared_match.go +++ b/mdl/executor/flow_declared_match.go @@ -170,6 +170,16 @@ func matchValue(d, s reflect.Value, mode matchMode) bool { } continue } + if d.Type() == noteStructType && name == "Text" { + // A note's line breaks are CRLF as Studio Pro stores them and + // describe prints them; an editor that saves the script with + // LF changed nothing the canvas shows (mendixlabs/mxcli#1505). + // A matched note is left as stored, so its bytes stay CRLF. + if lfLineBreaks(df.String()) != lfLineBreaks(s.Field(i).String()) { + return false + } + continue + } if d.Type() == retrieveStructType && name == "Where" { if !sameStoredConstraint(df, s.Field(i), retrieveEntity(d), retrieveEntity(s)) { return false @@ -210,6 +220,11 @@ func matchValue(d, s reflect.Value, mode matchMode) bool { } } +// lfLineBreaks returns text with every line break spelled LF. +func lfLineBreaks(text string) string { + return strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") +} + var errorHandlingPtrType = reflect.TypeOf((*ast.ErrorHandlingClause)(nil)) var dataTypeStructType = reflect.TypeOf(ast.DataType{}) diff --git a/mdl/executor/flow_declared_match_test.go b/mdl/executor/flow_declared_match_test.go index 9a5f723089..4e0882af7d 100644 --- a/mdl/executor/flow_declared_match_test.go +++ b/mdl/executor/flow_declared_match_test.go @@ -168,3 +168,68 @@ func TestLCSStatements_ExactMatchWinsOverAMove(t *testing.T) { t.Fatalf("pairs %v, want the declared statement paired with the stored one it matches exactly", pairs) } } + +// mendixlabs/mxcli#1505: Studio Pro stores a multi-line note with CRLF line +// breaks and describe prints those bytes as they are. An editor that turns +// them into LF changed nothing anyone can see on the canvas, yet the free +// annotations no longer matched and `create or modify` under mdl 1 refused +// the splice ("the free annotations change; the splice edits activities +// only"). A note on an activity re-spliced that activity for the same reason. +func TestDeclaredMatches_NoteLineEndingsAreNotADifference(t *testing.T) { + body := func(eol string) string { + return " @annotation 'First line." + eol + "Second line.'\n" + + " @position(200, 200)\n" + + " @annotation 'On the log." + eol + "Two lines.'\n" + + " log info node 'N' 'hello';" + } + stored := parseFlowBody(t, body("\r\n")) + storedStmts, storedFree := withoutFreeNotes(stored.Body) + if len(storedFree) != 1 || storedFree[0].Text != "First line.\r\nSecond line." { + t.Fatalf("the stored free note is not the CRLF one: %#v", storedFree) + } + cases := []struct { + name, eol string + want bool + }{ + {"CRLF as described", "\r\n", true}, + {"LF from an editor", "\n", true}, + {"a bare CR", "\r", true}, + // Control: a real change to the text is still a change. + {"the break taken out", " ", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + declared := parseFlowBody(t, body(c.eol)) + declaredStmts, declaredFree := withoutFreeNotes(declared.Body) + if got := declaredMatches(declaredFree, storedFree); got != c.want { + t.Errorf("free notes: declaredMatches = %v, want %v", got, c.want) + } + if got := declaredMatches(declaredStmts[0], storedStmts[0]); got != c.want { + t.Errorf("activity note: declaredMatches = %v, want %v", got, c.want) + } + }) + } +} + +// The refusal names the note, so a real change is found without diffing the +// whole flow by hand (mendixlabs/mxcli#1505). +func TestFreeNoteChange_NamesTheNote(t *testing.T) { + note := func(text string) ast.MicroflowAnnotation { return ast.MicroflowAnnotation{Text: text} } + cases := []struct { + name string + declared, stored []ast.MicroflowAnnotation + want string + }{ + {"reworded", []ast.MicroflowAnnotation{note("a"), note("b2")}, []ast.MicroflowAnnotation{note("a"), note("b")}, + `note "b" is now "b2"`}, + {"added", []ast.MicroflowAnnotation{note("a")}, nil, `note "a" is not in the stored flow`}, + {"removed", nil, []ast.MicroflowAnnotation{note("a")}, `note "a" is not in the script`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := freeNoteChange(c.declared, c.stored); got != c.want { + t.Errorf("got %s, want %s", got, c.want) + } + }) + } +} diff --git a/mdl/executor/microflow_document_properties.go b/mdl/executor/microflow_document_properties.go index dde8569d88..4852bd370c 100644 --- a/mdl/executor/microflow_document_properties.go +++ b/mdl/executor/microflow_document_properties.go @@ -22,17 +22,8 @@ import ( // which is the rule these clauses exist to let a script opt out of rather than // to abolish (mendixlabs/mxcli#1120). func applyMicroflowDocumentProperties(ctx *ExecContext, mf *microflows.Microflow, s *ast.CreateMicroflowStmt) error { - // Deep links are Mendix 10.6+. Gate on the clause being STATED, not on the - // resulting value: a rewrite that carries a stored URL forward on an older - // project is preserving what is already there, and refusing that would make - // the guard destroy the very thing it protects. - if s.URL != nil || s.URLSearchParameters != nil { - if err := checkFeature(ctx, "microflows", "deep_link_url", - "a microflow URL (deep link)", - "upgrade your project to 10.6+, or set the URL in Studio Pro"); err != nil { - return err - } - } + // Deep links are Mendix 10.6+: gated with the statement's other version + // gates in buildMicroflowFromStmt (statementVersionGates). if s.URL != nil { if err := checkURLNotTaken(ctx, mf, *s.URL); err != nil { return err diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index 2803d2d044..5f98e2162b 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -489,6 +489,10 @@ func validateProgramWithWarnings(ctx *ExecContext, prog *ast.Program) ([]error, // `Module.Enum.Key` in any expression: the key must be one of the // enumeration's values (mendixlabs/mxcli#1472). errors = append(errors, validateEnumValueRefs(ctx, prog, sc)...) + // A reference into a Protected (add-on) module's Hidden element, and a + // Usable microflow there exposing a Hidden entity: mxbuild's CE1406 and + // CE5400, after exec has written the model (mendixlabs/mxcli#1498). + errors = append(errors, validateProtectedModuleRefs(ctx, prog)...) return errors, sc.warnings } diff --git a/mdl/executor/validate_alter_unscoped.go b/mdl/executor/validate_alter_unscoped.go index 68df997eb9..7de3cc0a92 100644 --- a/mdl/executor/validate_alter_unscoped.go +++ b/mdl/executor/validate_alter_unscoped.go @@ -91,7 +91,12 @@ func validateAlterUnscopedBindings(ctx *ExecContext, prog *ast.Program, sc *scri if !mutator.FindWidget(target.Widget) { continue } - if msg := unscopedInsertion(ctx, sc, mutator, target.Widget, into, modName, containerID, widgets); msg != "" { + for _, msg := range unscopedInsertion(ctx, sc, mutator, target.Widget, into, modName, containerID, widgets) { + errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf("%s: %s", label, msg))) + } + // The same insertion point, asked exec's association-as-attribute + // question (mendixlabs/mxcli#1440, validate_assoc_as_attribute.go). + for _, msg := range alterAssociationAsAttribute(ctx, mutator, target.Widget, into, modName, containerID, widgets) { errs = append(errs, mdlerrors.NewValidation(fmt.Sprintf("%s: %s", label, msg))) } } @@ -99,35 +104,39 @@ func validateAlterUnscopedBindings(ctx *ExecContext, prog *ast.Program, sc *scri return errs } -// unscopedInsertion returns the finding for one INSERT/REPLACE, or "". +// unscopedInsertion returns the findings for one INSERT/REPLACE: the bare +// bindings, and the association-mode combo boxes that would start at the +// insertion point's object (mendixlabs/mxcli#1420; exec refuses them in the +// widget engine, from the same entity this asks alterEntityContext for). func unscopedInsertion(ctx *ExecContext, sc *scriptContext, mutator backend.PageMutator, target string, into bool, - modName string, modID model.ID, widgets []*ast.WidgetV3) string { + modName string, modID model.ID, widgets []*ast.WidgetV3) []string { entity, flow := alterEntityContext(ctx, mutator, target, into, modName, modID) if entity != "" { - return "" + return nil } if flow != "" && sc != nil { // A flow the script creates is not in the project yet; its declared // return type is what exec will resolve against. if sig, ok := sc.flowParams[strings.ToLower(flow)]; ok { if sig.Returns != "" { - return "" + return nil } } } + out := unanchoredComboBoxes(widgets) bare := bindingsWithoutScope(widgets) if len(bare) == 0 { - return "" + return out } where := fmt.Sprintf("the insertion point (`%s`) is in no data container", target) if flow != "" { where = fmt.Sprintf("the data container around `%s` is sourced by %s, which does not exist or returns no entity", target, flow) } - return fmt.Sprintf("%s, so no entity is in scope and these bindings cannot be qualified: %s. "+ + return append(out, fmt.Sprintf("%s, so no entity is in scope and these bindings cannot be qualified: %s. "+ "Written without an entity, a template parameter is stored bare (Mendix can no longer load the "+ "project) and an attribute binding is dropped (CE7005). Qualify them (Module.Entity.Attribute), "+ "or insert into a data container whose entity is known.", - where, strings.Join(bare, ", ")) + where, strings.Join(bare, ", "))) } // hasWidgetBuildingOp reports whether a statement carries an INSERT or REPLACE, diff --git a/mdl/executor/validate_assoc_as_attribute.go b/mdl/executor/validate_assoc_as_attribute.go new file mode 100644 index 0000000000..057c6fdcc4 --- /dev/null +++ b/mdl/executor/validate_assoc_as_attribute.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/model" +) + +// An association given to a pluggable widget's attribute-typed property passed +// `check` and was refused by `exec` (mendixlabs/mxcli#1440): +// +// alter snippet M.S { replace with { +// combobox x (Attribute: $Asset.Asset_Customer, CaptionAttribute: …) +// } }; +// +// check: Check passed! +// exec: … property `attributeEnumeration` binds `Asset_Customer`, which is an +// association … not an attribute +// +// exec is right — an association cannot be stored in an attribute-typed +// property (#830, rejectAssociationAsAttribute) — and check never asked. It now +// asks exec's own question, through the same function, with the entity exec +// resolves the name against: the parameter's entity for `$Param.Name`, the +// property's binding scope otherwise. CREATE PAGE / SNIPPET asks it in the +// pluggable attribute-scope walk (checkWidget); an ALTER's INSERT / REPLACE asks +// it here, against the stored document's parameters and insertion point. + +// associationAsAttributeError is exec's refusal for one attribute mapping's +// value: val as the engine's Attribute mapping reads it, entity the scope a bare +// name resolves in, paramEntity the entity of a document parameter by name. "" +// for an entity or a parameter that cannot be established leaves the value +// unjudged — exec reports an unknown parameter itself. +func associationAsAttributeError(pb *pageBuilder, w *ast.WidgetV3, key, val, entity string, paramEntity func(string) string) error { + if pb == nil || val == "" { + return nil + } + where := fmt.Sprintf("widget `%s` property `%s`", w.Name, key) + if name, rest, named := namedObjectBinding(val); named { + return pb.rejectAssociationAsAttributeOn(w, name, rest, paramEntity(name), where) + } + return pb.rejectAssociationAsAttributeOn(w, "", val, entity, where) +} + +// alterAssociationAsAttribute judges the pluggable widgets one INSERT / REPLACE +// places, bound against the insertion point's entity and the stored document's +// parameters. A widget with a data source of its own binds its attributes to +// that source, and a pluggable widget's children sit in slots this does not +// model, so neither is descended into or judged here. +func alterAssociationAsAttribute(ctx *ExecContext, mutator backend.PageMutator, target string, into bool, + modName string, modID model.ID, widgets []*ast.WidgetV3) []string { + registry := pageContextWidgetRegistry(ctx) + if registry == nil { + return nil + } + entity, _ := alterEntityContext(ctx, mutator, target, into, modName, modID) + _, params := mutator.ParamScope() + paramEntity := func(name string) string { + for p, e := range params { + if strings.EqualFold(p, name) { + return e + } + } + return "" + } + pb := &pageBuilder{ctx: ctx, backend: ctx.Backend, execCache: ctx.Cache, moduleName: modName, moduleID: modID} + + var out []string + var walk func(ws []*ast.WidgetV3) + walk = func(ws []*ast.WidgetV3) { + for _, w := range ws { + if w == nil || w.GetDataSource() != nil { + continue + } + def := lookupWidgetDef(w, registry) + if def == nil { + walk(w.Children) + continue + } + engine := &PluggableWidgetEngine{currentDef: def} + mappings, _, err := engine.selectMappings(def, w) + if err != nil { + continue + } + for _, m := range mappings { + if m.Source != "Attribute" { + continue + } + val := namedPropValue(m, w) + if val == "" && engine.isPrimaryAttributeMapping(m) { + val = w.GetAttribute() + } + if err := associationAsAttributeError(pb, w, m.PropertyKey, val, entity, paramEntity); err != nil { + out = append(out, err.Error()) + } + } + } + } + walk(widgets) + return out +} diff --git a/mdl/executor/validate_assoc_as_attribute_test.go b/mdl/executor/validate_assoc_as_attribute_test.go new file mode 100644 index 0000000000..f2a8fe1ea7 --- /dev/null +++ b/mdl/executor/validate_assoc_as_attribute_test.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + modelsdkbackend "github.com/mendixlabs/mxcli/mdl/backend/modelsdk" + "github.com/mendixlabs/mxcli/mdl/backend/pagemutator" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// mendixlabs/mxcli#1440: a combo box bound to an association through the +// `Attribute:` key passed check and was refused by exec — +// +// alter snippet M.S { replace with { +// combobox x (Attribute: $Asset.Asset_Customer, CaptionAttribute: …) +// } }; +// check: "Check passed!" +// exec: "property attributeEnumeration binds Asset_Customer, which is an +// association … not an attribute" +// +// The fixture is the report's shape: snippet M.S with parameter $Asset of +// M.Asset, an association M.Asset_Customer from M.Asset to M.Customer, and a +// placeholder widget at the top of the snippet to replace. +func assocAsAttributeCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("M") + snip := mkSnippet(mod.ID, "S") + asset := &domainmodel.Entity{BaseElement: model.BaseElement{ID: model.ID("e-asset")}, Name: "Asset", + Attributes: []*domainmodel.Attribute{{Name: "Code"}}} + customer := &domainmodel.Entity{BaseElement: model.BaseElement{ID: model.ID("e-customer")}, Name: "Customer", + Attributes: []*domainmodel.Attribute{{Name: "CustomerName"}}} + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: model.ID("dm-m")}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{asset, customer}, + Associations: []*domainmodel.Association{{Name: "Asset_Customer", ParentID: asset.ID, ChildID: customer.ID, + Type: domainmodel.AssociationTypeReference}}, + } + h := mkHierarchy(mod) + withContainer(h, snip.ID, mod.ID) + withContainer(h, dm.ID, mod.ID) + + stored := bson.D{ + {Key: "$Type", Value: "Forms$Snippet"}, + {Key: "Parameters", Value: bson.A{int32(2), bson.D{ + {Key: "$Type", Value: "Forms$SnippetParameter"}, + {Key: "Name", Value: "Asset"}, + {Key: "ParameterType", Value: bson.D{{Key: "$Type", Value: "DataTypes$ObjectType"}, {Key: "Entity", Value: "M.Asset"}}}, + }}}, + {Key: "Widgets", Value: bson.A{int32(2), bson.D{{Key: "$Type", Value: "Forms$DynamicText"}, {Key: "Name", Value: "placeholder"}}}}, + } + real := &modelsdkbackend.Backend{} + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + LoadWidgetTemplateFunc: real.LoadWidgetTemplate, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetModuleByNameFunc: func(string) (*model.Module, error) { return mod, nil }, + ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snip}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + OpenPageForMutationFunc: func(unitID model.ID) (backend.PageMutator, error) { + return pagemutator.New(stored, unitID, &countingDeps{}), nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("registry: %v", err) + } + ctx.widgetRegistry = reg + ctx.widgetRegistryLoaded = true + return ctx +} + +func checkAssocAsAttributeAlter(t *testing.T, ctx *ExecContext, widget string) []error { + t.Helper() + prog := parseMDL(t, "alter snippet M.S { replace placeholder with { "+widget+" } };") + sc := newScriptContext() + sc.collectDefinitions(prog) + return validateAlterUnscopedBindings(ctx, prog, sc) +} + +// The reported script: check now refuses what exec refuses, and the remedy +// names the association mode that builds (0 errors on mx 11.6.6). +func TestCheck1440_AlterComboBoxAttributeNamingAnAssociation(t *testing.T) { + ctx := assocAsAttributeCtx(t) + errs := checkAssocAsAttributeAlter(t, ctx, + `combobox x (Attribute: $Asset.Asset_Customer, CaptionAttribute: M.Customer.CustomerName)`) + if len(errs) != 1 { + t.Fatalf("got %d errors, want 1: %v", len(errs), errs) + } + msg := errs[0].Error() + for _, want := range []string{"alter snippet M.S", "attributeEnumeration", "`Asset_Customer`", + "association (M.Asset → M.Customer)", "CE1613", + "`combobox x (Attribute: $Asset.M.Asset_Customer, DataSource: database M.Customer"} { + if !strings.Contains(msg, want) { + t.Errorf("error does not name %q:\n%s", want, msg) + } + } +} + +// Controls: an attribute of the parameter, and the association mode the +// remedy suggests, are not refused. +func TestCheck1440_AlterComboBoxControls(t *testing.T) { + ctx := assocAsAttributeCtx(t) + for _, w := range []string{ + `combobox x (Attribute: $Asset.Code)`, + `combobox x (Attribute: $Asset.M.Asset_Customer, DataSource: database M.Customer, CaptionAttribute: CustomerName)`, + } { + if errs := checkAssocAsAttributeAlter(t, ctx, w); len(errs) != 0 { + t.Errorf("%s: unexpected refusal %v", w, errs) + } + } +} + +// CREATE SNIPPET had the same gap, in the pluggable attribute-scope walk. +func TestCheck1440_CreateSnippetComboBoxAttributeNamingAnAssociation(t *testing.T) { + ctx := assocAsAttributeCtx(t) + for _, tc := range []struct { + src string + refused bool + }{ + {`create snippet M.S2 (Params: ($Asset: M.Asset)) { combobox x (Attribute: $Asset.Asset_Customer) };`, true}, + {`create snippet M.S2 (Params: ($Asset: M.Asset)) { dataview dv (DataSource: $Asset) { combobox x (Attribute: Asset_Customer) } };`, true}, + {`create snippet M.S2 (Params: ($Asset: M.Asset)) { combobox x (Attribute: $Asset.Code) };`, false}, + } { + prog := parseMDL(t, tc.src) + s, ok := prog.Statements[0].(*ast.CreateSnippetStmtV3) + if !ok { + t.Fatalf("got %T", prog.Statements[0]) + } + errs := validatePluggableAttributeScopes(ctx, "", s.Parameters, s.Widgets, newScriptContext()) + if got := len(errs) == 1 && strings.Contains(errs[0], "which is an association"); got != tc.refused { + t.Errorf("%s:\n refused=%v, want %v (errors %q)", tc.src, got, tc.refused, errs) + } + } +} diff --git a/mdl/executor/validate_design_properties.go b/mdl/executor/validate_design_properties.go index 14c2d00fa1..54610a8f05 100644 --- a/mdl/executor/validate_design_properties.go +++ b/mdl/executor/validate_design_properties.go @@ -277,6 +277,29 @@ func validateDesignPropsAs(w *ast.WidgetV3, key string, reg *ThemeRegistry, loca return out } +// ThemeDeclaresDesignProperty reports whether reg declares design property key +// for the design-properties.json group widgetKey (e.g. "DivContainer", with +// the inherited "Widget" group) and accepts value for it: "on" for a Toggle, +// one of the declared option names otherwise. A multi-select property is never +// accepted, since a single value is the wrong stored shape for it (CE6084). +// +// For generated MDL that must not name a property the project's theme lacks: +// mxbuild refuses every such one with CE6083, and the theme is versioned with +// Atlas, not with mxcli (mendixlabs/mxcli#1433). +func ThemeDeclaresDesignProperty(reg *ThemeRegistry, widgetKey, key, value string) bool { + if reg == nil { + return false + } + tp := findThemeProp(reg.GetPropertiesForWidget(widgetKey), key) + if tp == nil || tp.MultiSelect { + return false + } + if tp.Type == "Toggle" { + return strings.EqualFold(value, "on") + } + return themeOptionAllowed(tp.Options, value) +} + // findThemeProp returns the property whose Name matches key exactly. func findThemeProp(props []ThemeProperty, key string) *ThemeProperty { for i := range props { diff --git a/mdl/executor/validate_entity_access_calls.go b/mdl/executor/validate_entity_access_calls.go new file mode 100644 index 0000000000..ae660a9bbf --- /dev/null +++ b/mdl/executor/validate_entity_access_calls.go @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "fmt" + "sort" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// entityAccessCallRule is the check-time counterpart of MxBuild's CE0114 +// (mendixlabs/mxcli#1500): +// +// "A microflow that does not apply entity access can only call microflows +// that also do not apply entity access." +// "A microflow that does not apply entity access can only call rules that +// also do not apply entity access." +// +// Measured with mxbuild 11.13.0 and 11.14.0 on fresh projects, one caller per +// shape: +// +// microflow off -> call microflow on -> CE0114 ("microflows") +// microflow off -> decision on a rule that is on -> CE0114 ("rules") +// microflow on -> microflow off, on -> on, off -> off -> nothing +// nanoflow (no such setting) -> microflow on -> nothing +// EXCLUDED microflow off -> microflow on -> nothing +// +// at security level Off and Prototype alike: unlike CE0106 it is not a +// security-level check, so it is always an error. +// +// It is reported by `check -p` only, which predicts the build; exec does not +// refuse on it, so no script changes meaning and there is nothing to gate on the +// language version — the same call MDL-SEC21 made for CE0106. +const entityAccessCallRule = "MDL-SEC22" + +// eaFlow is one microflow's or rule's simulated "apply entity access" state. +type eaFlow struct { + kind string // "microflow" or "rule" + name string + exists bool + excluded bool + ea bool + // origin says, for the message, where the setting came from. + origin string + calls []eaCall +} + +// eaCall is a call from a microflow body: a call-microflow activity or a +// decision on a rule. +type eaCall struct { + kind string // "microflow" or "rule" + name string +} + +// CheckEntityAccessCalls reports MDL-SEC22 for the script. +func (e *Executor) CheckEntityAccessCalls(prog *ast.Program) []linter.Violation { + if e == nil { + return nil + } + return CheckEntityAccessCalls(e.newExecContext(context.Background()), prog) +} + +// CheckEntityAccessCalls simulates the script's microflows and rules and reports +// each call the script leaves from a microflow that does not apply entity access +// to a microflow or rule that does. Only what the script CAUSES is reported: a +// call already in that state before the script is the project's existing +// problem. A callee neither the project nor the script holds is not judged. +func CheckEntityAccessCalls(ctx *ExecContext, prog *ast.Program) []linter.Violation { + if ctx == nil || prog == nil || !ctx.Connected() || !scriptTouchesEntityAccessCalls(prog) { + return nil + } + h, err := getHierarchy(ctx) + if err != nil || h == nil { + return nil + } + sim := &eaSim{flows: map[string]*eaFlow{}} + for _, typ := range []string{"Microflows$Microflow", "Microflows$Rule"} { + units, err := ctx.Backend.ListRawUnitsByType(typ) + if err != nil { + continue + } + kind := "microflow" + if typ == "Microflows$Rule" { + kind = "rule" + } + for _, u := range units { + if u.Type != typ || len(u.Contents) == 0 { + continue + } + var doc bson.D + if err := bson.Unmarshal(u.Contents, &doc); err != nil { + continue + } + name, _ := bsonField(doc, "Name").(string) + qn := h.GetQualifiedName(u.ContainerID, name) + excluded, _ := bsonField(doc, "Excluded").(bool) + if prev, ok := sim.flows[qn]; ok && !prev.excluded { + continue // a live namesake wins over excluded ones (#914) + } + ea, _ := bsonField(doc, "ApplyEntityAccess").(bool) + f := &eaFlow{kind: kind, name: qn, exists: true, excluded: excluded, ea: ea} + if kind == "microflow" { + f.calls = collectRawEntityAccessCalls(doc) + } + sim.flows[qn] = f + } + } + + before := sim.violating() + for _, stmt := range prog.Statements { + sim.apply(stmt) + } + after := sim.violating() + + var keys []string + for k := range after { + if _, had := before[k]; !had { + keys = append(keys, k) + } + } + sort.Strings(keys) + + var out []linter.Violation + for _, k := range keys { + edge := after[k] + caller, callee := sim.flows[edge.caller], sim.flows[edge.callee] + qn := splitQualifiedName(caller.name) + out = append(out, linter.Violation{ + RuleID: entityAccessCallRule, + Severity: linter.SeverityError, + Message: fmt.Sprintf( + "microflow %s does not apply entity access (%s) but calls %s %s, which does (%s) — MxBuild reports CE0114 \"A microflow that does not apply entity access can only call %ss that also do not apply entity access.\"", + caller.name, eaOrigin(caller), callee.kind, callee.name, eaOrigin(callee), callee.kind), + Suggestion: fmt.Sprintf( + "apply entity access in the caller (`@applyentityaccess` before `create or modify microflow %s`), or call a %s that does not apply it — turning it off in %s (`@applyentityaccess(false)`) widens what it may read and write for every caller", + caller.name, callee.kind, callee.name), + Location: linter.Location{Module: qn.Module, DocumentType: "microflow", DocumentName: qn.Name}, + }) + } + return out +} + +func eaOrigin(f *eaFlow) string { + v := "@applyentityaccess(false)" + if f.ea { + v = "@applyentityaccess(true)" + } + if f.origin == "" { + return v + " as stored" + } + return v + " " + f.origin +} + +// scriptTouchesEntityAccessCalls is the cheap gate: only a script that writes a +// microflow or rule, or alters a microflow, can add a call or change a setting. +func scriptTouchesEntityAccessCalls(prog *ast.Program) bool { + for _, stmt := range prog.Statements { + switch s := stmt.(type) { + case *ast.CreateMicroflowStmt, *ast.CreateRuleStmt: + return true + case *ast.AlterFlowStmt: + if !s.Nanoflow { + return true + } + } + } + return false +} + +// collectRawEntityAccessCalls reads the calls out of a stored microflow: the +// qualified name under `Microflow` in a Microflows$MicroflowCall (call +// activities, queued or not) or a Microflows$RuleCall (a rule split). +func collectRawEntityAccessCalls(doc primitive.D) []eaCall { + var out []eaCall + var walk func(v any) + walk = func(v any) { + switch t := v.(type) { + case primitive.D: + typ, _ := bsonField(t, "$Type").(string) + kind := "" + switch typ { + case "Microflows$MicroflowCall": + kind = "microflow" + case "Microflows$RuleCall": + kind = "rule" + } + if kind != "" { + if s, ok := bsonField(t, "Microflow").(string); ok && s != "" { + out = append(out, eaCall{kind, s}) + } + } + for _, e := range t { + walk(e.Value) + } + case primitive.A: + for _, x := range t { + walk(x) + } + } + } + walk(doc) + return out +} + +type eaSim struct { + flows map[string]*eaFlow +} + +type eaEdge struct { + caller, callee string +} + +// violating returns the calls that currently break CE0114, keyed by +// caller -> callee. +func (s *eaSim) violating() map[string]eaEdge { + out := map[string]eaEdge{} + for _, f := range s.flows { + if f.kind != "microflow" || !f.exists || f.excluded || f.ea { + continue + } + for _, c := range f.calls { + t, ok := s.flows[c.name] + // An unknown, dropped or excluded callee, or a name of the other + // kind, cannot be judged here. + if !ok || !t.exists || t.excluded || t.kind != c.kind || !t.ea { + continue + } + out[f.name+"->"+c.kind+":"+c.name] = eaEdge{f.name, c.name} + } + } + return out +} + +func (s *eaSim) write(kind string, name ast.QualifiedName, ifNotExists, excluded bool, stated *bool, body []ast.MicroflowStatement) { + qn := name.String() + f, ok := s.flows[qn] + if ok && f.exists && ifNotExists { + return + } + if !ok || !f.exists { + // A new document, or one this script dropped: nothing is carried. + f = &eaFlow{kind: kind, name: qn, origin: "by default, as a new " + kind} + s.flows[qn] = f + } else { + // create or modify (or a plain create exec refuses): exclusion and an + // unstated setting are carried (#914, carriedApplyEntityAccess). + excluded = excluded || f.excluded + } + f.kind = kind + f.exists = true + f.excluded = excluded + f.ea = carriedApplyEntityAccess(stated, f.ea) + if stated != nil { + f.origin = "in this script" + } + f.calls = nil + if kind == "microflow" { + f.calls = s.collect(body, nil) + } +} + +// collect reads the calls from a script body. A decision is a rule call only +// when the name is a rule at this point of the script — exec asks IsRule when +// it builds the flow, so a rule created later makes it an expression split. +func (s *eaSim) collect(stmts []ast.MicroflowStatement, out []eaCall) []eaCall { + rule := func(cond ast.Expression) { + if cond == nil { + return + } + call := unwrapParenCall(cond) + if call == nil || !strings.Contains(call.Name, ".") { + return + } + if t, ok := s.flows[call.Name]; ok && t.exists && t.kind == "rule" { + out = append(out, eaCall{"rule", call.Name}) + } + } + for _, stmt := range stmts { + switch st := stmt.(type) { + case *ast.CallMicroflowStmt: + if st.MicroflowName.Module != "" { + out = append(out, eaCall{"microflow", st.MicroflowName.String()}) + } + case *ast.IfStmt: + rule(st.Condition) + out = s.collect(st.ThenBody, out) + out = s.collect(st.ElseBody, out) + case *ast.EnumSplitStmt: + rule(st.Condition) + for _, c := range st.Cases { + out = s.collect(c.Body, out) + } + out = s.collect(st.ElseBody, out) + case *ast.InheritanceSplitStmt: + for _, c := range st.Cases { + out = s.collect(c.Body, out) + } + out = s.collect(st.ElseBody, out) + case *ast.LoopStmt: + out = s.collect(st.Body, out) + case *ast.WhileStmt: + out = s.collect(st.Body, out) + } + if eh := getErrorHandlerBody(stmt); eh != nil { + out = s.collect(eh, out) + } + } + return out +} + +func (s *eaSim) drop(qn string) { + if f, ok := s.flows[qn]; ok { + f.exists = false + f.calls = nil + } +} + +func (s *eaSim) apply(stmt ast.Statement) { + switch st := stmt.(type) { + case *ast.CreateMicroflowStmt: + s.write("microflow", st.Name, st.IfNotExists, st.Excluded, st.ApplyEntityAccess, st.Body) + case *ast.CreateRuleStmt: + s.write("rule", st.Name, st.IfNotExists, st.Excluded, st.ApplyEntityAccess, nil) + case *ast.DropMicroflowStmt: + s.drop(st.Name.String()) + case *ast.DropRuleStmt: + s.drop(st.Name.String()) + case *ast.AlterFlowStmt: + if st.Nanoflow { + return + } + // Only additions are modelled: an inserted or replacing fragment adds + // its calls. A call it removes was already counted before the script, + // so it cannot produce a report of its own. + f, ok := s.flows[st.Name.String()] + if !ok || !f.exists || f.kind != "microflow" { + return + } + for _, op := range st.Operations { + f.calls = s.collect(op.Body, f.calls) + } + } +} diff --git a/mdl/executor/validate_entity_access_calls_test.go b/mdl/executor/validate_entity_access_calls_test.go new file mode 100644 index 0000000000..c6034d782e --- /dev/null +++ b/mdl/executor/validate_entity_access_calls_test.go @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" +) + +// mendixlabs/mxcli#1500 (Mendix 11.14.0, MPR v2): `check -p --references` +// passed a microflow with @applyentityaccess(false) calling one with +// @applyentityaccess(true) — "no flow change exec would refuse … no used flow +// left without access … Check passed!" — and mxbuild refused it: +// +// CE0114 A microflow that does not apply entity access can only call +// microflows that also do not apply entity access. +// +// Measured with mxbuild 11.14.0 on a fresh project (see the finding): the +// reverse direction, true→true and false→false build clean; a decision calling +// a rule with entity access from a flow without it is CE0114 with "rules"; a +// nanoflow calling a microflow with entity access, and an EXCLUDED caller, are +// not reported. The security level does not matter (Off and Prototype alike). + +var eaCallsModule = &model.Module{BaseElement: model.BaseElement{ID: "mod-mymodule"}, Name: "MyModule"} + +type eaCallsFixture struct { + units []*types.RawUnit +} + +// addStored stores a microflow ("Microflows$Microflow") or rule +// ("Microflows$Rule") with the given apply-entity-access setting, whose body +// calls each of calls ("microflow:Mod.Name" or "rule:Mod.Name"). +func (f *eaCallsFixture) addStored(typ, name string, ea, excluded bool, calls ...string) { + var objs bson.A + objs = append(objs, int32(3)) + for _, c := range calls { + kind, target, _ := strings.Cut(c, ":") + switch kind { + case "microflow": + objs = append(objs, bson.D{ + {Key: "$Type", Value: "Microflows$ActionActivity"}, + {Key: "Action", Value: bson.D{ + {Key: "$Type", Value: "Microflows$MicroflowCallAction"}, + {Key: "MicroflowCall", Value: bson.D{ + {Key: "$Type", Value: "Microflows$MicroflowCall"}, + {Key: "Microflow", Value: target}, + }}, + }}, + }) + case "rule": + objs = append(objs, bson.D{ + {Key: "$Type", Value: "Microflows$ExclusiveSplit"}, + {Key: "SplitCondition", Value: bson.D{ + {Key: "$Type", Value: "Microflows$RuleSplitCondition"}, + {Key: "RuleCall", Value: bson.D{ + {Key: "$Type", Value: "Microflows$RuleCall"}, + {Key: "Microflow", Value: target}, + }}, + }}, + }) + } + } + doc := bson.D{ + {Key: "$Type", Value: typ}, + {Key: "Name", Value: name}, + {Key: "Excluded", Value: excluded}, + {Key: "ApplyEntityAccess", Value: ea}, + {Key: "ObjectCollection", Value: bson.D{ + {Key: "$Type", Value: "Microflows$MicroflowObjectCollection"}, + {Key: "Objects", Value: objs}, + }}, + } + raw, err := bson.Marshal(doc) + if err != nil { + panic(err) + } + f.units = append(f.units, &types.RawUnit{ + ID: nextID("mf"), ContainerID: eaCallsModule.ID, Type: typ, Contents: raw, + }) +} + +func (f *eaCallsFixture) check(t *testing.T, src string) []linter.Violation { + t.Helper() + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{eaCallsModule}, nil }, + ListRawUnitsByTypeFunc: func(prefix string) ([]*types.RawUnit, error) { + var out []*types.RawUnit + for _, u := range f.units { + if strings.HasPrefix(u.Type, prefix) { + out = append(out, u) + } + } + return out, nil + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(eaCallsModule))) + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + var out []linter.Violation + for _, v := range CheckEntityAccessCalls(ctx, prog) { + if v.RuleID == entityAccessCallRule { + out = append(out, v) + } + } + return out +} + +const eaCaller = `mdl 1; +@applyentityaccess(false) +create or modify microflow MyModule.DS_Tile_Count () returns Integer +begin + $List = call microflow MyModule.SUB_Scoped_List(); + return 1; +end;` + +const eaCallee = `@applyentityaccess(true) +create microflow MyModule.SUB_Scoped_List () returns List of MyModule.Entity +begin + retrieve $L from MyModule.Entity; + return $L; +end; +` + +func wantOneCE0114(t *testing.T, got []linter.Violation, kind, caller, callee string) { + t.Helper() + if len(got) != 1 { + t.Fatalf("want 1 %s violation, got %d: %+v", entityAccessCallRule, len(got), got) + } + v := got[0] + if v.Severity != linter.SeverityError { + t.Errorf("mxbuild refuses this (CE0114); want error, got %v", v.Severity) + } + for _, want := range []string{ + caller, callee, "CE0114", + "A microflow that does not apply entity access can only call " + kind + "s that also do not apply entity access.", + } { + if !strings.Contains(v.Message, want) { + t.Errorf("message lacks %q: %s", want, v.Message) + } + } + if v.Location.DocumentName != splitQualifiedName(caller).Name { + t.Errorf("located at %q, want the caller %s", v.Location.DocumentName, caller) + } +} + +// The reporter's first shape: the callee is already in the project. +func TestEntityAccessCalls_CalleeInProject(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + wantOneCE0114(t, f.check(t, eaCaller), "microflow", "MyModule.DS_Tile_Count", "MyModule.SUB_Scoped_List") +} + +// The reporter's second shape: the callee is created earlier in the same script. +func TestEntityAccessCalls_CalleeEarlierInScript(t *testing.T) { + f := &eaCallsFixture{} + src := strings.Replace(eaCaller, "mdl 1;\n", "mdl 1;\n"+eaCallee, 1) + wantOneCE0114(t, f.check(t, src), "microflow", "MyModule.DS_Tile_Count", "MyModule.SUB_Scoped_List") +} + +// Controls: every other combination builds at 0 errors (measured). +func TestEntityAccessCalls_AllowedDirectionsAreClean(t *testing.T) { + for name, tc := range map[string]struct { + calleeEA bool + callerAnn string + }{ + "caller on, callee off": {false, "@applyentityaccess(true)"}, + "both on": {true, "@applyentityaccess(true)"}, + "both off": {false, "@applyentityaccess(false)"}, + } { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", tc.calleeEA, false) + src := strings.Replace(eaCaller, "@applyentityaccess(false)", tc.callerAnn, 1) + if got := f.check(t, src); len(got) != 0 { + t.Errorf("%s: mxbuild accepts this; got %+v", name, got) + } + } +} + +// A callee neither the project nor the script holds cannot be judged: silent. +func TestEntityAccessCalls_UnresolvedCalleeIsSilent(t *testing.T) { + f := &eaCallsFixture{} + if got := f.check(t, eaCaller); len(got) != 0 { + t.Errorf("callee could not be established; got %+v", got) + } +} + +// A decision calling a rule with entity access is the same CE with "rules". +func TestEntityAccessCalls_RuleSplit(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Rule", "R_Scoped", true, false) + got := f.check(t, `mdl 1; +@applyentityaccess(false) +create microflow MyModule.UsesRule ($X: Integer) +begin + if MyModule.R_Scoped(X = $X) then + log info node 'T' 'big'; + end if; + return; +end;`) + wantOneCE0114(t, got, "rule", "MyModule.UsesRule", "MyModule.R_Scoped") + + // A rule created earlier in the script is a rule call too. + f = &eaCallsFixture{} + got = f.check(t, `mdl 1; +@applyentityaccess +create rule MyModule.R_New (X: Integer) returns Boolean +begin + return $X > 1; +end; +@applyentityaccess(false) +create microflow MyModule.UsesRule ($X: Integer) +begin + if MyModule.R_New(X = $X) then + log info node 'T' 'big'; + end if; + return; +end;`) + wantOneCE0114(t, got, "rule", "MyModule.UsesRule", "MyModule.R_New") +} + +// Measured clean: a nanoflow caller (it has no such setting) and an excluded +// microflow caller. +func TestEntityAccessCalls_NanoflowAndExcludedCallersAreClean(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + if got := f.check(t, `mdl 1; +create nanoflow MyModule.NF () +begin + $List = call microflow MyModule.SUB_Scoped_List(); + return; +end;`); len(got) != 0 { + t.Errorf("nanoflow caller: got %+v", got) + } + src := strings.Replace(eaCaller, "@applyentityaccess(false)", "@excluded\n@applyentityaccess(false)", 1) + if got := f.check(t, src); len(got) != 0 { + t.Errorf("excluded caller: got %+v", got) + } +} + +// An absent annotation on `create or modify` keeps the stored setting +// (carriedApplyEntityAccess), so a stored caller WITH entity access stays clean. +func TestEntityAccessCalls_AbsentAnnotationCarriesStored(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + f.addStored("Microflows$Microflow", "DS_Tile_Count", true, false) + src := strings.Replace(eaCaller, "@applyentityaccess(false)\n", "", 1) + if got := f.check(t, src); len(got) != 0 { + t.Errorf("stored caller applies entity access and keeps it; got %+v", got) + } + // Without a stored caller the new microflow gets the default, off. + f = &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + wantOneCE0114(t, f.check(t, src), "microflow", "MyModule.DS_Tile_Count", "MyModule.SUB_Scoped_List") +} + +// The script turning entity access ON for a stored callee breaks a stored +// caller it does not mention; the report is on that caller. +func TestEntityAccessCalls_ScriptChangesCallee(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", false, false) + f.addStored("Microflows$Microflow", "DS_Tile_Count", false, false, "microflow:MyModule.SUB_Scoped_List") + got := f.check(t, "mdl 1;\n"+strings.Replace(eaCallee, "create microflow", "create or modify microflow", 1)) + wantOneCE0114(t, got, "microflow", "MyModule.DS_Tile_Count", "MyModule.SUB_Scoped_List") +} + +// A project that already has the defect, untouched by the script, is not a +// verdict on the script. +func TestEntityAccessCalls_PreexistingNotReported(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + f.addStored("Microflows$Microflow", "DS_Tile_Count", false, false, "microflow:MyModule.SUB_Scoped_List") + if got := f.check(t, "mdl 1;\ncreate microflow MyModule.Other () begin end;"); len(got) != 0 { + t.Errorf("pre-existing CE0114 reported against an unrelated script: %+v", got) + } +} + +// alter microflow inserting the call into a stored caller without entity access. +func TestEntityAccessCalls_AlterInsertsCall(t *testing.T) { + f := &eaCallsFixture{} + f.addStored("Microflows$Microflow", "SUB_Scoped_List", true, false) + f.addStored("Microflows$Microflow", "DS_Tile_Count", false, false) + got := f.check(t, `mdl 1; +alter microflow MyModule.DS_Tile_Count { + insert before return { + $List = call microflow MyModule.SUB_Scoped_List(); + } +};`) + wantOneCE0114(t, got, "microflow", "MyModule.DS_Tile_Count", "MyModule.SUB_Scoped_List") +} diff --git a/mdl/executor/validate_import_mapping_find.go b/mdl/executor/validate_import_mapping_find.go index 42a8b7f62d..119105c030 100644 --- a/mdl/executor/validate_import_mapping_find.go +++ b/mdl/executor/validate_import_mapping_find.go @@ -244,6 +244,9 @@ type persistabilityResolver struct { script map[string]entityFacts project map[string]entityFacts reader backend.FullBackend + // views are the view entities the script creates. They are kept out of + // script so the persistability walk treats them as before (unresolved). + views []string } // entityFacts is what deciding persistability needs: the entity's own flag and @@ -251,16 +254,32 @@ type persistabilityResolver struct { type entityFacts struct { persistable bool generalization string + // view and uniqueAttrs serve the published-OData key rule (MDL-ODATA09): + // uniqueAttrs maps each of the entity's OWN attributes to whether it + // carries a unique validation rule, so an absent name is not an attribute + // of this entity. + view bool + uniqueAttrs map[string]bool } func newPersistabilityResolver(prog *ast.Program, projectPath string) *persistabilityResolver { r := &persistabilityResolver{script: map[string]entityFacts{}} for _, stmt := range prog.Statements { + if v, ok := stmt.(*ast.CreateViewEntityStmt); ok { + // Known to be a view, and nothing else: persistability stays + // unresolved, as it was before views were recorded here. + r.views = append(r.views, v.Name.String()) + continue + } s, ok := stmt.(*ast.CreateEntityStmt) if !ok { continue } - facts := entityFacts{persistable: s.Kind == ast.EntityPersistent} + facts := entityFacts{ + persistable: s.Kind == ast.EntityPersistent, + view: s.Kind == ast.EntityView, + uniqueAttrs: scriptUniqueAttrs(s), + } if s.Generalization != nil { facts.generalization = s.Generalization.String() } @@ -307,6 +326,11 @@ func (r *persistabilityResolver) persistable(entity string) (bool, bool) { } func (r *persistabilityResolver) lookup(entity string) (entityFacts, bool) { + for _, v := range r.views { + if v == entity { + return entityFacts{view: true}, true + } + } if facts, ok := r.script[entity]; ok { return facts, true } @@ -357,8 +381,32 @@ func projectEntityFacts(projectPath string) (map[string]entityFacts, backend.Ful } func entityFactsOf(e *domainmodel.Entity) entityFacts { + unique := make(map[string]bool, len(e.Attributes)) + byID := make(map[string]string, len(e.Attributes)) + for _, a := range e.Attributes { + if a == nil { + continue + } + unique[a.Name] = false + byID[string(a.ID)] = a.Name + } + for _, vr := range e.ValidationRules { + if vr == nil || vr.Type != "Unique" { + continue + } + // AttributeID is the attribute's $ID or, as stored, its qualified name. + name, ok := byID[string(vr.AttributeID)] + if !ok { + name = extractAttrNameFromQualified(string(vr.AttributeID)) + } + if _, own := unique[name]; own { + unique[name] = true + } + } return entityFacts{ persistable: e.Persistable, generalization: e.GeneralizationRef, + view: isViewSource(e.Source), + uniqueAttrs: unique, } } diff --git a/mdl/executor/validate_odata_keys.go b/mdl/executor/validate_odata_keys.go new file mode 100644 index 0000000000..283e7f91c9 --- /dev/null +++ b/mdl/executor/validate_odata_keys.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// What Mendix requires of a published entity's KEY (mendixlabs/mxcli#1426). +// +// Measured with mx 11.6.6 on a copy of testdata/expr-checker, one service per +// case in one app (mxbuild reports them all): +// +// no KEY (persistent, non-persistent, view; no expose; expose (*)) -> CE6585 +// one KEY, persistable, no unique validation rule -> CE6624 +// ... with only `not null`, or only an index on it -> CE6624 +// ... on OData3 and on OData4 alike -> CE6624 +// one KEY, persistable, `unique` (OData3 and OData4) -> 0 errors +// composite KEY, OData4, no unique rule on either part -> 0 errors +// one KEY on a non-persistent entity / a view entity, no rule -> 0 errors +// +// So CE6624 is about a single-attribute key on a persistable entity, and only a +// unique VALIDATION RULE satisfies it — a Mendix index has no unique flag, and +// is not one. A composite key needs no rule, but is OData4-only (CE7238), which +// is not this check's business. Non-persistable entities cannot carry a +// validation rule at all (CE0070), which is why they are exempt. + +// ValidateODataKeys flags a published entity with no KEY (MDL-ODATA08) or with a +// single KEY attribute that has no unique validation rule (MDL-ODATA09). +// +// MDL-ODATA08 needs nothing but the statement. MDL-ODATA09 needs the entity — +// from the script first, the project second — and says nothing about an entity +// it cannot resolve, about an inherited key attribute, or about an entity the +// script alters, rather than guess in the confident direction. +func ValidateODataKeys(prog *ast.Program, projectPath string) []linter.Violation { + if prog == nil { + return nil + } + var out []linter.Violation + var singles []singleKey + for _, stmt := range prog.Statements { + svc, ok := stmt.(*ast.CreateODataServiceStmt) + if !ok { + continue + } + for _, e := range svc.Entities { + if e == nil || e.Entity.Name == "" { + continue + } + where := fmt.Sprintf("publish entity %s in odata service %s", e.Entity.String(), svc.Name.String()) + loc := linter.Location{ + Module: svc.Name.Module, + DocumentType: "published odata service", + DocumentName: svc.Name.Name, + } + var keys []string + for _, m := range e.Members { + if m != nil && m.IsPartOfKey { + keys = append(keys, m.Name) + } + } + switch len(keys) { + case 0: + out = append(out, noKeyViolation(where, loc, e)) + case 1: + singles = append(singles, singleKey{where: where, loc: loc, entity: e.Entity.String(), attr: keys[0]}) + } + } + } + if len(singles) == 0 { + return out + } + return append(out, nonUniqueKeyViolations(prog, projectPath, singles)...) +} + +type singleKey struct { + where string + loc linter.Location + entity string + attr string +} + +func noKeyViolation(where string, loc linter.Location, e *ast.PublishedEntityDef) linter.Violation { + example := "Name" + for _, m := range e.Members { + if m != nil && m.Name != "" { + example = m.Name + break + } + } + return linter.Violation{ + RuleID: "MDL-ODATA08", + Severity: linter.SeverityError, + Location: loc, + Message: fmt.Sprintf("%s: no exposed member is marked KEY, and Mendix requires a published entity "+ + "to have one (CE6585 \"Published entity '%s' must have a key defined.\")", where, e.Entity.String()), + Suggestion: fmt.Sprintf("Mark the member that identifies a row, e.g. expose (%s (KEY), ...). On a "+ + "persistent entity a single KEY attribute needs a unique validation rule "+ + "(`%s: String(200) unique`); a composite key (two or more KEY members, OData4 only) does not.", + example, example), + } +} + +// nonUniqueKeyViolations reports MDL-ODATA09 for single keys on a persistable +// entity whose own key attribute carries no unique rule. +func nonUniqueKeyViolations(prog *ast.Program, projectPath string, keys []singleKey) []linter.Violation { + res := newPersistabilityResolver(prog, projectPath) + defer res.close() + + // An entity the script alters may gain or lose the rule on the way; its + // final state is not what either definition says, so leave it alone. + altered := map[string]bool{} + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.AlterEntityStmt); ok { + altered[s.Name.String()] = true + } + } + + var out []linter.Violation + for _, k := range keys { + if altered[k.entity] { + continue + } + facts, ok := res.lookup(k.entity) + if !ok || facts.view || facts.uniqueAttrs == nil { + continue + } + unique, own := facts.uniqueAttrs[k.attr] + if !own || unique { + // Not an attribute of the entity itself: inherited, an association, + // or a typo another check reports. None is decidable here. + continue + } + if persistable, known := res.persistable(k.entity); !known || !persistable { + continue + } + out = append(out, linter.Violation{ + RuleID: "MDL-ODATA09", + Severity: linter.SeverityError, + Location: k.loc, + Message: fmt.Sprintf("%s: KEY %s has no unique validation rule, which Mendix requires of a "+ + "single key attribute on a persistent entity (CE6624 \"Add a unique validation rule to "+ + "attribute '%s' of entity '%s' to be able to use it as the key.\")", + k.where, k.attr, k.attr, k.entity), + Suggestion: fmt.Sprintf("Declare the attribute unique — `%s: String(200) unique` in the entity, or "+ + "`alter entity %s modify attribute %s unique` for a stored one. Not null or an index "+ + "does not count.", k.attr, k.entity, k.attr), + }) + } + return out +} + +// scriptUniqueAttrs records which of a script entity's own attributes are +// declared unique. +func scriptUniqueAttrs(s *ast.CreateEntityStmt) map[string]bool { + out := make(map[string]bool, len(s.Attributes)) + for _, a := range s.Attributes { + out[a.Name] = a.Unique + } + return out +} + +// isViewSource reports whether a stored entity's rows come from an OQL view. +func isViewSource(source string) bool { + return strings.Contains(source, "OqlViewEntitySource") +} diff --git a/mdl/executor/validate_odata_keys_test.go b/mdl/executor/validate_odata_keys_test.go new file mode 100644 index 0000000000..df5040b6f0 --- /dev/null +++ b/mdl/executor/validate_odata_keys_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1426: a published OData entity with no KEY, or with a single +// KEY on an attribute that has no unique validation rule, passed `check` and +// `exec`, and only `mx check` said +// +// [CE6585] "Published entity 'ModA.EntA' must have a key defined." +// [CE6624] "Add a unique validation rule to attribute 'Name' of entity +// 'ModA.EntA' to be able to use it as the key." +// +// The cases below are the measured ones (mx 11.6.6) — see validate_odata_keys.go. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" + "github.com/mendixlabs/mxcli/mdl/visitor" +) + +const keyTestEntities = ` +create persistent entity K.Uniq ( Name: string(200) unique error message 'u', Code: string(50) ); +create persistent entity K.Plain ( Name: string(200), Code: string(50) ); +create persistent entity K.NotNull ( Name: string(200) not null error message 'n', Code: string(50) ); +create persistent entity K.Indexed ( Name: string(200), Code: string(50) ) index (Name); +create non-persistent entity K.Np ( Name: string(200), Code: string(50) ); +create view entity K.PlainView ( Name: String(200), Code: String(50) ) as ( + select p.Name as Name, p.Code as Code from K.Plain as p +); +` + +func keyService(version, entity, expose string) string { + return `create published odata service K.Svc ( path: 'odata/k/', version: '1.0.0', ODataVersion: ` + + version + `, namespace: 'K.Ns' ) +{ publish entity ` + entity + ` as 'Things' (ReadMode: source) ` + expose + `; };` +} + +func keyRuleViolations(t *testing.T, script, projectPath string) []linter.Violation { + t.Helper() + prog, errs := visitor.Build(script) + if len(errs) > 0 { + t.Fatalf("script does not parse: %v\n%s", errs, script) + } + var out []linter.Violation + for _, v := range ValidateODataKeys(prog, projectPath) { + if v.RuleID == "MDL-ODATA08" || v.RuleID == "MDL-ODATA09" { + out = append(out, v) + } + } + return out +} + +// TestODataKeyRules_Script covers every measured case with the entity defined +// in the same script, so no project is needed. +func TestODataKeyRules_Script(t *testing.T) { + cases := []struct { + name string + version string + entity string + expose string + wantRule string // "" means the build is clean, so check must be too + wantCE string + }{ + // The controls: what mxbuild accepts. + {"single key, unique rule (control)", "OData4", "K.Uniq", "expose (Name (KEY), Code)", "", ""}, + {"single key, unique rule, OData3 (control)", "OData3", "K.Uniq", "expose (Name (KEY), Code)", "", ""}, + {"composite key, no unique rule", "OData4", "K.Plain", "expose (Name (KEY), Code (KEY))", "", ""}, + {"single key on a non-persistent entity", "OData4", "K.Np", "expose (Name (KEY), Code)", "", ""}, + {"single key on a view entity", "OData4", "K.PlainView", "expose (Name (KEY), Code)", "", ""}, + + // CE6585 — any entity kind. + {"no key", "OData4", "K.Uniq", "expose (Name, Code)", "MDL-ODATA08", "CE6585"}, + {"no expose clause", "OData4", "K.Uniq", "", "MDL-ODATA08", "CE6585"}, + {"expose (*)", "OData4", "K.Uniq", "expose (*)", "MDL-ODATA08", "CE6585"}, + {"no key, non-persistent", "OData4", "K.Np", "expose (Name, Code)", "MDL-ODATA08", "CE6585"}, + {"no key, view entity", "OData4", "K.PlainView", "expose (Name, Code)", "MDL-ODATA08", "CE6585"}, + + // CE6624 — single key, persistable entity, no unique validation rule. + // Neither the OData version, NOT NULL, nor an index changes it. + {"single key, no unique rule", "OData4", "K.Plain", "expose (Name (KEY), Code)", "MDL-ODATA09", "CE6624"}, + {"single key, no unique rule, OData3", "OData3", "K.Plain", "expose (Name (KEY), Code)", "MDL-ODATA09", "CE6624"}, + {"single key, not null only", "OData4", "K.NotNull", "expose (Name (KEY), Code)", "MDL-ODATA09", "CE6624"}, + {"single key, index only", "OData4", "K.Indexed", "expose (Name (KEY), Code)", "MDL-ODATA09", "CE6624"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := keyRuleViolations(t, keyTestEntities+keyService(tc.version, tc.entity, tc.expose), "") + if tc.wantRule == "" { + if len(got) != 0 { + t.Fatalf("mxbuild accepts this, but check reports: %s", got[0].Message) + } + return + } + if len(got) != 1 || got[0].RuleID != tc.wantRule { + t.Fatalf("want exactly one %s, got %v", tc.wantRule, got) + } + if got[0].Severity != linter.SeverityError { + t.Errorf("%s is a build error, so it must refuse; severity is %v", tc.wantRule, got[0].Severity) + } + if !strings.Contains(got[0].Message, tc.wantCE) { + t.Errorf("message does not name %s: %s", tc.wantCE, got[0].Message) + } + }) + } +} + +// TestODataKeyRules_ProjectEntity is the issue's shape: the entity already +// exists in the project, and the script only publishes it. +func TestODataKeyRules_ProjectEntity(t *testing.T) { + p := projectFixture(t) + execAgainst(t, p, `create module K; +create persistent entity K.Uniq ( Name: string(200) unique error message 'u', Code: string(50) ); +create persistent entity K.Plain ( Name: string(200), Code: string(50) ); +create non-persistent entity K.Np ( Name: string(200), Code: string(50) );`) + + got := keyRuleViolations(t, keyService("OData4", "K.Plain", "expose (Name (KEY), Code)"), p) + if len(got) != 1 || got[0].RuleID != "MDL-ODATA09" { + t.Fatalf("key on a stored attribute without a unique rule: want MDL-ODATA09, got %v", got) + } + if !strings.Contains(got[0].Message, "K.Plain") || !strings.Contains(got[0].Message, "Name") { + t.Errorf("message should name the entity and attribute: %s", got[0].Message) + } + + // The control: the same service over the stored entity WITH the rule. + if got := keyRuleViolations(t, keyService("OData4", "K.Uniq", "expose (Name (KEY), Code)"), p); len(got) != 0 { + t.Fatalf("stored unique rule not seen: %v", got) + } + if got := keyRuleViolations(t, keyService("OData4", "K.Np", "expose (Name (KEY), Code)"), p); len(got) != 0 { + t.Fatalf("non-persistent stored entity needs no unique rule: %v", got) + } + + // The script's definition wins over the stored one. + script := `create or modify persistent entity K.Plain ( Name: string(200) unique error message 'u', Code: string(50) ); +` + keyService("OData4", "K.Plain", "expose (Name (KEY), Code)") + if got := keyRuleViolations(t, script, p); len(got) != 0 { + t.Fatalf("script adds the unique rule, yet check reports: %v", got) + } +} + +// TestODataKeyRules_UnknownEntityIsSilent pins the fail-open half: an entity +// neither the script nor a project defines cannot be judged, so CE6624 says +// nothing — while CE6585 needs no entity at all. +func TestODataKeyRules_UnknownEntityIsSilent(t *testing.T) { + if got := keyRuleViolations(t, keyService("OData4", "K.Elsewhere", "expose (Name (KEY), Code)"), ""); len(got) != 0 { + t.Fatalf("an unresolvable entity was judged: %v", got) + } + got := keyRuleViolations(t, keyService("OData4", "K.Elsewhere", "expose (Name, Code)"), "") + if len(got) != 1 || got[0].RuleID != "MDL-ODATA08" { + t.Fatalf("no key needs no entity facts: want MDL-ODATA08, got %v", got) + } +} + +// TestODataKeyRules_InValidateProgram proves the rules are on the path `exec` +// runs as its preflight, not only on `check`'s. +func TestODataKeyRules_InValidateProgram(t *testing.T) { + prog, errs := visitor.Build(keyTestEntities + keyService("OData4", "K.Plain", "expose (Name (KEY), Code)")) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + for _, v := range ValidateProgram(prog, "") { + if v.RuleID == "MDL-ODATA09" && v.Severity == linter.SeverityError { + return + } + } + t.Fatal("ValidateProgram (shared by check and exec) does not report MDL-ODATA09") +} diff --git a/mdl/executor/validate_program.go b/mdl/executor/validate_program.go index 93eb8f91f5..ff47b40ef8 100644 --- a/mdl/executor/validate_program.go +++ b/mdl/executor/validate_program.go @@ -246,6 +246,13 @@ func ValidateProgram(prog *ast.Program, projectPath string) []linter.Violation { // no error code, so there is nothing to look up. violations = append(violations, ValidateODataServiceShape(prog)...) + // Flag a published entity with no KEY (CE6585), or with a single KEY + // attribute that has no unique validation rule on a persistent entity + // (CE6624). Both were written and only failed the build + // (mendixlabs/mxcli#1426). The second resolves the entity from the script, + // then the project, and says nothing when it cannot. + violations = append(violations, ValidateODataKeys(prog, projectPath)...) + // Flag a page whose widgets point at a page created further down the same // script. `exec` resolves page references in statement order and is not // transactional, so this fails after earlier statements are already diff --git a/mdl/executor/validate_protected_module_refs.go b/mdl/executor/validate_protected_module_refs.go new file mode 100644 index 0000000000..adaa50d924 --- /dev/null +++ b/mdl/executor/validate_protected_module_refs.go @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/mdl/ast" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" +) + +// validateProtectedModuleRefs reports the two rules a Protected (add-on) module +// imposes, which `check -p --references` did not know and mxbuild enforces +// (mendixlabs/mxcli#1498). A module is Protected when its Projects$ModuleSettings +// says ExportLevel "Protected"; each element in it is then Hidden or Usable +// (stored "API"), and: +// +// - CE1406: nothing outside the module may reference a Hidden element in it. +// mxbuild words the source "project level documents" for navigation and +// "app modules" for a document in an ordinary module. +// - CE5400: a Usable microflow in the module may not expose a Hidden entity of +// it through a parameter or its return type. +// +// Measured on blank 11.14.0 and 11.13.0 apps, a control beside each refusal: +// a home microflow, menu item, call microflow and parameter type are refused +// into Hidden and accepted into API, and the module set back to Source accepts +// every one. A PAGE is refused even with ExportLevel "API" stored on it — from +// navigation and from a show page activity alike — so a page in an add-on +// module is reported whatever it stores; an API microflow inside the module +// that shows it is the measured way in. +// +// Covered shapes: navigation home pages, home flows, login / not-found pages and +// menu items; menu documents; microflow and nanoflow parameter and return +// types, call microflow and show page in their bodies. Everything else is left +// alone, and so is anything whose level cannot be established — settings that +// do not read, a target that does not resolve (the not-found checks own that), +// a nanoflow target (mxcli does not read a nanoflow's export level), or a +// reference between two Protected modules (not measured). +func validateProtectedModuleRefs(ctx *ExecContext, prog *ast.Program) []error { + if prog == nil || !ctx.Connected() || !hasProtectedModuleCandidates(prog) { + return nil + } + protected := protectedModuleNames(ctx) + if len(protected) == 0 { + return nil + } + r := &protectedRefResolver{ + ctx: ctx, + protected: protected, + scriptFlows: map[string]*string{}, + scriptEntity: map[string]bool{}, + scriptPages: map[string]bool{}, + entityLevels: map[string]map[string]string{}, + } + for _, stmt := range prog.Statements { + r.recordDefinition(stmt) + } + + var errs []error + for i, stmt := range prog.Statements { + for _, p := range r.check(stmt) { + errs = append(errs, mdlerrors.NewValidationf("statement %d: %s", i+1, p)) + } + } + return errs +} + +func hasProtectedModuleCandidates(prog *ast.Program) bool { + for _, stmt := range prog.Statements { + switch stmt.(type) { + case *ast.AlterNavigationStmt, *ast.CreateMenuStmt, *ast.CreateMicroflowStmt, *ast.CreateNanoflowStmt: + return true + } + } + return false +} + +// protectedModuleNames is the set of modules whose settings say Protected. +// Unreadable settings give the empty set, which turns the check off. +func protectedModuleNames(ctx *ExecContext) map[string]bool { + byID := moduleProtections(ctx) + if len(byID) == 0 { + return nil + } + mods, err := getModulesFromCache(ctx) + if err != nil { + return nil + } + out := map[string]bool{} + for _, m := range mods { + if byID[string(m.ID)] != "" { + out[m.Name] = true + } + } + return out +} + +// moduleProtection is a module's ProtectedModuleType ("AddOn", "Solution") +// when its settings say Protected, else "". Unreadable settings give "". +func moduleProtection(ctx *ExecContext, moduleID string) string { + return moduleProtections(ctx)[moduleID] +} + +// moduleProtections maps each Protected module's ID to its +// ProtectedModuleType, from one read of the settings. +func moduleProtections(ctx *ExecContext) map[string]string { + settings, err := ctx.Backend.ListModuleSettings() + if err != nil { + return nil + } + out := map[string]string{} + for _, ms := range settings { + if ms != nil && ms.ExportLevel == "Protected" { + kind := ms.ProtectedModuleType + if kind == "" { + kind = "AddOn" // the metamodel default + } + out[string(ms.ContainerID)] = kind + } + } + return out +} + +type protectedRefResolver struct { + ctx *ExecContext + protected map[string]bool + + // What the script defines: a flow's stated export level (nil = not stated), + // the entities and pages it creates. + scriptFlows map[string]*string + scriptEntity map[string]bool + scriptPages map[string]bool + + storedPages map[string]bool + entityLevels map[string]map[string]string // module -> entity -> stored level; nil = unreadable +} + +func (r *protectedRefResolver) recordDefinition(stmt ast.Statement) { + switch s := stmt.(type) { + case *ast.CreateMicroflowStmt: + r.scriptFlows[s.Name.String()] = s.ExportLevel + case *ast.CreateEntityStmt: + r.scriptEntity[s.Name.String()] = true + case *ast.CreatePageStmtV3: + r.scriptPages[s.Name.String()] = true + } +} + +func (r *protectedRefResolver) check(stmt ast.Statement) []string { + switch s := stmt.(type) { + case *ast.AlterNavigationStmt: + return r.checkNavigation(s) + case *ast.CreateMenuStmt: + if r.protected[s.Name.Module] { + return nil + } + return r.checkMenuItems(s.Items, "app modules") + case *ast.CreateMicroflowStmt: + return r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, effectiveFlowLevel(r, s)) + case *ast.CreateNanoflowStmt: + // A nanoflow's own export level is not read, so only its references out + // of an ordinary module are checked, never CE5400 on it. + return r.checkFlow(s.Name, s.Parameters, s.ReturnType, s.Body, "") + } + return nil +} + +func (r *protectedRefResolver) checkNavigation(s *ast.AlterNavigationStmt) []string { + const src = "project level documents" + who := fmt.Sprintf("navigation profile '%s'", s.ProfileName) + var out []string + for _, hp := range s.HomePages { + switch { + case hp.IsPage: + out = appendNonEmpty(out, r.pageRef(who, hp.Target, src)) + case !hp.IsNanoflow: + out = appendNonEmpty(out, r.microflowRef(who, hp.Target, src)) + } + } + for _, qn := range []*ast.QualifiedName{s.LoginPage, s.NotFoundPage} { + if qn != nil { + out = appendNonEmpty(out, r.pageRef(who, *qn, src)) + } + } + return append(out, r.checkMenuItems(s.MenuItems, src)...) +} + +func (r *protectedRefResolver) checkMenuItems(items []ast.NavMenuItemDef, src string) []string { + var out []string + for _, it := range items { + who := fmt.Sprintf("menu item '%s'", it.Caption) + var pages, flows []string + if it.Page != nil { + pages = append(pages, it.Page.String()) + } + if it.Microflow != nil { + flows = append(flows, it.Microflow.String()) + } + for a := it.Action; a != nil; a = a.ThenAction { + switch a.Type { + case "showPage": + pages = append(pages, a.Target) + case "microflow": + flows = append(flows, a.Target) + } + } + for _, p := range uniqueStrings(pages) { + out = appendNonEmpty(out, r.pageRef(who, parseQN(p), src)) + } + for _, f := range uniqueStrings(flows) { + out = appendNonEmpty(out, r.microflowRef(who, parseQN(f), src)) + } + out = append(out, r.checkMenuItems(it.Items, src)...) + } + return out +} + +// checkFlow checks a microflow or nanoflow. From an ordinary module: its +// parameter and return types, and the calls and pages in its body (CE1406). +// In a Protected module and Usable (level "API"): the Hidden entities of that +// module its signature exposes (CE5400). +func (r *protectedRefResolver) checkFlow(name ast.QualifiedName, params []ast.MicroflowParam, + ret *ast.MicroflowReturnType, body []ast.MicroflowStatement, level string) []string { + var out []string + if r.protected[name.Module] { + if level != types.ExportLevelAPI { + return nil + } + for _, p := range params { + if e, ok := signatureEntity(p.Type); ok && e.Module == name.Module && r.entityHidden(e) { + out = append(out, ce5400(e, fmt.Sprintf("parameter '%s'", p.Name))) + } + } + if ret != nil { + if e, ok := signatureEntity(ret.Type); ok && e.Module == name.Module && r.entityHidden(e) { + out = append(out, ce5400(e, fmt.Sprintf("microflow '%s'", name.String()))) + } + } + return out + } + + const src = "app modules" + for _, p := range params { + if e, ok := signatureEntity(p.Type); ok { + out = appendNonEmpty(out, r.entityRef(fmt.Sprintf("parameter '%s'", p.Name), e, src)) + } + } + if ret != nil { + if e, ok := signatureEntity(ret.Type); ok { + out = appendNonEmpty(out, r.entityRef(fmt.Sprintf("the return type of %s", name.String()), e, src)) + } + } + refs := &flowRefCollector{} + refs.collectFromStatements(body) + for _, p := range uniqueStrings(refs.pages) { + out = appendNonEmpty(out, r.pageRef("show page activity", parseQN(p), src)) + } + for _, f := range uniqueStrings(refs.microflows) { + out = appendNonEmpty(out, r.microflowRef("call microflow activity", parseQN(f), src)) + } + return out +} + +func effectiveFlowLevel(r *protectedRefResolver, s *ast.CreateMicroflowStmt) string { + if s.ExportLevel != nil { + return *s.ExportLevel + } + // Not stated: a rewrite keeps what is stored, a new microflow is Hidden. + if lvl, ok := r.storedMicroflowLevel(s.Name); ok { + return lvl + } + return types.ExportLevelHidden +} + +const ce1406Tail = "cannot reference hidden elements in add-on modules" + +func (r *protectedRefResolver) pageRef(who string, qn ast.QualifiedName, src string) string { + if !r.protected[qn.Module] || !r.pageExists(qn) { + return "" + } + return fmt.Sprintf("CE1406: %s is not allowed to use page '%s' because %s %s — %s is a protected module, "+ + "and a page in one cannot be made usable (mxbuild refuses it even with export level API stored); "+ + "reference a microflow in %s with `export level api` that shows the page instead", + who, qn.String(), src, ce1406Tail, qn.Module, qn.Module) +} + +func (r *protectedRefResolver) microflowRef(who string, qn ast.QualifiedName, src string) string { + if !r.protected[qn.Module] { + return "" + } + lvl, ok := r.flowLevel(qn) + if !ok || lvl == types.ExportLevelAPI { + return "" + } + return fmt.Sprintf("CE1406: %s is not allowed to use microflow '%s' because %s %s — %s is a protected module "+ + "and the microflow's export level is Hidden; give it `export level api` to make it usable", + who, qn.String(), src, ce1406Tail, qn.Module) +} + +func (r *protectedRefResolver) entityRef(who string, qn ast.QualifiedName, src string) string { + if !r.protected[qn.Module] || !r.entityHidden(qn) { + return "" + } + return fmt.Sprintf("CE1406: %s is not allowed to use entity '%s' because %s %s — %s is a protected module "+ + "and the entity's export level is Hidden (mxcli cannot change an entity's export level; set it to Usable in Studio Pro)", + who, qn.String(), src, ce1406Tail, qn.Module) +} + +func ce5400(entity ast.QualifiedName, exposer string) string { + return fmt.Sprintf("CE5400: the export level of entity '%s' should be 'Usable' because %s exposes it and has export level 'Usable' "+ + "(%s is a protected module and the entity is Hidden; mxcli cannot change an entity's export level — set it to Usable in Studio Pro, "+ + "or drop `export level api` from the microflow)", + entity.String(), exposer, entity.Module) +} + +// flowLevel is a microflow's effective export level: what the script states, +// else what is stored, else Hidden for one the script creates. ok is false when +// the microflow resolves nowhere. +func (r *protectedRefResolver) flowLevel(qn ast.QualifiedName) (string, bool) { + if lvl, defined := r.scriptFlows[qn.String()]; defined { + if lvl != nil { + return *lvl, true + } + if stored, ok := r.storedMicroflowLevel(qn); ok { + return stored, true + } + return types.ExportLevelHidden, true + } + return r.storedMicroflowLevel(qn) +} + +func (r *protectedRefResolver) storedMicroflowLevel(qn ast.QualifiedName) (string, bool) { + h, err := getHierarchy(r.ctx) + if err != nil { + return "", false + } + mfs, err := microflowsNamed(r.ctx, qn.Name) + if err != nil { + return "", false + } + for _, mf := range mfs { + if h.GetModuleName(h.FindModuleID(mf.ContainerID)) == qn.Module { + // "" is written as Hidden (microflowToGen) and read so by mxbuild. + return mf.ExportLevel, true + } + } + return "", false +} + +// entityHidden reports whether an entity is known to be Hidden: a stored one +// whose level is not API, or one the script creates (always written Hidden, +// a rewrite carrying the stored level). +func (r *protectedRefResolver) entityHidden(qn ast.QualifiedName) bool { + if lvl, ok := r.storedEntityLevel(qn); ok { + return lvl != types.ExportLevelAPI + } + return r.scriptEntity[qn.String()] +} + +func (r *protectedRefResolver) storedEntityLevel(qn ast.QualifiedName) (string, bool) { + levels, read := r.entityLevels[qn.Module] + if !read { + levels = nil + if mod, err := findModule(r.ctx, qn.Module); err == nil { + if dm, err := r.ctx.Backend.GetDomainModel(mod.ID); err == nil && dm != nil { + levels = map[string]string{} + for _, e := range dm.Entities { + levels[e.Name] = e.ExportLevel + } + } + } + r.entityLevels[qn.Module] = levels + } + lvl, ok := levels[qn.Name] + return lvl, ok +} + +func (r *protectedRefResolver) pageExists(qn ast.QualifiedName) bool { + if r.scriptPages[qn.String()] { + return true + } + if r.storedPages == nil { + r.storedPages = buildPageQualifiedNames(r.ctx) + } + return r.storedPages[qn.String()] +} + +// signatureEntity is the entity a parameter or return type names, if any. A +// bare `Module.Name` parses as an enumeration unless written `enum …`; it is +// tried as an entity too, and resolves only if an entity has that name. +func signatureEntity(dt ast.DataType) (ast.QualifiedName, bool) { + switch { + case (dt.Kind == ast.TypeEntity || dt.Kind == ast.TypeListOf) && dt.EntityRef != nil: + return *dt.EntityRef, true + case dt.Kind == ast.TypeEnumeration && !dt.ExplicitEnum && dt.EnumRef != nil: + return *dt.EnumRef, true + } + return ast.QualifiedName{}, false +} + +func parseQN(s string) ast.QualifiedName { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '.' { + return ast.QualifiedName{Module: s[:i], Name: s[i+1:]} + } + } + return ast.QualifiedName{Name: s} +} + +func appendNonEmpty(out []string, s string) []string { + if s != "" { + out = append(out, s) + } + return out +} diff --git a/mdl/executor/validate_protected_module_refs_test.go b/mdl/executor/validate_protected_module_refs_test.go new file mode 100644 index 0000000000..4124e3eb23 --- /dev/null +++ b/mdl/executor/validate_protected_module_refs_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "errors" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// protectedModuleFixture is a project with one Protected (add-on) module, +// MyAddOn, and one ordinary module, Open, holding the same kinds of element — +// the control: every reference that is refused into MyAddOn is accepted into +// Open, because only an add-on module hides anything. +func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext { + t.Helper() + app := mkModule("MyFirstModule") + addOn := mkModule("MyAddOn") + open := mkModule("Open") + + mf := func(mod *model.Module, name, level string) *microflows.Microflow { + m := mkMicroflow(mod.ID, name) + m.ExportLevel = level + return m + } + ent := func(mod *model.Module, name, level string) *domainmodel.Entity { + e := mkEntity(mod.ID, name) + e.ExportLevel = level + return e + } + flows := []*microflows.Microflow{ + mf(addOn, "ACT_Home", "API"), + mf(addOn, "ACT_HomeHidden", "Hidden"), + mf(addOn, "Hidden_MF", ""), // unset is Hidden: mxcli and mxbuild both read it so + mf(addOn, "API_MF", "API"), + mf(open, "MF", "Hidden"), + } + dms := map[model.ID]*domainmodel.DomainModel{ + addOn.ID: mkDomainModel(addOn.ID, + ent(addOn, "Request", "Hidden"), + ent(addOn, "Secret", "Hidden"), + ent(addOn, "Pub", "API")), + open.ID: mkDomainModel(open.ID, ent(open, "E", "Hidden")), + app.ID: mkDomainModel(app.ID), + } + pgs := []*pages.Page{mkPage(addOn.ID, "Home_Page"), mkPage(open.ID, "P"), mkPage(app.ID, "Home_Web")} + + mods := []*model.Module{app, addOn, open} + ctx, _ := newMockCtx(t, withBackend(&mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return mods, nil }, + ListModuleSettingsFunc: func() ([]*types.ModuleSettings, error) { + if settingsErr != nil { + return nil, settingsErr + } + return []*types.ModuleSettings{ + {ContainerID: app.ID, ExportLevel: "Source"}, + {ContainerID: addOn.ID, ExportLevel: "Protected", ProtectedModuleType: "AddOn"}, + {ContainerID: open.ID, ExportLevel: "Source"}, + }, nil + }, + ListMicroflowsFunc: func() ([]*microflows.Microflow, error) { return flows, nil }, + ListPagesFunc: func() ([]*pages.Page, error) { return pgs, nil }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dms[id], nil }, + }), withHierarchy(mkHierarchy(mods...))) + return ctx +} + +// mendixlabs/mxcli#1498: a reference into a Protected (add-on) module passed +// `check -p --references`, exec wrote it, and mxbuild refused the build. Every +// refused case below was measured on a blank Mendix 11.14.0 app (and 11.13.0) +// with MyAddOn's settings set to ExportLevel Protected; every accepted case is +// the measured control next to it. +func TestValidateProtectedModuleRefs(t *testing.T) { + cases := []struct { + name, src string + want []string // substrings of the one error expected; nil = clean + }{ + // --- CE1406, from a project-level document (navigation) --- + {"nav home page into add-on", + "create or modify navigation Responsive home page MyAddOn.Home_Page;", + []string{"CE1406", "navigation profile 'Responsive' is not allowed to use page 'MyAddOn.Home_Page'", "project level documents"}}, + {"nav role home page into add-on (the reported shape)", + "create or modify navigation Responsive home page MyFirstModule.Home_Web home page MyAddOn.Home_Page for Administrator;", + []string{"CE1406", "page 'MyAddOn.Home_Page'"}}, + {"nav home microflow, hidden", + "create or modify navigation Responsive home microflow MyAddOn.ACT_HomeHidden;", + []string{"CE1406", "microflow 'MyAddOn.ACT_HomeHidden'", "export level api"}}, + {"nav home microflow, api", "create or modify navigation Responsive home microflow MyAddOn.ACT_Home;", nil}, + {"nav menu item shows add-on page", + "create or modify navigation Responsive home page MyFirstModule.Home_Web { menu item 'A' ( OnClick: show page MyAddOn.Home_Page ) };", + []string{"CE1406", "menu item 'A' is not allowed to use page 'MyAddOn.Home_Page'"}}, + {"nav menu item calls hidden microflow", + "create or modify navigation Responsive home page MyFirstModule.Home_Web { menu item 'A' ( OnClick: call microflow MyAddOn.Hidden_MF ) };", + []string{"CE1406", "microflow 'MyAddOn.Hidden_MF'"}}, + + // --- CE1406, from an app module --- + {"call hidden microflow", + "create microflow MyFirstModule.Caller () begin $r = call microflow MyAddOn.Hidden_MF(); end;", + []string{"CE1406", "microflow 'MyAddOn.Hidden_MF'", "app modules cannot reference hidden elements"}}, + {"call api microflow", "create microflow MyFirstModule.Caller () begin $r = call microflow MyAddOn.API_MF(); end;", nil}, + {"show add-on page", + "create microflow MyFirstModule.Opener () begin show page MyAddOn.Home_Page; end;", + []string{"CE1406", "page 'MyAddOn.Home_Page'"}}, + {"parameter of hidden entity", + "create microflow MyFirstModule.TakesReq ($r: MyAddOn.Secret) begin end;", + []string{"CE1406", "parameter 'r' is not allowed to use entity 'MyAddOn.Secret'"}}, + {"parameter of usable entity", "create microflow MyFirstModule.TakesReq ($r: MyAddOn.Pub) begin end;", nil}, + + // --- CE5400, a Usable flow in the add-on exposing a Hidden entity --- + {"api microflow, hidden entity parameter (the reported shape)", + "create microflow MyAddOn.API_Request_Post ($body: MyAddOn.Request) returns Boolean export level api begin return true; end;", + []string{"CE5400", "entity 'MyAddOn.Request' should be 'Usable' because parameter 'body' exposes it"}}, + {"api microflow, hidden entity return type", + "create microflow MyAddOn.API_Get () returns MyAddOn.Request export level api begin return empty; end;", + []string{"CE5400", "because microflow 'MyAddOn.API_Get' exposes it"}}, + {"api microflow, list of hidden entity", + "create microflow MyAddOn.API_List ($l: list of MyAddOn.Secret) returns Boolean export level api begin return true; end;", + []string{"CE5400", "parameter 'l'"}}, + {"api microflow, usable entity", + "create microflow MyAddOn.API_Pub ($p: MyAddOn.Pub) returns Boolean export level api begin return true; end;", nil}, + {"hidden microflow, hidden entity", + "create microflow MyAddOn.Internal ($body: MyAddOn.Request) returns Boolean begin return true; end;", nil}, + + // --- script-defined targets resolve the way exec will write them --- + {"script microflow without export level is hidden", + "create microflow MyAddOn.New () begin end;\ncreate microflow MyFirstModule.C () begin call microflow MyAddOn.New(); end;", + []string{"statement 2", "CE1406", "microflow 'MyAddOn.New'"}}, + {"script microflow with export level api", + "create microflow MyAddOn.New () export level api begin end;\ncreate microflow MyFirstModule.C () begin call microflow MyAddOn.New(); end;", nil}, + {"script entity is hidden", + "create non-persistent entity MyAddOn.Fresh (X: String(10));\ncreate microflow MyAddOn.API_F ($f: MyAddOn.Fresh) returns Boolean export level api begin return true; end;", + []string{"statement 2", "CE5400", "entity 'MyAddOn.Fresh'"}}, + + // --- controls: an ordinary module hides nothing --- + {"open module page", "create or modify navigation Responsive home page Open.P;", nil}, + {"open module hidden microflow", "create microflow MyFirstModule.C () begin call microflow Open.MF(); end;", nil}, + {"open module hidden entity", "create microflow MyFirstModule.C ($e: Open.E) begin end;", nil}, + {"open module api microflow, hidden entity", + "create microflow Open.API ($e: Open.E) returns Boolean export level api begin return true; end;", nil}, + // --- silent where it cannot be established --- + {"unknown target is the not-found check's", "create microflow MyFirstModule.C () begin call microflow MyAddOn.Nope(); end;", nil}, + } + ctx := protectedModuleFixture(t, nil) + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got []string + for _, err := range validateProtectedModuleRefs(ctx, parseMDL(t, c.src)) { + got = append(got, err.Error()) + } + if c.want == nil { + if len(got) != 0 { + t.Fatalf("want no error, got %q", got) + } + return + } + if len(got) != 1 { + t.Fatalf("want 1 error containing %q, got %q", c.want, got) + } + for _, w := range c.want { + if !strings.Contains(got[0], w) { + t.Errorf("want %q in %q", w, got[0]) + } + } + }) + } +} + +// Unreadable module settings mean no module can be shown to be Protected, so +// nothing is reported — a false refusal would be worse than no check. +func TestValidateProtectedModuleRefs_SilentWithoutSettings(t *testing.T) { + ctx := protectedModuleFixture(t, errors.New("boom")) + prog := parseMDL(t, "create or modify navigation Responsive home page MyAddOn.Home_Page;") + if errs := validateProtectedModuleRefs(ctx, prog); len(errs) != 0 { + t.Fatalf("want no errors without module settings, got %v", errs) + } +} + +// The check is wired into the --references pass. +func TestValidateProgram_ReportsProtectedModuleRef(t *testing.T) { + ctx := protectedModuleFixture(t, nil) + prog := parseMDL(t, "create microflow MyFirstModule.Caller () begin $r = call microflow MyAddOn.Hidden_MF(); end;") + var found bool + for _, err := range validateProgram(ctx, prog) { + if strings.Contains(err.Error(), "CE1406") { + found = true + } + } + if !found { + t.Fatal("validateProgram did not report the CE1406 reference into the add-on module") + } +} diff --git a/mdl/executor/validate_widget_attribute_scope.go b/mdl/executor/validate_widget_attribute_scope.go index b733f28bca..877889afd5 100644 --- a/mdl/executor/validate_widget_attribute_scope.go +++ b/mdl/executor/validate_widget_attribute_scope.go @@ -74,6 +74,7 @@ func validatePluggableAttributeScopes(ctx *ExecContext, layout string, params [] types: checkMemberTypeIndex(ctx, sc), reactClient: usesReactClient(ctx) && !layoutIsNative(ctx, layout), assocs: checkAssociationShapes(ctx, sc), + pb: &pageBuilder{ctx: ctx, backend: ctx.Backend, execCache: ctx.Cache}, } for _, w := range widgets { v.walk(w, dataContext{}) @@ -95,6 +96,15 @@ type attributeScopeValidator struct { // assocs feeds MDL-ASSOCDS01 (validate_assoc_list_source.go). assocs map[string]assocShape + + // pb answers the association-as-attribute question with exec's own + // function (validate_assoc_as_attribute.go). + pb *pageBuilder +} + +// paramEntity is the entity of the document parameter `$name`, or "". +func (v *attributeScopeValidator) paramEntity(name string) string { + return v.pageParams[strings.ToLower(name)] } func (v *attributeScopeValidator) walk(w *ast.WidgetV3, enclosing dataContext) { @@ -186,6 +196,7 @@ func (v *attributeScopeValidator) checkWidget(w *ast.WidgetV3, def *WidgetDefini values[key] = val } sources := map[string]bool{} + fromMapping := map[string]bool{} for _, m := range mappings { if m.Source != "" { sources[m.Source] = true @@ -199,6 +210,7 @@ func (v *attributeScopeValidator) checkWidget(w *ast.WidgetV3, def *WidgetDefini } if val != "" { set(m.PropertyKey, val) + fromMapping[m.PropertyKey] = true } } for propName, propVal := range w.Properties { @@ -218,6 +230,15 @@ func (v *attributeScopeValidator) checkWidget(w *ast.WidgetV3, def *WidgetDefini for _, key := range order { entry, declared := ids[key] scope := resolveBindingScope(entry.DataSourceProperty, declared, outer, dsEntities, "") + // An association where the attribute belongs: exec refuses it in the + // engine's Attribute mapping, so only a value that mapping reads is + // judged (mendixlabs/mxcli#1440). + if fromMapping[key] { + if err := associationAsAttributeError(v.pb, w, key, values[key], scope.Entity, v.paramEntity); err != nil { + v.errs = append(v.errs, err.Error()) + continue + } + } if err := misboundAttributeError( "widget `"+w.Name+"` property `"+key+"`", values[key], scope, outer, dsEntities, v.index, diff --git a/mdl/executor/validate_widgets.go b/mdl/executor/validate_widgets.go index 56c7e3c2f2..f63dd3b0da 100644 --- a/mdl/executor/validate_widgets.go +++ b/mdl/executor/validate_widgets.go @@ -280,6 +280,8 @@ func validateWidgetTreeIn(widgets []*ast.WidgetV3, registry *WidgetRegistry, loc out = append(out, validateDatasourceXPathAssociationEmpty(w, locationPrefix)...) out = append(out, validateDatasourceXPathVariableTraversal(w, locationPrefix)...) out = append(out, validateComboBoxAssociation(w, locationPrefix)...) + // #1420: an association-mode combo box with no object to start from. + out = append(out, validateComboBoxAssociationObject(w, argCtx, locationPrefix)...) // #631: inputs inside a list view that will be written read-only. out = append(out, validateListViewEditableInputs(w, registry, locationPrefix)...) // A show_page argument naming anything but the context object is dropped. diff --git a/mdl/executor/validate_workflow_esp.go b/mdl/executor/validate_workflow_esp.go index e3fae43b73..a783aa8335 100644 --- a/mdl/executor/validate_workflow_esp.go +++ b/mdl/executor/validate_workflow_esp.go @@ -26,6 +26,8 @@ // notification-started event sub-process yes yes yes yes workflows.event_subprocesses (11.8, gen) // notification activity / boundary events no yes yes yes workflows.notification_events // timer-started event sub-process no no no yes workflows.timer_event_subprocesses +// +// The gates on these floors are in statementVersionGates (version_gates.go). package executor import ( @@ -76,33 +78,6 @@ func workflowUsesNotificationEvents(activities []ast.WorkflowActivityNode) bool return found } -// checkEventSubProcessFeatures refuses what the project's Mendix version cannot -// load (see the table above). -func checkEventSubProcessFeatures(ctx *ExecContext, stmt *ast.CreateWorkflowStmt) error { - if len(stmt.EventSubProcesses) > 0 { - if err := checkFeature(ctx, "workflows", "event_subprocesses", "event subprocess", - "event sub-processes need Mendix 11.8 or later"); err != nil { - return err - } - } - for _, esp := range stmt.EventSubProcesses { - if esp.Timer { - if err := checkFeature(ctx, "workflows", "timer_event_subprocesses", "event subprocess … on … timer", - "a timer-started event sub-process needs Mendix 11.13 or later — start it with a notification on older projects"); err != nil { - return err - } - break - } - } - if workflowUsesNotificationEvents(workflowStatementActivities(stmt)) { - if err := checkFeature(ctx, "workflows", "notification_events", "notification activities and notification boundary events", - "these need Mendix 11.11 or later — use `wait for notification` on older projects"); err != nil { - return err - } - } - return nil -} - // ValidateWorkflowEventSubProcesses applies MDL-WF14 and MDL-WF15. func ValidateWorkflowEventSubProcesses(stmt *ast.CreateWorkflowStmt) []linter.Violation { loc := workflowLocation(stmt.Name) diff --git a/mdl/executor/version_gates.go b/mdl/executor/version_gates.go new file mode 100644 index 0000000000..428e9d0f91 --- /dev/null +++ b/mdl/executor/version_gates.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "context" + "fmt" + "strings" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// versionGate is one feature a statement needs from the project's Mendix +// version: the sdk/versions registry entry (area, name) that holds its floor, +// how the refusal names the statement, and what to do instead. +type versionGate struct { + area, name string + statement string + hint string +} + +// statementVersionGates lists the version gates stmt has to pass, in the order +// exec applies them. It is the one place a statement is mapped to the features +// it needs: the handlers refuse through checkVersionGates, and `mxcli check -p` +// and exec's pre-flight report through CheckVersionGates, so the three cannot +// disagree about which statement needs which version (mendixlabs/mxcli#1434 — +// check passed a script exec then refused for the project's version). The +// floors themselves live in sdk/versions/*.yaml. +// +// A gate that depends on what the project stores, not only on the statement +// (requireMessageDefinitionDocuments on a drop/alter of a document that is not +// there), stays with its handler: check cannot know it from the script. +func statementVersionGates(stmt ast.Statement) []versionGate { + switch s := stmt.(type) { + case *ast.CreateRestClientStmt: + return []versionGate{{"integration", "rest_client_basic", "create rest client", + "upgrade your project to 10.1+"}} + case *ast.CreateDataTransformerStmt: + return []versionGate{{"integration", "data_transformer", "create data transformer", + "upgrade your project to 11.9+"}} + case *ast.CreatePublishedRestServiceStmt: + return []versionGate{{"integration", "published_rest_service", "create published rest service", + "upgrade your project to 10.0+"}} + case *ast.AlterPublishedRestServiceStmt: + return []versionGate{{"integration", "published_rest_alter", "alter published rest service", + "upgrade your project to 10.0+"}} + case *ast.GrantPublishedRestServiceAccessStmt: + return []versionGate{{"integration", "published_rest_grant_revoke", "grant access on published rest service", + "upgrade your project to 10.0+"}} + case *ast.CreateODataServiceStmt: + if s.SupportsGraphQL { + return []versionGate{{"integration", "odata_graphql", "SupportsGraphQL on a published OData service", + "Publishing a service as GraphQL as well arrived in Studio Pro 10.14. " + + "Remove SupportsGraphQL to publish OData only."}} + } + case *ast.CreateMessageDefinitionStmt: + return []versionGate{messageDefinitionDocumentGate("create message definition")} + case *ast.AlterSettingsStmt: + if (s.AddGroup || s.ModifyGroup || s.UpsertGroup || s.RemoveGroup) && strings.EqualFold(s.Section, "workflows") { + return []versionGate{{"workflows", "groups", "alter settings workflows add group ''", + "Workflow groups (Settings$WorkflowGroup) were introduced in the 11.2 metamodel. " + + "On an earlier version, model the buckets as your own entity and target user tasks with a microflow."}} + } + case *ast.CreateViewEntityStmt: + return []versionGate{{"domain_model", "view_entities", "create view entity", + "upgrade your project to 10.18+ or use a regular entity with a microflow data source"}} + case *ast.CreateModelStmt: + return []versionGate{agentDocumentGate("agent_model", "create model")} + case *ast.CreateConsumedMCPServiceStmt: + return []versionGate{agentDocumentGate("agent_consumed_mcp_service", "create consumed mcp service")} + case *ast.CreateKnowledgeBaseStmt: + return []versionGate{agentDocumentGate("agent_knowledge_base", "create knowledge base")} + case *ast.CreateAgentStmt: + return []versionGate{agentDocumentGate("agent", "create agent")} + case *ast.CreatePageStmtV3: + // Pages$PageParameter is 9.4+ (mendixlabs/mxcli#1121): 9.0–9.3 has none. + if len(s.Parameters) > 0 { + return []versionGate{{"pages", "page_parameters", "create page with parameters", + "pass data via a non-persistent entity or microflow parameter instead"}} + } + case *ast.CreateMicroflowStmt: + var gates []versionGate + // SEND EMAIL writes the 11.13+ shape (StringTemplate subject and + // bodies); an older project stores a different EmailMessage, or no + // such activity. + if bodyContains(s.Body, func(st ast.MicroflowStatement) bool { _, ok := st.(*ast.SendEmailStmt); return ok }) { + gates = append(gates, versionGate{"microflows", "send_email", "send email", + "use the Email Connector module, or upgrade the project to 11.13+"}) + } + // Deep links are Mendix 10.6+. Gated on the clause being STATED, not on + // the resulting value: a rewrite that carries a stored URL forward on + // an older project preserves what is already there. + if s.URL != nil || s.URLSearchParameters != nil { + gates = append(gates, versionGate{"microflows", "deep_link_url", "a microflow URL (deep link)", + "upgrade your project to 10.6+, or set the URL in Studio Pro"}) + } + return gates + case *ast.CreateNanoflowStmt: + // SYNCHRONIZE UNSYNCHRONIZED is the one mode with a floor inside + // Mendix 9: SynchronizationType.Unsynchronized was introduced in 9.4.0, + // while the activity and its other two modes go back further. Gate the + // mode, not the statement, so a 9.0-9.3 project keeps `synchronize all`. + if bodyUsesUnsynchronized(s.Body) { + return []versionGate{{"microflows", "synchronize_unsynchronized", "synchronize unsynchronized", + "use `synchronize all` or `synchronize $Var`, or upgrade the project to 9.4+"}} + } + case *ast.CreateWorkflowStmt: + var gates []versionGate + if len(s.EventHandlers) > 0 { + gates = append(gates, versionGate{"workflows", "event_handlers", "on workflow events", + "remove the `on … workflow event` clauses, or upgrade the project"}) + } + activities := workflowStatementActivities(s) + if workflowUsesAgentTask(activities) { + gates = append(gates, agentTaskGate) + } + if len(s.EventSubProcesses) > 0 { + gates = append(gates, versionGate{"workflows", "event_subprocesses", "event subprocess", + "event sub-processes need Mendix 11.8 or later"}) + } + for _, esp := range s.EventSubProcesses { + if esp.Timer { + gates = append(gates, versionGate{"workflows", "timer_event_subprocesses", "event subprocess … on … timer", + "a timer-started event sub-process needs Mendix 11.13 or later — start it with a notification on older projects"}) + break + } + } + if workflowUsesNotificationEvents(activities) { + gates = append(gates, notificationEventsGate) + } + return gates + case *ast.AlterWorkflowStmt: + // Workflows require Mendix 9.12+. + gates := []versionGate{{"workflows", "basic", "alter workflow", + "upgrade your project to Mendix 9.12+ to use workflows"}} + added := alterWorkflowAddedActivities(s) + if workflowUsesAgentTask(added) { + gates = append(gates, agentTaskGate) + } + if workflowUsesNotificationEvents(added) { + gates = append(gates, notificationEventsGate) + } + return gates + } + return nil +} + +var ( + agentTaskGate = versionGate{"workflows", "ai_agent_task", "call agent microflow", + "AI agent tasks need Mendix 11.9 or later — use `call microflow` on older projects"} + notificationEventsGate = versionGate{"workflows", "notification_events", + "notification activities and notification boundary events", + "these need Mendix 11.11 or later — use `wait for notification` on older projects"} +) + +// agentDocumentGate is the gate of an agent-editor document. Nothing downstream +// catches an older project: the documents are custom blobs, so mxbuild does not +// validate them and the build stays green while Studio Pro cannot open the +// result. +func agentDocumentGate(name, statement string) versionGate { + return versionGate{"agent_documents", name, statement, + "upgrade your project to Mendix 11.9+ and install the AgentEditorCommons module"} +} + +// messageDefinitionDocumentGate refuses the per-document message definition +// form below 11.15. +func messageDefinitionDocumentGate(statement string) versionGate { + return versionGate{"integration", "message_definition_document", statement, + "below Mendix 11.15 a message definition is an entry of a collection: " + + "create message definition collection Module.Name { definition Name for Module.Entity { … } };"} +} + +// requireMessageDefinitionDocuments refuses the per-document form below 11.15. +func requireMessageDefinitionDocuments(ctx *ExecContext, statement string) error { + return checkGate(ctx, messageDefinitionDocumentGate(statement)) +} + +func checkGate(ctx *ExecContext, g versionGate) error { + return checkFeature(ctx, g.area, g.name, g.statement, g.hint) +} + +// checkVersionGates refuses stmt when the connected project's version lacks a +// feature it needs: the first of statementVersionGates that fails. Nil when not +// connected, as checkFeature is. +func checkVersionGates(ctx *ExecContext, stmt ast.Statement) error { + for _, g := range statementVersionGates(stmt) { + if err := checkGate(ctx, g); err != nil { + return err + } + } + return nil +} + +// CheckVersionGates reports every statement of prog the connected project's +// Mendix version cannot store — the refusals exec's handlers would raise when +// they reach it, after the statements before it. `mxcli check -p` and exec's +// pre-flight run it, so both refuse the script before anything is written. +func (e *Executor) CheckVersionGates(prog *ast.Program) []linter.Violation { + return versionGateViolations(e.newExecContext(context.Background()), prog) +} + +func versionGateViolations(ctx *ExecContext, prog *ast.Program) []linter.Violation { + var out []linter.Violation + for i, stmt := range prog.Statements { + if err := checkVersionGates(ctx, stmt); err != nil { + msg, hint, _ := strings.Cut(err.Error(), "\n hint: ") + out = append(out, linter.Violation{ + RuleID: "MDL-VERSION01", + Severity: linter.SeverityError, + Message: fmt.Sprintf("statement %d: %s", i+1, msg), + Suggestion: hint, + }) + } + } + return out +} diff --git a/mdl/executor/version_gates_test.go b/mdl/executor/version_gates_test.go new file mode 100644 index 0000000000..f9952e3d32 --- /dev/null +++ b/mdl/executor/version_gates_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1434: every version gate an exec handler applies is +// reported by CheckVersionGates — the pass `mxcli check -p` and exec's +// pre-flight run — on a project below the gate's floor, and none on one at or +// above it. The handlers refuse through checkVersionGates(ctx, stmt), so a +// statement in this table is refused by exec exactly when check reports it. +func TestCheckVersionGates_ReportsEveryHandlerGate(t *testing.T) { + url := "item/{Id}" + cases := []struct { + name string + stmt ast.Statement + below [3]int // a version under the gate's floor + want string // the statement the refusal names + }{ + {"rest client", &ast.CreateRestClientStmt{}, [3]int{10, 0, 0}, "create rest client"}, + {"data transformer", &ast.CreateDataTransformerStmt{}, [3]int{11, 8, 0}, "create data transformer"}, + {"published rest", &ast.CreatePublishedRestServiceStmt{}, [3]int{9, 24, 0}, "create published rest service"}, + {"alter published rest", &ast.AlterPublishedRestServiceStmt{}, [3]int{9, 24, 0}, "alter published rest service"}, + {"grant published rest", &ast.GrantPublishedRestServiceAccessStmt{}, [3]int{9, 24, 0}, "grant access on published rest service"}, + {"odata graphql", &ast.CreateODataServiceStmt{SupportsGraphQL: true}, [3]int{10, 13, 0}, "SupportsGraphQL"}, + {"message definition document", &ast.CreateMessageDefinitionStmt{}, [3]int{11, 14, 0}, "create message definition"}, + {"workflow group", &ast.AlterSettingsStmt{Section: "WORKFLOWS", AddGroup: true, GroupName: "G"}, [3]int{11, 1, 0}, "add group"}, + {"view entity", &ast.CreateViewEntityStmt{}, [3]int{10, 17, 0}, "create view entity"}, + {"agent model", &ast.CreateModelStmt{}, [3]int{11, 8, 0}, "create model"}, + {"consumed mcp", &ast.CreateConsumedMCPServiceStmt{}, [3]int{11, 8, 0}, "create consumed mcp service"}, + {"knowledge base", &ast.CreateKnowledgeBaseStmt{}, [3]int{11, 8, 0}, "create knowledge base"}, + {"agent", &ast.CreateAgentStmt{}, [3]int{11, 8, 0}, "create agent"}, + {"page parameters", &ast.CreatePageStmtV3{Parameters: []ast.PageParameter{{Name: "P"}}}, [3]int{9, 3, 0}, "create page with parameters"}, + {"send email", &ast.CreateMicroflowStmt{Body: []ast.MicroflowStatement{ + &ast.IfStmt{ThenBody: []ast.MicroflowStatement{&ast.SendEmailStmt{}}}}}, [3]int{11, 12, 0}, "send email"}, + {"deep link", &ast.CreateMicroflowStmt{URL: &url}, [3]int{10, 5, 0}, "a microflow URL"}, + {"synchronize unsynchronized", &ast.CreateNanoflowStmt{Body: []ast.MicroflowStatement{ + &ast.SynchronizeStmt{SyncType: "Unsynchronized"}}}, [3]int{9, 3, 0}, "synchronize unsynchronized"}, + {"workflow event handlers", &ast.CreateWorkflowStmt{EventHandlers: []ast.WorkflowEventHandlerNode{{AnyEvent: true}}}, [3]int{10, 6, 0}, "on workflow events"}, + {"workflow agent task", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{ + &ast.WorkflowCallMicroflowNode{Agent: true}}}, [3]int{11, 8, 0}, "call agent microflow"}, + {"event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E"}}}, [3]int{11, 7, 0}, "event subprocess"}, + {"timer event subprocess", &ast.CreateWorkflowStmt{EventSubProcesses: []ast.WorkflowEventSubProcessNode{{Name: "E", Timer: true}}}, [3]int{11, 12, 0}, "on … timer"}, + {"notification", &ast.CreateWorkflowStmt{Activities: []ast.WorkflowActivityNode{&ast.WorkflowNotificationNode{}}}, [3]int{11, 10, 0}, "notification activities"}, + {"alter workflow", &ast.AlterWorkflowStmt{}, [3]int{8, 18, 0}, "alter workflow"}, + {"alter workflow agent task", &ast.AlterWorkflowStmt{Operations: []ast.AlterWorkflowOp{ + &ast.InsertAfterOp{NewActivities: []ast.WorkflowActivityNode{&ast.WorkflowCallMicroflowNode{Agent: true}}}}}, [3]int{11, 8, 0}, "call agent microflow"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{&ast.CreateModuleStmt{Name: "M"}, c.stmt}} + + vs := versionGateViolations(versionCtx(t, c.below[0], c.below[1], c.below[2]), prog) + if len(vs) != 1 { + t.Fatalf("want one MDL-VERSION01 below the floor, got %d: %+v", len(vs), vs) + } + if v := vs[0]; v.RuleID != "MDL-VERSION01" || !strings.HasPrefix(v.Message, "statement 2: ") || + !strings.Contains(v.Message, c.want) || v.Suggestion == "" { + t.Errorf("violation = %+v, want statement 2 naming %q with a hint", v, c.want) + } + // The handler's own refusal is the same text: exec and check agree. + if err := checkVersionGates(versionCtx(t, c.below[0], c.below[1], c.below[2]), c.stmt); err == nil || + !strings.Contains(vs[0].Message, strings.SplitN(err.Error(), "\n", 2)[0]) { + t.Errorf("exec's refusal %v differs from check's %q", err, vs[0].Message) + } + + // Control: a version with every feature passes. + if vs := versionGateViolations(versionCtx(t, 11, 99, 0), prog); len(vs) != 0 { + t.Errorf("control: reported at 11.99: %+v", vs) + } + }) + } +} + +// Statements with no gate, or a gated statement without the gated clause, are +// not reported — `synchronize all` on 9.0, a page without parameters, OData +// without GraphQL. +func TestCheckVersionGates_UngatedFormsPass(t *testing.T) { + prog := &ast.Program{Statements: []ast.Statement{ + &ast.CreateModuleStmt{Name: "M"}, + &ast.CreateMicroflowStmt{}, + &ast.CreateNanoflowStmt{Body: []ast.MicroflowStatement{&ast.SynchronizeStmt{SyncType: "All"}}}, + &ast.CreatePageStmtV3{}, + &ast.CreateODataServiceStmt{}, + &ast.AlterSettingsStmt{Section: "MODEL"}, + }} + if vs := versionGateViolations(versionCtx(t, 9, 0, 0), prog); len(vs) != 0 { + t.Errorf("ungated forms reported on 9.0: %+v", vs) + } +} + +// One source of truth: a handler that called checkFeature with its own feature +// key would be a gate check -p cannot see. Every gate goes through +// statementVersionGates (or, for a gate that depends on stored state, a helper +// in version_gates.go). +func TestCheckFeatureIsOnlyCalledFromTheGateTable(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + call := regexp.MustCompile(`\bcheckFeature\(`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") || f == "version_gates.go" || f == "cmd_features.go" { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + if call.MatchString(line) && !strings.HasPrefix(strings.TrimSpace(line), "//") { + t.Errorf("%s:%d calls checkFeature directly; add the gate to statementVersionGates so `check -p` applies it too", f, i+1) + } + } + } +} diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 6aebecba23..747560200c 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -263,6 +263,10 @@ type PluggableWidgetEngine struct { // an association the widget binds — must be qualified against this, not // against the widget's own datasource entity. (issuetracker #19) outerEntityContext string + // inBuild says outerEntityContext was set by Build, so "" in it means the + // widget sits where no entity is in scope rather than "not recorded" (a + // mapping resolved on its own, as the engine tests do). + inBuild bool // currentDef is the widget definition of the build in progress. resolveMapping // consults it to tell the widget's PRIMARY attribute property from its others @@ -319,8 +323,10 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* // Remember the containing context for properties that name members of it // rather than of this widget's own data. Saved/restored for nested widgets. oldOuterEntityContext := e.outerEntityContext + oldInBuild := e.inBuild e.outerEntityContext = oldEntityContext - defer func() { e.outerEntityContext = oldOuterEntityContext }() + e.inBuild = true + defer func() { e.outerEntityContext, e.inBuild = oldOuterEntityContext, oldInBuild }() // The definition being built, for mapping decisions that need to see the // widget's OTHER mappings (which attribute property a bare `Attribute:` @@ -1416,8 +1422,8 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W if err != nil { return nil, err } - if err := e.pageBuilder.rejectAssociationAsAttribute( - rest, entity, + if err := e.pageBuilder.rejectAssociationAsAttributeOn( + w, name, rest, entity, fmt.Sprintf("widget `%s` property `%s`", w.Name, mapping.PropertyKey), ); err != nil { return nil, err @@ -1432,8 +1438,8 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W entity := scope.Entity // An association named where an attribute belongs cannot be stored; // refuse it here rather than writing a dangling AttributeRef (#830). - if err := e.pageBuilder.rejectAssociationAsAttribute( - attr, entity, + if err := e.pageBuilder.rejectAssociationAsAttributeOn( + w, "", attr, entity, fmt.Sprintf("widget `%s` property `%s`", w.Name, mapping.PropertyKey), ); err != nil { return nil, err @@ -1532,6 +1538,15 @@ func (e *PluggableWidgetEngine) resolveMapping(mapping PropertyMapping, w *ast.W ctx.AssocPath = e.pageBuilder.resolveAssociationPathIn(rest, entity) ctx.SourceVariable = &wv } else if attr != "" { + // With no enclosing object the association has nowhere to start: + // written anyway it has no SourceVariable and mxbuild reports + // CE7007 (mendixlabs/mxcli#1420). Check refuses the same widgets + // (combobox_association_object.go). + if e.inBuild && e.outerEntityContext == "" { + if key, binding, ok := comboBoxAssociationNeedingObject(w); ok { + return nil, mdlerrors.NewValidation(comboBoxAssociationObjectProblem(w, key, binding)) + } + } // The association belongs to the CONTAINING entity, not to this // widget's option list. A `DataSource:` mapping listed before this // one has already moved entityContext to the option entity, so diff --git a/mdl/roundtrip/flow_modify_notes_test.go b/mdl/roundtrip/flow_modify_notes_test.go index bd44fdd167..0c65bfdec0 100644 --- a/mdl/roundtrip/flow_modify_notes_test.go +++ b/mdl/roundtrip/flow_modify_notes_test.go @@ -256,3 +256,42 @@ func TestSpliceRerun_PedAppNanoflowsUnderTheHeader(t *testing.T) { t.Fatal("PedApp has no nanoflow — the enumeration is broken") } } + +// mendixlabs/mxcli#1505: a multi-line free annotation holds CRLF, as Studio +// Pro stores it and describe prints it. The same flow with LF line breaks — +// what an editor saves — was refused under mdl 1 ("the free annotations +// change; the splice edits activities only"), on every run. Line breaks are +// not a change: the run writes nothing, and the note keeps its CRLF bytes. +func TestSpliceRerun_FreeNoteLineEndings(t *testing.T) { + h := newHarness(t) + defer h.close() + flow := func(eol string) string { + return "mdl 1;\ncreate or modify microflow MyFirstModule.Rerun_FreeNoteEol ()\nbegin\n" + + " @annotation 'First line." + eol + "Second line.'\n" + + " @position(200, 200)\n" + + " @annotation 'On the log." + eol + "Two lines.'\n" + + " log info node 'Notes' 'hello';\nend;\n" + } + if err := h.exec(flow("\r\n")); err != nil { + t.Fatalf("create: %v\n%s", err, h.out.String()) + } + got := h.describeUnder("mdl 1;", "microflow MyFirstModule.Rerun_FreeNoteEol") + if !strings.Contains(got, "First line.\r\nSecond line.") { + t.Fatalf("the free note was not stored with CRLF; the case is gone:\n%q", got) + } + for _, eol := range []string{"\r\n", "\n"} { + before := h.snapshot() + if err := h.exec(flow(eol)); err != nil { + t.Fatalf("re-run with %q line breaks: %v\n%s", eol, err, h.out.String()) + } + if changed := before.diff(h.snapshot()); len(changed) != 0 { + t.Errorf("re-run with %q line breaks wrote %d unit(s)", eol, len(changed)) + } + } + // Control: a real change to the free note is still refused, and the + // refusal names the note. + err := h.exec(strings.Replace(flow("\n"), "Second line.", "Second line, reworded.", 1)) + if err == nil || !strings.Contains(err.Error(), `note "First line.\r\nSecond line." is now "First line.\nSecond line, reworded."`) { + t.Fatalf("a reworded free note: err = %v\n%s", err, h.out.String()) + } +} diff --git a/modelsdk/widgets/augment.go b/modelsdk/widgets/augment.go index c3017c0083..0194c961ee 100644 --- a/modelsdk/widgets/augment.go +++ b/modelsdk/widgets/augment.go @@ -38,37 +38,8 @@ func AugmentTemplate(tmpl *WidgetTemplate, def *mpk.WidgetDefinition) error { } // Build set of existing template property keys (non-system only) - templateKeys := make(map[string]bool) - // Also build a map of XML type -> exemplar index for cloning - typeExemplars := make(map[string]int) // ValueType.Type -> index in propTypes systemKeys := def.SystemPropertyKeys() - - for i, pt := range propTypes { - ptMap, ok := pt.(map[string]any) - if !ok { - continue - } - key, _ := ptMap["PropertyKey"].(string) - if key == "" { - continue - } - // Skip system properties - if systemKeys[key] { - continue - } - templateKeys[key] = true - - // Record exemplar for this value type - vt, ok := getMapField(ptMap, "ValueType") - if ok { - vtType, _ := vt["Type"].(string) - if vtType != "" { - if _, exists := typeExemplars[vtType]; !exists { - typeExemplars[vtType] = i - } - } - } - } + templateKeys, _ := topLevelExemplars(propTypes, systemKeys) // Determine mpk property keys (regular only) mpkKeys := def.PropertyKeys() @@ -115,6 +86,13 @@ func AugmentTemplate(tmpl *WidgetTemplate, def *mpk.WidgetDefinition) error { propTypes, objProps = removeProperties(propTypes, objProps, staleSet) } + // Exemplar indices are positions in propTypes, so they are taken AFTER the + // stale removal above. Taken before it, every removal ahead of an exemplar + // shifted it onto a different property: on a 10.24.0 project (Data Widgets + // 2.30.3) the new `filterList` object list was cloned from an Integer + // property and lost its nested ObjectType — CE0463 (mendixlabs/mxcli#1424). + _, typeExemplars := topLevelExemplars(propTypes, systemKeys) + // Create a cloner for property pair deep-cloning cloner := defaultCloner() @@ -226,6 +204,34 @@ func AugmentTemplate(tmpl *WidgetTemplate, def *mpk.WidgetDefinition) error { return nil } +// topLevelExemplars indexes a widget's top-level PropertyTypes, skipping system +// properties: the set of keys present, and for each ValueType.Type the index of +// the first PropertyType of that type (the exemplar a missing property of the +// same type is cloned from). Indices are only valid for propTypes as passed. +func topLevelExemplars(propTypes []any, systemKeys map[string]bool) (keys map[string]bool, exemplars map[string]int) { + keys = make(map[string]bool) + exemplars = make(map[string]int) + for i, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + key, _ := ptMap["PropertyKey"].(string) + if key == "" || systemKeys[key] { + continue + } + keys[key] = true + if vt, ok := getMapField(ptMap, "ValueType"); ok { + if vtType, _ := vt["Type"].(string); vtType != "" { + if _, exists := exemplars[vtType]; !exists { + exemplars[vtType] = i + } + } + } + } + return keys, exemplars +} + // reorderPropertyTypes reorders the top-level ObjectType.PropertyTypes to match the // installed .mpk's property declaration order. Leading array markers are preserved; // keys the .mpk does not declare (system/accessibility props absent from @@ -306,20 +312,59 @@ func reorderObjectTypePropertyTypes(objType map[string]any, props []mpk.Property } } -// mpkPropDefsByKey indexes a widget's PropertyDefs by key, across both top-level and -// nested (object-list) properties. +// scopedKey is how the reconcile passes address a property: its key for a +// top-level property, "listKey/key" for a sub-property of an object list. +// +// A bare key is NOT unique across scopes. Data Widgets 2.30.3 declares `filter` +// twice — a column's `filter` (widgets) and a `filterList` item's `filter` +// (attribute) — and a flat by-key index let the later one overwrite the column's +// definition: Type, Caption, AllowedTypes, DataSourceProperty and Required all +// came from the wrong property, which is CE0463 on every Data grid 2 mxcli wrote +// to a 10.24.0 project (mendixlabs/mxcli#1424). +func scopedKey(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "/" + key +} + +// walkPropertyTypes calls fn for every WidgetPropertyType under node with its +// scoped key, descending into nested object-list ObjectTypes. +func walkPropertyTypes(node any, prefix string, fn func(pt map[string]any, scoped string)) { + switch v := node.(type) { + case map[string]any: + if v["$Type"] == "CustomWidgets$WidgetPropertyType" { + if key, _ := v["PropertyKey"].(string); key != "" { + scoped := scopedKey(prefix, key) + fn(v, scoped) + prefix = scoped + } + } + for _, val := range v { + walkPropertyTypes(val, prefix, fn) + } + case []any: + for _, item := range v { + walkPropertyTypes(item, prefix, fn) + } + } +} + +// mpkPropDefsByKey indexes a widget's PropertyDefs by scoped key (see scopedKey), +// across both top-level and nested (object-list) properties. func mpkPropDefsByKey(def *mpk.WidgetDefinition) map[string]mpk.PropertyDef { out := map[string]mpk.PropertyDef{} - var add func([]mpk.PropertyDef) - add = func(props []mpk.PropertyDef) { + var add func(string, []mpk.PropertyDef) + add = func(prefix string, props []mpk.PropertyDef) { for _, p := range props { - out[p.Key] = p + scoped := scopedKey(prefix, p.Key) + out[scoped] = p if len(p.Children) > 0 { - add(p.Children) + add(scoped, p.Children) } } } - add(def.Properties) + add("", def.Properties) return out } @@ -329,36 +374,26 @@ func mpkPropDefsByKey(def *mpk.WidgetDefinition) map[string]mpk.PropertyDef { // Only non-empty .mpk values are applied (the .mpk always carries a category/caption; // DefaultValue is present for enumeration/boolean/integer types). func reconcilePropertyMetadata(node any, byKey map[string]mpk.PropertyDef) { - switch v := node.(type) { - case map[string]any: - if v["$Type"] == "CustomWidgets$WidgetPropertyType" { - if key, _ := v["PropertyKey"].(string); key != "" { - if pd, ok := byKey[key]; ok { - if pd.Category != "" { - v["Category"] = pd.Category - } - if pd.Caption != "" { - v["Caption"] = pd.Caption - } - if pd.Description != "" { - v["Description"] = pd.Description - } - if pd.DefaultValue != "" { - if vt, ok := v["ValueType"].(map[string]any); ok { - vt["DefaultValue"] = pd.DefaultValue - } - } - } - } + walkPropertyTypes(node, "", func(v map[string]any, scoped string) { + pd, ok := byKey[scoped] + if !ok { + return } - for _, val := range v { - reconcilePropertyMetadata(val, byKey) + if pd.Category != "" { + v["Category"] = pd.Category } - case []any: - for _, item := range v { - reconcilePropertyMetadata(item, byKey) + if pd.Caption != "" { + v["Caption"] = pd.Caption } - } + if pd.Description != "" { + v["Description"] = pd.Description + } + if pd.DefaultValue != "" { + if vt, ok := v["ValueType"].(map[string]any); ok { + vt["DefaultValue"] = pd.DefaultValue + } + } + }) } // reconcileValueTypesFromMPK overwrites the schema-derived scalar fields of every @@ -375,82 +410,91 @@ func reconcileValueTypesFromMPK(tmpl *WidgetTemplate, byKey map[string]mpk.Prope // PropertyType $IDs whose Type changed → their Object WidgetValue must be reset. changedTypeIDs := map[string]mpk.PropertyDef{} - var walk func(any) - walk = func(node any) { - switch v := node.(type) { - case map[string]any: - if v["$Type"] == "CustomWidgets$WidgetPropertyType" { - if key, _ := v["PropertyKey"].(string); key != "" { - if pd, ok := byKey[key]; ok { - if vt, ok := v["ValueType"].(map[string]any); ok { - bsonType := xmlTypeToBSONType(pd.Type) - if bsonType != "" { - if old, _ := vt["Type"].(string); old != bsonType { - if id, _ := v["$ID"].(string); id != "" { - changedTypeIDs[id] = pd - } - } - vt["Type"] = bsonType - } - vt["Required"] = pd.Required - vt["IsList"] = pd.IsList - vt["Multiline"] = pd.Multiline - vt["DefaultValue"] = pd.DefaultValue - vt["AllowedTypes"] = buildAllowedTypesArray(pd.AllowedTypes) - vt["SelectionTypes"] = buildSelectionTypesArray(pd.SelectionTypes) - vt["DataSourceProperty"] = pd.DataSource - // Normalize the mutually-exclusive type-specific fields to the - // authoritative .mpk type. A ValueType cloned from a wrong-typed - // exemplar (or whose Type changed across widget versions) otherwise - // keeps stale fields that don't apply to its current type — e.g. a - // TextTemplate carrying EnumerationValues from an Enumeration - // exemplar, or a Widgets property carrying a cloned ReturnType. - // mxbuild emits these empty for the non-matching type, so a - // leftover is a within-key definition mismatch → CE0463. - switch bsonType { - case "Enumeration": - vt["EnumerationValues"] = buildEnumValuesArray(pd.EnumValues) - vt["ReturnType"] = nil - case "Expression": - vt["EnumerationValues"] = []any{float64(2)} - if pd.ReturnType != "" || pd.ReturnTypeAssignableTo != "" { - vt["ReturnType"] = buildReturnType(pd.ReturnType, pd.ReturnTypeAssignableTo) - } else { - vt["ReturnType"] = nil - } - default: - vt["EnumerationValues"] = []any{float64(2)} - vt["ReturnType"] = nil - } - // Widget-shipped caption/template translations (from the .mpk - // ), emitted into the definition as a - // WidgetTranslation list. Absent here → CE0463 on widgets that - // ship localized captions (DataGrid2 nl_NL). - if len(pd.Translations) > 0 { - vt["Translations"] = buildTranslationsArray(pd.Translations) - } - // The package's action variables. A template from an older - // widget version (or one cloned without them) otherwise keeps - // a list that disagrees with the installed definition — CE0463 - // (#1200). Rewritten only when it differs, so a template that - // already agrees keeps its entries as they are. - if !actionVariablesMatch(vt["ActionVariables"], pd.ActionVariables) { - vt["ActionVariables"] = buildActionVariablesArray(pd.ActionVariables) - } - } - } + walkPropertyTypes(tmpl.Type, "", func(v map[string]any, scoped string) { + pd, ok := byKey[scoped] + if !ok { + return + } + vt, ok := v["ValueType"].(map[string]any) + if !ok { + return + } + bsonType := xmlTypeToBSONType(pd.Type) + if bsonType != "" { + if old, _ := vt["Type"].(string); old != bsonType { + if id, _ := v["$ID"].(string); id != "" { + changedTypeIDs[id] = pd } } - for _, val := range v { - walk(val) - } - case []any: - for _, item := range v { - walk(item) + vt["Type"] = bsonType + } + vt["Required"] = pd.Required + vt["IsList"] = pd.IsList + vt["Multiline"] = pd.Multiline + vt["DefaultValue"] = pd.DefaultValue + vt["AllowedTypes"] = buildAllowedTypesArray(pd.AllowedTypes) + vt["SelectionTypes"] = buildSelectionTypesArray(pd.SelectionTypes) + vt["DataSourceProperty"] = pd.DataSource + // Association definition fields. Overwritten wherever the ValueType carries + // them (never added — see syncDefinitionAttrs on #759): an augment-added + // property is cloned from an exemplar of the same Type, so without this a + // column's `filterAssociationOptionLabelAttr` (Attribute) inherited the + // exemplar attribute's [Reference, ReferenceSet] while `filterAssociation` + // kept an empty list and no selectable-objects link — CE0463 on Data + // Widgets 2.30.3 (mendixlabs/mxcli#1424). + if _, ok := vt["AssociationTypes"]; ok { + vt["AssociationTypes"] = buildAllowedTypesArray(pd.AssociationTypes) + } + if _, ok := vt["SelectableObjectsProperty"]; ok { + vt["SelectableObjectsProperty"] = pd.SelectableObjects + } + // Normalize the mutually-exclusive type-specific fields to the + // authoritative .mpk type. A ValueType cloned from a wrong-typed + // exemplar (or whose Type changed across widget versions) otherwise + // keeps stale fields that don't apply to its current type — e.g. a + // TextTemplate carrying EnumerationValues from an Enumeration + // exemplar, or a Widgets property carrying a cloned ReturnType. + // mxbuild emits these empty for the non-matching type, so a + // leftover is a within-key definition mismatch → CE0463. + switch bsonType { + case "Enumeration": + vt["EnumerationValues"] = buildEnumValuesArray(pd.EnumValues) + vt["ReturnType"] = nil + case "Expression": + vt["EnumerationValues"] = []any{float64(2)} + if pd.ReturnType != "" || pd.ReturnTypeAssignableTo != "" { + vt["ReturnType"] = buildReturnType(pd.ReturnType, pd.ReturnTypeAssignableTo) + } else { + vt["ReturnType"] = nil } + default: + vt["EnumerationValues"] = []any{float64(2)} + vt["ReturnType"] = nil + } + // Widget-shipped caption/template translations (from the .mpk + // ), emitted into the definition as a + // WidgetTranslation list. Absent here → CE0463 on widgets that + // ship localized captions (DataGrid2 nl_NL). + // + // Also when the package ships NONE: the template's are then stale. Data + // Widgets 2.30.3's drop-down filter declares no translations for + // `emptyOptionCaption`, the 3.4-era template carries None/Niets, and + // `mx update-widgets` drops them — CE0463 on a 10.24.0 project + // (mendixlabs/mxcli#1424, doctype-tests/31 ddfStatus/ddfActive). + if len(pd.Translations) > 0 { + vt["Translations"] = buildTranslationsArray(pd.Translations) + } else if _, ok := vt["Translations"]; ok { + vt["Translations"] = []any{float64(2)} + } + // The package's action variables. A template from an older + // widget version (or one cloned without them) otherwise keeps + // a list that disagrees with the installed definition — CE0463 + // (#1200). Rewritten only when it differs, so a template that + // already agrees keeps its entries as they are. + if !actionVariablesMatch(vt["ActionVariables"], pd.ActionVariables) { + vt["ActionVariables"] = buildActionVariablesArray(pd.ActionVariables) } - } - walk(tmpl.Type) + }) if len(changedTypeIDs) == 0 { return @@ -605,18 +649,11 @@ func buildSelectionTypesArray(names []string) []any { // across both top-level and nested (object-list) properties. func mpkEnumValuesByKey(def *mpk.WidgetDefinition) map[string][]mpk.EnumValue { out := map[string][]mpk.EnumValue{} - var add func([]mpk.PropertyDef) - add = func(props []mpk.PropertyDef) { - for _, p := range props { - if len(p.EnumValues) > 0 { - out[p.Key] = p.EnumValues - } - if len(p.Children) > 0 { - add(p.Children) - } + for scoped, p := range mpkPropDefsByKey(def) { + if len(p.EnumValues) > 0 { + out[scoped] = p.EnumValues } } - add(def.Properties) return out } @@ -624,25 +661,13 @@ func mpkEnumValuesByKey(def *mpk.WidgetDefinition) map[string][]mpk.EnumValue { // whose key has a .mpk option set, rebuilds its ValueType.EnumerationValues from // the .mpk so the embedded Type's enum members exactly match the installed widget. func reconcileEnumValues(node any, byKey map[string][]mpk.EnumValue) { - switch v := node.(type) { - case map[string]any: - if v["$Type"] == "CustomWidgets$WidgetPropertyType" { - if vt, ok := v["ValueType"].(map[string]any); ok && vt["Type"] == "Enumeration" { - if key, _ := v["PropertyKey"].(string); key != "" { - if opts, ok := byKey[key]; ok { - vt["EnumerationValues"] = buildEnumValuesArray(opts) - } - } + walkPropertyTypes(node, "", func(v map[string]any, scoped string) { + if vt, ok := v["ValueType"].(map[string]any); ok && vt["Type"] == "Enumeration" { + if opts, ok := byKey[scoped]; ok { + vt["EnumerationValues"] = buildEnumValuesArray(opts) } } - for _, val := range v { - reconcileEnumValues(val, byKey) - } - case []any: - for _, item := range v { - reconcileEnumValues(item, byKey) - } - } + }) } // buildEnumValuesArray builds a CustomWidgets$WidgetEnumerationValue list (with the @@ -688,6 +713,8 @@ func augmentNestedObjectType(propTypes []any, objProps []any, mpkProp mpk.Proper if len(staleKeys) > 0 { nestedPropTypes, nestedObjProps = removeNestedProperties(nestedPropTypes, nestedObjProps, staleKeys) + // Re-index: the removal shifted every exemplar behind it (see AugmentTemplate). + _, nestedExemplars = buildExemplarIndex(nestedPropTypes) } nestedPropTypes, nestedObjProps, err := addMissingProperties(nestedPropTypes, nestedObjProps, nestedExemplars, missing) @@ -1291,64 +1318,33 @@ func remapObjectTypePointers(objProps []any, idRemap map[string]string) { // but the XML does not carry is left alone — overwriting it with a zero value // would trade one definition mismatch for another. func syncDefinitionAttrs(propTypes []any, props []mpk.PropertyDef) { - byKey := make(map[string]*mpk.PropertyDef, len(props)) - var index func([]mpk.PropertyDef) - index = func(ps []mpk.PropertyDef) { - for i := range ps { - byKey[ps[i].Key] = &ps[i] - if len(ps[i].Children) > 0 { - index(ps[i].Children) - } - } - } - index(props) - - var walk func([]any) - walk = func(pts []any) { - for _, pt := range pts { - ptMap, ok := pt.(map[string]any) - if !ok { - continue - } - if key, _ := ptMap["PropertyKey"].(string); key != "" { - if p := byKey[key]; p != nil { - // These live on the PropertyType's ValueType, not on the - // PropertyType itself — targeting the wrong node made this a - // silent no-op. Verified against `mx update-widgets` output: - // the differing paths are - // PropertyTypes[N]/ValueType/{Required,OnChangeProperty}. - // - // Update in place only. Adding a key the node does not already - // carry invents a property this Mendix version may not define — - // the mendixlabs/mxcli#759 failure shape. - target := ptMap - if vt, ok := getMapField(ptMap, "ValueType"); ok { - target = vt - } - if _, ok := target["Required"]; ok { - target["Required"] = p.Required - } - if _, ok := target["OnChangeProperty"]; ok { - target["OnChangeProperty"] = p.OnChange - } - } - } - // Object-typed properties nest their own PropertyTypes. - if vt, ok := getMapField(ptMap, "ValueType"); ok { - if ot, ok := getMapField(vt, "ObjectType"); ok { - if nested, ok := getArrayField(ot, "PropertyTypes"); ok { - walk(nested) - } - } - } - if ot, ok := getMapField(ptMap, "ObjectType"); ok { - if nested, ok := getArrayField(ot, "PropertyTypes"); ok { - walk(nested) - } - } + // Scoped keys: a bare key is not unique across object lists (see scopedKey). + byKey := mpkPropDefsByKey(&mpk.WidgetDefinition{Properties: props}) + walkPropertyTypes(propTypes, "", func(ptMap map[string]any, scoped string) { + p, ok := byKey[scoped] + if !ok { + return + } + // These live on the PropertyType's ValueType, not on the + // PropertyType itself — targeting the wrong node made this a + // silent no-op. Verified against `mx update-widgets` output: + // the differing paths are + // PropertyTypes[N]/ValueType/{Required,OnChangeProperty}. + // + // Update in place only. Adding a key the node does not already + // carry invents a property this Mendix version may not define — + // the mendixlabs/mxcli#759 failure shape. + target := ptMap + if vt, ok := getMapField(ptMap, "ValueType"); ok { + target = vt + } + if _, ok := target["Required"]; ok { + target["Required"] = p.Required + } + if _, ok := target["OnChangeProperty"]; ok { + target["OnChangeProperty"] = p.OnChange } - } - walk(propTypes) + }) } // NewPropertyPair builds the (WidgetPropertyType, WidgetProperty) pair for a property diff --git a/modelsdk/widgets/augment_scoped_keys_test.go b/modelsdk/widgets/augment_scoped_keys_test.go new file mode 100644 index 0000000000..906abc6d4c --- /dev/null +++ b/modelsdk/widgets/augment_scoped_keys_test.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import ( + "reflect" + "testing" + + "github.com/mendixlabs/mxcli/modelsdk/widgets/mpk" +) + +// mendixlabs/mxcli#1424: a Data grid 2 written by mxcli to a blank 10.24.0 +// project (Data Widgets 2.30.3) failed `mx check` with CE0463 until +// `fix widgets` ran. The embedded template is Data Widgets 3.4-shaped, so +// augmentation has to rebuild the 2.30.3 definition, and diffing mxcli's Type +// against `mx update-widgets` output showed it got three things wrong: +// +// 1. A column's `filter` (widgets) took the definition of the `filterList` +// item's `filter` (attribute): the reconcile passes indexed PropertyDefs by +// bare key across every nesting level, so the later declaration won. +// 2. The new `filterList` object list had no ObjectType: exemplar indices were +// taken before the stale properties were removed, so the clone came from +// whatever property had shifted into the Object exemplar's slot (an Integer). +// 3. Association definition fields were never reconciled: the parser did not +// read or selectableObjects, so the column's +// `filterAssociation` had none and `filterAssociationOptionLabelAttr` kept +// the [Reference, ReferenceSet] of the attribute it was cloned from. +// +// The test builds that package shape from the real embedded template: same +// properties, minus one stale top-level property AHEAD of `columns`, plus the +// 2.30.3-only properties. +func TestAugmentTemplate_DataWidgets2303Shape(t *testing.T) { + const widgetID = "com.mendix.widget.web.datagrid.Datagrid" + tmpl, err := GetTemplate(widgetID) + if err != nil || tmpl == nil { + t.Fatalf("GetTemplate(%s) = %v, %v", widgetID, tmpl, err) + } + ResetPlaceholderCounter() + clone, err := deepCloneTemplate(tmpl) + if err != nil { + t.Fatal(err) + } + + def := &mpk.WidgetDefinition{ID: widgetID, Version: "2.30.3"} + propTypes := clone.Type["ObjectType"].(map[string]any)["PropertyTypes"].([]any) + droppedStale := "" + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + pd := propertyDefFromType(ptMap) + if pd.IsSystem { + def.SystemProps = append(def.SystemProps, pd) + continue + } + // Drop the first ordinary property before `columns`: stale in this + // package, and its removal shifts every exemplar index behind it. + if droppedStale == "" && pd.Key != "columns" { + droppedStale = pd.Key + continue + } + if pd.Key == "exportDialogLabel" { + pd.Translations = []mpk.Translation{{Lang: "en_US", Text: "Export progress"}} + } + if pd.Key == "columns" { + pd.Children = append(pd.Children, + mpk.PropertyDef{Key: "filterAssociation", Type: "association", Caption: "Entity", + DataSource: "../datasource", SelectableObjects: "filterAssociationOptions", + AssociationTypes: []string{"Reference", "ReferenceSet"}}, + mpk.PropertyDef{Key: "filterAssociationOptions", Type: "datasource", Caption: "Selectable objects", IsList: true}, + mpk.PropertyDef{Key: "filterAssociationOptionLabelAttr", Type: "attribute", Caption: "Option caption", + DataSource: "filterAssociationOptions", AllowedTypes: []string{"String"}}, + ) + } + def.Properties = append(def.Properties, pd) + } + if droppedStale == "" { + t.Fatal("no top-level property found ahead of columns") + } + def.Properties = append(def.Properties, mpk.PropertyDef{ + Key: "filterList", Type: "object", Caption: "Filters", IsList: true, + Children: []mpk.PropertyDef{{ + Key: "filter", Type: "attribute", Caption: "Filter attribute", Required: true, + DataSource: "../datasource", AllowedTypes: []string{"String", "Integer"}, + }}, + }) + + if err := AugmentTemplate(clone, def); err != nil { + t.Fatalf("AugmentTemplate: %v", err) + } + + got := map[string]map[string]any{} + collectByPath(clone.Type["ObjectType"].(map[string]any), "", got) + vt := func(scoped string) map[string]any { + t.Helper() + pt, ok := got[scoped] + if !ok { + t.Errorf("PropertyType %q missing after augmentation", scoped) + return map[string]any{} + } + return pt["ValueType"].(map[string]any) + } + + // 1. The column's filter keeps ITS definition. + if typ := vt("columns/filter")["Type"]; typ != "Widgets" { + t.Errorf("columns/filter Type = %v, want Widgets (took filterList/filter's definition)", typ) + } + if c := got["columns/filter"]["Caption"]; c == "Filter attribute" { + t.Errorf("columns/filter Caption = %q, the filterList item's caption", c) + } + if typ := vt("filterList/filter")["Type"]; typ != "Attribute" { + t.Errorf("filterList/filter Type = %v, want Attribute", typ) + } + + // 2. The new object list has its nested ObjectType. + if vt("filterList")["ObjectType"] == nil { + t.Error("filterList ObjectType = nil: cloned from a shifted (non-Object) exemplar") + } + + // 3. Association definition fields come from the package. + if at := vt("columns/filterAssociation")["AssociationTypes"]; !reflect.DeepEqual(at, []any{float64(1), "Reference", "ReferenceSet"}) { + t.Errorf("columns/filterAssociation AssociationTypes = %v, want [1 Reference ReferenceSet]", at) + } + if so := vt("columns/filterAssociation")["SelectableObjectsProperty"]; so != "filterAssociationOptions" { + t.Errorf("columns/filterAssociation SelectableObjectsProperty = %q, want filterAssociationOptions", so) + } + if at := vt("columns/filterAssociationOptionLabelAttr")["AssociationTypes"]; !reflect.DeepEqual(at, []any{float64(1)}) { + t.Errorf("columns/filterAssociationOptionLabelAttr AssociationTypes = %v, want [1] (none declared)", at) + } + + // 4. Translations follow the package in both directions: the template's are + // dropped where the package ships none (a 2.30.3 drop-down filter's + // emptyOptionCaption), replaced where it ships its own. + if tr := vt("loadMoreButtonCaption")["Translations"]; !reflect.DeepEqual(tr, []any{float64(2)}) { + t.Errorf("loadMoreButtonCaption Translations = %v, want [2] (package declares none)", tr) + } + if tr, _ := vt("exportDialogLabel")["Translations"].([]any); len(tr) != 2 || tr[1].(map[string]any)["Text"] != "Export progress" { + t.Errorf("exportDialogLabel Translations = %v, want the package's one translation", tr) + } +} + +// collectByPath indexes an ObjectType's PropertyTypes as "key" / "list/key". +func collectByPath(objType map[string]any, prefix string, out map[string]map[string]any) { + pts, _ := objType["PropertyTypes"].([]any) + for _, pt := range pts { + m, ok := pt.(map[string]any) + if !ok { + continue + } + path, _ := m["PropertyKey"].(string) + if prefix != "" { + path = prefix + "/" + path + } + out[path] = m + if vt, ok := m["ValueType"].(map[string]any); ok { + if ot, ok := vt["ObjectType"].(map[string]any); ok { + collectByPath(ot, path, out) + } + } + } +} + +// propertyDefFromType mirrors a template PropertyType as the PropertyDef a +// package declaring it unchanged would yield, recursing into object lists. +func propertyDefFromType(pt map[string]any) mpk.PropertyDef { + key, _ := pt["PropertyKey"].(string) + vt, _ := pt["ValueType"].(map[string]any) + vtType, _ := vt["Type"].(string) + if vtType == "System" { + return mpk.PropertyDef{Key: key, IsSystem: true} + } + pd := mpk.PropertyDef{Key: key, Type: bsonTypeToXmlType(vtType)} + pd.Caption, _ = pt["Caption"].(string) + pd.IsList, _ = vt["IsList"].(bool) + pd.Required, _ = vt["Required"].(bool) + pd.DataSource, _ = vt["DataSourceProperty"].(string) + pd.SelectableObjects, _ = vt["SelectableObjectsProperty"].(string) + if at, ok := vt["AssociationTypes"].([]any); ok { + for _, a := range at { + if s, ok := a.(string); ok { + pd.AssociationTypes = append(pd.AssociationTypes, s) + } + } + } + if ot, ok := vt["ObjectType"].(map[string]any); ok { + for _, child := range ot["PropertyTypes"].([]any) { + if cm, ok := child.(map[string]any); ok { + pd.Children = append(pd.Children, propertyDefFromType(cm)) + } + } + } + return pd +} diff --git a/modelsdk/widgets/mpk/associationtypes_test.go b/modelsdk/widgets/mpk/associationtypes_test.go new file mode 100644 index 0000000000..345b285b6b --- /dev/null +++ b/modelsdk/widgets/mpk/associationtypes_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package mpk + +import ( + "reflect" + "testing" +) + +// An association property's and selectableObjects are part of +// the widget DEFINITION (ValueType.AssociationTypes / SelectableObjectsProperty). +// Not reading them left augmentation nothing to write, so a Data Widgets 2.30.3 +// column's filterAssociation was emitted without them — CE0463 on a 10.24.0 +// project (mendixlabs/mxcli#1424). +func TestParse_ReadsAssociationTypesAndSelectableObjects(t *testing.T) { + const widgetXML = ` + + W + + + + Columns + + + + Entity + + + + + + + + + + Top + + + + Plain + + + + +` + + def := parseWidgetXML(t, widgetXML) + byKey := map[string]PropertyDef{} + for _, p := range def.Properties { + byKey[p.Key] = p + } + + nested := byKey["columns"].Children[0] + if !reflect.DeepEqual(nested.AssociationTypes, []string{"Reference", "ReferenceSet"}) { + t.Errorf("nested AssociationTypes = %v", nested.AssociationTypes) + } + if nested.SelectableObjects != "filterAssociationOptions" { + t.Errorf("nested SelectableObjects = %q", nested.SelectableObjects) + } + if top := byKey["topAssoc"]; !reflect.DeepEqual(top.AssociationTypes, []string{"Reference"}) || top.SelectableObjects != "opts" { + t.Errorf("top-level association = %v / %q", top.AssociationTypes, top.SelectableObjects) + } + if p := byKey["plain"]; len(p.AssociationTypes) != 0 || p.SelectableObjects != "" { + t.Errorf("plain attribute = %v / %q, want none", p.AssociationTypes, p.SelectableObjects) + } +} diff --git a/modelsdk/widgets/mpk/mpk.go b/modelsdk/widgets/mpk/mpk.go index 84bdef6bf1..6f16e3dc06 100644 --- a/modelsdk/widgets/mpk/mpk.go +++ b/modelsdk/widgets/mpk/mpk.go @@ -46,9 +46,17 @@ type PropertyDef struct { // "../staticAttribute"), when the expression's return type is derived from another // property rather than a concrete type. mxbuild emits Type "None" with this set. ReturnTypeAssignableTo string - AllowedTypes []string // for attribute properties: Mendix type names ("String", "Decimal", etc.) - EnumValues []EnumValue // for enumeration properties: the declared options (key + caption) - Translations []Translation // widget-shipped caption/template translations () + AllowedTypes []string // for attribute properties: Mendix type names ("String", "Decimal", etc.) + // AssociationTypes are an association/attribute property's + // …; SelectableObjects is + // an association property's selectableObjects attribute. Both are part of the + // widget DEFINITION (ValueType.AssociationTypes / SelectableObjectsProperty), + // so leaving a template's or a cloned exemplar's values in place is CE0463 + // (mendixlabs/mxcli#1424, Data Widgets 2.30.3 column filterAssociation). + AssociationTypes []string + SelectableObjects string + EnumValues []EnumValue // for enumeration properties: the declared options (key + caption) + Translations []Translation // widget-shipped caption/template translations () // ActionVariables are the variables an action property passes to the flow // it calls (``). Part of the widget DEFINITION — Studio Pro // stores them on the ValueType whether or not an action is configured — so @@ -222,22 +230,24 @@ type xmlAttributeType struct { // xmlProperty represents element. type xmlProperty struct { - Key string `xml:"key,attr"` - Type string `xml:"type,attr"` - DefaultValue string `xml:"defaultValue,attr"` - DefaultType string `xml:"defaultType,attr"` - Required string `xml:"required,attr"` - OnChange string `xml:"onChange,attr"` - IsList string `xml:"isList,attr"` - Multiline string `xml:"multiline,attr"` - DataSource string `xml:"dataSource,attr"` - Caption string `xml:"caption"` - Description string `xml:"description"` - AttributeTypes []xmlAttributeType `xml:"attributeTypes>attributeType"` - EnumValues []xmlEnumValue `xml:"enumerationValues>enumerationValue"` - SelectionTypes []xmlSelectionType `xml:"selectionTypes>selectionType"` - ReturnType xmlReturnType `xml:"returnType"` - Translations []xmlTranslation `xml:"translations>translation"` + Key string `xml:"key,attr"` + Type string `xml:"type,attr"` + DefaultValue string `xml:"defaultValue,attr"` + DefaultType string `xml:"defaultType,attr"` + Required string `xml:"required,attr"` + OnChange string `xml:"onChange,attr"` + IsList string `xml:"isList,attr"` + Multiline string `xml:"multiline,attr"` + DataSource string `xml:"dataSource,attr"` + SelectableObjects string `xml:"selectableObjects,attr"` + Caption string `xml:"caption"` + Description string `xml:"description"` + AttributeTypes []xmlAttributeType `xml:"attributeTypes>attributeType"` + AssociationTypes []xmlAttributeType `xml:"associationTypes>associationType"` + EnumValues []xmlEnumValue `xml:"enumerationValues>enumerationValue"` + SelectionTypes []xmlSelectionType `xml:"selectionTypes>selectionType"` + ReturnType xmlReturnType `xml:"returnType"` + Translations []xmlTranslation `xml:"translations>translation"` // ActionVariables of an action property (). ActionVariables []xmlActionVariable `xml:"actionVariables>actionVariable"` // Nested properties for object type @@ -437,6 +447,8 @@ func walkPropertyGroup(pg xmlPropGroup, parentCategory string, def *WidgetDefini ReturnType: p.ReturnType.Type, ReturnTypeAssignableTo: p.ReturnType.AssignableTo, AllowedTypes: allowedTypes, + AssociationTypes: associationTypeNames(p.AssociationTypes), + SelectableObjects: p.SelectableObjects, EnumValues: enumValues, Translations: toTranslations(p.Translations), ActionVariables: toActionVariables(p.ActionVariables), @@ -467,6 +479,17 @@ func walkPropertyGroup(pg xmlPropGroup, parentCategory string, def *WidgetDefini } } +// associationTypeNames returns the names of a property's entries. +func associationTypeNames(ats []xmlAttributeType) []string { + var out []string + for _, at := range ats { + if at.Name != "" { + out = append(out, at.Name) + } + } + return out +} + // collectNestedProperties extracts child properties from nested propertyGroups // within an object-type property and appends them to the parent PropertyDef. The // property group's caption chain becomes each child's Category (joined with "::", @@ -512,6 +535,8 @@ func collectNestedProperties(pg xmlPropGroup, parent *PropertyDef, parentCategor ReturnType: p.ReturnType.Type, ReturnTypeAssignableTo: p.ReturnType.AssignableTo, AllowedTypes: allowedTypes, + AssociationTypes: associationTypeNames(p.AssociationTypes), + SelectableObjects: p.SelectableObjects, EnumValues: enumValues, Translations: toTranslations(p.Translations), ActionVariables: toActionVariables(p.ActionVariables), diff --git a/sdk/domainmodel/domainmodel.go b/sdk/domainmodel/domainmodel.go index a7899b57e2..7e5e366e89 100644 --- a/sdk/domainmodel/domainmodel.go +++ b/sdk/domainmodel/domainmodel.go @@ -61,6 +61,12 @@ type Entity struct { Generalization Generalization `json:"generalization,omitempty"` GeneralizationID model.ID `json:"generalizationId,omitempty"` + // ExportLevel is "Hidden" or "API" (Studio Pro's "Usable"). It only means + // something in a Protected (add-on) module, where a Hidden entity may not be + // referenced from outside it (CE1406) nor exposed by a Usable microflow + // (CE5400). Read-only here: the writer carries the stored value. + ExportLevel string `json:"exportLevel,omitempty"` + // Persistability Persistable bool `json:"persistable"` diff --git a/vscode-mdl/bun.lock b/vscode-mdl/bun.lock index 2289e6341d..4d06648f42 100644 --- a/vscode-mdl/bun.lock +++ b/vscode-mdl/bun.lock @@ -42,57 +42,57 @@ "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], @@ -248,7 +248,7 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -328,7 +328,7 @@ "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], @@ -342,9 +342,9 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -366,7 +366,7 @@ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -402,8 +402,6 @@ "vscode-languageclient/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.0", "", { "dependencies": { "vscode-jsonrpc": "9.0.0", "vscode-languageserver-types": "3.18.0" } }, "sha512-Zdz+kJ12Iz6tc11xfZyEo501bBATHXrCjmMfnaR3pMnf1CoqZBKIynba3P+/bi9VEdrMbNtAVKYpKhbODvqy+Q=="], - "vscode-languageclient/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "vscode-languageclient/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@9.0.0", "", {}, "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg=="], "vscode-languageclient/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],