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
2 changes: 2 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,5 @@ jobs:
# the glob above because it needs Go and lives in its own directory.
- name: Lint-rule unit suite (tests/lint-rules/test-ux-rules.sh)
run: bash tests/lint-rules/test-ux-rules.sh
- name: Lint-rule unit suite (tests/lint-rules/test-batch1-rules.sh)
run: bash tests/lint-rules/test-batch1-rules.sh
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are.

## Unreleased

- process(lint): **ERR001/LOOP001 first field count recorded.** Same run as the UX001/UX002 calibration (mxcli 0.26.0, scratch copy of a 21-module Mendix 11.12 app, 876 microflows): ERR001 8 findings, LOOP001 4, all on the app's own modules, none on marketplace or System, no load errors, 1.7 s for 55 rules. Scored by reading each flagged flow: LOOP001 4 of 4 true (every retrieve sits in the loop body and filters on the iterator); ERR001 3 true (`on error continue` with nothing logged, raised or handled — one silently drops a callee's gate failure) and 5 debatable (the flow returns an error payload or an empty result as its signal, and carries an annotation saying the handling is deliberate, which the rule does not read). No false positives, so the rule text stands; whether an annotation or an explicit error-payload return should count as handling is logged as an open question in `process/lint-backlog.md`'s spirit — decide after the second project. Both rules stay at warning. — MendixMau
- new(lint): **ERR001 ErrorSwallowed and LOOP001 ExpensiveActionInLoop** — Batch 1 of `process/lint-backlog.md`. ERR001 flags a flow where an activity is set to Continue / Custom / Custom-without-rollback and nothing in the flow logs, raises an error event, or calls a handler-named flow (the "Continue and pray" pattern from `learned-microflow-patterns.md`); it is the complement of mxcli's CONV013/CONV014, which only check that handling is set. LOOP001 flags a database retrieve, delete, REST, external or Java call inside a loop body at any depth (CONV011 covers commit-in-loop; this is the rest of the list); association retrieves and sub-flow calls are left alone. Both read `error_handling_type` / `loop_depth` / `retrieve_source` as mxcli ≥ 0.25.0 projects them (values taken from the mxcli source, `sdk/microflows/error_handling.go` and the catalog builder), use `activities_for(…, nested=True)`, ship at warning severity, and emit a `_rule` blindness finding on an older binary or an empty column. Unit-tested under the real Starlark interpreter, `tests/lint-rules/test-batch1-rules.sh` (29 cases incl. the 3000-flow perf run, in CI); the test harness gained `nested=` to mirror the 0.25.0 builtin. First field count still owed before promotion — the PR stays draft until a real project reports one. — MendixMau
- fix(lint): **UX001/UX002 field-calibrated on a real model; both had a vocabulary defect the unit suite could not see.** First run of the pair with mxcli 0.26.0 against a scratch copy of a 21-module Mendix 11.12 app (876 microflows, 124 pages, 1.7 s, no load errors): UX001 fired 0 times and UX002's decision leg 0 times across 728 decisions. Catalog probe showed why — the stored microflow-button action type is `Forms$MicroflowAction` (290 rows), not the `Forms$MicroflowClientAction` read from the source, so UX001 legs 2–3 could never fire for a microflow button; and `auto_generate_caption` is 0 on every `ExclusiveSplit`, a bare decision instead carries its condition expression as its caption, so UX002's test could never be true. Fixed: UX001 lists both spellings; UX002 flags a caption that is empty or equals `condition_expression` (39 of 39 custom top-level decisions on that model, while marketplace decisions read "Passwords equal?"). Fixtures now carry the probed action-type name and a new case pins the caption==condition shape (36 cases). Lesson for every rule here: a self-check that only tests "count is non-zero" passes on a wrong vocabulary — the field run is the check. — MendixMau
- fix(tests): **`test-stock-hashes.sh` no longer demands a stock hash for a toolkit-authored rule.** Its coverage check walked all of `MXTK_LINT_RULES`, but `mxcli init` only seeds three of those files, so the day UX001/UX002 joined the list every version went red (`v0.17.0 is missing: ux001… ux002…`, 3 FAIL) for a hash that cannot exist. `bin/lib/install-manifest.sh` gains `MXTK_LINT_RULES_STOCK` (the three init-seeded rules); the fixture covers, lists and live-captures against that set and asserts it is a subset of `MXTK_LINT_RULES`. Verified by replaying the fixture's awk against the real list: all three versions cover all three stock rules, and a listed non-stock name still fails. Same disease in `tests/test-lint-delivery.sh`: its fresh-install case asserted a literal 4 installed files; it now derives the count from the manifest lists and checks that no `MXTK_LINT_RULES_NOINSTALL` file landed. — MendixMau
- new(lint): **UX001 ConfirmBeforeDestructive and UX002 DecisionsCaptioned.** Two Starlark rules in `lint-rules/`, delivered by the existing install/refresh path: UX001 flags a button that deletes (direct delete action, which has no confirmation setting) or calls a flow that deletes or is named Approve/Reject/Submit/Archive/… without "Ask confirmation"; UX002 flags decisions left on their auto-generated caption and flows of 8+ activities with no annotation or documentation. Both need mxcli ≥ 0.25.0 (widget `action_type`/`has_confirmation`, activity `caption`) and emit a `_rule` blindness finding on anything older. Vocabulary read from the mxcli source, not from the bundled API guide; first field calibration still owed (no mxcli binary reachable in the authoring session) — the README row says so. Logic is unit-tested under the real Starlark interpreter: `tests/lint-rules/test-ux-rules.sh` (35 cases incl. a synthetic 3000-flow perf run, wired into CI) — the suite caught two defects before they shipped: start/end events inflated UX002's activity count, and its version probe only fired on flows that had a decision. Prompted by a CLAUDE.md-only enforcement of the same house rules (confirm before approve, feedback on close/commit) that no session could be held to. — MendixMau
Expand Down
2 changes: 1 addition & 1 deletion bin/lib/install-manifest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ MXTK_PROJECT_BIN_NOINSTALL="report-disposition-check.sh" # gate-check.sh runs i
# project that re-runs init loses these fixes and its lint gate goes quietly blind. Re-running
# sync-project.sh restores them; lint-rules/STOCK-HASHES.txt is what lets it tell a
# reverted-to-stock file (safe to restore) from one a project deliberately tuned (hands off).
MXTK_LINT_RULES="conv010_act_microflow_content.star data_change_microflows.star entity_business_key.star ux001_confirm_before_destructive.star ux002_decisions_captioned.star"
MXTK_LINT_RULES="conv010_act_microflow_content.star data_change_microflows.star entity_business_key.star ux001_confirm_before_destructive.star ux002_decisions_captioned.star err001_error_swallowed.star loop001_expensive_action_in_loop.star"
# MXTK_LINT_RULES_STOCK — the subset of MXTK_LINT_RULES that `mxcli init` ALSO seeds, i.e. the
# only files lint-rules/STOCK-HASHES.txt can ever hold a hash for. The ux001/ux002 rules are
# toolkit-authored: init never writes them, so no stock hash can exist and a coverage check
Expand Down
6 changes: 6 additions & 0 deletions lint-rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ and configure the marked constants — **after** running `mxcli init`, never bef
| `entity_business_key.star` | ARCH003 — persistent entities with no UNIQUE NOT NULL business key | no |
| `ux001_confirm_before_destructive.star` | UX001 — a button that deletes, approves, rejects, submits or archives (direct delete action, or a flow that deletes / is named so) without "Ask confirmation" | `DESTRUCTIVE_NAME_PATTERN` if your verbs differ |
| `ux002_decisions_captioned.star` | UX002 — decisions (`ExclusiveSplit`) with an auto-generated caption instead of the question they answer; flows of 8+ activities with neither an annotation nor a documentation text | `ANNOTATE_FROM` |
| `err001_error_swallowed.star` | ERR001 — an activity set to Continue / Custom / Custom-without-rollback in a flow that never logs, raises an error event, or calls a flow named like a handler (`SUB_LogError`, `ERR_…`): the error is swallowed | `HANDLER_NAME_PATTERN` if your handler flows are named differently |
| `loop001_expensive_action_in_loop.star` | LOOP001 — a database retrieve, delete, REST call, external call or Java action inside a loop body (any depth): one round trip per row | `EXPENSIVE` if a call type is cheap in your setup |

