diff --git a/.claude/lint-rules/orphaned_elements.star b/.claude/lint-rules/orphaned_elements.star index b9b22f4dbf..bbaf9115af 100644 --- a/.claude/lint-rules/orphaned_elements.star +++ b/.claude/lint-rules/orphaned_elements.star @@ -7,9 +7,12 @@ # - Work in progress that was forgotten # # Checks: -# - Microflows with no callers (except ACT_ which are UI entry points) -# - Pages with no show_page references (except entry pages) -# - Entities with no references at all +# - Microflows, nanoflows and rules nothing references (except entry-point +# prefixes such as ACT_) +# - Pages nothing references (except likely entry pages) +# +# "Referenced" means what `mxcli refs` and CATALOG.GRAPH_DEAD_ASSETS mean: any +# edge in the catalog's reference graph that points at the document. # # This rule requires REFRESH CATALOG FULL to populate the refs table. # @@ -34,13 +37,23 @@ ENTRY_POINT_PREFIXES = ["ACT_", "SCH_", "WS_", "REST_", "OData_"] # Page name patterns that are likely entry points ENTRY_PAGE_PATTERNS = ["Home", "Login", "Index", "Dashboard"] -# Reference kinds that mean "something causes this microflow to run". These are -# catalog RefKind values (mdl/catalog/builder_references.go); a kind missing here -# turns a live document into a false "not called from anywhere" finding. -MICROFLOW_ENTRY_KINDS = ["call", "schedule", "publish", "datasource", "action", "calculate", "settings", "event"] +# There is deliberately no list of the reference kinds that "count". Every edge +# into a microflow or a page is something that runs or opens it — a call, a +# widget, a calculated attribute, a scheduled event, a published service, an +# event handler, a project setting, the navigation — and the catalog keeps +# adding ways. A hand-kept list of them fell behind: it had the page kinds for a +# home page and a menu item but not the microflow ones, so a microflow started +# only from the navigation was reported with "Remove if unused" while +# `mxcli refs` listed the reference (ako/mxcli#1484). With no list, a new kind +# counts the day the catalog emits it. +# +# TestQUAL004CountsEveryRefKind (mdl/linter) drives every kind the catalog +# declares at a microflow, a nanoflow and a page, and fails on one that does not +# count. -# Reference kinds that mean "something opens this page". -PAGE_ENTRY_KINDS = ["show_page", "home_page", "login_page", "menu_item", "action"] +def is_referenced(qualified_name): + """True when anything in the catalog's reference graph points at the document.""" + return len(refs_to(qualified_name)) > 0 def is_entry_point_microflow(name, prefixes): """Check if a microflow name suggests it's a UI/scheduled entry point.""" @@ -70,34 +83,14 @@ def check(): if is_entry_point_microflow(mf.name, entry_prefixes): continue - # Get references to this microflow - refs = refs_to(mf.qualified_name) - # Anything that causes the microflow to run counts as a caller, not just - # a literal "call" edge. A microflow reached only through one of the other - # kinds was reported as orphaned with the suggestion "Remove if unused": - # - # schedule a scheduled event runs it (Mendix's cron) - # datasource a page or widget uses it as a data source - # action a widget button calls it - # calculate a calculated attribute computes with it - # settings a project setting names it (after-startup, before-shutdown, - # health-check) — the RUNTIME calls it, and nothing in the - # model does - # - # The banking-app report hit the 'datasource' case: DS_CurrentCustomer and - # DS_MyAccounts are both page data sources and both were flagged. The - # CapTrackV4 report hit 'settings': the project's own AfterStartupMicroflow - # was reported as "not called from anywhere. Remove if unused", and taking - # that advice left a dangling name mx check did not catch either — only the - # runtime refused to start. - has_callers = False - for ref in refs: - if ref.ref_kind in MICROFLOW_ENTRY_KINDS: - has_callers = True - break - - if not has_callers: + # a literal "call" edge. Each of these once reached a live microflow + # that this rule then advised deleting: a page data source (DS_ microflows in + # the banking-app report), the project's AfterStartupMicroflow (whose + # removal mx check did not catch — the runtime refused to start), a + # scheduled event, a published REST operation, an entity event handler, + # and a home or menu-item microflow (ako/mxcli#1484). + if not is_referenced(mf.qualified_name): loc = location( module=mf.module_name, document_type=mf.document_noun_title, @@ -116,21 +109,10 @@ def check(): if is_entry_point_page(page.name): continue - # Get references to this page - refs = refs_to(page.qualified_name) - # A page is reachable if anything opens it. Navigation counts: a page that # is only a home page, a login page or a menu item is reached by the - # client, not by a microflow. Counting only 'show_page' reported those as - # orphaned — masked until now by ENTRY_PAGE_PATTERNS, which happens to - # cover the pages most likely to be navigation targets. - is_shown = False - for ref in refs: - if ref.ref_kind in PAGE_ENTRY_KINDS: - is_shown = True - break - - if not is_shown: + # client, not by a microflow. + if not is_referenced(page.qualified_name): loc = location( module=page.module_name, document_type="Page", diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-lint-location-repeats-module-name.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-lint-location-repeats-module-name.json new file mode 100644 index 0000000000..9272676c7b --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-lint-location-repeats-module-name.json @@ -0,0 +1 @@ +{"area": "mdl/linter", "date": "2026-10-27", "symptom": "Every QUAL004 finding (and CONV001-010, the SEC rules, most bundled Starlark rules, and missing_translations) prints its location with the module twice: `at M.M.NAV_Home`, `at Sales.Sales.Order.Status`; CONV018's module-level finding prints `at Sales.Sales`", "cause": "`Location.DocumentName` is documented as the document's own name and `QualifiedName()` prefixes the module, but ~30 bundled Starlark rules pass `document_name=.qualified_name`, as does the Go rule missing_translations. CONV018 passes the module as the document of a module-level finding", "file": "`mdl/linter/linter.go` (`Location.QualifiedName`); test `mdl/linter/location_test.go`", "insight": "Fix the one place the name is assembled, not the rules: text, Markdown, SARIF and `lint -d` all go through `QualifiedName()`, and fixing ~30 rule files would leave every project's stale `mxcli init` copies and every user-written rule still doubling. It is safe to strip on prefix because a Mendix document name cannot contain a dot, so a leading `.` is always the module; the controls are a document named like its module (`Sales.Sales` stays) and a module name that is a prefix of the document's (`M` + `MX_Home`). The JSON `document` field still carries whichever spelling the rule passed — `lint -d` already accepts both. Found while reproducing ako/mxcli#1484", "refs": ["ako/mxcli#1484"], "rules": ["QUAL004", "CONV018"]} diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-published-odata-service-microflows-no-refs-edge.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-published-odata-service-microflows-no-refs-edge.json new file mode 100644 index 0000000000..b4c3138ce1 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-published-odata-service-microflows-no-refs-edge.json @@ -0,0 +1 @@ +{"area": "mdl/catalog", "date": "2026-10-27", "symptom": "`mxcli refs ` says \"no references found\" for a microflow that is a published OData resource's `ReadMode: microflow`, and likewise for the service's `Authentication: (microflow X)`; `impact` agrees and QUAL004 says \"Remove if unused\" — 35 of one backend's 43 QUAL004 findings were the read microflows and the authentication microflow of six published services (ako/mxcli#1639)", "cause": "`buildODataServices` wrote only service-level columns and emitted no edge for any microflow the service names: entity-set read/change modes (`CallMicroflow:` from the reader), published OData actions (`svc.Microflows`), `AuthMicroflow`. Same shape on three siblings the reader already populates: a published REST service's `AuthenticationMicroflow`, a business event service operation's `Microflow` (stored in business_events_data and dropped), a consumed OData service's configuration / headers / error-handling microflows", "file": "`mdl/catalog/builder_service_refs.go` (new: `serviceMicroflowRef`, `addServiceMicroflowRefs`, `publishedODataMicroflows`, `extractServiceMicroflowRefs`), collection in `builder_modules.go` (`buildODataServices`, `buildODataClients`, `buildBusinessEventServices`) and `builder_rest.go`, emission in `builder_references.go`, `RefObject*Service` source types, `CatalogSchemaVersion` 28->29; tests `mdl/catalog/builder_service_refs_test.go`", "insight": "Fifth instance of the class after widget actions, scheduled events, settings, published REST operations and entity events — **audit the whole document family when one pointer is missing**: the reported two (ReadMode, Authentication) sat beside the change modes, the OData actions and three sibling services with the identical gap, found by grepping modelsdk/gen for `*Microflow*QualifiedName()` and checking each against the builders. **Reuse a kind whose consumers already treat it as an entry point** rather than minting one: `publish` (a published service runs it), `event` (an event handler — entity or business event; detail stays in CATALOG.BUSINESS_EVENTS), `call` (the consumed service calls its configuration microflow, as a workflow calls its handlers). A new kind would have needed `callerRefKinds`, `graphRefKinds`, the skill's ref_kind row and the docs; reusing one needed none. The source is the service with its catalog id in SourceId, which joins to the service's own table and to `objects` (the RefObject values equal the objects ObjectType values). Bumped CatalogSchemaVersion: no column changed, but a cached catalog would keep the old edge set until the .mpr changed. Controls: stubbing the collection hooks empties every edge test and GRAPH_DEAD_ASSETS lists Read_Rows again. Real 11.13 app, same model, baseline vs fixed binary: refs for the read, authentication and action microflows, the REST authentication microflow and a business event handler go from \"no references\" to one row each; QUAL004 drops them, an unreferenced microflow is still reported. Studio Pro-authored ako/TestApp: its OData actions (GetOrder, GetCustomerOrders, SaveOrder) gain publish edges; no Studio Pro document with a read microflow or authentication microflow was available, so those two read paths are verified on mxcli-written documents (keys match generated/metamodel). Not fixed, reported: published SOAP web service operations and header-auth microflow, import/export mapping microflows, ProjectSecurity.SignInMicroflow, Microflow.ConcurrencyErrorMicroflow, the navigation not-found page, document-template data sources, app services", "refs": ["ako/mxcli#1639", "ako/mxcli#1484"], "rules": ["QUAL004"]} diff --git a/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-qual004-navigation-microflow-reported-unused.json b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-qual004-navigation-microflow-reported-unused.json new file mode 100644 index 0000000000..a2a769bdf5 --- /dev/null +++ b/.claude/skills/fix-issue/findings/mdl-other/2026-10-27-qual004-navigation-microflow-reported-unused.json @@ -0,0 +1 @@ +{"area": "mdl/linter", "date": "2026-10-27", "symptom": "QUAL004 reports a microflow started only from the navigation — a profile's home microflow, or a menu item's `call microflow` — as \"is not called from anywhere. Remove if unused\", while `mxcli refs` lists its `home_page` / `menu_item` edge (ako/mxcli#1484, upstream mendixlabs/mxcli#1579; the 4 NAV_ findings left in ako/mxcli#1639's Formula1Frontend)", "cause": "The rule decided \"has a caller\" from its own copy of the reference vocabulary, MICROFLOW_ENTRY_KINDS. The page list (PAGE_ENTRY_KINDS) had `home_page` and `menu_item`; the microflow list did not, though the catalog emits both kinds into a MICROFLOW too (a home microflow, a menu item that calls one)", "file": "`.claude/lint-rules/orphaned_elements.star` (`is_referenced`), `mdl/catalog/builder_references.go` (`RefKinds`); tests `mdl/linter/starlark_qual004_refs_test.go`, `mdl/catalog/lint_rule_doc_vocabulary_test.go` (`RefKind` row of `TestEveryObjectTypeConstantIsInAPublishedList`)", "insight": "This was the fourth time the same list fell behind (datasource/action/calculate, schedule, settings/publish, event, now the navigation pair) — **a consumer that keeps a copy of an allowlist of edge kinds will fall behind the producer again**, and the upstream one-line fix (add the two kinds) would have reset the clock. The rule now counts any edge into the document, i.e. the graph `refs` and GRAPH_DEAD_ASSETS already read; no kind targeting a microflow or page means anything but \"this runs/opens it\", so no exclusion list was needed. Drift is guarded behaviourally rather than by a second list: `catalog.RefKinds` is held complete against the RefKind constants (AST scan), and TestQUAL004CountsEveryRefKind drives every one of them at a microflow, a nanoflow and a page through the real rule, with an unreferenced microflow as the per-kind control — so a new kind is tested the moment it is declared, and a reintroduced allowlist fails by kind name. Verified on a real 11.13 app with the BASELINE binary and only the rule swapped: NAV_Home/NAV_Orders drop out, an unreferenced microflow and page are still reported. The OData microflows in the same report are a catalog gap, not a rule gap: `refs` was empty for them too (ako/mxcli#1639)", "refs": ["ako/mxcli#1484", "mendixlabs/mxcli#1579", "ako/mxcli#1639"], "rules": ["QUAL004"]} diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index a32c87198a..e695d17ed6 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -561,13 +561,13 @@ Returned by `permissions()` (all types) or `permissions_for()` (entity-specific) ### reference | Property | Type | Example | |----------|------|---------| -| `source_type` | string | The document the edge comes FROM, upper-case: `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"SNIPPET"`, `"ENTITY"`, `"ASSOCIATION"`, `"WORKFLOW"`, `"NAVIGATION"`, `"SCHEDULED_EVENT"`, `"PUBLISHED_REST_OPERATION"`, `"PROJECT_SETTINGS"`, `"IMPORT_MAPPING"`, `"EXPORT_MAPPING"` | +| `source_type` | string | The document the edge comes FROM, upper-case: `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"SNIPPET"`, `"ENTITY"`, `"ASSOCIATION"`, `"WORKFLOW"`, `"NAVIGATION"`, `"SCHEDULED_EVENT"`, `"PUBLISHED_REST_OPERATION"`, `"PUBLISHED_REST_SERVICE"`, `"PUBLISHED_ODATA_SERVICE"`, `"CONSUMED_ODATA_SERVICE"`, `"BUSINESS_EVENT_SERVICE"`, `"PROJECT_SETTINGS"`, `"IMPORT_MAPPING"`, `"EXPORT_MAPPING"` | | `source_id` | string | Source UUID | | `source_name` | string | `"Sales.ACT_Customer_Create"` | -| `target_type` | string | What it points AT, upper-case: `"ENTITY"`, `"ASSOCIATION"`, `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"LAYOUT"`, `"WORKFLOW"`, `"WIDGET"`, `"JAVA_ACTION"`, `"JAVASCRIPT_ACTION"`, `"REST_OPERATION"`, `"REGULAR_EXPRESSION"`, `"ATTRIBUTE"`, `"ENUMERATION"`, `"ENUMERATION_VALUE"`. `LAYOUT`, `WIDGET`, `ATTRIBUTE`, `ENUMERATION` and `ENUMERATION_VALUE` are only ever targets; `SCHEDULED_EVENT` and `PROJECT_SETTINGS` only ever sources | +| `target_type` | string | What it points AT, upper-case: `"ENTITY"`, `"ASSOCIATION"`, `"MICROFLOW"`, `"NANOFLOW"`, `"RULE"`, `"PAGE"`, `"LAYOUT"`, `"WORKFLOW"`, `"WIDGET"`, `"JAVA_ACTION"`, `"JAVASCRIPT_ACTION"`, `"REST_OPERATION"`, `"REGULAR_EXPRESSION"`, `"ATTRIBUTE"`, `"ENUMERATION"`, `"ENUMERATION_VALUE"`. `LAYOUT`, `WIDGET`, `ATTRIBUTE`, `ENUMERATION` and `ENUMERATION_VALUE` are only ever targets; `SCHEDULED_EVENT`, `PROJECT_SETTINGS` and the service types only ever sources | | `target_id` | string | Target UUID | | `target_name` | string | `"Sales.Customer"`; three-part for an attribute or an enumeration value: `"Sales.Order.Total"`, `"Sales.OrderStatus.Open"` | -| `ref_kind` | string | How it references: `"call"`, `"create"`, `"retrieve"`, `"change"`, `"delete"`, `"commit"` (a commit action, or a create/change that commits — beside its `"create"`/`"change"` edge; a commit of a variable whose entity the flow cannot tell has no edge), `"show_page"`, `"datasource"`, `"action"`, `"layout"`, `"parameter"`, `"return"`, `"generalize"`, `"associate"`, `"home_page"`, `"login_page"`, `"menu_item"`, `"calculate"`, `"schedule"`, `"validate"`, `"settings"`, `"widget"`, `"sync"`, `"publish"`, `"event"`, `"member"` (binds/reads/writes an attribute or navigates an association), `"xpath"` (an XPath constraint names it), `"type"` (typed as an enumeration), `"value"` (an expression names an enumeration value), `"mapping"` (an import/export mapping maps the entity) — lower-case, unlike the types above. Attribute names used only through a variable in a free-text expression (`$Order/Total`) have no edge | +| `ref_kind` | string | How it references: `"call"`, `"create"`, `"retrieve"`, `"change"`, `"delete"`, `"commit"` (a commit action, or a create/change that commits — beside its `"create"`/`"change"` edge; a commit of a variable whose entity the flow cannot tell has no edge), `"show_page"`, `"datasource"`, `"action"`, `"layout"`, `"parameter"`, `"return"`, `"generalize"`, `"associate"`, `"home_page"`, `"login_page"`, `"menu_item"`, `"calculate"`, `"schedule"`, `"validate"`, `"settings"`, `"widget"`, `"sync"`, `"publish"` (a published REST operation, OData resource or action, or a service's authentication microflow), `"event"` (an entity event or a business event handler), `"member"` (binds/reads/writes an attribute or navigates an association), `"xpath"` (an XPath constraint names it), `"type"` (typed as an enumeration), `"value"` (an expression names an enumeration value), `"mapping"` (an import/export mapping maps the entity) — lower-case, unlike the types above. Attribute names used only through a variable in a free-text expression (`$Order/Total`) have no edge | | `module_name` | string | Source module | ### project_security diff --git a/cmd/mxcli/lint_rules_shipped.txt b/cmd/mxcli/lint_rules_shipped.txt index ba1c27c80b..fdc6c714cb 100644 --- a/cmd/mxcli/lint_rules_shipped.txt +++ b/cmd/mxcli/lint_rules_shipped.txt @@ -68,6 +68,7 @@ bbd935d1ee0fe6c635572f19c030f511e1010f13b38f49622f28060c3b3e8305 missing_docume a5fa3f0f37f7fde0206df2880fdeb676d0db656957ce89d7b09033af8a7a4476 orphaned_elements.star c27185493f9b8f0b26eff68634c522b61ddf41a59b614d14e046c28bbc60a7d3 orphaned_elements.star e05678135cbc750a3cfef07ddf348b7f207196f0a7762907e416e1a3c973de79 orphaned_elements.star +d5d37b24359dd2d8110860ced7107781016a730ac883e2353f3ae797ede58055 orphaned_elements.star 4500272ad6d0ae8876081de0825c633117b471913079be08fb78311e10470071 sec_guest_access.star 922394f50a7d8b8fab406dcd623536f98dc0d01b9970053776cd609900b2dc39 sec_missing_member_restriction.star 46d8bf64c7a30453685c4c470d6864e9b8f7cfd69d33985a50e24a358c309324 sec_pii_attributes.star diff --git a/docs-site/src/internals/catalog-schema.md b/docs-site/src/internals/catalog-schema.md index 54d708dff6..f167b9077a 100644 --- a/docs-site/src/internals/catalog-schema.md +++ b/docs-site/src/internals/catalog-schema.md @@ -289,7 +289,7 @@ rather than trusting a list here: | RefKind | Edge | |---------|------| -| `call` | flow calls a microflow / nanoflow / rule / Java action / REST operation | +| `call` | flow calls a microflow / nanoflow / rule / Java action / REST operation; a workflow calls a handler microflow; a consumed OData service calls its configuration / headers / error-handling microflow | | `create` / `change` / `delete` / `retrieve` | flow acts on an entity object | | `commit` | flow commits an entity object: a commit action, or a create / change that commits (`Yes` or `YesWithoutEvents`), beside its `create` / `change` edge | | `return` | flow returns an entity type | @@ -300,11 +300,11 @@ rather than trusting a list here: | `datasource` | page or widget reads an entity | | `action` | widget calls a microflow / nanoflow | | `show_page` | flow or widget action opens a page | -| `home_page` / `login_page` / `menu_item` | navigation profile references a page | +| `home_page` / `login_page` / `menu_item` | navigation profile references a page, or a microflow (home microflow, menu item that calls one) | | `calculate` | calculated attribute uses a microflow | | `schedule` | scheduled event runs a microflow | -| `publish` | published REST operation runs a microflow | -| `event` | entity event handler runs a microflow | +| `publish` | published service runs a microflow: a REST operation, an OData resource's read / insert / update / delete microflow, an OData action, a REST or OData service's custom authentication microflow | +| `event` | event handler runs a microflow: an entity event, or a business event a service subscribes to | | `settings` | a project setting (after-startup / before-shutdown / health check) names a microflow | | `sync` | offline navigation profile synchronizes an entity | | `validate` | attribute validation rule uses a regular expression | @@ -322,6 +322,11 @@ over), so it would mostly double existing edges. outside the call graph runs the microflow, so nothing in the model calls it. They are what stops `GRAPH_DEAD_ASSETS`, `LIST CALLERS OF` and lint rule QUAL004 from reporting a live scheduled job, API handler or event handler as unused. +`GRAPH_DEAD_ASSETS` and QUAL004 count an edge of any kind; `LIST CALLERS OF` +counts the kinds that invoke. The source of a service's edge is the service +itself (`PUBLISHED_ODATA_SERVICE`, `PUBLISHED_REST_SERVICE`, +`BUSINESS_EVENT_SERVICE`, `CONSUMED_ODATA_SERVICE`) with its catalog id in +`SourceId`; a published REST operation's edge comes from the operation. `sync` is not one — it names an entity a profile downloads, which is a use of a type, so `SHOW CALLERS` excludes it for the same reason it excludes `datasource`. diff --git a/docs-site/src/tools/starlark-rules.md b/docs-site/src/tools/starlark-rules.md index ed0dd18617..59dc74bf9d 100644 --- a/docs-site/src/tools/starlark-rules.md +++ b/docs-site/src/tools/starlark-rules.md @@ -159,11 +159,15 @@ list holding a number) is not silently ignored — the rule runs with its defaul and the run reports a warning naming the option, listed with the rules that could not run and kept out of the score. -QUAL004 counts a microflow as called only when something **in the model** runs -it (a call, a schedule, a page or widget, a published service, a project -setting). A call from an MDL test script (`tests/*.test.mdl`) is not in the +QUAL004 counts a microflow or page as used when anything in the catalog's +reference graph points at it — exactly what `mxcli refs` lists: a call, a page +or widget, the navigation (a home page or home microflow, a menu item), a +schedule, a published service, an event handler, a project setting. Every edge +kind counts, including ones the catalog adds later, so the rule cannot fall +behind it. A call from an MDL test script (`tests/*.test.mdl`) is not in the model, so a helper used only by tests is reported; give such helpers a prefix -and list it in `entry_point_prefixes`. +and list it in `entry_point_prefixes`. If `refs` finds nothing for a document +you know is live, the gap is in the catalog, not the rule. ### CONV018 options {#conv018-options} diff --git a/mdl-examples/bug-tests/catalog-1639-service-microflow-refs.mdl b/mdl-examples/bug-tests/catalog-1639-service-microflow-refs.mdl new file mode 100644 index 0000000000..413006b8c6 --- /dev/null +++ b/mdl-examples/bug-tests/catalog-1639-service-microflow-refs.mdl @@ -0,0 +1,140 @@ +mdl 1; +-- ============================================================================ +-- Issue ako/mxcli#1639 — the catalog has no edge for the microflows a +-- published OData service runs, so `refs`, `impact` and QUAL004 call them dead +-- ============================================================================ +-- +-- Named with a topic prefix rather than a bare `1639-`: the historical files +-- here are named after mendixlabs/mxcli PR numbers, and the fork's issue +-- numbers collide with them. +-- +-- A published OData resource's `ReadMode: microflow X` and the service's +-- `Authentication: (microflow X)` are entry points: a client's request reaches +-- them, nothing in the model calls them. The catalog recorded neither as an +-- edge, so on the reporter's backend: +-- +-- mxcli refs Formula1Backend.Read_Drivers +-- (no references found: nothing in the catalog's reference graph points at …) +-- mxcli lint +-- ℹ Microflow 'Read_Drivers' is not called from anywhere. [QUAL004] +-- → Remove if unused, … +-- +-- for 34 Read_ microflows and the authentication microflow of six services — +-- 35 of the backend's 43 QUAL004 findings. Same gap, same mechanism, on the +-- siblings below: an OData action, a published REST service's authentication +-- microflow, a business event handler. (A consumed OData service's +-- configuration microflows got the same fix; they need a reachable $metadata +-- to author, so they are covered by unit tests, not here.) +-- +-- ---------------------------------------------------------------------------- +-- Running this example +-- +-- mxcli exec catalog-1639-service-microflow-refs.mdl -p app.mpr +-- mxcli -p app.mpr -c "refresh catalog full force" +-- mxcli refs Svc.Read_Rows -p app.mpr +-- # PUBLISHED_ODATA_SERVICE | Svc.LiveAPI | publish +-- mxcli refs Svc.AuthenticateApiClient -p app.mpr # same +-- mxcli refs Svc.OD_Recalculate -p app.mpr # same +-- mxcli refs Svc.AuthenticateRestClient -p app.mpr +-- # PUBLISHED_REST_SERVICE | Svc.PingAPI | publish +-- mxcli refs Svc.OnCustomerChanged -p app.mpr +-- # BUSINESS_EVENT_SERVICE | Svc.CustomerEvents | event +-- mxcli refs Svc.NoCaller -p app.mpr +-- # control: (no references found …) +-- mxcli lint -p app.mpr +-- # QUAL004 in Svc: Svc.NoCaller only +-- +-- Custom authentication also needs app security on (CE6600) before mx check +-- is clean; the edges do not depend on it. +-- ============================================================================ + +create module Svc; + +create non-persistent entity Svc.LiveRow ( RowKey: string(60), Label: string(200) ); + +create microflow Svc.Read_Rows () +returns List of Svc.LiveRow as $Rows +begin + $Rows = create list of Svc.LiveRow; + return $Rows; +end; + +create microflow Svc.AuthenticateApiClient ( + $Headers: List of System.HttpHeader +) +returns System.User as $User +begin + return empty; +end; + +create microflow Svc.OD_Recalculate ( $Note: string ) +returns Boolean as $Ok +begin + return true; +end; + +create published odata service Svc.LiveAPI ( + path: 'odata/live/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'Svc.LiveAPI', + ServiceName: 'LiveAPI', + Authentication: (microflow Svc.AuthenticateApiClient) +) +{ + publish entity Svc.LiveRow as 'Rows' ( + ReadMode: microflow Svc.Read_Rows, + InsertMode: not_supported, UpdateMode: not_supported, DeleteMode: not_supported, + Countable: false, TopSupported: false, SkipSupported: false + ) + expose ( RowKey as 'rowKey' (KEY), Label as 'label' ) + publish microflow Svc.OD_Recalculate as 'Recalculate' + expose ( Note as 'note' ); +}; + +create microflow Svc.PRS_Ping ( $httpRequest: System.HttpRequest ) +returns String as $R +begin + return 'pong'; +end; + +create microflow Svc.AuthenticateRestClient ( + $httpRequest: System.HttpRequest +) +returns System.User as $User +begin + return empty; +end; + +create published rest service Svc.PingAPI ( + Path: 'rest/ping/v1', + Version: '1.0.0', + ServiceName: 'Ping API', + Authentication: (microflow Svc.AuthenticateRestClient) +) +{ + resource 'ping' { + get '' microflow Svc.PRS_Ping; + } +}; + +create persistent entity Svc.BE_CustomerChanged ( CustomerId: long ); + +create microflow Svc.OnCustomerChanged ( $Event: Svc.BE_CustomerChanged ) begin + log info 'changed'; +end; + +create business event service Svc.CustomerEvents +( + ServiceName: 'CustomerEvents', + EventNamePrefix: '' +) +{ + message CustomerChanged (CustomerId: long) subscribe + entity Svc.BE_CustomerChanged microflow Svc.OnCustomerChanged; +}; + +-- control: nothing runs this one +create microflow Svc.NoCaller () begin + log info 'never'; +end; diff --git a/mdl-examples/bug-tests/qual004-1484-navigation-microflows.mdl b/mdl-examples/bug-tests/qual004-1484-navigation-microflows.mdl new file mode 100644 index 0000000000..881983abea --- /dev/null +++ b/mdl-examples/bug-tests/qual004-1484-navigation-microflows.mdl @@ -0,0 +1,69 @@ +mdl 1; +-- ============================================================================ +-- Issue ako/mxcli#1484 (upstream mendixlabs/mxcli#1579) — QUAL004 reports a +-- microflow started only from the navigation as "not called from anywhere" +-- ============================================================================ +-- +-- Named with a topic prefix rather than a bare `1484-`: the historical files +-- here are named after mendixlabs/mxcli PR numbers, and the fork's issue +-- numbers collide with them. +-- +-- A profile's home microflow and a menu item's `call microflow` are entry +-- points: the client starts them, nothing in the model calls them. The catalog +-- records both (`home_page` / `menu_item` edges, which `mxcli refs` lists), but +-- QUAL004 decided "has a caller" from its own list of reference kinds, +-- MICROFLOW_ENTRY_KINDS, which had the page kinds for the navigation and not the +-- microflow ones. So it said: +-- +-- ℹ Microflow 'NAV_Home' is not called from anywhere. [QUAL004] +-- at Shop.Shop.NAV_Home +-- → Remove if unused, ... +-- +-- The rule now counts every edge into a document — the same graph `refs` and +-- CATALOG.GRAPH_DEAD_ASSETS read — so a kind the catalog adds later cannot fall +-- behind it. (The doubled module in `at Shop.Shop.NAV_Home` was a separate +-- defect in the lint location, fixed alongside.) +-- +-- ---------------------------------------------------------------------------- +-- Running this example (QUAL004 is a Starlark rule: the project needs +-- .claude/lint-rules/orphaned_elements.star, which `mxcli init` writes) +-- +-- mxcli exec qual004-1484-navigation-microflows.mdl -p app.mpr +-- mxcli -p app.mpr -c "refresh catalog full force" +-- mxcli refs Shop.NAV_Orders -p app.mpr +-- # NAVIGATION | Navigation.Responsive | menu_item +-- mxcli lint -p app.mpr +-- # expected in Shop: QUAL004 for Shop.NAV_Unused only, `at Shop.NAV_Unused` +-- # before the fix: also Shop.NAV_Home and Shop.NAV_Orders +-- ============================================================================ + +create module Shop; + +-- started only from the navigation +create microflow Shop.NAV_Home () +begin + log info node 'Shop' 'home'; +end; + +create microflow Shop.NAV_Orders () +begin + log info node 'Shop' 'orders'; +end; + +-- control: no caller at all — still reported, also with a NAV_ name +create microflow Shop.NAV_Unused () +begin + log info node 'Shop' 'never'; +end; + +-- control: no caller, but an entry-point prefix exempts it by name +create microflow Shop.ACT_Entry () +begin + log info node 'Shop' 'act'; +end; + +create or modify navigation Responsive + home microflow Shop.NAV_Home +{ + menu item 'Orders' ( OnClick: call microflow Shop.NAV_Orders ) +}; diff --git a/mdl/catalog/builder.go b/mdl/catalog/builder.go index 1a0c47f674..422984f454 100644 --- a/mdl/catalog/builder.go +++ b/mdl/catalog/builder.go @@ -121,6 +121,12 @@ type Builder struct { // cataloguing the handlers and emitted by buildReferences. eventHandlerRefs []eventHandlerRef + // Integration service → microflow edges (published OData resources, + // actions and authentication; published REST authentication; business + // event handlers; consumed OData configuration microflows), collected by + // the service builders and emitted by buildReferences (ako/mxcli#1639). + serviceMicroflowRefs []serviceMicroflowRef + // Page/snippet → document edges a widget makes beyond its single // widgets_data columns (a data grid 2's datasource AND on-click // microflow), collected by buildPages/buildSnippets and emitted by diff --git a/mdl/catalog/builder_graph.go b/mdl/catalog/builder_graph.go index 12e2a9de01..f5009eed09 100644 --- a/mdl/catalog/builder_graph.go +++ b/mdl/catalog/builder_graph.go @@ -30,10 +30,12 @@ var graphRefKinds = []string{ // of the analysis graph, so communities, layers, cycles and centrality all // see the API and scheduling surface as unreachable roots. // schedule a scheduled event runs it - // publish a published REST operation runs it + // publish a published service runs it: a REST operation, an OData + // resource or action, a service's authentication microflow // event an entity event handler runs it on every commit/delete — the // entity rather than the platform, but reached the same way, and - // structural rather than UI coupling + // structural rather than UI coupling — or a business event + // service runs it when a subscribed event arrives "schedule", "publish", "event", diff --git a/mdl/catalog/builder_modules.go b/mdl/catalog/builder_modules.go index a33a3f1ae7..d301fd484d 100644 --- a/mdl/catalog/builder_modules.go +++ b/mdl/catalog/builder_modules.go @@ -512,6 +512,11 @@ func (b *Builder) buildODataClients() error { qualifiedName := moduleName + "." + svc.Name + // The runtime calls these on every request the service makes, so the + // service is their caller. + b.addServiceMicroflowRefs(RefObjectConsumedODataService, svc.ID, qualifiedName, moduleName, + RefKindCall, svc.ConfigurationMicroflow, svc.HeadersMicroflow, svc.ErrorHandlingMicroflow) + validated := 0 if svc.Validated { validated = 1 @@ -563,6 +568,13 @@ func (b *Builder) buildODataServices() error { qualifiedName := moduleName + "." + svc.Name authTypes := strings.Join(svc.AuthenticationTypes, ", ") + // Every microflow the service runs is an entry point: a client's + // request reaches it, nothing in the model calls it. Without the edge a + // resource's read microflow and the service's authentication microflow + // read as dead to `refs`, `impact` and QUAL004 (ako/mxcli#1639). + b.addServiceMicroflowRefs(RefObjectPublishedODataService, svc.ID, qualifiedName, moduleName, + RefKindPublish, publishedODataMicroflows(svc)...) + _, err := stmt.Exec( string(svc.ID), svc.Name, @@ -616,6 +628,7 @@ func (b *Builder) buildBusinessEventServices() error { msgCount += len(ch.Messages) } } + handlers := make([]string, 0, len(svc.OperationImplementations)) for _, op := range svc.OperationImplementations { switch op.Operation { case "publish": @@ -623,7 +636,13 @@ func (b *Builder) buildBusinessEventServices() error { case "subscribe": subscribeCount++ } + handlers = append(handlers, op.Microflow) } + // An operation's microflow is the handler the platform runs when the + // event arrives — an event handler, like an entity's, and reached the + // same way: nothing in the model calls it. + b.addServiceMicroflowRefs(RefObjectBusinessEventService, svc.ID, qualifiedName, moduleName, + RefKindEvent, handlers...) serviceName := "" eventNamePrefix := "" diff --git a/mdl/catalog/builder_references.go b/mdl/catalog/builder_references.go index adade8b909..b0e66da663 100644 --- a/mdl/catalog/builder_references.go +++ b/mdl/catalog/builder_references.go @@ -14,7 +14,7 @@ import ( // Reference kinds for the refs table const ( - RefKindCall = "call" // Microflow calls microflow + RefKindCall = "call" // Microflow calls microflow (also: a workflow's handler, a consumed OData service's configuration microflow) RefKindCreate = "create" // Microflow creates entity RefKindRetrieve = "retrieve" // Microflow retrieves entity RefKindShowPage = "show_page" // Microflow shows page @@ -37,8 +37,8 @@ const ( RefKindWidget = "widget" // Page/snippet uses a pluggable or custom widget RefKindSettings = "settings" // A project setting names a microflow RefKindSync = "sync" // An offline navigation profile synchronizes an entity - RefKindPublish = "publish" // A published REST operation runs a microflow - RefKindEvent = "event" // An entity event handler runs a microflow + RefKindPublish = "publish" // A published service runs a microflow (REST operation, OData resource or action, authentication) + RefKindEvent = "event" // An event handler runs a microflow (entity event, or a business event a service subscribes to) RefKindMember = "member" // A document binds, reads or writes an attribute, or navigates an association RefKindXPath = "xpath" // An XPath constraint names an attribute, association or enumeration value RefKindType = "type" // An attribute, parameter or variable is typed as an enumeration @@ -46,6 +46,23 @@ const ( RefKindMapping = "mapping" // An import or export mapping maps an entity ) +// RefKinds is every value that reaches refs.RefKind. It is the vocabulary a +// consumer of the reference graph iterates when it must decide something per +// kind: TestQUAL004CountsEveryRefKind drives each one at the orphan rule, so an +// edge kind added here is covered there without anyone remembering the rule. +// TestEveryObjectTypeConstantIsInAPublishedList holds it complete against the +// RefKind constants above. +var RefKinds = []string{ + RefKindCall, RefKindCreate, RefKindRetrieve, RefKindShowPage, + RefKindGeneralize, RefKindAssociate, RefKindLayout, RefKindDatasource, + RefKindParameter, RefKindAction, RefKindHomePage, RefKindLoginPage, + RefKindMenuItem, RefKindChange, RefKindDelete, RefKindCommit, + RefKindCalculate, RefKindReturn, RefKindSchedule, RefKindValidate, + RefKindWidget, RefKindSettings, RefKindSync, RefKindPublish, + RefKindEvent, RefKindMember, RefKindXPath, RefKindType, + RefKindValue, RefKindMapping, +} + // Object types recorded in refs.SourceType and refs.TargetType — the catalog's // name for "what kind of document is at this end of the edge". They are the SDK // names, upper-cased, never Mendix's BSON storage names. @@ -71,6 +88,10 @@ const ( RefObjectJavaScriptAction = "JAVASCRIPT_ACTION" RefObjectRestOperation = "REST_OPERATION" RefObjectPublishedRestOperation = "PUBLISHED_REST_OPERATION" + RefObjectPublishedRestService = "PUBLISHED_REST_SERVICE" + RefObjectPublishedODataService = "PUBLISHED_ODATA_SERVICE" + RefObjectConsumedODataService = "CONSUMED_ODATA_SERVICE" + RefObjectBusinessEventService = "BUSINESS_EVENT_SERVICE" RefObjectRegularExpression = "REGULAR_EXPRESSION" RefObjectScheduledEvent = "SCHEDULED_EVENT" RefObjectProjectSettings = "PROJECT_SETTINGS" @@ -99,6 +120,10 @@ var ( RefObjectNavigation, RefObjectScheduledEvent, RefObjectPublishedRestOperation, + RefObjectPublishedRestService, + RefObjectPublishedODataService, + RefObjectConsumedODataService, + RefObjectBusinessEventService, RefObjectProjectSettings, RefObjectImportMapping, RefObjectExportMapping, @@ -799,6 +824,14 @@ func (b *Builder) buildReferences() error { // by GRAPH_DEAD_ASSETS, whose advice is to delete them (#1126). refCount += b.extractPublishedRestRefs(stmt, projectID, snapshotID) + // The other microflows an integration service names: a published OData + // resource's read/change microflow and actions, a published service's + // authentication microflow, a business event handler, a consumed OData + // service's configuration microflows. Same class again — the platform runs + // them — and found the same way: 34 Read_ microflows and the authentication + // microflow of six published OData services read as dead (ako/mxcli#1639). + refCount += b.extractServiceMicroflowRefs(stmt, projectID, snapshotID) + // Members, enumerations and mapped entities. Without these the graph ended // at documents, so `impact` on an attribute, an enumeration or a value said // "not referenced" however much it was used — the answer an agent reads as diff --git a/mdl/catalog/builder_rest.go b/mdl/catalog/builder_rest.go index 5b6f33a47c..0de19f65ed 100644 --- a/mdl/catalog/builder_rest.go +++ b/mdl/catalog/builder_rest.go @@ -161,6 +161,11 @@ func (b *Builder) buildPublishedRestServices() error { moduleName := b.hierarchy.getModuleName(moduleID) qualifiedName := moduleName + "." + svc.Name + // The custom authentication microflow runs on every authenticated + // request; the operation edges below do not cover it (ako/mxcli#1639). + b.addServiceMicroflowRefs(RefObjectPublishedRestService, svc.ID, qualifiedName, moduleName, + RefKindPublish, svc.AuthenticationMicroflow) + // Count total operations across all resources opCount := 0 for _, res := range svc.Resources { diff --git a/mdl/catalog/builder_service_refs.go b/mdl/catalog/builder_service_refs.go new file mode 100644 index 0000000000..6bc6568e7c --- /dev/null +++ b/mdl/catalog/builder_service_refs.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "database/sql" + "strings" + + "github.com/mendixlabs/mxcli/model" +) + +// serviceMicroflowRef is one edge from an integration service document to a +// microflow the platform runs on the service's behalf. The source is the +// service itself (its catalog id and qualified name), so the edge joins back to +// the service's own table — odata_services_data, published_rest_services_data, +// business_event_services_data or odata_clients_data — on SourceId. +// +// Collected while the service builders catalogue the services, and emitted by +// buildReferences, like scheduledEventRefs and publishedRestRefs. +type serviceMicroflowRef struct { + sourceType, sourceID, sourceName, moduleName, microflow, kind string +} + +// addServiceMicroflowRefs records one edge per distinct microflow the service +// names. An empty name is a real shape (a mode or a method chosen before its +// microflow is) and is skipped: an edge to "" would collide with every other +// unnamed target. A microflow named twice by one service — the same read +// microflow behind two resources — is one edge, as a page's widgets are. +func (b *Builder) addServiceMicroflowRefs(sourceType string, sourceID model.ID, sourceName, moduleName, kind string, microflows ...string) { + seen := map[string]bool{} + for _, mf := range microflows { + mf = strings.TrimSpace(mf) + if mf == "" || seen[mf] { + continue + } + seen[mf] = true + b.serviceMicroflowRefs = append(b.serviceMicroflowRefs, serviceMicroflowRef{ + sourceType: sourceType, + sourceID: string(sourceID), + sourceName: sourceName, + moduleName: moduleName, + microflow: mf, + kind: kind, + }) + } +} + +// publishedODataMicroflows returns every microflow a published OData service +// runs: the custom authentication microflow, each resource's read and +// insert/update/delete microflows, and each microflow published as an action. +func publishedODataMicroflows(svc *model.PublishedODataService) []string { + out := []string{svc.AuthMicroflow} + for _, es := range svc.EntitySets { + for _, mode := range []string{es.ReadMode, es.InsertMode, es.UpdateMode, es.DeleteMode} { + out = append(out, odataModeMicroflow(mode)) + } + } + for _, pm := range svc.Microflows { + out = append(out, pm.Microflow) + } + return out +} + +// odataModeMicroflow returns the microflow of a published resource's read or +// change mode, or "" when the mode names none. The reader spells a +// microflow-backed mode "CallMicroflow:Module.Name" (parseODataModeRaw), and a +// database-backed or unsupported one by a bare word. +func odataModeMicroflow(mode string) string { + if mf, ok := strings.CutPrefix(mode, "CallMicroflow:"); ok { + return mf + } + return "" +} + +// extractServiceMicroflowRefs emits the edges the service builders collected. +func (b *Builder) extractServiceMicroflowRefs(stmt *sql.Stmt, projectID, snapshotID string) int { + count := 0 + for _, r := range b.serviceMicroflowRefs { + if _, err := stmt.Exec( + r.sourceType, r.sourceID, r.sourceName, + RefObjectMicroflow, "", r.microflow, + r.kind, r.moduleName, projectID, snapshotID, + ); err == nil { + count++ + } + } + return count +} diff --git a/mdl/catalog/builder_service_refs_test.go b/mdl/catalog/builder_service_refs_test.go new file mode 100644 index 0000000000..a28a53c374 --- /dev/null +++ b/mdl/catalog/builder_service_refs_test.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "reflect" + "sort" + "testing" + + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// A published OData service names microflows the platform runs for it — a +// resource's read microflow (`ReadMode: microflow X`), its insert/update/delete +// microflows, a microflow published as an OData action, and the custom +// authentication microflow (`Authentication: (microflow X)`). The catalog +// recorded none of them as an edge, so `mxcli refs` said "no references found", +// `impact` agreed, and QUAL004 advised "Remove if unused" on the read +// implementations behind six published services and the one microflow all six +// authenticate through — 35 of one backend's 43 findings (ako/mxcli#1639). +// +// The same gap, same mechanism — the builder holds the name and drops it — was +// open on three siblings: a published REST service's authentication microflow, +// a business event service's handler microflow, and a consumed OData service's +// configuration / headers / error-handling microflows. + +// serviceRefsFixture runs the real collecting halves — the service builders — +// and the real emitting half over the given services, and returns the catalog. +func serviceRefsFixture(t *testing.T, r *mock.MockBackend) *Catalog { + t.Helper() + cat, err := New() + if err != nil { + t.Fatalf("new catalog: %v", err) + } + t.Cleanup(func() { cat.Close() }) + + tx, err := cat.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + // Every service list the builders read; the ones a test does not set are + // empty rather than the mock's "not configured" error. + if r.ListPublishedODataServicesFunc == nil { + r.ListPublishedODataServicesFunc = func() ([]*model.PublishedODataService, error) { return nil, nil } + } + if r.ListPublishedRestServicesFunc == nil { + r.ListPublishedRestServicesFunc = func() ([]*model.PublishedRestService, error) { return nil, nil } + } + if r.ListBusinessEventServicesFunc == nil { + r.ListBusinessEventServicesFunc = func() ([]*model.BusinessEventService, error) { return nil, nil } + } + if r.ListConsumedODataServicesFunc == nil { + r.ListConsumedODataServicesFunc = func() ([]*model.ConsumedODataService, error) { return nil, nil } + } + + b := &Builder{ + catalog: cat, + reader: r, + snapshot: &Snapshot{ID: "snap-1"}, + hierarchy: &hierarchy{ + moduleIDs: map[model.ID]bool{restRefsModuleID: true}, + moduleNames: map[model.ID]string{restRefsModuleID: "Sales"}, + containerParent: map[model.ID]model.ID{}, + folderNames: map[model.ID]string{}, + }, + tx: tx, + } + for _, step := range []struct { + name string + build func() error + }{ + {"buildODataClients", b.buildODataClients}, + {"buildODataServices", b.buildODataServices}, + {"buildBusinessEventServices", b.buildBusinessEventServices}, + {"buildPublishedRestServices", b.buildPublishedRestServices}, + } { + if err := step.build(); err != nil { + t.Fatalf("%s: %v", step.name, err) + } + } + + stmt, err := tx.Prepare(` + INSERT INTO refs (SourceType, SourceId, SourceName, TargetType, TargetId, TargetName, RefKind, ModuleName, ProjectId, SnapshotId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + t.Fatalf("prepare refs: %v", err) + } + projectID, snapshotID := b.snapshotMeta() + b.extractPublishedRestRefs(stmt, projectID, snapshotID) + b.extractServiceMicroflowRefs(stmt, projectID, snapshotID) + stmt.Close() + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + return cat +} + +// edgeRows returns "SourceType SourceName -> TargetName kind" for every refs +// row with the given source type, sorted. +func edgeRows(t *testing.T, cat *Catalog, sourceType string) []string { + t.Helper() + var out []string + for _, row := range queryRows(t, cat, `SELECT SourceName, TargetType, TargetName, RefKind, ModuleName FROM refs WHERE SourceType = '`+sourceType+`'`) { + out = append(out, row[0].(string)+" -> "+row[1].(string)+" "+row[2].(string)+" "+row[3].(string)+" ["+row[4].(string)+"]") + } + sort.Strings(out) + return out +} + +func publishedOData() *model.PublishedODataService { + svc := &model.PublishedODataService{ + ContainerID: restRefsModuleID, + Name: "LiveAPI", + AuthenticationTypes: []string{"Microflow"}, + AuthMicroflow: "Sales.AuthenticateApiClient", + EntitySets: []*model.PublishedEntitySet{ + { + ExposedName: "Rows", + ReadMode: "CallMicroflow:Sales.Read_Rows", + InsertMode: "CallMicroflow:Sales.Insert_Row", + UpdateMode: "ChangeFromDatabase", + DeleteMode: "NotSupported", + }, + // The same read microflow behind a second resource is one edge, + // and a microflow mode with no microflow named is none. + {ExposedName: "AllRows", ReadMode: "CallMicroflow:Sales.Read_Rows", UpdateMode: "CallMicroflow"}, + // A database-backed resource names no microflow. + {ExposedName: "Plain", ReadMode: "ReadFromDatabase", InsertMode: "ChangeFromDatabase"}, + }, + Microflows: []*model.PublishedMicroflow{{Microflow: "Sales.OD_DoThing", ExposedName: "DoThing"}}, + } + svc.ID = "odata-1" + return svc +} + +// TestPublishedODataServiceEmitsPublishEdges is the reported half of +// ako/mxcli#1639: ReadMode and Authentication, plus the change modes and the +// published actions, which are the same property family on the same document. +func TestPublishedODataServiceEmitsPublishEdges(t *testing.T) { + cat := serviceRefsFixture(t, &mock.MockBackend{ + ListPublishedODataServicesFunc: func() ([]*model.PublishedODataService, error) { + return []*model.PublishedODataService{publishedOData()}, nil + }, + }) + + got := edgeRows(t, cat, RefObjectPublishedODataService) + want := []string{ + "Sales.LiveAPI -> MICROFLOW Sales.AuthenticateApiClient publish [Sales]", + "Sales.LiveAPI -> MICROFLOW Sales.Insert_Row publish [Sales]", + "Sales.LiveAPI -> MICROFLOW Sales.OD_DoThing publish [Sales]", + "Sales.LiveAPI -> MICROFLOW Sales.Read_Rows publish [Sales]", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("published OData edges:\n got %v\nwant %v", got, want) + } + + // The edge joins back to the service row, so "who runs this microflow" is + // one join from the service's path and version. + joined := queryRows(t, cat, ` + SELECT COUNT(*) FROM refs r + JOIN odata_services_data s ON s.Id = r.SourceId + WHERE r.SourceType = 'PUBLISHED_ODATA_SERVICE'`) + if joined[0][0].(int64) != int64(len(want)) { + t.Errorf("the edges do not join to odata_services_data on SourceId; got %v of %d", joined[0][0], len(want)) + } +} + +// TestPublishedRestServiceAuthenticationMicroflowEmitsPublishEdge: the +// operation edges already existed; the service-level authentication microflow, +// which every authenticated request runs, had none. +func TestPublishedRestServiceAuthenticationMicroflowEmitsPublishEdge(t *testing.T) { + svc := oneService(&model.PublishedRestOperation{HTTPMethod: "GET", Path: "{id}", Microflow: "Sales.GetOrder"}) + svc.AuthenticationTypes = []string{"Microflow"} + svc.AuthenticationMicroflow = "Sales.AuthenticateRestClient" + cat := serviceRefsFixture(t, &mock.MockBackend{ + ListPublishedRestServicesFunc: func() ([]*model.PublishedRestService, error) { + return []*model.PublishedRestService{svc}, nil + }, + }) + + got := edgeRows(t, cat, RefObjectPublishedRestService) + want := []string{"Sales.OrdersApi -> MICROFLOW Sales.AuthenticateRestClient publish [Sales]"} + if !reflect.DeepEqual(got, want) { + t.Errorf("published REST service edges:\n got %v\nwant %v", got, want) + } + // CONTROL: the operation edge is still there, under its own source type. + if ops := edgeRows(t, cat, RefObjectPublishedRestOperation); len(ops) != 1 { + t.Errorf("control: want the one operation edge beside it, got %v", ops) + } +} + +// TestBusinessEventServiceHandlerEmitsEventEdge: a subscribe operation's +// microflow runs whenever the event arrives. business_events_data recorded it +// as SubscribeMicroflow and no edge followed. +func TestBusinessEventServiceHandlerEmitsEventEdge(t *testing.T) { + svc := &model.BusinessEventService{ + ContainerID: restRefsModuleID, + Name: "CustomerEvents", + OperationImplementations: []*model.ServiceOperation{ + {MessageName: "CustomerChanged", Operation: "subscribe", Entity: "Sales.CustomerChanged", Microflow: "Sales.OnCustomerChanged"}, + // A publish operation binds an entity and no microflow. + {MessageName: "OrderPlaced", Operation: "publish", Entity: "Sales.OrderPlaced"}, + }, + } + svc.ID = "bes-1" + cat := serviceRefsFixture(t, &mock.MockBackend{ + ListBusinessEventServicesFunc: func() ([]*model.BusinessEventService, error) { + return []*model.BusinessEventService{svc}, nil + }, + }) + + got := edgeRows(t, cat, RefObjectBusinessEventService) + want := []string{"Sales.CustomerEvents -> MICROFLOW Sales.OnCustomerChanged event [Sales]"} + if !reflect.DeepEqual(got, want) { + t.Errorf("business event service edges:\n got %v\nwant %v", got, want) + } +} + +// TestConsumedODataServiceMicroflowsEmitCallEdges: the runtime calls a consumed +// OData service's configuration, headers and error-handling microflows on every +// request it makes through the service. +func TestConsumedODataServiceMicroflowsEmitCallEdges(t *testing.T) { + svc := &model.ConsumedODataService{ + ContainerID: restRefsModuleID, + Name: "TripPin", + ConfigurationMicroflow: "Sales.ConfigureTripPin", + HeadersMicroflow: "Sales.TripPinHeaders", + ErrorHandlingMicroflow: "Sales.TripPinErrors", + } + svc.ID = "codata-1" + cat := serviceRefsFixture(t, &mock.MockBackend{ + ListConsumedODataServicesFunc: func() ([]*model.ConsumedODataService, error) { + return []*model.ConsumedODataService{svc}, nil + }, + }) + + got := edgeRows(t, cat, RefObjectConsumedODataService) + want := []string{ + "Sales.TripPin -> MICROFLOW Sales.ConfigureTripPin call [Sales]", + "Sales.TripPin -> MICROFLOW Sales.TripPinErrors call [Sales]", + "Sales.TripPin -> MICROFLOW Sales.TripPinHeaders call [Sales]", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("consumed OData service edges:\n got %v\nwant %v", got, want) + } +} + +// TestServiceMicroflowsAreNotDead is the consumer-facing symptom. +// GRAPH_DEAD_ASSETS is kind-agnostic, as are `refs` and (since ako/mxcli#1484) +// QUAL004, so the edge alone clears all three. The unreferenced microflow is the +// control: without it, a fix that emptied the view would pass. +func TestServiceMicroflowsAreNotDead(t *testing.T) { + cat := serviceRefsFixture(t, &mock.MockBackend{ + ListPublishedODataServicesFunc: func() ([]*model.PublishedODataService, error) { + return []*model.PublishedODataService{publishedOData()}, nil + }, + }) + for _, mf := range []string{"Read_Rows", "AuthenticateApiClient", "Orphan"} { + if _, err := cat.db.Exec( + `INSERT INTO microflows_data (Id, Name, QualifiedName, ModuleName, MicroflowType) + VALUES (?, ?, ?, 'Sales', 'MICROFLOW')`, + "mf-"+mf, mf, "Sales."+mf); err != nil { + t.Fatalf("insert microflow: %v", err) + } + } + + dead := map[string]bool{} + for _, row := range queryRows(t, cat, `SELECT QualifiedName FROM graph_dead_assets`) { + dead[row[0].(string)] = true + } + if !dead["Sales.Orphan"] { + t.Fatal("control failed: a microflow nothing references is not reported dead, " + + "so this test cannot detect the bug") + } + for _, live := range []string{"Sales.Read_Rows", "Sales.AuthenticateApiClient"} { + if dead[live] { + t.Errorf("%s runs behind a published OData service and is reported dead (ako/mxcli#1639)", live) + } + } +} diff --git a/mdl/catalog/lint_rule_doc_vocabulary_test.go b/mdl/catalog/lint_rule_doc_vocabulary_test.go index a1fdecc8d4..52bdc9b58d 100644 --- a/mdl/catalog/lint_rule_doc_vocabulary_test.go +++ b/mdl/catalog/lint_rule_doc_vocabulary_test.go @@ -285,6 +285,9 @@ func TestEveryObjectTypeConstantIsInAPublishedList(t *testing.T) { published []string }{ {"builder_references.go", "RefObject", append(append([]string{}, RefSourceObjectTypes...), RefTargetObjectTypes...)}, + // RefKinds is what TestQUAL004CountsEveryRefKind (mdl/linter) drives at + // the orphan rule, so a kind missing from it is a kind nothing checks. + {"builder_references.go", "RefKind", RefKinds}, {"builder_permissions.go", "PermissionElement", PermissionElementTypes}, {"builder_permissions.go", "AccessType", PermissionAccessTypes}, } { diff --git a/mdl/catalog/lint_rule_vocabulary_test.go b/mdl/catalog/lint_rule_vocabulary_test.go index f95d6df118..8c8cb5f6a2 100644 --- a/mdl/catalog/lint_rule_vocabulary_test.go +++ b/mdl/catalog/lint_rule_vocabulary_test.go @@ -28,7 +28,7 @@ import ( // QUAL004 had the sibling bug in the other vocabulary: it counted only the "call" // and "schedule" reference kinds, so a microflow reached through a page data // source, a widget action or a calculated attribute was reported as -// "not called from anywhere". +// "not called from anywhere". It now counts every kind (see below). func lintRulesDir(t *testing.T) string { t.Helper() @@ -95,63 +95,13 @@ func starListItems(src, name string) []string { return out } -// TestQUAL004EntryKindsAreRealRefKinds keeps the orphan rule's two kind lists -// spelled the way the reference builder emits them. A typo or an invented kind -// silently narrows the rule into false positives. -func TestQUAL004EntryKindsAreRealRefKinds(t *testing.T) { - known := map[string]bool{} - for _, k := range []string{ - RefKindCall, RefKindCreate, RefKindRetrieve, RefKindShowPage, - RefKindGeneralize, RefKindAssociate, RefKindLayout, RefKindDatasource, - RefKindParameter, RefKindAction, RefKindHomePage, RefKindLoginPage, - RefKindMenuItem, RefKindChange, RefKindDelete, RefKindCalculate, - RefKindReturn, RefKindSchedule, RefKindValidate, RefKindSettings, - RefKindWidget, RefKindSync, RefKindPublish, RefKindEvent, RefKindCommit, - } { - known[k] = true - } - - src := readRule(t, "orphaned_elements.star") - for _, listName := range []string{"MICROFLOW_ENTRY_KINDS", "PAGE_ENTRY_KINDS"} { - items := starListItems(src, listName) - if len(items) == 0 { - t.Fatalf("%s not found in orphaned_elements.star, or is empty", listName) - } - for _, kind := range items { - if !known[kind] { - t.Errorf("%s lists %q, which the reference builder never emits", listName, kind) - } - } - } -} - -// The kinds that mean "this runs" / "this opens" must actually be listed. Losing -// one is how the rule regresses into reporting live documents as dead. -func TestQUAL004CountsEveryEntryPointKind(t *testing.T) { - src := readRule(t, "orphaned_elements.star") - - for _, want := range []string{ - RefKindCall, RefKindSchedule, RefKindDatasource, RefKindAction, RefKindCalculate, - RefKindSettings, RefKindPublish, - // An entity event handler runs on every commit/delete of its entity. - // Of every kind in this list it is the one whose absence flags the - // hottest code (mendixlabs/mxcli#1127). - RefKindEvent, - } { - if !contains(starListItems(src, "MICROFLOW_ENTRY_KINDS"), want) { - t.Errorf("MICROFLOW_ENTRY_KINDS is missing %q — a microflow reached only that way "+ - "is reported as 'not called from anywhere'", want) - } - } - for _, want := range []string{ - RefKindShowPage, RefKindHomePage, RefKindLoginPage, RefKindMenuItem, - } { - if !contains(starListItems(src, "PAGE_ENTRY_KINDS"), want) { - t.Errorf("PAGE_ENTRY_KINDS is missing %q — a page reached only that way "+ - "is reported as orphaned", want) - } - } -} +// QUAL004 no longer keeps a list of the reference kinds that count as a caller: +// the two lists that were checked here (MICROFLOW_ENTRY_KINDS, PAGE_ENTRY_KINDS) +// drifted behind the catalog — the microflow one lacked home_page and menu_item, +// so a microflow started only from the navigation was reported as unused +// (ako/mxcli#1484). Every edge into a document now counts, and +// TestQUAL004CountsEveryRefKind in mdl/linter drives each of RefKinds at the +// rule instead of pinning a copy of the list here. func contains(hay []string, needle string) bool { for _, h := range hay { diff --git a/mdl/catalog/tables.go b/mdl/catalog/tables.go index da0b941349..28113f3ecc 100644 --- a/mdl/catalog/tables.go +++ b/mdl/catalog/tables.go @@ -7,6 +7,14 @@ package catalog // // History: // +// 29 (service microflow edges): refs gains the edges from an integration +// service to the microflows it runs — a published OData resource's +// read/change microflows, its actions and authentication microflow, a +// published REST service's authentication microflow (`publish`), a +// business event handler (`event`), a consumed OData service's +// configuration microflows (`call`) (ako/mxcli#1639). No column changes, +// but a cached catalog would keep answering "no references" for every one +// of them until something else rebuilt it. // 28 (message definitions): message_definition_collections and // message_definitions, unioned into objects as // MESSAGE_DEFINITION_COLLECTION / MESSAGE_DEFINITION, so documents() @@ -148,7 +156,7 @@ package catalog // SnapshotSource / SourceId / SourceBranch / SourceRevision columns // from every row (issue #576). // 1 — initial flat schema with denormalized snapshot columns on every row. -const CatalogSchemaVersion = "28" +const CatalogSchemaVersion = "29" // MetaSchemaVersion is the catalog_meta key that records the schema version // the cache was built against. diff --git a/mdl/executor/cmd_search.go b/mdl/executor/cmd_search.go index 74614a20db..a908a46591 100644 --- a/mdl/executor/cmd_search.go +++ b/mdl/executor/cmd_search.go @@ -25,14 +25,17 @@ import ( // The recurring shape is an ENTRY POINT: something outside the call graph // invokes it, so nothing in the model calls it. 'schedule' was the first (a // microflow run only by a scheduled event reported "(no callers found)" on a job -// that runs nightly); 'publish' is a published REST operation (#1126); 'event' -// is an entity event handler, run by the entity on commit or delete; 'settings' +// that runs nightly); 'publish' is a published REST operation (#1126), or a +// published OData resource, action or authentication microflow +// (ako/mxcli#1639); 'event' is an entity event handler, run by the entity on +// commit or delete, or a business event handler; 'settings' // is a microflow wired as after-startup, before-shutdown or health check, whose // edge shipped in v0.22.0 and was added to QUAL004 but not here, so // `show callers` stayed blind to it. Every new way for something to run a -// microflow belongs in this list, in graphRefKinds, and in QUAL004's -// MICROFLOW_ENTRY_KINDS — three consumers, none of which shares the others' -// list. +// microflow belongs in this list and in graphRefKinds — two consumers, neither +// of which shares the other's list. (QUAL004 had a third, MICROFLOW_ENTRY_KINDS; +// it fell behind on the navigation kinds and was removed in favour of counting +// every edge, ako/mxcli#1484.) // // Deliberately excluded: 'datasource', 'parameter', 'return', 'retrieve', // 'create', 'change', 'delete', 'commit', 'associate', 'generalize', 'layout' diff --git a/mdl/linter/linter.go b/mdl/linter/linter.go index 0141a8a96f..70f611fd7e 100644 --- a/mdl/linter/linter.go +++ b/mdl/linter/linter.go @@ -5,6 +5,7 @@ package linter import ( "context" + "strings" ) // Severity indicates how serious a violation is. @@ -72,10 +73,28 @@ type Location struct { } // QualifiedName returns the full qualified name of the location. +// +// DocumentName is meant to be the document's own name, but most bundled +// Starlark rules pass the qualified one (`document_name=mf.qualified_name`), and +// so does missing_translations. Prefixing the module again printed every such +// finding as `at M.M.NAV_Home` (ako/mxcli#1484). This is the one place a +// location is turned into a name — text, Markdown, SARIF and `lint -d` all come +// through here — so both spellings are accepted here rather than in each rule, +// which would leave a project's stale rule copies and every user-written rule +// still repeating the module. A dot cannot occur in a Mendix document name, so +// "." at the front is always the module and never part of the name. func (l Location) QualifiedName() string { if l.Module == "" { return l.DocumentName } + if strings.HasPrefix(l.DocumentName, l.Module+".") { + return l.DocumentName + } + // A finding about the module itself (CONV018) names the module as its + // document; "Sales.Sales" would read as a document called Sales. + if strings.EqualFold(l.DocumentType, "module") && l.DocumentName == l.Module { + return l.Module + } return l.Module + "." + l.DocumentName } diff --git a/mdl/linter/location_test.go b/mdl/linter/location_test.go new file mode 100644 index 0000000000..5cd32cc3dd --- /dev/null +++ b/mdl/linter/location_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/linter" +) + +// Location.DocumentName is documented as the document's own name ("Customer"), +// and QualifiedName put the module in front of it. Most of the bundled Starlark +// rules pass `document_name=.qualified_name` instead — QUAL004, CONV001-010, +// the SEC rules, ~30 in all — so every one of their findings printed as +// `at M.M.NAV_Home`, the module twice (ako/mxcli#1484's repro). The Go rules +// mostly strip the module first; missing_translations does not. +// +// Fixing the rules one by one would leave a project's stale copies of them +// (rules are written into the project by `mxcli init` and kept until it is +// re-run) and every user-written rule still printing the module twice. So the +// one place the qualified name is assembled tolerates both spellings. +func TestLocationQualifiedNameDoesNotRepeatTheModule(t *testing.T) { + for _, tc := range []struct { + name string + loc linter.Location + want string + }{ + {"short document name", linter.Location{Module: "M", DocumentType: "Microflow", DocumentName: "NAV_Home"}, "M.NAV_Home"}, + {"qualified document name", linter.Location{Module: "M", DocumentType: "Microflow", DocumentName: "M.NAV_Home"}, "M.NAV_Home"}, + // missing_translations names an element below a document. + {"qualified name of a member", linter.Location{Module: "Sales", DocumentType: "attribute", DocumentName: "Sales.Order.Status"}, "Sales.Order.Status"}, + // CONV018 reports a module about itself. + {"module-level finding", linter.Location{Module: "Sales", DocumentType: "Module", DocumentName: "Sales"}, "Sales"}, + {"project-level finding", linter.Location{DocumentType: "security", DocumentName: "ProjectSecurity"}, "ProjectSecurity"}, + + // CONTROLS: a document may share its module's name, and a module name + // may be a prefix of another module's — neither is a repeat. + {"document named like its module", linter.Location{Module: "Sales", DocumentType: "Microflow", DocumentName: "Sales"}, "Sales.Sales"}, + {"module name is a prefix of the document's", linter.Location{Module: "M", DocumentType: "Microflow", DocumentName: "MX_Home"}, "M.MX_Home"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.loc.QualifiedName(); got != tc.want { + t.Errorf("QualifiedName() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestShippedRuleLocationPrintsTheModuleOnce is the reported line, end to end: +// the shipped QUAL004 rule over a catalog, through the text formatter. +func TestShippedRuleLocationPrintsTheModuleOnce(t *testing.T) { + r, err := linter.LoadStarlarkRule(filepath.Join(flowRulesDir, "orphaned_elements.star")) + if err != nil { + t.Fatalf("LoadStarlarkRule: %v", err) + } + violations := r.Check(linter.NewLintContextFromDB(flowKindsFixtureDB(t))) + + var hit *linter.Violation + for i := range violations { + if strings.HasSuffix(violations[i].Location.DocumentName, "Big_MF") { + hit = &violations[i] + } + } + // CONTROL: the fixture's Big_MF is unreferenced, so QUAL004 reports it. + if hit == nil { + t.Fatalf("QUAL004 did not report Sales.Big_MF; got %d findings", len(violations)) + } + + var out bytes.Buffer + if err := (&linter.TextFormatter{NoSummary: true}).Format([]linter.Violation{*hit}, &out); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Sales.Sales.") { + t.Errorf("the location repeats the module:\n%s", out.String()) + } + if !strings.Contains(out.String(), "at Sales.Big_MF") { + t.Errorf("want the location `at Sales.Big_MF`, got:\n%s", out.String()) + } +} diff --git a/mdl/linter/starlark_qual004_refs_test.go b/mdl/linter/starlark_qual004_refs_test.go new file mode 100644 index 0000000000..55f9d3672f --- /dev/null +++ b/mdl/linter/starlark_qual004_refs_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package linter_test + +import ( + "database/sql" + "path/filepath" + "slices" + "sort" + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" + "github.com/mendixlabs/mxcli/mdl/linter" + _ "modernc.org/sqlite" +) + +// QUAL004 decided "has a caller" from a hand-kept list of reference kinds, +// MICROFLOW_ENTRY_KINDS, and the list drifted behind the catalog every time an +// edge kind was added. A microflow started only from the navigation — a +// profile's home microflow, or a menu item's `call microflow` — has a +// `home_page` / `menu_item` edge that `mxcli refs` lists, and QUAL004 still said +// "is not called from anywhere. Remove if unused" (ako/mxcli#1484, upstream +// mendixlabs/mxcli#1579; four such NAV_ microflows in ako/mxcli#1639's +// Formula1Frontend). PAGE_ENTRY_KINDS had both kinds; the microflow list did not. + +// qual004Ref is one refs row pointing at a document. +type qual004Ref struct{ sourceType, sourceName, targetType, targetName, kind string } + +// qual004FixtureDB is a catalog with microflows and pages in module M, and the +// given refs rows. Nothing references a document unless a row says so. +func qual004FixtureDB(t *testing.T, refs []qual004Ref) catalog.CatalogDB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + stmts := []string{ + `CREATE TABLE modules (Id TEXT, Name TEXT, Source TEXT)`, + `INSERT INTO modules VALUES ('m1', 'M', '')`, + `CREATE TABLE microflows ( + Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + MicroflowType TEXT, Description TEXT, ReturnType TEXT, + ParameterCount INTEGER, ActivityCount INTEGER, TotalActivityCount INTEGER DEFAULT 0, Complexity INTEGER)`, + `INSERT INTO microflows VALUES + ('f1','NAV_Home','M.NAV_Home','M','','MICROFLOW','','',0,1,1,1), + ('f2','NAV_Orders','M.NAV_Orders','M','','MICROFLOW','','',0,1,1,1), + ('f3','Unused','M.Unused','M','','MICROFLOW','','',0,1,1,1), + ('f4','ACT_Entry','M.ACT_Entry','M','','MICROFLOW','','',0,1,1,1), + ('f5','Target','M.Target','M','','MICROFLOW','','',0,1,1,1), + ('f6','TargetNF','M.TargetNF','M','','NANOFLOW','','',0,1,1,1)`, + `CREATE TABLE pages (Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + Title TEXT, URL TEXT, Description TEXT, WidgetCount INTEGER)`, + `INSERT INTO pages VALUES + ('p1','Orders_Overview','M.Orders_Overview','M','','','','',0), + ('p2','Unused_Page','M.Unused_Page','M','','','','',0), + ('p3','TargetPage','M.TargetPage','M','','','','',0)`, + `CREATE TABLE refs ( + SourceType TEXT, SourceId TEXT, SourceName TEXT, TargetType TEXT, + TargetId TEXT, TargetName TEXT, RefKind TEXT, ModuleName TEXT)`, + // One unrelated row, so the refs table reads as populated. + `INSERT INTO refs VALUES ('PAGE','','M.X','ENTITY','','M.E','parameter','M')`, + `CREATE TABLE entities ( + Id TEXT, Name TEXT, QualifiedName TEXT, ModuleName TEXT, Folder TEXT, + EntityType TEXT, Description TEXT, Generalization TEXT, + AttributeCount INTEGER, AccessRuleCount INTEGER, ValidationRuleCount INTEGER, + HasEventHandlers INTEGER, IsExternal INTEGER, + HasCreatedDate INTEGER, HasChangedDate INTEGER, + HasOwner INTEGER, HasChangedBy INTEGER)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("fixture %q: %v", s, err) + } + } + for _, r := range refs { + if _, err := db.Exec(`INSERT INTO refs VALUES (?, '', ?, ?, '', ?, ?, 'M')`, + r.sourceType, r.sourceName, r.targetType, r.targetName, r.kind); err != nil { + t.Fatalf("insert ref %+v: %v", r, err) + } + } + return catalog.WrapSqlDB(db) +} + +// qual004Flagged runs the shipped QUAL004 rule and returns the qualified names +// it reports. +func qual004Flagged(t *testing.T, db catalog.CatalogDB) []string { + t.Helper() + r, err := linter.LoadStarlarkRule(filepath.Join(flowRulesDir, "orphaned_elements.star")) + if err != nil { + t.Fatalf("LoadStarlarkRule: %v", err) + } + var out []string + for _, v := range r.Check(linter.NewLintContextFromDB(db)) { + if v.RuleFailure { + t.Fatalf("QUAL004 failed to run: %s", v.Message) + } + out = append(out, v.Location.QualifiedName()) + } + sort.Strings(out) + return out +} + +// TestQUAL004CountsNavigationAsACaller is the reported case: a home microflow +// and a menu item's microflow, with the controls the report itself used. +func TestQUAL004CountsNavigationAsACaller(t *testing.T) { + flagged := qual004Flagged(t, qual004FixtureDB(t, []qual004Ref{ + {"NAVIGATION", "Navigation.Responsive", "MICROFLOW", "M.NAV_Home", catalog.RefKindHomePage}, + {"NAVIGATION", "Navigation.Responsive", "MICROFLOW", "M.NAV_Orders", catalog.RefKindMenuItem}, + {"NAVIGATION", "Navigation.Responsive", "PAGE", "M.Orders_Overview", catalog.RefKindMenuItem}, + })) + + for _, live := range []string{"M.NAV_Home", "M.NAV_Orders", "M.Orders_Overview"} { + if slices.Contains(flagged, live) { + t.Errorf("QUAL004 reports %s, which the navigation starts — `mxcli refs` lists the edge, "+ + "and the rule advises deleting a live entry point", live) + } + } + + // CONTROLS: an unreferenced microflow and page are still reported — a rule + // that reported nothing would pass the assertions above — and an + // entry-point prefix still exempts by name. + for _, dead := range []string{"M.Unused", "M.Unused_Page"} { + if !slices.Contains(flagged, dead) { + t.Errorf("control: %s has no reference and is not reported; got %v", dead, flagged) + } + } + if slices.Contains(flagged, "M.ACT_Entry") { + t.Errorf("control: M.ACT_Entry carries an entry-point prefix and is reported; got %v", flagged) + } +} + +// TestQUAL004CountsEveryRefKind is the drift guard. The rule does not keep its +// own list of the kinds that mean "something starts this" — every edge into a +// document counts, which is the graph `mxcli refs` and GRAPH_DEAD_ASSETS read — +// so a kind the catalog adds later counts without anyone remembering this rule. +// +// Every kind the catalog declares is driven at a microflow, a nanoflow and a +// page, one at a time. catalog.RefKinds is held complete against the RefKind +// constants by TestEveryObjectTypeConstantIsInAPublishedList, so a new kind is +// in this loop the moment it is declared; re-introducing an allowlist that +// omits it fails here, by name. +func TestQUAL004CountsEveryRefKind(t *testing.T) { + // CONTROL: a scan of an empty vocabulary would pass whatever the rule did. + if len(catalog.RefKinds) < 20 { + t.Fatalf("catalog.RefKinds holds %d kinds — the vocabulary is broken, not the rule", len(catalog.RefKinds)) + } + for _, kind := range catalog.RefKinds { + t.Run(kind, func(t *testing.T) { + flagged := qual004Flagged(t, qual004FixtureDB(t, []qual004Ref{ + {"MICROFLOW", "M.Caller", "MICROFLOW", "M.Target", kind}, + {"MICROFLOW", "M.Caller", "NANOFLOW", "M.TargetNF", kind}, + {"MICROFLOW", "M.Caller", "PAGE", "M.TargetPage", kind}, + })) + for _, target := range []string{"M.Target", "M.TargetNF", "M.TargetPage"} { + if slices.Contains(flagged, target) { + t.Errorf("a %q edge into %s does not count: QUAL004 reports it as not called from "+ + "anywhere while `mxcli refs` lists the reference", kind, target) + } + } + // CONTROL, per kind: the edge must not silence the unrelated document. + if !slices.Contains(flagged, "M.Unused") { + t.Errorf("control: M.Unused stopped being reported; got %v", flagged) + } + }) + } +}