Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .claude/lint-rules/conv018_module_folder_organization.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# CONV018: Module Folder Organization
#
# Flags a module that has grown past a readable size while keeping every
# document loose in the module root — no folders at all.
#
# Studio Pro lets a module hold folders, and every Mendix style guide expects
# them once a module is more than a handful of documents. Nothing in mxcli
# reported their absence, so a project with 200 documents in one flat module
# scored clean.
#
# THE CONDITION IS DELIBERATELY NARROW, and both halves matter:
#
# 1. more than MAX_ROOT_DOCUMENTS documents sit directly in the module root, AND
# 2. NOT ONE document in the module is in a folder.
#
# The second half is what keeps this from becoming noise. A module that has
# started to organise itself — even one folder — is never flagged, however many
# documents remain at its root, because the team has evidently made a choice
# about where things go and a linter guessing at the rest would be nagging. The
# first half exempts genuinely small modules, where a folder tree costs more
# than it repays. Together they catch exactly one thing: a module nobody has
# ever organised.
#
# Options (.claude/lint-config.yaml):
# rules:
# CONV018:
# options:
# max_root_documents: 40 # default 20 — documents allowed at a module
# # root before folders are expected
#
# Document properties (documents()):
# .kind - catalog ObjectType: "MICROFLOW", "PAGE", "WORKFLOW", …
# .name - document name
# .qualified_name - Module.Name
# .module_name - the module holding it
# .folder - folder path, or "" when the document is in the module root

RULE_ID = "CONV018"
RULE_NAME = "ModuleFolderOrganization"
DESCRIPTION = "Modules past a readable size should organise their documents into folders"
CATEGORY = "quality"
SEVERITY = "info"

# Default; override with the max_root_documents option.
MAX_ROOT_DOCUMENTS = 20

# The kinds Studio Pro actually lets you file in a folder.
#
# `documents()` projects the whole catalog `objects` view, which includes rows
# whose Folder is hardcoded empty because the element is not a document at all —
# an association belongs to the domain model, an external entity to a consumed
# service, an entity to the Domain Model document. Counting those would report a
# module as unorganised on the strength of elements no one can move.
#
# An allow-list rather than a deny-list on purpose: a document type added to the
# catalog and forgotten here is undercounted, so the rule stays quiet. The other
# polarity would invent violations out of a new non-foldered kind.
FOLDERABLE_KINDS = [
"MICROFLOW",
"NANOFLOW",
"RULE",
"PAGE",
"SNIPPET",
"BUILDING_BLOCK",
"LAYOUT",
"ENUMERATION",
"CONSTANT",
"JAVA_ACTION",
"JAVASCRIPT_ACTION",
"IMAGE_COLLECTION",
"ICON_COLLECTION",
"MENU",
"PAGE_TEMPLATE",
"SCHEDULED_EVENT",
"QUEUE",
"REGULAR_EXPRESSION",
"DATA_TRANSFORMER",
"WORKFLOW",
"AGENT",
"AI_MODEL",
"KNOWLEDGE_BASE",
"CONSUMED_MCP_SERVICE",
"DATABASE_CONNECTION",
"REST_CLIENT",
"PUBLISHED_REST_SERVICE",
]

# The kinds a person recognises from the App Explorer, in the order a summary
# reads best. Anything else is counted but summarised under its own kind name.
KIND_LABELS = {
"MICROFLOW": "microflows",
"NANOFLOW": "nanoflows",
"PAGE": "pages",
"WORKFLOW": "workflows",
"SNIPPET": "snippets",
"ENUMERATION": "enumerations",
"JAVA_ACTION": "Java actions",
"JAVASCRIPT_ACTION": "JavaScript actions",
"LAYOUT": "layouts",
"RULE": "rules",
}

def _summarise(counts):
"""Render the two or three biggest kinds, so the message says what the
clutter actually IS rather than only how much of it there is."""
pairs = [(kind, n) for kind, n in counts.items()]
pairs = sorted(pairs, key = lambda p: (-p[1], p[0]))

parts = []
for kind, n in pairs[:3]:
label = KIND_LABELS.get(kind, kind.lower().replace("_", " "))
parts.append("{} {}".format(n, label))

if len(pairs) > 3:
parts.append("and more")
return ", ".join(parts)

def check():
max_root = get_option("max_root_documents", MAX_ROOT_DOCUMENTS)

foldered = {} # module -> True once any document is in a folder
root_counts = {} # module -> {kind: count} for documents at the root

for doc in documents():
if doc.kind not in FOLDERABLE_KINDS:
continue