`err001` and `loop001` first field count (2026-10-12, mxcli 0.26.0, 21-module Mendix 11.12 model,
876 microflows): ERR001 8 (3 true, 5 debatable — flows that return an error payload instead of
logging), LOOP001 4 (4 true). Warning severity until a second project reports.

`data_change_microflows` and `entity_business_key` are **repaired copies of rules `mxcli init`
already seeds**, not new ones. Both shipped comparing `entity.entity_type` to `"PERSISTENT"`
Expand Down
143 changes: 143 additions & 0 deletions lint-rules/err001_error_swallowed.star
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# mxtk-lint-rule: err001_error_swallowed
# Shipped by mxcli-project-toolkit. Needs no per-project configuration; sync-project.sh
# refreshes it while it is unedited and reports (never overwrites) a locally edited copy.
# See lint-rules/README.md.
# --- end mxtk-lint-rule header ---
# ERR001: An error that is caught must leave a trace
#
# "Continue" on an activity means: if this fails, carry on as if it worked. "Custom" and
# "Custom without rollback" route the error to a handler branch. All three are fine when the
# flow then logs the error, raises it again, or hands it to a flow that does. All three are a
# silent swallow when nothing in the flow does any of that: the user sees a half-done action,
# the log shows nothing, and the next session's first hour goes to finding out which of forty
# flows ate the exception (the "Continue and pray" pattern, learned-microflow-patterns.md).
#
# What counts as a trace, anywhere in the SAME flow (loop bodies included):
# 1. a Log message activity (action_type LogMessageAction / log_level set)
# 2. an error end event (activity_type ErrorEvent — raise/re-throw)
# 3. a call to a flow whose name says it handles or logs errors
# (SUB_LogError, ERR_Handle, ..._HandleError — HANDLER_NAME_PATTERN, CamelCase-strict)
#
# Deliberately NOT flagged: Rollback and Abort (the platform surfaces those itself), the
# default "" (no handling set, which Mendix treats as Rollback), flows in System/Administration,
# and RULE-type flows. One finding per flow, naming the swallowing activities.
#
# What CONV013/CONV014 (mxcli stock) do NOT cover: they check that an external call HAS error
# handling. This rule checks that the handling leads somewhere. Different question.
#
# REQUIRES mxcli >= 0.25.0: error_handling_type / log_level / nested activities were added to
# the Starlark activity projection there (mendixlabs/mxcli#1266). On an older binary the rule
# does not go quiet: it emits a `_rule` finding saying it saw nothing.
#
# Vocabulary read from the mxcli source (sdk/microflows/error_handling.go: the action's stored
# ErrorHandlingType verbatim — "Rollback", "Continue", "Custom", "CustomWithoutRollBack" with
# a capital B, "Abort"; builder_microflows.go: activity_type "ErrorEvent", action_type with
# the "Microflows$" prefix stripped). Probe it on your model before trusting it:
# sqlite3 .mxcli/catalog.db "SELECT DISTINCT ErrorHandlingType FROM activities ORDER BY 1;"
# sqlite3 .mxcli/catalog.db "SELECT DISTINCT ActivityType, ActionType FROM activities WHERE LogLevel != '' OR ActivityType='ErrorEvent';"

