diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cf17e2fc..ecb3ac1d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -85,3 +85,10 @@ jobs: bash "$t" || { echo "FAILED: $t"; rc=1; } done exit $rc + + # Lint-rule unit suite. Added 2026-10-08 with UX001/UX002: executes the Starlark rules + # under go.starlark.net at mxcli's pinned version (tests/lint-rules/starrun, built here + # with the runner's Go), so rule logic is proven before any field calibration. Not under + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e00e47d9..228c8a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased +- 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 - perf(ui-loop): **`test-stack-up.sh` recognises a live `mxcli run --local` as this project's app, and `--restart` no longer builds twice.** The script read only Docker labels, so an app started with `./mxcli run --local --watch` (mxcli's own ~20s page loop) was found by port scan, published as `APP_OWNERSHIP=unverified`, and refused by the e2e harness unless `ALLOW_UNVERIFIED_APP=1`. It now reads mxcli's `.mxcli/run-local.json` handshake (v0.24+; beside the `.mpr` or the `-p` path on a two-tree checkout), checks its pid is alive, and publishes `verified` with a new `APP_SOURCE=run-local` line in `stack.env` (`docker` / `scan` otherwise). A model edited after boot reads as applied under `--watch` and as STALE without it — the script never restarts a loop it did not start. Docker bring-up and restart now wait on `mxcli docker run --wait` instead of a 240s poll loop, and still prove HTTP; `--restart` used to fall through into the bring-up block and run `docker run` a second time. New fixture `tests/wave2/test-stack-up-local.sh` over a redacted capture of the handshake. Field run 2026-10-07, existing app: same app, same port, `unverified` → `verified`; a touched model read `watch`; stopping the loop removed the handshake and the script fell back. — MendixMau - fix(init): **local by default, no Dev Container.** `init-project.sh` now parks the `.devcontainer/` that `mxcli new`/`init` always writes under `.mxtk-backup/` (keep it with `MXTK_KEEP_DEVCONTAINER=1`), and the README says "say no" to VS Code's reopen prompt. Field incident 2026-10-08: a first-time user followed the docs and landed in the container with "claude: command not found" (the template installs `claude` to `~/.local/bin` and never puts it on PATH) and a fresh login prompt. Local use was always the intent. — MendixMau - docs(lint): **Mendix quality-source review: 19 new lint-backlog rows (Batch 4) plus seven skill additions.** Recommender, QSM/AQM, ATS, Menditect and docs.mendix.com mapped against shipped rules; indexes, scheduled events, Atlas 4 tokens, accessibility floor, workflow targeting, test isolation and rollback semantics added to their skills; record in `process/quality-sources-review-2026-10-08.md`; no rule probed on a model yet — MendixMau diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index 3b831d7d..17b14b43 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -136,7 +136,8 @@ MXTK_PROJECT_BIN_NOINSTALL="report-disposition-check.sh" # gate-check.sh runs i # lint-rules/README.md. conv010 (CONV010) is the top rule by # volume everywhere it runs; the de-noised version here took PROJECT-C 399 -> 51. # -# The toolkit ships ONLY these three of mxcli's ~29. Copying the rest would pin every project +# The toolkit repairs ONLY these three of mxcli's ~29 (MXTK_LINT_RULES_STOCK below); rules +# after them in the list are toolkit-authored, never seeded by init. Copying the rest would pin every project # to whatever mxcli shipped the day someone copied them. # # MXTK_LINT_RULES_CONFIGURABLE — installed once, then owned by the project. conv020 is not an @@ -150,7 +151,14 @@ 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" +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_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 +# that demanded one (tests/wave2/test-stock-hashes.sh, until 2026-10-08) went red on every +# version the day a non-stock rule joined MXTK_LINT_RULES. Every name here must also be in +# MXTK_LINT_RULES; the fixture asserts it. +MXTK_LINT_RULES_STOCK="conv010_act_microflow_content.star data_change_microflows.star entity_business_key.star" MXTK_LINT_RULES_CONFIGURABLE="conv020_action_user_feedback.star" # Files in lint-rules/ deliberately NOT installed. Same contract as MXTK_PROJECT_BIN_NOINSTALL: diff --git a/lint-rules/README.md b/lint-rules/README.md index 31aa4926..48ce28e9 100644 --- a/lint-rules/README.md +++ b/lint-rules/README.md @@ -10,6 +10,8 @@ and configure the marked constants — **after** running `mxcli init`, never bef | `conv020_action_user_feedback.star` | Page-triggered microflows that commit / import / delete but tell the user nothing | no — `PROJECT_MODULES` defaults to `"*"` (all modules the lint run includes, minus `.claude/lint-vendor-modules.txt`); `ACTION_PREFIX` if your prefixes differ | | `data_change_microflows.star` | ARCH002 — persistent entities written from pages instead of microflows | no | | `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` | `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"` @@ -24,6 +26,26 @@ while it was dead. It now carries the same `SKIP_MODULES` list, and the same pro are excluded at the gate (`bin/lint-gate.sh -e`, from a per-project `.claude/lint-vendor-modules.txt`), not hardcoded in a shared rule. +`ux001` and `ux002` read widget `action_type` / `has_confirmation` and activity +`caption` / `auto_generate_caption`, which exist from **mxcli 0.25.0** (catalog schema with +widget actions and activity captions). On an older binary both rules emit a single `_rule` +finding ("needs mxcli >= 0.25.0") and nothing else — `bin/lint-gate.sh` reads that as blindness, +never as a clean pass. Their vocabulary (`Forms$DeleteClientAction`, `Forms$MicroflowAction`, +`ExclusiveSplit`, `DeleteObjectAction`) was read from the mxcli source at 0.25.0, then field-probed +on 2026-10-12 with mxcli 0.26.0 against a scratch copy of a 21-module Mendix 11.12 model (876 +microflows, 124 pages). That probe found two defects the unit suite could not see: the stored +microflow-button action type is `Forms$MicroflowAction`, not the `…ClientAction` the source read +suggested, and `auto_generate_caption` is never set on a decision — a bare decision carries its +condition expression as its caption. Both fixed; after the fix UX002 reports every one of the +model's 39 top-level custom decisions as bare (all were), UX001 stays at 0 there (its 4 microflow +buttons reach no delete and match no verb). Re-probe on your own model before trusting the counts. Their *logic* is proven: `tests/lint-rules/` +runs both under the real Starlark interpreter (`go.starlark.net` at mxcli's pinned version) over +33 hand-written fixtures — self-checks, call depth both sides of the bound, cycles, the name +pattern, module skips, snippet locations, start/end events not counted — plus a synthetic +3000-flow / 20000-widget model each rule finishes in under a second. Fixtures encode the +projection as read from the mxcli source; they are not captured output, so they prove the +rule, not the vocabulary. + `conv010` here is likewise the repaired form of the rule mxcli seeds, and supersedes the version this directory shipped on 11 Aug: one violation per microflow instead of one per activity, plus four action types allowlisted. On PROJECT-C that is 399 rows -> 51 findings. diff --git a/lint-rules/ux001_confirm_before_destructive.star b/lint-rules/ux001_confirm_before_destructive.star new file mode 100644 index 00000000..6b06c37e --- /dev/null +++ b/lint-rules/ux001_confirm_before_destructive.star @@ -0,0 +1,212 @@ +# mxtk-lint-rule: ux001_confirm_before_destructive +# 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 --- +# UX001: A button that destroys or finalises must ask first +# +# A click that deletes, approves, rejects, submits or archives cannot be undone by the +# person who clicked it. Mendix has a confirmation dialog for exactly this ("Ask +# confirmation" on a microflow/nanoflow button), and the house rule is that every such +# button uses it. Three shapes are flagged, one finding per button: +# +# 1. the button uses the DELETE client action directly +# (Forms$DeleteClientAction). That action has NO confirmation setting at all, so +# the only fix is to route it through a microflow that deletes, and confirm there. +# 2. the button calls a microflow or nanoflow WITHOUT confirmation, and that flow (or a +# flow it calls, bounded depth) contains a Delete objects action. +# 3. the button calls a flow WITHOUT confirmation whose NAME says it is irreversible +# (ACT_Order_Approve, ACT_RejectRequest, SUB_Submit...). Name-based, so it is the +# weakest leg; the pattern below is deliberately CamelCase-strict ("Approved" in +# ACT_ShowApprovedOrders does not match, "Approve" at a word end does). +# +# What is deliberately NOT flagged: Save/Cancel, show page, create, datasource +# microflows (they are not buttons and carry no action_type), and buttons that already +# ask. One finding per button, located at the page or snippet holding it. +# +# REQUIRES mxcli >= 0.25.0: widget action_type / has_confirmation / page_ref were added +# to the Starlark widgets() projection there (mendixlabs/mxcli#1268). On an older +# binary the rule does not go quiet: it emits a `_rule` finding saying it saw nothing. +# +# Vocabulary below was read from the mxcli catalog builder source (builder_pages.go +# widgetPrimaryAction stores the action's stored $Type verbatim; builder_microflows.go +# strips the "Microflows$" prefix from an action's type), NOT from write-lint-rules.md, +# then probed on a real model (see FLOW_CALLS: the source read got one name wrong). +# Probe it on your model before trusting it: +# sqlite3 .mxcli/catalog.db "SELECT DISTINCT ActionType FROM widgets ORDER BY 1;" +# sqlite3 .mxcli/catalog.db "SELECT DISTINCT ActionType FROM activities ORDER BY 1;" + +RULE_ID = "UX001" +RULE_NAME = "ConfirmBeforeDestructive" +DESCRIPTION = "Buttons that delete, or call a flow that deletes or finalises, must ask for confirmation" +CATEGORY = "quality" +SEVERITY = "warning" + +REQUIRES = ["full"] + +# Platform modules are never ours to fix. Marketplace modules are excluded at the +# gate instead (bin/lint-gate.sh reads .claude/lint-vendor-modules.txt), not hardcoded here. +SKIP_MODULES = ("System", "Administration") + +# Stored client-action types on a button / on-click action (widgets.ActionType). +DIRECT_DELETE = "Forms$DeleteClientAction" +# Field-probed 2026-10-12 on mxcli 0.26.0 against a 21-module Mendix 11.12 model: the stored +# microflow-button value is "Forms$MicroflowAction" (290 rows, 37 with confirmation), NOT the +# "Forms$MicroflowClientAction" the source read suggested -- with the old name legs 2 and 3 +# could never fire for a microflow button, and the self-check did not notice because other +# widgets still reported an action type. Both spellings stay listed; the nanoflow and direct +# delete names were confirmed as written. +FLOW_CALLS = ("Forms$MicroflowAction", "Forms$MicroflowClientAction", "Forms$CallNanoflowClientAction") + +# Microflow action types that destroy data (activities.ActionType, "Microflows$" stripped). +DESTRUCTIVE_ACTIONS = ("DeleteObjectAction",) + +# Flow names that announce an irreversible step. Anchored to a name segment start and a +# CamelCase/segment end, so "Approved", "Deleted", "Submitted" never match. +DESTRUCTIVE_NAME_PATTERN = "(^|_)(Delete|Remove|Approve|Reject|Submit|Archive|Finalize|Finalise|Publish|Withdraw|Revoke|Discard|Purge)($|_|[A-Z0-9])" + +# Flows walked from the button: the target, what it calls, what that calls (3 flows deep). +# A delete four flows away is not seen; the unit suite pins both sides of that line. +MAX_CALL_DEPTH = 3 + + +def _action_types(qname, cache): + """Action types directly inside one flow.""" + if qname in cache: + return cache[qname] + found = [] + for act in activities_for(qname): + if act.action_type: + found.append(act.action_type) + cache[qname] = found + return found + + +def _callees(qname, cache): + """Flows called by this one (microflows and nanoflows).""" + if qname in cache: + return cache[qname] + found = [] + for ref in refs_from(qname): + if ref.target_type in ("MICROFLOW", "NANOFLOW") and ref.target_name != qname: + found.append(ref.target_name) + cache[qname] = found + return found + + +def _destroys(qname, act_cache, call_cache): + """True when the flow, or anything it calls within MAX_CALL_DEPTH, deletes objects. + Starlark has no recursion, so the call graph is walked with a bounded frontier.""" + seen = {qname: True} + frontier = [qname] + for _ in range(MAX_CALL_DEPTH): + next_frontier = [] + for q in frontier: + for a in _action_types(q, act_cache): + if a in DESTRUCTIVE_ACTIONS: + return True + for callee in _callees(q, call_cache): + if callee not in seen: + seen[callee] = True + next_frontier.append(callee) + frontier = next_frontier + if not frontier: + break + return False + + +def _doc_type(container_type): + # container_type is UPPERCASE in the API ("PAGE" / "SNIPPET"), like every other *_type. + if container_type == "SNIPPET": + return "Snippet" + return "Page" + + +def check(): + violations = [] + act_cache = {} + call_cache = {} + widgets_in_scope = 0 + widgets_with_action = 0 + api_missing = False + + for w in widgets(): + if w.module_name in SKIP_MODULES: + continue + widgets_in_scope += 1 + + # getattr, not w.action_type: on a pre-0.25.0 binary the attribute does not exist + # and a bare access aborts the rule with "struct has no .action_type attribute", + # which the gate would then baseline as a crash rather than a finding. + at = getattr(w, "action_type", None) + if at == None: + api_missing = True + break + if not at: + continue + widgets_with_action += 1 + + loc = location( + module=w.module_name, + document_type=_doc_type(w.container_type), + document_name=w.container_qualified_name, + ) + + if at == DIRECT_DELETE: + violations.append(violation( + message="Button '{}' on {} deletes directly (Forms$DeleteClientAction), which has no confirmation setting.".format( + w.name, w.container_qualified_name + ), + location=loc, + suggestion="Call a microflow that deletes the object instead, and tick 'Ask confirmation' on the button (ACTIONBUTTON ... (Action: MICROFLOW Mod.ACT_X_Delete(...), Confirmation: 'Delete this ...?')).", + )) + continue + + if at not in FLOW_CALLS: + continue + if getattr(w, "has_confirmation", False): + continue + + target = w.microflow_ref + if not target: + target = getattr(w, "nanoflow_ref", "") + if not target: + continue + + why = "" + if _destroys(target, act_cache, call_cache): + why = "deletes objects (directly or through a called flow)" + elif matches(target.split(".")[-1], DESTRUCTIVE_NAME_PATTERN): + why = "is named as an irreversible step" + if not why: + continue + + violations.append(violation( + message="Button '{}' on {} calls {} without confirmation, and that flow {}.".format( + w.name, w.container_qualified_name, target, why + ), + location=loc, + suggestion="Add a confirmation to the button (Confirmation: '...?' on the ACTIONBUTTON, or 'Ask confirmation' in Studio Pro) saying what will happen and that it cannot be undone.", + )) + + # SELF-CHECKS. A rule that cannot see must say so, never report a clean pass. + if api_missing: + violations.append(violation( + message="UX001 cannot run on this mxcli: widgets() has no action_type/has_confirmation (added in mxcli 0.25.0). This rule inspected NOTHING.", + location=location(module="_rule", document_type="Page", document_name="UX001"), + suggestion="Upgrade mxcli to 0.25.0 or later, or remove this rule deliberately rather than leaving it inert.", + )) + elif widgets_in_scope == 0: + violations.append(violation( + message="UX001 saw no widgets outside System/Administration. widgets() needs the FULL catalog; this rule inspected NOTHING.", + location=location(module="_rule", document_type="Page", document_name="UX001"), + suggestion="Run `REFRESH CATALOG FULL` (mxcli lint should request it itself from 0.24 on) and lint again.", + )) + elif widgets_with_action == 0: + violations.append(violation( + message="UX001 saw {} widgets and not one reports an action type, so no button was inspected. The catalog is probably from an older schema.".format(widgets_in_scope), + location=location(module="_rule", document_type="Page", document_name="UX001"), + suggestion="Run `REFRESH CATALOG FULL` on mxcli >= 0.25.0 so the widgets table carries ActionType, then lint again.", + )) + + return violations diff --git a/lint-rules/ux002_decisions_captioned.star b/lint-rules/ux002_decisions_captioned.star new file mode 100644 index 00000000..07fd2f6a --- /dev/null +++ b/lint-rules/ux002_decisions_captioned.star @@ -0,0 +1,141 @@ +# mxtk-lint-rule: ux002_decisions_captioned +# 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 --- +# UX002: Decisions carry a question, long flows carry an annotation +# +# The next reader of a microflow is a reviewer, a tester, or the next session's agent, +# and none of them can run it in their head. Two things make a flow readable on the +# canvas and in DESCRIBE MICROFLOW output, and both are cheap at write time and +# expensive to add later: +# +# 1. every decision (ExclusiveSplit) has its own caption, written as the question it +# answers ("Order already approved?"), not the auto-generated expression. In MDL: +# @caption 'Order already approved?' +# IF $Order/Status = 'Approved' THEN ... +# 2. a flow with ANNOTATE_FROM or more top-level activities (start/end events and +# annotations themselves not counted) has at least one +# annotation or a documentation text saying what the flow is for. In MDL: +# @annotation 'Rejects the order, notifies the requester, closes the task.' +# before an activity, or a /** ... */ doc block before CREATE MICROFLOW. +# +# One finding per microflow per leg (a 30-activity flow with 6 bare decisions is two +# findings, not seven), so the ratchet in bin/lint-gate.sh moves one step per flow. +# Rules (RULE microflow_type) are skipped: they are expressions, not narratives. +# +# REQUIRES mxcli >= 0.25.0: activity caption / auto_generate_caption / description / +# condition_expression (mendixlabs/mxcli#1266, #1267). Before that every activity's +# caption was the placeholder "Activity". On an older binary the rule emits a `_rule` +# finding instead of a silent clean pass. Field note (0.26.0): auto_generate_caption is +# never set on a decision; a bare decision is detected by caption == condition_expression. +# +# Vocabulary: activity_type "ExclusiveSplit" / "Annotation" are the names +# getMicroflowObjectType() in mxcli's catalog builder returns (builder_microflows.go); +# microflow_type is UPPERCASE ("MICROFLOW" / "NANOFLOW" / "RULE"), verified on 0.24.0 +# (skills/lint-that-actually-runs.md). activities_for() is TOP-LEVEL only: decisions +# inside a loop body are not seen (documented limitation, same as CONV009). It also +# returns the StartEvent and EndEvent objects, which are not activities and are not +# counted towards ANNOTATE_FROM (found by the unit suite, tests/lint-rules). + +RULE_ID = "UX002" +RULE_NAME = "DecisionsCaptioned" +DESCRIPTION = "Decisions need a caption written as a question; flows of 8+ activities need an annotation or documentation" +CATEGORY = "quality" +SEVERITY = "warning" + +REQUIRES = ["full"] + +SKIP_MODULES = ("System", "Administration") +FLOW_TYPES = ("MICROFLOW", "NANOFLOW") + +# Top-level activity count from which a flow owes the reader an annotation. +ANNOTATE_FROM = 8 + +# Objects activities_for() returns that are not activities to a reader. +NOT_COUNTED = ("StartEvent", "EndEvent", "Annotation") + + +def check(): + violations = [] + inspected = 0 + saw_any_activity = False + api_missing = False + + for mf in microflows(): + if mf.module_name in SKIP_MODULES: + continue + if mf.microflow_type not in FLOW_TYPES: + continue + inspected += 1 + + acts = activities_for(mf.qualified_name) + if not acts: + continue + saw_any_activity = True + + # Version probe on the FIRST object of any kind, not only on a decision: a flow + # with no decisions on an old binary must still produce the blindness finding. + if not hasattr(acts[0], "auto_generate_caption"): + api_missing = True + break + + bare_decisions = 0 + has_annotation = False + counted = 0 + for act in acts: + if act.activity_type == "Annotation": + has_annotation = True + if act.activity_type not in NOT_COUNTED: + counted += 1 + if act.activity_type != "ExclusiveSplit": + continue + # Field-probed 2026-10-12 on mxcli 0.26.0 (21-module Mendix 11.12 model, 728 + # decisions): auto_generate_caption is 0 on EVERY ExclusiveSplit, and a decision + # left on its generated caption carries its condition expression AS the caption. + # So "bare" means: empty, or equal to the condition expression (39 of 39 custom + # decisions on that model; the marketplace ones read "Passwords equal?"). + cap = act.caption.strip() + cond = getattr(act, "condition_expression", "").strip() + if act.auto_generate_caption or cap == "" or (cond != "" and cap == cond): + bare_decisions += 1 + + loc = location( + module=mf.module_name, + document_type="Microflow", + document_name=mf.qualified_name, + ) + + if bare_decisions > 0: + violations.append(violation( + message="'{}' has {} decision(s) with no caption of their own (empty, or still the condition expression).".format( + mf.name, bare_decisions + ), + location=loc, + suggestion="Give each decision a caption written as the question it answers: `@caption 'Already approved?'` before the IF in MDL, or the Caption field in Studio Pro.", + )) + + if counted >= ANNOTATE_FROM and not has_annotation and mf.description.strip() == "": + violations.append(violation( + message="'{}' has {} top-level activities, no annotation and no documentation.".format( + mf.name, counted + ), + location=loc, + suggestion="Add `@annotation '...'` before the first activity saying what the flow does and for whom, or a /** ... */ documentation block before CREATE MICROFLOW.", + )) + + # SELF-CHECKS. A rule that cannot see must say so, never report a clean pass. + if api_missing: + violations.append(violation( + message="UX002 cannot run on this mxcli: activities have no auto_generate_caption (added in mxcli 0.25.0). This rule inspected NOTHING.", + location=location(module="_rule", document_type="Microflow", document_name="UX002"), + suggestion="Upgrade mxcli to 0.25.0 or later, or remove this rule deliberately rather than leaving it inert.", + )) + elif inspected > 0 and not saw_any_activity: + violations.append(violation( + message="UX002 could not read any microflow activities ({} flows inspected, all reported zero). activities_for() is returning nothing -- this rule checked NOTHING.".format(inspected), + location=location(module="_rule", document_type="Microflow", document_name="UX002"), + suggestion="Run `REFRESH CATALOG FULL` and lint again; if it stays empty, this mxcli build does not populate activities_for().", + )) + + return violations diff --git a/tests/lint-rules/fixtures/ux001-a-direct-delete.json b/tests/lint-rules/fixtures/ux001-a-direct-delete.json new file mode 100644 index 00000000..368460b2 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-a-direct-delete.json @@ -0,0 +1,14 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$DeleteClientAction" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-b-confirmed.json b/tests/lint-rules/fixtures/ux001-b-confirmed.json new file mode 100644 index 00000000..56757976 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-b-confirmed.json @@ -0,0 +1,28 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Order_Delete", + "has_confirmation": true + } + ], + "activities": { + "Sales.ACT_Order_Delete": [ + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "DeleteObjectAction", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux001-c-flow-deletes.json b/tests/lint-rules/fixtures/ux001-c-flow-deletes.json new file mode 100644 index 00000000..9f6099ff --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-c-flow-deletes.json @@ -0,0 +1,27 @@ +{ + "widgets": [ + { + "id": "w-btnRemove", + "name": "btnRemove", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Order_Clear" + } + ], + "activities": { + "Sales.ACT_Order_Clear": [ + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "DeleteObjectAction", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux001-d-depth2.json b/tests/lint-rules/fixtures/ux001-d-depth2.json new file mode 100644 index 00000000..762ec52e --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-d-depth2.json @@ -0,0 +1,39 @@ +{ + "widgets": [ + { + "id": "w-btnGo", + "name": "btnGo", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Alpha" + } + ], + "refs_from": { + "Sales.ACT_Alpha": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.ACT_Alpha", + "target_type": "MICROFLOW", + "target_name": "Sales.SUB_Beta", + "ref_kind": "CALL", + "module_name": "Sales" + } + ] + }, + "activities": { + "Sales.SUB_Beta": [ + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "DeleteObjectAction", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux001-e-depth4.json b/tests/lint-rules/fixtures/ux001-e-depth4.json new file mode 100644 index 00000000..9f5ce697 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-e-depth4.json @@ -0,0 +1,59 @@ +{ + "widgets": [ + { + "id": "w-btnGo", + "name": "btnGo", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Alpha" + } + ], + "refs_from": { + "Sales.ACT_Alpha": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.ACT_Alpha", + "target_type": "MICROFLOW", + "target_name": "Sales.SUB_Beta", + "ref_kind": "CALL", + "module_name": "Sales" + } + ], + "Sales.SUB_Beta": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.SUB_Beta", + "target_type": "MICROFLOW", + "target_name": "Sales.SUB_Gamma", + "ref_kind": "CALL", + "module_name": "Sales" + } + ], + "Sales.SUB_Gamma": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.SUB_Gamma", + "target_type": "MICROFLOW", + "target_name": "Sales.SUB_Delta", + "ref_kind": "CALL", + "module_name": "Sales" + } + ] + }, + "activities": { + "Sales.SUB_Delta": [ + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "DeleteObjectAction", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux001-f-name-irreversible.json b/tests/lint-rules/fixtures/ux001-f-name-irreversible.json new file mode 100644 index 00000000..6929decf --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-f-name-irreversible.json @@ -0,0 +1,15 @@ +{ + "widgets": [ + { + "id": "w-btnApprove", + "name": "btnApprove", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Order_Approve" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-g-name-camelcase.json b/tests/lint-rules/fixtures/ux001-g-name-camelcase.json new file mode 100644 index 00000000..178cee69 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-g-name-camelcase.json @@ -0,0 +1,15 @@ +{ + "widgets": [ + { + "id": "w-btnShow", + "name": "btnShow", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_ShowApprovedOrders" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-h-nanoflow.json b/tests/lint-rules/fixtures/ux001-h-nanoflow.json new file mode 100644 index 00000000..4744b252 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-h-nanoflow.json @@ -0,0 +1,15 @@ +{ + "widgets": [ + { + "id": "w-btnReject", + "name": "btnReject", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$CallNanoflowClientAction", + "nanoflow_ref": "Sales.ACT_Reject" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-i-system-module.json b/tests/lint-rules/fixtures/ux001-i-system-module.json new file mode 100644 index 00000000..deeefd36 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-i-system-module.json @@ -0,0 +1,25 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-System.Some_Page", + "container_qualified_name": "System.Some_Page", + "container_type": "PAGE", + "module_name": "System", + "action_type": "Forms$DeleteClientAction" + }, + { + "id": "w-btnOpen", + "name": "btnOpen", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$ShowPageClientAction", + "page_ref": "Sales.Order_Edit" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-i2-system-only.json b/tests/lint-rules/fixtures/ux001-i2-system-only.json new file mode 100644 index 00000000..c425fefa --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-i2-system-only.json @@ -0,0 +1,14 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-System.Some_Page", + "container_qualified_name": "System.Some_Page", + "container_type": "PAGE", + "module_name": "System", + "action_type": "Forms$DeleteClientAction" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-j-no-flow-actions.json b/tests/lint-rules/fixtures/ux001-j-no-flow-actions.json new file mode 100644 index 00000000..6319afd6 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-j-no-flow-actions.json @@ -0,0 +1,25 @@ +{ + "widgets": [ + { + "id": "w-btnOpen", + "name": "btnOpen", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$ShowPageClientAction", + "page_ref": "Sales.Order_Edit" + }, + { + "id": "w-lblPlain", + "name": "lblPlain", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-k-snippet.json b/tests/lint-rules/fixtures/ux001-k-snippet.json new file mode 100644 index 00000000..79a7040a --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-k-snippet.json @@ -0,0 +1,14 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.SNIPPET_Actions", + "container_qualified_name": "Sales.SNIPPET_Actions", + "container_type": "SNIPPET", + "module_name": "Sales", + "action_type": "Forms$DeleteClientAction" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-l-cycle.json b/tests/lint-rules/fixtures/ux001-l-cycle.json new file mode 100644 index 00000000..bcd16f5f --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-l-cycle.json @@ -0,0 +1,37 @@ +{ + "widgets": [ + { + "id": "w-btnGo", + "name": "btnGo", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Alpha" + } + ], + "refs_from": { + "Sales.ACT_Alpha": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.ACT_Alpha", + "target_type": "MICROFLOW", + "target_name": "Sales.SUB_Beta", + "ref_kind": "CALL", + "module_name": "Sales" + } + ], + "Sales.SUB_Beta": [ + { + "source_type": "MICROFLOW", + "source_name": "Sales.SUB_Beta", + "target_type": "MICROFLOW", + "target_name": "Sales.ACT_Alpha", + "ref_kind": "CALL", + "module_name": "Sales" + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux001-m-strip.json b/tests/lint-rules/fixtures/ux001-m-strip.json new file mode 100644 index 00000000..03b3df78 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-m-strip.json @@ -0,0 +1,24 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$DeleteClientAction" + }, + { + "id": "w-btnOpen", + "name": "btnOpen", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$ShowPageClientAction" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-n-empty.json b/tests/lint-rules/fixtures/ux001-n-empty.json new file mode 100644 index 00000000..88fa1f57 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-n-empty.json @@ -0,0 +1,3 @@ +{ + "widgets": [] +} diff --git a/tests/lint-rules/fixtures/ux001-o-no-action-types.json b/tests/lint-rules/fixtures/ux001-o-no-action-types.json new file mode 100644 index 00000000..63ce6192 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-o-no-action-types.json @@ -0,0 +1,24 @@ +{ + "widgets": [ + { + "id": "w-a", + "name": "a", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "" + }, + { + "id": "w-b", + "name": "b", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Order_Overview", + "container_qualified_name": "Sales.Order_Overview", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "" + } + ] +} diff --git a/tests/lint-rules/fixtures/ux001-p-locations.json b/tests/lint-rules/fixtures/ux001-p-locations.json new file mode 100644 index 00000000..ae5e0790 --- /dev/null +++ b/tests/lint-rules/fixtures/ux001-p-locations.json @@ -0,0 +1,48 @@ +{ + "widgets": [ + { + "id": "w-btnDelete", + "name": "btnDelete", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Orders.Order_Overview", + "container_qualified_name": "Orders.Order_Overview", + "container_type": "PAGE", + "module_name": "Orders", + "action_type": "Forms$DeleteClientAction" + }, + { + "id": "w-btnRemove", + "name": "btnRemove", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Billing.Invoice_Edit", + "container_qualified_name": "Billing.Invoice_Edit", + "container_type": "PAGE", + "module_name": "Billing", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Billing.ACT_Invoice_Clear" + }, + { + "id": "w-btnApprove", + "name": "btnApprove", + "widget_type": "ACTIONBUTTON", + "container_id": "c-Sales.Quote_View", + "container_qualified_name": "Sales.Quote_View", + "container_type": "PAGE", + "module_name": "Sales", + "action_type": "Forms$MicroflowAction", + "microflow_ref": "Sales.ACT_Quote_Approve" + } + ], + "activities": { + "Billing.ACT_Invoice_Clear": [ + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "DeleteObjectAction", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-a-two-bare.json b/tests/lint-rules/fixtures/ux002-a-two-bare.json new file mode 100644 index 00000000..a059c204 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-a-two-bare.json @@ -0,0 +1,32 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Sales.ACT_Check", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-b-captioned.json b/tests/lint-rules/fixtures/ux002-b-captioned.json new file mode 100644 index 00000000..a85b7df3 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-b-captioned.json @@ -0,0 +1,25 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Sales.ACT_Check", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": "Already approved?", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": false, + "condition_expression": "$Order/Status = 'Approved'" + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-c-blank-caption.json b/tests/lint-rules/fixtures/ux002-c-blank-caption.json new file mode 100644 index 00000000..be8fabf6 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-c-blank-caption.json @@ -0,0 +1,24 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Sales.ACT_Check", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": " ", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-d-eight.json b/tests/lint-rules/fixtures/ux002-d-eight.json new file mode 100644 index 00000000..c90adb95 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-d-eight.json @@ -0,0 +1,80 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-e-seven.json b/tests/lint-rules/fixtures/ux002-e-seven.json new file mode 100644 index 00000000..ac7129b4 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-e-seven.json @@ -0,0 +1,72 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-f-eight-annotated.json b/tests/lint-rules/fixtures/ux002-f-eight-annotated.json new file mode 100644 index 00000000..82a8580b --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-f-eight-annotated.json @@ -0,0 +1,80 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "Annotation", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-g-eight-described.json b/tests/lint-rules/fixtures/ux002-g-eight-described.json new file mode 100644 index 00000000..d40c381d --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-g-eight-described.json @@ -0,0 +1,80 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "Rejects the order." + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-h-rule.json b/tests/lint-rules/fixtures/ux002-h-rule.json new file mode 100644 index 00000000..44e168be --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-h-rule.json @@ -0,0 +1,24 @@ +{ + "microflows": [ + { + "id": "m-RULE_IsValid", + "name": "RULE_IsValid", + "qualified_name": "Sales.RULE_IsValid", + "module_name": "Sales", + "microflow_type": "RULE", + "description": "" + } + ], + "activities": { + "Sales.RULE_IsValid": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-i-administration.json b/tests/lint-rules/fixtures/ux002-i-administration.json new file mode 100644 index 00000000..7814e22a --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-i-administration.json @@ -0,0 +1,24 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Administration.ACT_Check", + "module_name": "Administration", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Administration.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-j-both-legs.json b/tests/lint-rules/fixtures/ux002-j-both-legs.json new file mode 100644 index 00000000..19dfdfb5 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-j-both-legs.json @@ -0,0 +1,88 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + }, + { + "id": "a8", + "name": "a8", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-k-strip.json b/tests/lint-rules/fixtures/ux002-k-strip.json new file mode 100644 index 00000000..e86087d2 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-k-strip.json @@ -0,0 +1,24 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Sales.ACT_Check", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": true + } + ] + } +} diff --git a/tests/lint-rules/fixtures/ux002-l-no-activities.json b/tests/lint-rules/fixtures/ux002-l-no-activities.json new file mode 100644 index 00000000..62ade62b --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-l-no-activities.json @@ -0,0 +1,29 @@ +{ + "microflows": [ + { + "id": "m-ACT_One", + "name": "ACT_One", + "qualified_name": "Sales.ACT_One", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + }, + { + "id": "m-ACT_Two", + "name": "ACT_Two", + "qualified_name": "Sales.ACT_Two", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + }, + { + "id": "m-ACT_Three", + "name": "ACT_Three", + "qualified_name": "Sales.ACT_Three", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": {} +} diff --git a/tests/lint-rules/fixtures/ux002-m-no-microflows.json b/tests/lint-rules/fixtures/ux002-m-no-microflows.json new file mode 100644 index 00000000..1e64c2ad --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-m-no-microflows.json @@ -0,0 +1,4 @@ +{ + "microflows": [], + "activities": {} +} diff --git a/tests/lint-rules/fixtures/ux002-n-events-counted.json b/tests/lint-rules/fixtures/ux002-n-events-counted.json new file mode 100644 index 00000000..2b593549 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-n-events-counted.json @@ -0,0 +1,96 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "StartEvent", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a8", + "name": "a8", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a9", + "name": "a9", + "caption": "Step", + "activity_type": "EndEvent", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} \ No newline at end of file diff --git a/tests/lint-rules/fixtures/ux002-o-events-not-counted.json b/tests/lint-rules/fixtures/ux002-o-events-not-counted.json new file mode 100644 index 00000000..16ba945d --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-o-events-not-counted.json @@ -0,0 +1,88 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "StartEvent", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a4", + "name": "a4", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a5", + "name": "a5", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a6", + "name": "a6", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a7", + "name": "a7", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a8", + "name": "a8", + "caption": "Step", + "activity_type": "EndEvent", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} \ No newline at end of file diff --git a/tests/lint-rules/fixtures/ux002-p-strip-no-decision.json b/tests/lint-rules/fixtures/ux002-p-strip-no-decision.json new file mode 100644 index 00000000..4c8f5a78 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-p-strip-no-decision.json @@ -0,0 +1,48 @@ +{ + "microflows": [ + { + "id": "m-ACT_Big", + "name": "ACT_Big", + "qualified_name": "Sales.ACT_Big", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Big": [ + { + "id": "a0", + "name": "a0", + "caption": "Step", + "activity_type": "StartEvent", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a1", + "name": "a1", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a2", + "name": "a2", + "caption": "Step", + "activity_type": "ActionActivity", + "action_type": "", + "auto_generate_caption": false + }, + { + "id": "a3", + "name": "a3", + "caption": "Step", + "activity_type": "EndEvent", + "action_type": "", + "auto_generate_caption": false + } + ] + } +} \ No newline at end of file diff --git a/tests/lint-rules/fixtures/ux002-q-caption-is-condition.json b/tests/lint-rules/fixtures/ux002-q-caption-is-condition.json new file mode 100644 index 00000000..12feaa71 --- /dev/null +++ b/tests/lint-rules/fixtures/ux002-q-caption-is-condition.json @@ -0,0 +1,34 @@ +{ + "microflows": [ + { + "id": "m-ACT_Check", + "name": "ACT_Check", + "qualified_name": "Sales.ACT_Check", + "module_name": "Sales", + "microflow_type": "MICROFLOW", + "description": "" + } + ], + "activities": { + "Sales.ACT_Check": [ + { + "id": "a0", + "name": "a0", + "caption": "$Order/Status = 'Approved'", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": false, + "condition_expression": "$Order/Status = 'Approved'" + }, + { + "id": "a1", + "name": "a1", + "caption": "Already approved?", + "activity_type": "ExclusiveSplit", + "action_type": "", + "auto_generate_caption": false, + "condition_expression": "$Order/Status = 'Approved'" + } + ] + } +} diff --git a/tests/lint-rules/gen-perf-fixture.py b/tests/lint-rules/gen-perf-fixture.py new file mode 100755 index 00000000..888678ca --- /dev/null +++ b/tests/lint-rules/gen-perf-fixture.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""gen-perf-fixture.py -- write a synthetic fixture for timing the UX rules. + +usage: gen-perf-fixture.py OUT.json [--flows N=3000] [--widgets M=20000] [--seed S=1] + +N microflows with 10-14 activities each (2 are ExclusiveSplit), a refs_from chain of +depth 2 for every third flow, and M widgets, 10% of them microflow buttons without +confirmation pointing at random flows. Stdlib only. Synthetic, never captured output. +""" +import argparse, json, random + +ap = argparse.ArgumentParser() +ap.add_argument("out") +ap.add_argument("--flows", type=int, default=3000) +ap.add_argument("--widgets", type=int, default=20000) +ap.add_argument("--seed", type=int, default=1) +a = ap.parse_args() +rnd = random.Random(a.seed) +MODS = ["Sales", "Orders", "Billing", "Stock", "Support"] + +def q(i): + return "%s.ACT_Flow%05d" % (MODS[i % len(MODS)], i) + +flows, acts, refs = [], {}, {} +for i in range(a.flows): + n = rnd.randint(10, 14) + qn = q(i) + flows.append(dict(id="m%d" % i, name=qn.split(".")[1], qualified_name=qn, + module_name=MODS[i % len(MODS)], microflow_type="MICROFLOW", + description="", activity_count=n)) + items = [] + for k in range(n): + if k in (2, 5): + items.append(dict(id="a%d_%d" % (i, k), name="d%d" % k, caption="Decision", + activity_type="ExclusiveSplit", auto_generate_caption=True)) + else: + items.append(dict(id="a%d_%d" % (i, k), name="s%d" % k, caption="Step", + activity_type="ActionActivity", + action_type="DeleteObjectAction" if rnd.random() < 0.02 else "CommitObjectsAction" if rnd.random() < 0.2 else "")) + acts[qn] = items + if i % 3 == 0 and i + 2 < a.flows: + def r(s, t): + return dict(source_type="MICROFLOW", source_name=s, target_type="MICROFLOW", + target_name=t, ref_kind="CALL", module_name=s.split(".")[0]) + refs[qn] = [r(qn, q(i + 1))] + refs[q(i + 1)] = [r(q(i + 1), q(i + 2))] + +widgets = [] +for j in range(a.widgets): + mod = MODS[j % len(MODS)] + cont = "%s.Page%04d" % (mod, j // 20) + d = dict(id="w%d" % j, name="w%d" % j, widget_type="ACTIONBUTTON", container_id="p%d" % (j // 20), + container_qualified_name=cont, container_type="PAGE", module_name=mod) + if j % 10 == 0: + d.update(action_type="Forms$MicroflowAction", has_confirmation=False, + microflow_ref=q(rnd.randrange(a.flows))) + elif j % 10 < 6: + d.update(action_type="Forms$ShowPageClientAction", page_ref="Sales.Order_Edit") + else: + d.update(action_type="") + widgets.append(d) + +with open(a.out, "w") as f: + json.dump(dict(widgets=widgets, microflows=flows, activities=acts, refs_from=refs), f) +print("wrote %s: %d flows, %d widgets" % (a.out, a.flows, a.widgets)) diff --git a/tests/lint-rules/starrun/go.mod b/tests/lint-rules/starrun/go.mod new file mode 100644 index 00000000..11a69110 --- /dev/null +++ b/tests/lint-rules/starrun/go.mod @@ -0,0 +1,7 @@ +module mxtk/starrun + +go 1.22 + +require go.starlark.net v0.0.0-20260102030733-3fee463870c9 + +require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect diff --git a/tests/lint-rules/starrun/go.sum b/tests/lint-rules/starrun/go.sum new file mode 100644 index 00000000..d92a3136 --- /dev/null +++ b/tests/lint-rules/starrun/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk= +go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/tests/lint-rules/starrun/main.go b/tests/lint-rules/starrun/main.go new file mode 100644 index 00000000..1ae7ac88 --- /dev/null +++ b/tests/lint-rules/starrun/main.go @@ -0,0 +1,275 @@ +// starrun — run one toolkit Starlark lint rule under the real Go Starlark interpreter +// (go.starlark.net, the engine mxcli embeds) with the mxcli builtins the rule needs +// served from a JSON fixture instead of a live catalog. +// +// Why: lint-rules/*.star have no test path that does not need an mxcli binary and a +// real .mpr. This harness proves a rule's LOGIC and its blindness self-checks against +// fixed input, using the same struct type (starlarkstruct) and the same regexp engine +// (Go RE2 via `matches`) the real linter uses, so a wrong attribute name or a +// Python-only idiom fails here the way it fails in `mxcli lint`. +// +// What it does NOT prove: the vocabulary of a real catalog (action type strings, +// casing). That is the field calibration, still owed per lint-rules/README.md. +// +// Usage: +// go run ./tests/lint-rules/starrun -rule lint-rules/x.star -fixture f.json [-strip-fields a,b] +// Prints a JSON array of {module,document_type,document_name,message,suggestion}. +// Exit 0 on success, 1 on a load/eval error (message on stderr). +// +// Fixture schema (every key optional, missing struct fields default to ""/false/0): +// {"widgets":[{...}], "microflows":[{...}], "pages":[{...}], +// "activities":{"Mod.Flow":[{...}]}, "refs_from":{"Mod.Flow":[{...}]}} +// Field names are the mxcli Starlark projection names (mdl/linter/starlark.go): +// widget: id name widget_type container_id container_qualified_name container_type +// module_name entity_ref attribute_ref microflow_ref nanoflow_ref page_ref +// parent_widget_id depth class_name style dynamic_classes action_type has_confirmation +// microflow: id name qualified_name module_name folder microflow_type description +// return_type parameter_count activity_count total_activity_count complexity +// activity: id name caption activity_type action_type microflow_id +// microflow_qualified_name module_name entity_ref service_ref action_ref +// use_request_timeout timeout_expression auto_generate_caption description +// parent_loop_id loop_depth condition_expression condition_rule error_handling_type +// log_level log_node_expression log_message commit_type with_events retrieve_source queue_ref +// reference: source_type source_id source_name target_type target_id target_name ref_kind module_name +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "regexp" + "strings" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +type fixture struct { + Widgets []map[string]any `json:"widgets"` + Microflows []map[string]any `json:"microflows"` + Pages []map[string]any `json:"pages"` + Activities map[string][]map[string]any `json:"activities"` + RefsFrom map[string][]map[string]any `json:"refs_from"` +} + +// Projection field lists with their zero values. Order does not matter to Starlark. +var widgetFields = fields{ + s: []string{"id", "name", "widget_type", "container_id", "container_qualified_name", "container_type", + "module_name", "entity_ref", "attribute_ref", "microflow_ref", "nanoflow_ref", "page_ref", + "parent_widget_id", "class_name", "style", "dynamic_classes", "action_type"}, + b: []string{"has_confirmation"}, + i: []string{"depth"}, +} +var microflowFields = fields{ + s: []string{"id", "name", "qualified_name", "module_name", "folder", "microflow_type", "description", + "return_type", "document_noun", "document_noun_title"}, + i: []string{"parameter_count", "activity_count", "total_activity_count", "complexity"}, +} +var pageFields = fields{ + s: []string{"id", "name", "qualified_name", "module_name", "folder", "title", "url", "description"}, + i: []string{"widget_count"}, +} +var activityFields = fields{ + s: []string{"id", "name", "caption", "activity_type", "action_type", "microflow_id", + "microflow_qualified_name", "module_name", "entity_ref", "service_ref", "action_ref", + "timeout_expression", "description", "parent_loop_id", "condition_expression", "condition_rule", + "error_handling_type", "log_level", "log_node_expression", "log_message", "commit_type", + "retrieve_source", "queue_ref"}, + b: []string{"use_request_timeout", "auto_generate_caption", "with_events"}, + i: []string{"loop_depth"}, +} +var refFields = fields{ + s: []string{"source_type", "source_id", "source_name", "target_type", "target_id", "target_name", + "ref_kind", "module_name"}, +} + +type fields struct{ s, b, i []string } + +var strip = map[string]bool{} + +func toStruct(kind string, f fields, m map[string]any) starlark.Value { + d := starlark.StringDict{} + for _, k := range f.s { + if strip[k] { + continue + } + v, _ := m[k].(string) + d[k] = starlark.String(v) + } + for _, k := range f.b { + if strip[k] { + continue + } + v, _ := m[k].(bool) + d[k] = starlark.Bool(v) + } + for _, k := range f.i { + if strip[k] { + continue + } + v, _ := m[k].(float64) + d[k] = starlark.MakeInt(int(v)) + } + for k := range m { + if _, known := d[k]; !known && !strip[k] { + fmt.Fprintf(os.Stderr, "starrun: fixture %s has unknown field %q (not in the mxcli projection)\n", kind, k) + os.Exit(1) + } + } + return starlarkstruct.FromStringDict(starlark.String(kind), d) +} + +func list(kind string, f fields, rows []map[string]any) *starlark.List { + out := make([]starlark.Value, 0, len(rows)) + for _, r := range rows { + out = append(out, toStruct(kind, f, r)) + } + return starlark.NewList(out) +} + +func str(v starlark.Value) string { + if s, ok := starlark.AsString(v); ok { + return s + } + return "" +} + +func main() { + rule := flag.String("rule", "", "path to the .star rule") + fix := flag.String("fixture", "", "path to the fixture JSON") + stripFlag := flag.String("strip-fields", "", "comma-separated struct fields to omit (simulate an older mxcli)") + flag.Parse() + if *rule == "" || *fix == "" { + fmt.Fprintln(os.Stderr, "usage: starrun -rule file.star -fixture file.json [-strip-fields a,b]") + os.Exit(2) + } + for _, f := range strings.Split(*stripFlag, ",") { + if f = strings.TrimSpace(f); f != "" { + strip[f] = true + } + } + raw, err := os.ReadFile(*fix) + if err != nil { + fmt.Fprintln(os.Stderr, "starrun:", err) + os.Exit(1) + } + var fx fixture + if err := json.Unmarshal(raw, &fx); err != nil { + fmt.Fprintln(os.Stderr, "starrun: fixture:", err) + os.Exit(1) + } + + oneStr := func(name string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (string, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (string, error) { + var s string + err := starlark.UnpackArgs(name, args, kwargs, "qualified_name", &s) + return s, err + } + } + noArgs := func(name string, v starlark.Value) *starlark.Builtin { + return starlark.NewBuiltin(name, func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if len(args) != 0 || len(kwargs) != 0 { + return nil, fmt.Errorf("%s() takes no arguments", name) + } + return v, nil + }) + } + + predeclared := starlark.StringDict{ + "struct": starlark.NewBuiltin("struct", starlarkstruct.Make), + "widgets": noArgs("widgets", list("widget", widgetFields, fx.Widgets)), + "microflows": noArgs("microflows", list("microflow", microflowFields, fx.Microflows)), + "pages": noArgs("pages", list("page", pageFields, fx.Pages)), + "activities_for": starlark.NewBuiltin("activities_for", func(t *starlark.Thread, b *starlark.Builtin, a starlark.Tuple, k []starlark.Tuple) (starlark.Value, error) { + q, err := oneStr("activities_for")(t, b, a, k) + if err != nil { + return nil, err + } + return list("activity", activityFields, fx.Activities[q]), nil + }), + "refs_from": starlark.NewBuiltin("refs_from", func(t *starlark.Thread, b *starlark.Builtin, a starlark.Tuple, k []starlark.Tuple) (starlark.Value, error) { + q, err := oneStr("refs_from")(t, b, a, k) + if err != nil { + return nil, err + } + return list("reference", refFields, fx.RefsFrom[q]), nil + }), + // Same signature and engine as mxcli's builtinMatches: Go regexp (RE2). + "matches": starlark.NewBuiltin("matches", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var s, pattern string + if err := starlark.UnpackArgs("matches", args, kwargs, "s", &s, "pattern", &pattern); err != nil { + return nil, err + } + ok, err := regexp.MatchString(pattern, s) + if err != nil { + return nil, fmt.Errorf("matches: %v", err) + } + return starlark.Bool(ok), nil + }), + "location": starlark.NewBuiltin("location", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var module, docType, docName, docID starlark.String + if err := starlark.UnpackArgs("location", args, kwargs, "module", &module, "document_type", &docType, "document_name", &docName, "document_id?", &docID); err != nil { + return nil, err + } + return starlarkstruct.FromStringDict(starlark.String("location"), starlark.StringDict{ + "module": module, "document_type": docType, "document_name": docName, "document_id": docID}), nil + }), + "violation": starlark.NewBuiltin("violation", func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var message, suggestion starlark.String + var loc starlark.Value = starlark.None + if err := starlark.UnpackArgs("violation", args, kwargs, "message", &message, "location?", &loc, "suggestion?", &suggestion); err != nil { + return nil, err + } + return starlarkstruct.FromStringDict(starlark.String("violation"), starlark.StringDict{ + "message": message, "location": loc, "suggestion": suggestion}), nil + }), + } + + thread := &starlark.Thread{Name: "starrun"} + globals, err := starlark.ExecFile(thread, *rule, nil, predeclared) + if err != nil { + fmt.Fprintln(os.Stderr, "starrun: load:", err) + os.Exit(1) + } + check, ok := globals["check"] + if !ok { + fmt.Fprintln(os.Stderr, "starrun: rule defines no check()") + os.Exit(1) + } + res, err := starlark.Call(thread, check, nil, nil) + if err != nil { + fmt.Fprintln(os.Stderr, "starrun: check():", err) + os.Exit(1) + } + type out struct { + Module, DocumentType, DocumentName, Message, Suggestion string + } + rows := []map[string]string{} + iter := res.(starlark.Iterable).Iterate() + defer iter.Done() + var v starlark.Value + for iter.Next(&v) { + st := v.(*starlarkstruct.Struct) + row := map[string]string{"module": "", "document_type": "", "document_name": ""} + if m, err := st.Attr("message"); err == nil { + row["message"] = str(m) + } + if s, err := st.Attr("suggestion"); err == nil { + row["suggestion"] = str(s) + } + if l, err := st.Attr("location"); err == nil { + if ls, ok := l.(*starlarkstruct.Struct); ok { + for _, k := range []string{"module", "document_type", "document_name"} { + if x, err := ls.Attr(k); err == nil { + row[k] = str(x) + } + } + } + } + rows = append(rows, row) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(rows) +} diff --git a/tests/lint-rules/test-ux-rules.sh b/tests/lint-rules/test-ux-rules.sh new file mode 100755 index 00000000..00b29bac --- /dev/null +++ b/tests/lint-rules/test-ux-rules.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# test-ux-rules.sh -- unit tests for lint-rules/ux001_confirm_before_destructive.star and +# lint-rules/ux002_decisions_captioned.star, run under the real Go Starlark interpreter with +# the mxcli builtins mocked from JSON (tests/lint-rules/starrun). +# +# IMPORTANT: every fixture in tests/lint-rules/fixtures/ is a HAND-WRITTEN unit fixture that +# encodes the catalog projection AS READ FROM THE MXCLI SOURCE. It is not captured tool +# output. These tests prove the rule logic and the self-checks; they do NOT prove the +# vocabulary (action_type strings, activity_type strings) matches a real model. That is +# what the field probe in each rule's header is for. +# +# Needs `go` on PATH. A missing toolchain is a FAILURE (exit 1), never a quiet skip: a rule +# test that did not run must not read as a rule test that passed. +# +# Keep bash-3.2 compatible. +# Usage: tests/lint-rules/test-ux-rules.sh Exit 0 only if every assertion holds. + +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TK="$(cd "$HERE/../.." && pwd)" +FX="$HERE/fixtures" +R1="$TK/lint-rules/ux001_confirm_before_destructive.star" +R2="$TK/lint-rules/ux002_decisions_captioned.star" + +if ! command -v go >/dev/null 2>&1; then + echo "FAIL go toolchain not found; these tests cannot run (NOT a pass)." + echo " Install Go (https://go.dev/dl/, or: brew install go / apt install golang) and re-run." + exit 1 +fi +# shellcheck source=../../bin/lib/portable.sh +. "$TK/bin/lib/portable.sh" +require_py # sets $PY or exits 2 with an actionable message + +W=$(mktemp -d "${TMPDIR:-/tmp}/uxrules.XXXXXX") +trap 'rm -rf "$W"' EXIT +PASS=0; FAILED=0 +ok(){ printf ' PASS %s\n' "$1"; PASS=$((PASS+1)); } +no(){ printf ' FAIL %s\n' "$1"; FAILED=$((FAILED+1)); } + +cd "$TK" || exit 1 +OUT="$W/out.json"; ERR="$W/err.txt"; RC=0 + +# Build the harness once (its go.mod lives in its own directory, so `go run ./tests/...` +# from the repo root does not resolve). One build also keeps compile time out of the +# perf numbers below. +BIN="$W/starrun" +if ! (cd "$HERE/starrun" && GOFLAGS=-mod=mod go build -o "$BIN" . 2>"$ERR"); then + echo "FAIL could not build tests/lint-rules/starrun: $(head -c 400 "$ERR")"; exit 1 +fi + +# run RULE FIXTURE [STRIP] -> OUT holds stdout, RC the exit code +run(){ + local rule="$1" fix="$2" strip="${3:-}" + if [ -n "$strip" ]; then + "$BIN" -rule "$rule" -fixture "$FX/$fix.json" -strip-fields "$strip" >"$OUT" 2>"$ERR" + else + "$BIN" -rule "$rule" -fixture "$FX/$fix.json" >"$OUT" 2>"$ERR" + fi + RC=$? +} + +# check LABEL PY-EXPR : v is the parsed violation list; the expression must be truthy. +check(){ + local label="$1" expr="$2" + if [ "$RC" -ne 0 ]; then no "$label (harness exit $RC: $(head -c 300 "$ERR"))"; return; fi + if "$PY" - "$OUT" "$expr" <<'PY' +import json, sys +v = json.load(open(sys.argv[1])) +sys.exit(0 if eval(sys.argv[2]) else 1) +PY + then ok "$label"; else no "$label (got: $(head -c 400 "$OUT"))"; fi +} + +echo "== UX001 ==" +run "$R1" ux001-a-direct-delete +check "a direct delete button -> 1 finding 'deletes directly'" 'len(v)==1 and "deletes directly" in v[0]["message"]' +run "$R1" ux001-b-confirmed +check "b confirmed flow button -> 0 findings" 'len(v)==0' +run "$R1" ux001-c-flow-deletes +check "c unconfirmed, flow deletes -> 1 finding 'deletes objects'" 'len(v)==1 and "deletes objects" in v[0]["message"]' +run "$R1" ux001-d-depth2 +check "d delete reached at call depth 2 -> 1 finding" 'len(v)==1' +run "$R1" ux001-e-depth4 +check "e delete only at depth 4 (MAX_CALL_DEPTH=3) -> 0 findings" 'len(v)==0' +run "$R1" ux001-f-name-irreversible +check "f ACT_Order_Approve -> 1 finding 'irreversible'" 'len(v)==1 and "irreversible" in v[0]["message"]' +run "$R1" ux001-g-name-camelcase +check "g ACT_ShowApprovedOrders -> 0 findings" 'len(v)==0' +run "$R1" ux001-h-nanoflow +check "h nanoflow button to ACT_Reject -> 1 finding" 'len(v)==1' +run "$R1" ux001-i-system-module +check "i System-module delete (plus one Sales show-page button) -> 0 findings" 'len(v)==0' +run "$R1" ux001-i2-system-only +check "i2 System-only widgets -> only the 'no widgets' self-check" 'len(v)==1 and v[0]["module"]=="_rule" and "no widgets" in v[0]["message"]' +run "$R1" ux001-j-no-flow-actions +check "j show-page button + action-less widget -> 0 findings" 'len(v)==0' +run "$R1" ux001-k-snippet +check "k button on SNIPPET -> document_type Snippet" 'len(v)==1 and v[0]["document_type"]=="Snippet"' +run "$R1" ux001-l-cycle +check "l call cycle A->B->A terminates, 0 findings" 'len(v)==0' +run "$R1" ux001-m-strip "action_type,has_confirmation" +check "m stripped projection -> 1 _rule finding mentioning 0.25.0" 'len(v)==1 and v[0]["module"]=="_rule" and "0.25.0" in v[0]["message"]' +run "$R1" ux001-n-empty +check "n empty widgets -> 1 _rule finding 'no widgets'" 'len(v)==1 and v[0]["module"]=="_rule" and "no widgets" in v[0]["message"]' +run "$R1" ux001-o-no-action-types +check "o every action_type empty -> 1 _rule finding" 'len(v)==1 and v[0]["module"]=="_rule" and "not one reports an action type" in v[0]["message"]' +run "$R1" ux001-p-locations +check "p findings located at the widget's module and container" ' +len(v)==3 and sorted((x["module"],x["document_name"],x["document_type"]) for x in v)==[ + ("Billing","Billing.Invoice_Edit","Page"),("Orders","Orders.Order_Overview","Page"),("Sales","Sales.Quote_View","Page")]' + +echo "== UX002 ==" +run "$R2" ux002-a-two-bare +check "a two auto-captioned decisions -> 1 finding '2 decision(s)'" 'len(v)==1 and "2 decision(s)" in v[0]["message"]' +run "$R2" ux002-b-captioned +check "b caption 'Already approved?' -> 0 findings" 'len(v)==0' +run "$R2" ux002-c-blank-caption +check "c blank caption -> 1 finding" 'len(v)==1' +run "$R2" ux002-d-eight +check "d 8 activities, no annotation/description -> '8 top-level activities'" 'len(v)==1 and "8 top-level activities" in v[0]["message"]' +run "$R2" ux002-e-seven +check "e 7 activities -> 0 findings" 'len(v)==0' +run "$R2" ux002-f-eight-annotated +check "f 8 activities incl. Annotation -> 0 findings" 'len(v)==0' +run "$R2" ux002-g-eight-described +check "g 8 activities + description -> 0 findings" 'len(v)==0' +run "$R2" ux002-h-rule +check "h RULE with bare decisions -> 0 findings" 'len(v)==0' +run "$R2" ux002-i-administration +check "i Administration flow -> 0 findings" 'len(v)==0' +run "$R2" ux002-j-both-legs +check "j 9 activities + 3 bare decisions -> 2 findings at the flow" ' +len(v)==2 and all(x["document_name"]=="Sales.ACT_Big" and x["document_type"]=="Microflow" for x in v)' +run "$R2" ux002-k-strip "auto_generate_caption" +check "k stripped projection -> 1 _rule finding mentioning 0.25.0" 'len(v)==1 and v[0]["module"]=="_rule" and "0.25.0" in v[0]["message"]' +run "$R2" ux002-l-no-activities +check "l 3 flows, no activities -> 1 _rule finding 'checked NOTHING'" 'len(v)==1 and v[0]["module"]=="_rule" and "checked NOTHING" in v[0]["message"]' +run "$R2" ux002-m-no-microflows +check "m zero microflows -> 0 findings" 'len(v)==0' +run "$R2" ux002-n-events-counted +check "n 8 activities between StartEvent and EndEvent -> 1 finding saying 8, not 10" 'len(v)==1 and "has 8 top-level" in v[0]["message"]' +run "$R2" ux002-o-events-not-counted +check "o 7 activities + start/end events -> 0 findings (events are not activities)" 'len(v)==0' +run "$R2" ux002-p-strip-no-decision auto_generate_caption +check "p stripped projection, flow with no decision -> still 1 _rule finding" 'len(v)==1 and v[0]["module"]=="_rule" and "0.25.0" in v[0]["message"]' +run "$R2" ux002-q-caption-is-condition +check "q caption equal to condition_expression (auto flag false, as on 0.26.0) -> 1 finding '1 decision(s)'" 'len(v)==1 and "1 decision(s)" in v[0]["message"]' + +echo "== performance (synthetic, 3000 flows / 20000 widgets) ==" +PF="$W/perf.json" +"$PY" "$HERE/gen-perf-fixture.py" "$PF" >/dev/null || { no "perf fixture generation"; } +TIMEFORMAT='%R' +for pair in "UX001:$R1" "UX002:$R2"; do + nm="${pair%%:*}"; rule="${pair#*:}" + { time "$BIN" -rule "$rule" -fixture "$PF" >"$W/perf-out.json" 2>"$W/perf-err.txt"; } 2>"$W/time.txt" + rc=$? + secs=$(tail -n 1 "$W/time.txt") + if [ "$rc" -eq 0 ] && awk -v t="$secs" 'BEGIN{exit !(t+0 < 60)}'; then + ok "$nm perf run exit 0 in ${secs}s (limit 60s)" + else + no "$nm perf run rc=$rc in ${secs}s (limit 60s): $(head -c 300 "$W/perf-err.txt")" + fi +done + +echo +echo "passed: $PASS failed: $FAILED" +[ "$FAILED" -eq 0 ] diff --git a/tests/test-lint-delivery.sh b/tests/test-lint-delivery.sh index ec558926..6f1d57a9 100755 --- a/tests/test-lint-delivery.sh +++ b/tests/test-lint-delivery.sh @@ -48,7 +48,13 @@ RECEIPT="$P/.claude/.mxtk-lint-receipt" echo "== 1 fresh install ==" mxtk_install_lint_rules "$W/tk" "$P" 0 "" >/dev/null { [ -f "$A" ] && [ -f "$C" ] && [ -f "$RECEIPT" ]; } && ok "rules + receipt written" || no "install" -[ "$(ls "$R" | wc -l | tr -d ' ')" = 4 ] && ok "NOINSTALL list respected (4 files)" || no "leaked: $(ls "$R")" +# Expected count comes from the manifest, not a literal: every rule in MXTK_LINT_RULES and +# MXTK_LINT_RULES_CONFIGURABLE lands, nothing from MXTK_LINT_RULES_NOINSTALL does. (A literal +# 4 went red the day two toolkit-authored rules joined the managed list, 2026-10-08.) +want=$(set -- $MXTK_LINT_RULES $MXTK_LINT_RULES_CONFIGURABLE; echo $#) +got=$(ls "$R" | wc -l | tr -d ' ') +[ "$got" = "$want" ] && ok "NOINSTALL list respected ($want files)" || no "expected $want files, got $got: $(ls "$R")" +for f in $MXTK_LINT_RULES_NOINSTALL; do [ -e "$R/$f" ] && no "leaked NOINSTALL file: $f"; done echo "== 2 re-run is silent and rewrites nothing ==" before=$(portable_md5 "$A"); out=$(mxtk_install_lint_rules "$W/tk" "$P" 0 "") @@ -104,7 +110,7 @@ echo "== 10 caller forgot the manifest: the lib sources it itself ==" P4="$W/proj4"; mkdir -p "$P4/.claude"; echo x > "$P4/PROJECT.md" bash -c "set -u; . '$W/tk/bin/lib/install-lint-rules.sh'; \ mxtk_install_lint_rules '$W/tk' '$P4' 0 ''" >/dev/null 2>&1 -[ "$(ls "$P4/.claude/lint-rules" 2>/dev/null | wc -l | tr -d ' ')" = 4 ] \ +[ "$(ls "$P4/.claude/lint-rules" 2>/dev/null | wc -l | tr -d ' ')" = 6 ] \ && ok "rules landed anyway" || no "silent no-op" echo "== 11 manifest genuinely unavailable: warn, never pretend success ==" diff --git a/tests/wave2/test-stock-hashes.sh b/tests/wave2/test-stock-hashes.sh index 056410f7..f9f31196 100755 --- a/tests/wave2/test-stock-hashes.sh +++ b/tests/wave2/test-stock-hashes.sh @@ -10,8 +10,10 @@ # projects saw. # # T1 every entry is <32-hex md5> , no duplicate (rule, version) -# T2 every rule named is one the toolkit owns (MXTK_LINT_RULES), and every listed -# version covers all of them — a half-captured version is caught here +# T2 every rule named is a stock one the toolkit repairs (MXTK_LINT_RULES_STOCK, itself a +# subset of MXTK_LINT_RULES), and every listed version covers all of them — a +# half-captured version is caught here. Toolkit-authored rules (ux001, …) are never +# seeded by `mxcli init`, so no version can list them and none is asked to. # T3 the real reader (_mxtk_lint_is_stock) accepts every entry and rejects a foreign hash # T4 live, only with a binary: `mxcli init` into an EMPTY dir, every seeded rule's md5 is # listed. This is the capture procedure in the file's own header, run as a check. @@ -45,16 +47,21 @@ dup=$(entries | awk '{print $1" "$3}' | sort | uniq -d) echo "== T2 coverage ==" [ -n "${MXTK_LINT_RULES:-}" ] && ok "MXTK_LINT_RULES loaded" || no "MXTK_LINT_RULES empty — manifest not loaded" +[ -n "${MXTK_LINT_RULES_STOCK:-}" ] && ok "MXTK_LINT_RULES_STOCK loaded" || no "MXTK_LINT_RULES_STOCK empty — manifest not loaded" +for f in ${MXTK_LINT_RULES_STOCK:-}; do + case " $MXTK_LINT_RULES " in *" $f "*) ok "stock rule is also toolkit-owned: $f" ;; + *) no "$f is in MXTK_LINT_RULES_STOCK but not MXTK_LINT_RULES — sync would never deliver it" ;; esac +done for f in $(entries | awk '{print $1}' | sort -u); do - case " $MXTK_LINT_RULES " in *" $f "*) ok "listed rule is toolkit-owned: $f" ;; - *) no "$f is listed but not in MXTK_LINT_RULES — nothing ever looks it up" ;; esac + case " ${MXTK_LINT_RULES_STOCK:-} " in *" $f "*) ok "listed rule is a stock one the toolkit repairs: $f" ;; + *) no "$f is listed but not in MXTK_LINT_RULES_STOCK — init never seeds it, nothing ever looks it up" ;; esac done for v in $(entries | awk '{print $3}' | sort -u); do missing="" - for f in $MXTK_LINT_RULES; do + for f in ${MXTK_LINT_RULES_STOCK:-}; do entries | awk -v f="$f" -v v="$v" '$1==f && $3==v {x=1} END {exit !x}' || missing="$missing $f" done - [ -z "$missing" ] && ok "$v covers all toolkit-owned rules" || no "$v is missing:$missing" + [ -z "$missing" ] && ok "$v covers all stock rules" || no "$v is missing:$missing" done echo "== T3 the real reader agrees ==" @@ -82,7 +89,7 @@ else W=$(mktemp -d "${TMPDIR:-/tmp}/stockhash.XXXXXX"); trap 'rm -rf "$W"' EXIT ( cd "$W" && "$MX" init . >/dev/null 2>&1 ) seeded=0 - for f in $MXTK_LINT_RULES; do + for f in $MXTK_LINT_RULES_STOCK; do s="$W/.claude/lint-rules/$f" [ -f "$s" ] || continue seeded=$((seeded+1)) @@ -90,8 +97,8 @@ else _mxtk_lint_is_stock "$f" "$h" "$HASHES" && ok "seeded $f is listed" \ || no "seeded $f ($h) is NOT listed — add it with this mxcli's version" done - [ "$seeded" -gt 0 ] && ok "init seeded $seeded toolkit-owned rule(s)" \ - || no "init seeded none of: $MXTK_LINT_RULES — did it run?" + [ "$seeded" -gt 0 ] && ok "init seeded $seeded stock rule(s)" \ + || no "init seeded none of: $MXTK_LINT_RULES_STOCK — did it run?" fi echo ""