module = doc.module_name
if module not in root_counts:
root_counts[module] = {}
foldered[module] = False

if doc.folder != "":
foldered[module] = True
else:
counts = root_counts[module]
counts[doc.kind] = counts.get(doc.kind, 0) + 1

violations = []
for module in sorted(root_counts.keys()):
if foldered[module]:
continue

counts = root_counts[module]
total = 0
for n in counts.values():
total += n

if total <= max_root:
continue

violations.append(violation(
message = "Module '{}' keeps all {} of its documents ({}) in the module root and has no folders.".format(
module,
total,
_summarise(counts),
),
location = location(
module = module,
document_type = "Module",
document_name = module,
),
suggestion = "Group the documents into folders by feature or by type — `mxcli -p <app.mpr> -c \"show structure in {}\"` to see the current layout, then MOVE documents into folders. Set CONV018.options.max_root_documents in .claude/lint-config.yaml if a flat module is intended here.".format(module),
))

return violations
96 changes: 96 additions & 0 deletions .claude/lint-rules/conv019_navigation_page_url.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# CONV019: Navigation Pages Should Be Addressable
#
# Flags a page a navigation profile routes to that has no URL.
#
# A Mendix page is only reachable at `/p/<url>` if it has been given one. Without
# it the page exists solely at the end of a click path: it cannot be bookmarked,
# linked to from an email, reopened after a refresh, or captured directly by
# `mxcli run --local --screenshot-url` — verifying one screen means driving a
# browser through login and the menu (ako/CapTrackV4 FINDINGS 014, R4).
#
# WHY ONLY NAVIGATION TARGETS. Measured on a blank Mendix 11.12.1 app: 0 of 16
# pages carry a URL, Mendix's own included. Reporting every page without one
# would warn about every page of every project from the day it is created, which
# is noise rather than a finding. The pages a profile routes to are different:
# they are the app's top-level screens, the ones a user lands on, bookmarks and
# shares. A page reached only from a button inside another screen is not
# expected to be addressable and is never reported.
#
# Deliberately not reported:
# - the login page and the not-found page — the platform routes to those
# itself, so a URL on them buys nothing;
# - microflow-valued menu items and home pages — the microflow decides what
# opens, so there is no page here to be addressable;
# - targets in System and Marketplace modules — that is somebody else's model,
# and it drops out of the join against pages() rather than being listed here.
#
# Options (.claude/lint-config.yaml):
# rules:
# CONV019:
# enabled: false # a project that does not want deep links at all
#
# Navigation target properties (navigation_targets()):
# .profile - "Responsive", "Phone", "Tablet", …
# .kind - "home", "role_home" or "menu"
# .role - user role, for a role_home
# .caption - menu item caption, for a menu target
# .page - qualified page name
#
# Page properties (pages()):
# .qualified_name, .url - "" when the page has no URL

RULE_ID = "CONV019"
RULE_NAME = "NavigationPageURL"
DESCRIPTION = "Pages reachable from navigation should have a URL so they can be linked to directly"
CATEGORY = "quality"
SEVERITY = "info"

def _describe(target):
"""How this page is reached, in the words the navigation document uses."""
if target.kind == "home":
return "the home page of the '{}' profile".format(target.profile)
if target.kind == "role_home":
return "the home page for role '{}' in the '{}' profile".format(target.role, target.profile)
if target.caption != "":
return "the '{}' menu item in the '{}' profile".format(target.caption, target.profile)
return "a menu item in the '{}' profile".format(target.profile)

def check():
# Pages the linter can see. A target missing from this map is in a System or
# Marketplace module — not the user's page to give a URL to.
urls = {}
modules = {}
for page in pages():
urls[page.qualified_name] = page.url
modules[page.qualified_name] = page.module_name

# One page can be routed to several ways (home page AND a menu item). Report
# the page once, naming every route, rather than once per route.
routes = {}
for target in navigation_targets():
if target.page not in urls:
continue
if urls[target.page] != "":
continue
routes.setdefault(target.page, []).append(_describe(target))

violations = []
for page in sorted(routes.keys()):
how = routes[page]
violations.append(violation(
message = "Page '{}' is reachable from navigation ({}) but has no URL, so it cannot be linked to or bookmarked.".format(
page,
", ".join(how),
),
location = location(
module = modules[page],
document_type = "Page",
document_name = page,
),
suggestion = "Give the page a URL — `Url: '{}'` on CREATE PAGE, or the URL property in Studio Pro — so it is reachable at /p/{}. Disable CONV019 in .claude/lint-config.yaml if this app deliberately has no deep links.".format(
page.split(".")[-1].lower(),
page.split(".")[-1].lower(),
),
))