RULE_ID = "ERR001"
RULE_NAME = "ErrorSwallowed"
DESCRIPTION = "An activity that continues or custom-handles on error must be in a flow that logs, raises, or calls an error handler"
CATEGORY = "quality"
SEVERITY = "warning"

REQUIRES = ["full"]

SKIP_MODULES = ("System", "Administration")
FLOW_TYPES = ("MICROFLOW", "NANOFLOW")

# Stored error handling values that catch the error (activities.ErrorHandlingType).
SWALLOWING = ("Continue", "Custom", "CustomWithoutRollBack")

# Evidence the error went somewhere.
LOG_ACTION = "LogMessageAction"
RAISE_EVENT = "ErrorEvent"
HANDLER_NAME_PATTERN = "(^|_)(Log|Error|Err|Handle)([A-Z0-9_]|$)"


def _handler_called(qname):
"""True when this flow calls a flow whose name announces error handling or logging."""
for ref in refs_from(qname):
if ref.target_type not in ("MICROFLOW", "NANOFLOW"):
continue
if ref.target_name == qname:
continue
if matches(ref.target_name.split(".")[-1], HANDLER_NAME_PATTERN):
return True
return False


def check():
violations = []
flows_seen = 0
activities_seen = 0
handling_seen = 0
api_missing = False