return violations
1 change: 1 addition & 0 deletions .claude/skills/fix-issue/findings/mdl-backend.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@
{"area": "mdl/backend", "date": "2026-08-27", "symptom": "No export mapping mxcli writes matches its Studio Pro original, so every one stays in #260's silent-loss set even once its source kind is authorable", "cause": "The export writers hardcoded three properties the IMPORT twin already read off the element: `MinOccurs 0` (a schema root has **1**), `MaxLength 0`, and `IsKey` not written at all. `MaxLength` is the one that cannot be a constant — Studio Pro stores **0 for a string element and -1 for a numeric one**, mirroring the bound schema element exactly as `MaxOccurs` already did", "file": "`mdl/backend/modelsdk/mapping_write.go` (`exportValueElementToGen`, `exportObjectElementToGen`) + `mapping_read.go`, `sdk/mpr/writer_export_mapping.go`, `modelsdk/mpr/serialize_mappings.go`, `mdl/executor/cmd_export_mappings.go`, `model.ExportMappingElement`", "insight": "The export element model had **none** of these fields and the reader populated none of them, so this is four layers (model, reader, builder, three writers), not a writer patch — and a writer patch alone would have written zeros from an empty model. Clone them from the schema element where the builder already clones `MaxOccurs`. Check the import twin first when an export property looks wrong: this was a divergence between the two writers, not a considered decision, and the import side is the correct reference. Repro `mdl-examples/bug-tests/mapping-277-export-property-set.mdl`, tests `sdk/mpr/writer_export_mapping_properties_test.go`. Issue ako/mxcli#277", "refs": ["#260", "ako/mxcli#277"]}
{"area": "mdl/backend", "date": "2026-08-27", "symptom": "A rebuilt mapping drops `MessageDefinition2`, so a document written by Studio Pro 11.10+ never round-trips", "cause": "The key is version-introduced — `modelsdk/gen/mappings/version.go` records `messageDefinition2` as `Introduced: \"11.10.0\"` — and gen generates **no accessor** for it, so nothing read or wrote it. A blank 11.13 app's own mappings carry it as `\"\"`; none of the older pinned fixtures has it at all", "file": "`model.ImportMapping`/`ExportMapping` (`MessageDefinition2 *string`), `mdl/backend/modelsdk/mapping_read.go` (`messageDefinition2FromRaw`) + `mapping_write.go`, `sdk/mpr/parser_*_mapping.go` + `writer_*_mapping.go`, `mdl/executor/cmd_import_mappings.go` + `cmd_export_mappings.go`", "insight": "**Carry it, do not derive it.** A pointer, because nil (absent) is NOT the same as present-and-empty — writing the key onto a pre-11.10 document is the overlay-rule mistake CLAUDE.md warns about, which mxbuild tolerates and Studio Pro refuses to open. The executor decides: carry `existing.MessageDefinition2` on an update, apply the version gate only on a CREATE where there is nothing to read it off. A plain version gate in the writer looks equivalent and is not — it re-adds the key to every older document a rewrite touches, which turned four previously-clean fixtures red. gen having no accessor means the read goes to raw BSON, the route `parameterEntityFromRaw` takes. **`MappingSourceReference` is the same family and deliberately NOT carried**: its gate (10.16) predates every project in the field, and the codec emits it through a package-level `TypeDefaults` registration, so making it conditional would touch every mapping write to preserve one pre-10.16 fixture. Issue ako/mxcli#279", "refs": ["ako/mxcli#279"]}
{"area": "mdl/backend", "cause": "The **reader**, not the writer: `mdl/backend/modelsdk/navigation_read.go` type-asserted only the `$Type`s `modelsdk/gen` declares for those two slots, and neither is what the documents carry \u2014 `LoginPageSettings` is stored as `Forms$FormSettings` with the page under `Form` (gen expects `Navigation$NavigationProfileLoginFormSettings` / `LoginPage`), and `NotFoundHomepage` as `Navigation$HomePage` (gen and `generated/metamodel` both expect `Navigation$NotFoundHomePage`). A failed type assertion leaves the field empty, so the loss is silent", "date": "2026-09-01", "file": "`mdl/backend/modelsdk/navigation_read.go` (`navLoginPageOf`, `navNotFoundPageOf`), cross-check `generated/metamodel/types.go` `NavigationNavigationProfile`", "insight": "**The other engine is the control.** Legacy read the same bytes correctly throughout, which is what identifies a reader bug: `describe navigation X` on both engines must agree, and a disagreement localises the defect to the one that reads through gen. Accept the `$Type` the documents actually carry and keep gen's as a fallback branch. Note the two slots fail in **opposite directions** and want opposite fixes: for the login page a real Studio Pro document and `generated/metamodel` agree with the writers, so **gen** is wrong; for the not-found page \u2014 Studio Pro's **\"Fallback page\"** \u2014 metamodel and gen agree with each other and the three mxcli **writers** are the odd one out, emitting `Navigation$HomePage` where Studio Pro stores `Navigation$NotFoundHomePage`. ako/TestApp supplied the reference document that settled it. **Correction (2026-09-01): mxbuild does NOT accept either.** Measured on 11.13 against a build emitting the old spelling, `mx check` and `mxbuild --target=deploy` both exit 1 with \"Object of type 'Mendix.Modeler.WebUI.Navigation.HomePage' cannot be converted to type '...NotFoundHomePage'\" -- the project will not LOAD, so every downstream check is lost. What actually let it through is that nothing ever BUILT a project with a fallback page set: the automated mx-check coverage is doctype-tests/ only and no script there sets one, so the first was added by the fix itself. **Generalisable: 'the build tolerates it' is a claim that needs the same control as the fix** -- revert the writer, rebuild, and run the tool, or the reason a bug escaped gets recorded backwards and sends the next reader looking in the wrong place. Keep reading both `$Type`s regardless, but for the repair path rather than round-tripping: a pre-fix project does not build, and mxcli reads BSON directly, so accepting the old spelling is what lets it open and fix one. Repro `mdl-examples/bug-tests/navigation-describe-profile-pages.mdl`", "symptom": "`DESCRIBE NAVIGATION` prints `home page` and the menu but **silently omits `login page` and `not found page`**, so pasting its output back (the documented copy workflow) deletes both from the profile. The clauses are on disk and `MXCLI_ENGINE=legacy` prints them"}
{"area": "mdl/backend", "date": "2026-09-07", "symptom": "CE0066 \"Entity access is out of date\" after `ALTER ENTITY M.Gen ADD ATTRIBUTE ...` where a SPECIALISATION of Gen also has an access rule — and `UPDATE SECURITY`, project-wide or scoped, reported \"All entity access rules are up to date\" and changed nothing (mendixlabs/mxcli#1047, reported against 0.21.0 on MPR v2, reproduced on 11.12.1 on BOTH engines). The reporter's own tool named the one missing member — `+ Spec: ProbeSecond.Gen.AfterSpec (ReadWrite)` — and took mx check 1 -> 0.", "cause": "ReconcileMemberAccesses computes the same-module ancestor set (sameModuleAncestors -> ownerIDs) and then uses it ONLY for the association pass. The attribute pass walked the entity's OWN attributes (ent.AttributesItems() / entityDoc[\"Attributes\"]), so a specialisation's expected member set never contained an inherited attribute: nothing looked missing, nothing was added, and `modified` stayed 0 — which the command prints as \"up to date\". Fixed by walking the chain for attributes too, qualifying each against the entity that DECLARES it, with a nearer entity's attribute shadowing an ancestor's of the same name.", "file": "`mdl/backend/modelsdk/domainmodel_security_write.go` (ReconcileMemberAccesses, collectAttrs) and `sdk/mpr/writer_security.go` (entityAttrsInChain, ownAttrsOf, generalizationRefOf); tests `mdl/backend/modelsdk/reconcile_inherited_attr_test.go`, `sdk/mpr/writer_security_inherited_test.go`; example `mdl-examples/bug-tests/1047-inherited-member-reconcile.mdl`", "insight": "The bug was one line of REUSE that never happened: the ancestor set was already computed two lines above the attribute loop and only the association loop consumed it. When a function computes a set and uses it for one of two symmetric passes, check the other pass. Three things worth carrying: (1) A count that drives a message is part of the contract — reporting 0 modified is what turned a broken model into the words \"All entity access rules are up to date\", and a false success is worse than an error because it ends the investigation. Assert the count, not just the stored state. (2) Legacy keyed coverage by BARE attribute name while preserving any ref not qualified against this entity, so naively adding inherited attributes would have preserved AND re-added the same member — the duplicate only shows on the second reconcile, so the regression test runs it twice. (3) The repair path cannot be proven by an .mdl file once the write path is fixed: ALTER ENTITY reconciles as it writes, so the script ends clean either way. Break the project with a PRE-FIX binary and repair it with the fixed one — that finally showed `update security` taking a real CE0066 from 1 to 0, which an earlier fix had recorded as unproven.", "ce": ["CE0066"]}
Loading
Loading