for mf in microflows():
if mf.module_name in SKIP_MODULES:
continue
if mf.microflow_type not in FLOW_TYPES:
continue
flows_seen += 1

swallowing = []
traced = False
for act in activities_for(mf.qualified_name, nested=True):
activities_seen += 1
# getattr, not act.error_handling_type: on a pre-0.25.0 binary the attribute does
# not exist and a bare access aborts the rule as a crash, which the gate would
# baseline. A missing attribute is reported as blindness instead.
eh = getattr(act, "error_handling_type", None)
if eh == None:
api_missing = True
break
if eh:
handling_seen += 1
if eh in SWALLOWING:
swallowing.append(act.caption or act.name or act.activity_type)
if act.activity_type == RAISE_EVENT:
traced = True
elif act.action_type == LOG_ACTION or getattr(act, "log_level", ""):
traced = True
if api_missing:
break
if not swallowing or traced:
continue
if _handler_called(mf.qualified_name):
continue

violations.append(violation(
message="{} catches errors on {} activit{} ({}) but never logs, raises, or calls an error handler: the error is swallowed.".format(
mf.qualified_name, len(swallowing), "y" if len(swallowing) == 1 else "ies",
", ".join(["'" + s + "'" for s in swallowing[:4]]) + (", ..." if len(swallowing) > 4 else ""),
),
location=location(module=mf.module_name, document_type="Microflow", document_name=mf.qualified_name),
suggestion="In the handler branch (or right after a 'Continue' activity) add a Log message at ERROR level with $latestError/Message, or raise the error again; or call the project's SUB_LogError. If the error truly does not matter, say so in an annotation next to the activity.",
))

# SELF-CHECKS. A rule that cannot see must say so, never report a clean pass.
if api_missing:
violations.append(violation(
message="ERR001 cannot run on this mxcli: activities have no error_handling_type (added in mxcli 0.25.0). This rule inspected NOTHING.",
location=location(module="_rule", document_type="Microflow", document_name="ERR001"),
suggestion="Upgrade mxcli to 0.25.0 or later, or remove this rule deliberately rather than leaving it inert.",
))
elif flows_seen > 0 and activities_seen == 0:
violations.append(violation(
message="ERR001 saw {} flows and not one activity. activities_for() needs the FULL catalog; this rule inspected NOTHING.".format(flows_seen),
location=location(module="_rule", document_type="Microflow", document_name="ERR001"),
suggestion="Run `REFRESH CATALOG FULL` (mxcli lint should request it itself from 0.24 on) and lint again.",
))
elif activities_seen >= 20 and handling_seen == 0:
# Every real action activity stores a handling value (Rollback at minimum). Twenty
# activities without a single one means the column is empty: a catalog from before
# the field existed, not a project that never set it.
violations.append(violation(
message="ERR001 saw {} activities and none carries an error_handling_type, so no handling was inspected. The catalog is probably from an older schema.".format(activities_seen),
location=location(module="_rule", document_type="Microflow", document_name="ERR001"),
suggestion="Run `REFRESH CATALOG FULL` on mxcli >= 0.25.0 so the activities table carries ErrorHandlingType, then lint again.",
))

return violations
Loading
Loading