From 1a9f8696e983334441a0567ce972212998330b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:55:33 +0000 Subject: [PATCH 01/53] Plan: decompose-monolith and MPR-to-MDL rehost pipelines Research summary, platform constraints, mxcli capability probe, target apps, stage tables for both tracks, increments and open questions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016n4dpM66droAmJMuW4YwhV --- CHANGELOG.md | 1 + .../decompose-and-rehost-plan-2026-10-12.md | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 process/decompose-and-rehost-plan-2026-10-12.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e00e47d9..d70c86e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased +- process(plan): **`process/decompose-and-rehost-plan-2026-10-12.md` — two new pipelines proposed: Decompose (split a Mendix monolith by scored cut edges, strangler per slice) and Rehost (read a Mendix 9/10 `.mpr` with `describe module`, regenerate as MDL into Mendix 11, with a fidelity ledger).** Research-backed: Mendix multi-app constraints, mxcli v0.26.0 capabilities probed in a cloud container, candidate public targets (FSM template 9.24.2, GenAI Showcase 48 modules). Nothing built yet; increments and open questions listed. — 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/process/decompose-and-rehost-plan-2026-10-12.md b/process/decompose-and-rehost-plan-2026-10-12.md new file mode 100644 index 00000000..7b5c05cb --- /dev/null +++ b/process/decompose-and-rehost-plan-2026-10-12.md @@ -0,0 +1,133 @@ +# Plan: monolith decomposition and MPR-to-MDL rehost — two new pipelines + +**Status:** draft for discussion, 2026-10-12. Nothing in this plan is built yet. +**Decisions already taken in the conversation that produced it:** Mendix version upgrades are +out of scope (apps on Mendix 9 and above only; the upgrade hop itself is the customer's job). +Two tracks remain and run in parallel: **Decompose** (split one app into several) and +**Rehost** (read an existing `.mpr`, regenerate it as MDL into a fresh Mendix 11 app). + +--- + +## 1. What the research established + +### Decompose — the platform facts that bound every cut + +| Fact | Consequence for a cut | +|---|---| +| Mendix apps cannot share a database; integration is OData (external entities), REST or Business Events | Every edge across the cut becomes an HTTP call. Edge weight is the cost of the cut | +| External entities cannot own associations, cannot aggregate, and every *Send External Object* is its own transaction | An edge carrying an association, an aggregate, or a shared transaction scores as a bad cut | +| No cross-app workflow; no shared user table (OIDC Provider module brokers identity; Mendix SSO deprecated May 2026) | A workflow or a security role that spans the cut is a blocker, not a cost | +| Business Events need a paid Event Broker licence and carry flat payloads | Async edges are an option only when the licence exists (open question §6) | +| Every app is a cloud node; pricing is per app | The number of target apps is a licensing decision before an architecture one | +| Module export to `.mpk` works only for a self-contained module and the same Studio Pro version; no tool moves a module with its transitive closure | The cut has to be executed by regeneration (MDL), not by export/import | + +Mendix's own position (Lennevi): start monolithic, keep domains in modules, break up when team +size or release cadence demands. Nobody has published a Mendix-specific decomposition order or a +cut-scoring method. The toolkit already has the measurement half: `bin/app-facts.sh` +(`dependencies.json`), `skills/module-dependency-review.md` (tangles, pairs, cohesion, hubs) and +`skills/layering-review.md` + `bin/app-layer-map.sh` (the edges that point the wrong way, with a +cut ladder). The missing half is **scoring a proposed cut and executing it**. + +### Rehost — what mxcli can and cannot do today (v0.26.0, probed in a cloud container 2026-10-12) + +- `mxcli describe module ` emits a whole module as re-executable MDL (`mdl 1;` header). 25 + document types are covered, including nanoflows, Java actions (signature only — the body is a + stub, `migrate-general.md`), JavaScript actions, scheduled events, queues, REST and OData + services, mappings, JSON structures. +- mxcli reads MPR v1 (single SQLite, Mendix 9 and 10 before 10.21) and MPR v2 directly. It claims + Mendix 8 to 11 but is **tested only against 11.x**. Reading a Mendix 9/10 `.mpr` is the first + probe, not an assumption. +- `mxcli new --version 10.24.0` creates Mendix 10 fixtures headless; MxBuild for any version downloads + from the CDN in the cloud container (verified HTTP 206 on `mxbuild-11.12.0.tar.gz`). +- Known non-expressible (`skills/learned-mdl-cannot-express.md`): pluggable widgets with + list-valued properties, page-variable writes, file upload widgets, Java action bodies, custom + Dojo widgets (no React target anyway), theme/CSS (not in the model). Every rehost therefore ends + with a **fidelity ledger**, never a claim of 100 %. + +### Target apps (public, free) + +| Target | Mendix | Fits | Caveat | +|---|---|---|---| +| **Maintenance & Field Service Management template** (Marketplace 204718, v1.1.1) | 9.24.2 | The only free, business-domain, plausibly multi-domain monolith: assets, scheduling, work orders, mobile client | Account needed to download; depends on Mendix SSO (removed from Marketplace 1 Nov 2026 — pull it before then); module/entity counts unpublished | +| **GenAI Showcase App** (github.com/mendix/genai-showcase-app) | 11.12 (older releases 9.24.2 / 10.24 on Marketplace 220475) | 48 modules, MIT, `git clone` — the scale test for `describe module` round-trip | Modules are deliberately independent: good for Rehost, weak for Decompose | +| Siemens manufacturing templates (Paperless SOP 120142, Smart Warehousing, Quality Inspection, Workforce Mgmt, …) | 9.x | Candidate pool for a second Mendix 9 target | Versions not indexed; each smaller than FSM | +| User's own large Mendix 11 project | 11 | The honest fidelity ledger — real widgets, Java, theme | Private; read-only runs only; never a public fixture | +| ako/mxcli-banking, -timeregistration, -ledger (GitHub, Apache-2.0) | 11.13/11.14 | Single-module round-trip smoke tests | Too small for Decompose | + +Everything else on GitHub is single-module scale. Marketplace pages need SSO even to read. + +--- + +## 2. Where the work lands (authoring rule, `CLAUDE.md` → "Writing in this toolkit") + +| Piece | Home | Reads | +|---|---|---| +| MPR reader / MDL emitter wrapper (`describe module` per module, fidelity ledger) | `pipelines/mendix-mpr/` | a source (the `.mpr`) | +| Cut scoring | `bin/cut-score.sh` (+ `bin/lib/`) | `analysis/app-facts/dependencies.json`, catalog | +| Decompose judgement, landscape blueprint, slice loop | `skills/decompose-app.md` | the score, the dossier | +| Rehost judgement (what to regenerate, what to hand-fix, done criteria) | `skills/rehost-app.md` | the fidelity ledger | +| Entry-mode rows | `skills/conversion-runbook.md` → Entry Modes; `bin/lib/skill-routing.tsv` | — | + +Rehost is a **migration whose extractor reads a Mendix model**: upstream of the BRD it lives in +`pipelines/`, downstream it reuses Stages 2–6 unchanged. Decompose is **existing-app-change +mode with a new Stage 3 instrument and a per-slice Stage 5 loop**. + +--- + +## 3. Decompose pipeline — stages + +| Stage | Reuses | New | +|---|---|---| +| 0a Map | `app-facts.sh`, `app-report.sh`, dossier §3, layer map | — | +| 0b Scope ✋ | interview protocol | The three questions: why split (team, cadence, licence, risk), how many apps may exist (licensing), which integration lanes are allowed (OData/REST; Business Events only with licence) | +| 3 Cut scoring ✋ | `layering-review.md` cut ladder | `bin/cut-score.sh`: for each candidate partition (from the layer map's levels, or user-named), score every crossing edge by ref kind: `associate` (external-entity rewrite, no aggregates), `call` (OData action or REST), workflow crossing (blocker), security role crossing (blocker), shared `System.FileDocument` (REST, binaries), scheduled event ownership. Output: `analysis/cut-score.html|json`, one row per edge with a lane and a cost class; a partition's total is the sum. User picks or amends; decision in `PROJECT.md` | +| 4 Landscape blueprint | `architecture-blueprint.md` | A target-landscape section: apps, published services per edge, identity broker, the data ownership per entity | +| 5 Slice loop (strangler) | `iterative-build-loop.md`, `existing-app-change.md` | Per slice: (1) regenerate the slice's module(s) into the new app with the Rehost extractor; (2) replace each crossing edge in the monolith by a consumed service and in the new app by a published one; (3) regression baseline from Track B on both sides; (4) data copy per owned entity; (5) retire the module in the monolith. The monolith stays live throughout — the "keep the core and phase out" answer | +| 6 Verify | journeys, e2e | Cross-app journeys: one journey crosses each cut edge at least once (denominator: all scored edges) | + +**Answer to "take apart or rebuild":** take apart, by slice, each slice regenerated with the +Rehost extractor. Rebuild-from-scratch is only the right call when the fidelity ledger of a +Rehost dry run says the model carries more non-expressible content than expressible — that is a +number the Rehost track produces, not an opinion. + +--- + +## 4. Rehost pipeline — stages + +| Stage | What | +|---|---| +| 0 Read probe | `mxcli -p old.mpr -c "list modules"` on the real Mendix 9/10 file. Pass/fail is the first field result; a failure is a bug report to mendixlabs/mxcli and the track stops until it reads | +| 1 Extract | `describe module` per own module → `mdlsource/rehost/.mdl`; marketplace modules are recorded by name and version, never regenerated (reinstalled via `marketplace install`) | +| 1b Fidelity ledger | Per document: `emitted` / `stub` (Java body) / `not-expressible` (from the cannot-express list, detected by `check` or by a widget inventory) / `marketplace`. Denominator: every document in the source catalog. This is the artifact that decides rebuild-vs-split and sizes the hand-fix work | +| 2–4 | BRD/architecture only where the user wants to redesign; the default is a 1:1 regenerate, so these stages are WAIVED with a reason | +| 5 Regenerate | `mxcli new --version 11.x`, reinstall marketplace modules, exec the module scripts in dependency order (layer map order), mxbuild gate | +| 6 Verify | `diff --format struct` old vs regenerated per module (zero structural drift target); Track B regression baseline recorded on the old app replayed on the new one | + +Theme, Java bodies, custom widgets and data migration are hand-off items on the ledger, never +silent. + +--- + +## 5. Increments — small first, parallel tracks + +| # | Track | Target | Deliverable | Runs where | +|---|---|---|---|---| +| 1 | Rehost | ako/mxcli-banking (11.x, one module) | round-trip: describe → new app → exec → struct diff; first ledger format | cloud, now | +| 1 | Decompose | Tier 0 synthetic: 4 modules, one tangle, built in Mendix **10.24** with `mxcli new` | `cut-score.sh` v0 over `dependencies.json`; proves the edge classifier on a known answer | cloud, now | +| 2 | Rehost | GenAI Showcase (48 modules, 11.12) | scale: time per module, ledger over a real widget mix | cloud | +| 2 | Decompose | FSM template (9.24.2) | **the Mendix 9 read probe**, then cut scoring on a real tangle | cloud, needs user PAT for `mxcli auth login` + `marketplace download` | +| 3 | both | FSM | first actual slice: one module out, two edges rewired, journeys across the cut | cloud | +| 4 | both | user's Mendix 11 project | fidelity ledger and cut score on a real customer-shaped app; read-only | user's container | + +Field-proof rules apply (`CLAUDE.md` → "Shipping an instrument"): captured fixtures, both +layouts, a cited field run per instrument, a producer for every artifact consumed. + +--- + +## 6. Open questions (answers go to `PROJECT.md` of the first field project) + +1. Target landscape: Mendix apps only, or may a slice leave Mendix? (changes the lane table in cut scoring) +2. Is the Event Broker licence available to the customers this is for? If not, Business Events is not a lane. +3. Rehost done-criterion: model builds clean, or parity proven by the Track B regression baseline? +4. FSM download: the user's Mendix PAT is needed in the cloud container; pull before 1 Nov 2026. +5. Data migration during a slice (copy per owned entity): in scope of the toolkit, or always a customer job? From 5e791cc426e6a69e9714c79d1cb8ab555a6648e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:37:44 +0000 Subject: [PATCH 02/53] =?UTF-8?q?Decompose:=20bin/cut-score.sh=20=E2=80=94?= =?UTF-8?q?=20Stage=203=20cut=20scoring,=20fixture=20on=20a=20Mendix=2010.?= =?UTF-8?q?24=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scores a proposed partition of own modules into apps: every crossing edge gets the integration lane its ref kind must become (OData external entity, published action/REST, or none = blocker), aggregates over a foreign entity are read from the mxcli catalog, and each partition gets a clean/costly/blocked verdict with the denominator printed. Business Events is not a lane (no Event Broker licence). Field run 2026-10-12 on a four-module Mendix 10.24.0 fixture built by MDL with a planted Orders<->Billing tangle and a Reporting->Billing COUNT/SUM: both found. Fixture is the verbatim app-facts, layer-map and reduced catalog output. tests/wave2/test-cut-score.sh: 56 checks pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016n4dpM66droAmJMuW4YwhV --- bin/cut-score.sh | 559 ++++++++++++++++++ tests/wave2/fixtures/cut-score/CAPTURE.txt | 23 + .../fixtures/cut-score/app-layer-map.json | 56 ++ tests/wave2/fixtures/cut-score/catalog.db | Bin 0 -> 110592 bytes .../cut-score/facts/dependencies.json | 363 ++++++++++++ .../fixtures/cut-score/facts/manifest.json | 52 ++ tests/wave2/test-cut-score.sh | 204 +++++++ 7 files changed, 1257 insertions(+) create mode 100755 bin/cut-score.sh create mode 100644 tests/wave2/fixtures/cut-score/CAPTURE.txt create mode 100644 tests/wave2/fixtures/cut-score/app-layer-map.json create mode 100644 tests/wave2/fixtures/cut-score/catalog.db create mode 100644 tests/wave2/fixtures/cut-score/facts/dependencies.json create mode 100644 tests/wave2/fixtures/cut-score/facts/manifest.json create mode 100644 tests/wave2/test-cut-score.sh diff --git a/bin/cut-score.sh b/bin/cut-score.sh new file mode 100755 index 00000000..7d18f9a7 --- /dev/null +++ b/bin/cut-score.sh @@ -0,0 +1,559 @@ +#!/bin/bash +# cut-score.sh: score a proposed decomposition of an existing app into several apps: +# analysis/cut-score.html + analysis/cut-score.json +# +# WHAT THIS IS. The Stage 3 instrument of process/decompose-and-rehost-plan-2026-10-12.md. Given a +# CUT — a partition of the own modules into would-be apps — it lists every module-level edge that +# would cross an app boundary, names the integration LANE each ref kind would have to become +# (an OData external entity, a published OData action or REST call, or nothing at all because the +# platform has no lane for it), and sums that per partition into a verdict: clean / costly / +# blocked. Business Events is not a lane here: the customer this was written for holds no Event +# Broker licence, so a crossing can only become OData or REST, or it blocks. +# +# WHAT IT READS. analysis/app-facts/dependencies.json, written by project-bin/app-facts.sh (the +# mandatory Stage 0 facts step) — the edges array with its per-ref-kind counts, and +# scope.own_modules. Optionally .mxcli/catalog.db, which app-facts.sh leaves behind (graph-report +# builds it in full mode): that is the only place an AGGREGATE over a foreign entity can be seen, +# because dependencies.json records that a microflow retrieves Billing.Invoice but not what it +# then does with the list. With no catalog the aggregate column reads NOT DETECTED and says why; +# it is never silently blank. The default cut (one app per layer-map level) reads +# analysis/app-layer-map.json, written by bin/app-layer-map.sh; without it, --cut is required. +# +# WHAT THIS IS NOT. Not a judge. The lane table below is platform fact (an external entity cannot +# own an association, cannot be aggregated server-side, cannot be the subject of a workflow; a +# synchronous call across apps is a published action or a REST operation and the transaction no +# longer spans), and the verdict is arithmetic over that table. Whether a costly partition is +# worth it is skills/layering-review.md and the Stage 3 interview, not this file. +# +# WHAT IT CANNOT SEE, and prints on the page: Java and JavaScript actions, published REST called +# by name, workflow references, page URLs (same blind spots as app-layer-map.sh); an aggregate +# over a list that came back from a cross-partition CALL (the catalog has no variable linkage, so +# the aggregate is attributed to the retrieve in the same microflow, never to the call); the +# volume of data an OData hop would move at runtime. +# +# Usage: +# bin/cut-score.sh [project-dir] --cut "Core,Util|Orders|Billing|Reporting" +# bin/cut-score.sh [project-dir] # default cut: one app per layer-map level +# bin/cut-score.sh --facts DIR --catalog DB --cut ... -o OUT +# bin/cut-score.sh ... --json # the same numbers to stdout, no HTML +# +# A module in scope.own_modules that --cut does not name lands in a partition called "_unassigned" +# and is reported as a gap; a name in --cut that is not an own module is reported as a gap too. +# Neither is ever inferred into a neighbouring partition. +# +# Exit: 0 rendered · 2 no dependencies.json, no cut (no --cut and no app-layer-map.json), or +# malformed --cut +# +# Field run: lab checkout lab/decompose (CutFixture, Mendix 10.24.0, mxcli v0.26.0, 2026-10-12) — +# 7 own edges, 6 crossing on the four-way cut; found the Orders<->Billing call tangle as the +# blocker and the Reporting->Billing retrieve+aggregate from the catalog. +# +# portability-ok: bash 3.2, Python 3 resolved through portable.sh + +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +. "$HERE/lib/portable.sh" +require_py + +PROJECT=""; FACTS=""; OUT=""; JSON=0; CUT=""; CATALOG=""; LAYERMAP="" +while [ $# -gt 0 ]; do + case "$1" in + --facts) FACTS="$2"; shift 2 ;; + --catalog) CATALOG="$2"; shift 2 ;; + --layer-map) LAYERMAP="$2"; shift 2 ;; + --cut) CUT="$2"; shift 2 ;; + -o|--out) OUT="$2"; shift 2 ;; + --json) JSON=1; shift ;; + -h|--help) sed -n '2,50p' "$0"; exit 0 ;; + -*) echo "cut-score: unknown option $1" >&2; exit 2 ;; + *) PROJECT="$1"; shift ;; + esac +done +[ -n "$PROJECT" ] || PROJECT="." +PROJECT="$(cd "$PROJECT" 2>/dev/null && pwd)" || { echo "cut-score: no such directory" >&2; exit 2; } +[ -n "$FACTS" ] || FACTS="$PROJECT/analysis/app-facts" +[ -n "$OUT" ] || OUT="$PROJECT/analysis/cut-score.html" +[ -n "$LAYERMAP" ] || LAYERMAP="$PROJECT/analysis/app-layer-map.json" +if [ -z "$CATALOG" ]; then + # The catalog sits beside the .mpr: single-tree (project root) or two-tree (app/). + for c in "$PROJECT/.mxcli/catalog.db" "$PROJECT/app/.mxcli/catalog.db"; do + [ -f "$c" ] && { CATALOG="$c"; break; } + done + [ -n "$CATALOG" ] || CATALOG="$PROJECT/.mxcli/catalog.db" +fi + +if [ ! -f "$FACTS/dependencies.json" ]; then + echo "cut-score: no $FACTS/dependencies.json. Run project-bin/app-facts.sh in the project first" >&2 + exit 2 +fi +if [ -z "$CUT" ] && [ ! -f "$LAYERMAP" ]; then + echo "cut-score: no --cut given and no $LAYERMAP to take the default cut from." >&2 + echo " Run bin/app-layer-map.sh first (one app per level) or pass --cut \"A,B|C|D\"." >&2 + exit 2 +fi + +FACTS="$FACTS" OUT="$OUT" JSON="$JSON" CUT="$CUT" CATALOG="$CATALOG" LAYERMAP="$LAYERMAP" \ + "$PY" - <<'PY' +import json, os, sys, html, datetime, collections, sqlite3 + +F = os.environ["FACTS"]; OUT = os.environ["OUT"]; JSON = os.environ["JSON"] == "1" +CUT = os.environ["CUT"]; CATALOG = os.environ["CATALOG"]; LAYERMAP = os.environ["LAYERMAP"] + +def load(path, required=False): + if not os.path.exists(path): + if required: + print("cut-score: %s is missing" % path, file=sys.stderr); sys.exit(2) + return None + try: + return json.load(open(path, encoding="utf-8")) + except ValueError as e: + if required: + print("cut-score: %s is not valid JSON: %s" % (path, e), file=sys.stderr); sys.exit(2) + return None + +dep = load(os.path.join(F, "dependencies.json"), required=True) +man = load(os.path.join(F, "manifest.json")) or {} + +gaps = [] # every fact this page wanted and did not get; printed, never swallowed +if not man: + gaps.append("manifest.json is missing or unreadable, so the page cannot say when the facts were collected") + +edges_raw = dep.get("edges") or [] +if not edges_raw: + gaps.append("dependencies.json carries no edges array: nothing can cross a boundary that nothing crosses") +own = sorted(set(x for x in (dep.get("scope", {}).get("own_modules") or []) if x)) +if not own: + own = sorted({e.get("from") for e in edges_raw} | {e.get("to") for e in edges_raw} - {None}) + gaps.append("scope.own_modules is absent, so the module set was derived from the edges; " + "a module with no edge at all is missing from this page") + +# ---- the cut ---------------------------------------------------------------------------------- +partitions = collections.OrderedDict() # name -> [modules] +cut_source = "" +if CUT.strip(): + cut_source = "--cut" + for i, part in enumerate(CUT.split("|")): + mods = [m.strip() for m in part.split(",") if m.strip()] + if not mods: + print("cut-score: --cut has an empty partition at position %d" % (i + 1), file=sys.stderr); sys.exit(2) + name = "+".join(mods) if len(mods) <= 2 else "%s+%d more" % (mods[0], len(mods) - 1) + if name in partitions: + name = "%s#%d" % (name, i + 1) + partitions[name] = mods +else: + lm = load(LAYERMAP, required=True) + levels = lm.get("levels_by_module") or {} + if not levels: + print("cut-score: %s has no levels_by_module" % LAYERMAP, file=sys.stderr); sys.exit(2) + cut_source = "default: one app per level of %s" % LAYERMAP + by_level = collections.defaultdict(list) + for m, lv in levels.items(): + by_level[int(lv)].append(m) + for lv in sorted(by_level): + partitions["L%d" % lv] = sorted(by_level[lv]) + +part_of = {} +for name, mods in partitions.items(): + for m in mods: + if m in part_of: + gaps.append("module %s is named in two partitions (%s and %s); the first wins" % (m, part_of[m], name)) + continue + part_of[m] = name +unknown = sorted(m for m in part_of if m not in own) +if unknown: + gaps.append("named in the cut but not an own module of this app (typo, marketplace, or dropped): %s" + % ", ".join(unknown)) +unassigned = sorted(m for m in own if m not in part_of) +if unassigned: + partitions["_unassigned"] = unassigned + for m in unassigned: + part_of[m] = "_unassigned" + gaps.append("own modules the cut does not place: %s — scored as a partition of their own named " + "_unassigned; decide where they go, nothing here guesses" % ", ".join(unassigned)) + +# ---- lane table (platform fact) --------------------------------------------------------------- +# kind -> (lane, cost, class, why) +LANES = { + "associate": ("odata-external-entity", "medium", "acceptable", + "the referenced entity becomes an external entity in the referencing app; the local " + "side keeps the association (an external entity cannot own one); the data stays with the owner"), + "retrieve": ("odata-external-entity", "medium", "acceptable", + "a database retrieve becomes an OData read; XPath is limited to what the service exposes"), + "datasource": ("odata-external-entity", "medium", "acceptable", + "a page datasource over a foreign entity reads an external entity; no client-side XPath beyond the service"), + "xpath": ("odata-external-entity", "medium", "acceptable", + "an XPath constraint over a foreign attribute must be expressible as an OData filter"), + "member": ("odata-external-entity", "low", "acceptable", + "attribute access on a foreign entity rides on the external entity; exposed attributes only"), + "parameter": ("odata-external-entity", "low", "acceptable", + "a parameter typed on a foreign entity retypes to the external entity"), + "return": ("odata-external-entity", "low", "acceptable", + "a return typed on a foreign entity retypes to the external entity"), + "type": ("odata-external-entity", "low", "acceptable", + "a type reference to a foreign entity retypes to the external entity"), + "create": ("odata-external-entity (write)", "high", "costly", + "creating a foreign object needs a writable external entity (OData v4) or an action in the owning app"), + "change": ("odata-external-entity (write)", "high", "costly", + "changing a foreign object needs a writable external entity or an action in the owning app"), + "commit": ("odata-external-entity (write)", "high", "costly", + "committing a foreign object needs a writable external entity; the transaction no longer spans both apps"), + "delete": ("odata-external-entity (write)", "high", "costly", + "deleting a foreign object needs a writable external entity or an action in the owning app"), + "call": ("odata-action-or-rest", "high", "costly", + "an in-process microflow call becomes a published OData action or a REST operation; " + "synchronous, no shared transaction, error handling changes"), + "action": ("odata-action-or-rest", "high", "costly", + "a widget action on a foreign microflow becomes a published action or REST call"), + "show_page": ("deep-link", "high", "costly", + "a page in another app cannot be shown in-process; the navigation becomes a URL"), + "home_page": ("deep-link", "high", "costly", "a home page in another app becomes a URL"), + "menu_item": ("deep-link", "high", "costly", "a menu item into another app becomes a URL"), + "layout": ("copy", "low", "acceptable", "a layout cannot be shared across apps; copy it or take Atlas"), + "mapping": ("copy", "low", "acceptable", "an import/export mapping cannot be shared; copy it"), + "value": ("copy", "low", "acceptable", "a constant/enum value reference; copy the document"), + "generalize": ("none", "blocker", "blocker", + "inheritance cannot cross an app boundary: an external entity has no specializations"), + "workflow": ("none", "blocker", "blocker", "a workflow cannot reference documents in another app"), + "user_task": ("none", "blocker", "blocker", "a user task cannot target a page or microflow in another app"), + "security_role": ("none", "blocker", "blocker", "a module role cannot be granted by another app's user roles"), + "role": ("none", "blocker", "blocker", "a role reference cannot cross an app boundary"), +} +UNKNOWN = ("unclassified", "unknown", "unclassified", + "this ref kind is not in the lane table; it is counted in the denominator and must be read by hand") +COST_RANK = {"low": 1, "medium": 2, "high": 3, "blocker": 4, "unknown": 3} + +# ---- crossing edges --------------------------------------------------------------------------- +rows = [] +total_edges = 0; total_weight = 0 +calls_between = collections.defaultdict(int) # (P,Q) -> call weight +for e in edges_raw: + a, b = e.get("from"), e.get("to") + if not a or not b or a == b: + continue + kinds = e.get("kinds") or {} + w_edge = int(e.get("edges") or sum(kinds.values()) or 0) + total_edges += 1; total_weight += w_edge + pa, pb = part_of.get(a), part_of.get(b) + if pa is None or pb is None: + gaps.append("edge %s -> %s has an endpoint outside the own-module set; skipped" % (a, b)) + continue + if pa == pb: + continue + if not kinds: + kinds = {"(kinds not recorded)": w_edge} + for kind, n in sorted(kinds.items()): + lane, cost, cls, why = LANES.get(kind, UNKNOWN) + if kind not in LANES: + gaps.append("ref kind %r on %s -> %s is not in the lane table" % (kind, a, b)) + rows.append({"from": a, "to": b, "from_partition": pa, "to_partition": pb, "kind": kind, + "weight": int(n), "lane": lane, "cost": cost, "class": cls, "why": why, + "aggregate": "NOT DETECTED"}) + if kind in ("call", "action"): + calls_between[(pa, pb)] += int(n) + +crossing_edges = len({(r["from"], r["to"]) for r in rows}) +crossing_weight = sum(r["weight"] for r in rows) + +# ---- bidirectional call pairs at partition level: the blocker --------------------------------- +bidir = [] +seen = set() +for (pa, pb), w in sorted(calls_between.items()): + if (pb, pa) in calls_between and (pb, pa) not in seen: + seen.add((pa, pb)) + bidir.append({"a": pa, "b": pb, "a_to_b_calls": w, "b_to_a_calls": calls_between[(pb, pa)], + "edges": sorted({"%s -> %s" % (r["from"], r["to"]) for r in rows + if r["kind"] in ("call", "action") + and {r["from_partition"], r["to_partition"]} == {pa, pb}})}) +bidir_pairs = {frozenset((x["a"], x["b"])) for x in bidir} +for r in rows: + if r["kind"] in ("call", "action") and frozenset((r["from_partition"], r["to_partition"])) in bidir_pairs: + r["lane"] = "none"; r["cost"] = "blocker"; r["class"] = "blocker" + r["why"] = ("part of a bidirectional call pair between %s and %s: each side would publish an action the " + "other consumes, and neither can deploy first" % (r["from_partition"], r["to_partition"])) + +# ---- aggregate detection from the catalog ----------------------------------------------------- +aggregate = {"status": "NOT DETECTED", "reason": "", "catalog": CATALOG, "hits": []} +if not os.path.exists(CATALOG): + aggregate["reason"] = ("no catalog at %s; app-facts.sh leaves one beside the .mpr after graph-report, " + "pass --catalog to point at it" % CATALOG) +else: + try: + db = sqlite3.connect("file:%s?mode=ro" % CATALOG, uri=True) + names = {r[0] for r in db.execute("select name from sqlite_master where type='table'")} + if "activities_data" not in names: + aggregate["reason"] = "catalog has no activities_data table; this mxcli wrote a catalog this script cannot read" + else: + cols = {r[1] for r in db.execute("pragma table_info(activities_data)")} + need = {"MicroflowQualifiedName", "ModuleName", "ActionType", "EntityRef"} + if not need <= cols: + aggregate["reason"] = "activities_data lacks columns %s" % ", ".join(sorted(need - cols)) + else: + mode = db.execute("select Value from catalog_meta where Key='build_mode'").fetchone() \ + if "catalog_meta" in names else None + if mode and mode[0] != "full": + aggregate["reason"] = "catalog build_mode is %r, activities are not trustworthy; rerun app-facts.sh" % mode[0] + else: + per_mf = collections.defaultdict(lambda: {"module": None, "aggregates": 0, "retrieves": []}) + for mf, mod, at, ent in db.execute( + "select MicroflowQualifiedName, ModuleName, ActionType, EntityRef from activities_data " + "where ActionType in ('AggregateListAction','RetrieveAction')"): + d = per_mf[mf]; d["module"] = mod + if at == "AggregateListAction": + d["aggregates"] += 1 + elif ent: + d["retrieves"].append(ent) + hits = [] + for mf, d in sorted(per_mf.items()): + if d["aggregates"] == 0 or not d["retrieves"]: + continue + pa = part_of.get(d["module"]) + for ent in sorted(set(d["retrieves"])): + emod = ent.split(".")[0] + pb = part_of.get(emod) + if pa and pb and pa != pb: + hits.append({"microflow": mf, "from": d["module"], "to": emod, "entity": ent, + "aggregates": d["aggregates"], "from_partition": pa, "to_partition": pb}) + aggregate["hits"] = hits + aggregate["status"] = "DETECTED" if hits else "none found" + aggregate["reason"] = ("basis: a microflow with a RetrieveAction on a foreign entity and an " + "AggregateListAction in the same microflow; the catalog stores no " + "variable linkage, so an aggregate over a list returned by a foreign CALL " + "is invisible here") + for h in hits: + for r in rows: + if r["from"] == h["from"] and r["to"] == h["to"] and r["kind"] == "retrieve": + r["aggregate"] = "yes (%s, %d aggregate%s)" % (h["microflow"], h["aggregates"], + "" if h["aggregates"] == 1 else "s") + r["cost"] = "high"; r["class"] = "costly" + r["why"] = ("the retrieved list is aggregated in %s: over an external entity that pulls " + "the whole set across the wire or moves the aggregation into the owning app" + % h["microflow"]) + db.close() + except sqlite3.Error as e: + aggregate["reason"] = "catalog could not be read: %s" % e +if aggregate["status"] == "NOT DETECTED": + gaps.append("aggregate detection: NOT DETECTED — %s" % aggregate["reason"]) +for r in rows: + if r["aggregate"] == "NOT DETECTED": + if aggregate["status"] == "NOT DETECTED": + r["aggregate"] = "NOT DETECTED (no catalog)" + elif r["kind"] in ("retrieve", "datasource"): + r["aggregate"] = "no" if r["kind"] == "retrieve" else "n/a (page)" + else: + r["aggregate"] = "n/a" + +# ---- per partition ---------------------------------------------------------------------------- +parts = collections.OrderedDict() +for name, mods in partitions.items(): + out_rows = [r for r in rows if r["from_partition"] == name] + in_rows = [r for r in rows if r["to_partition"] == name] + blockers = [r for r in out_rows + in_rows if r["class"] == "blocker"] + costly = [r for r in out_rows if r["class"] in ("costly", "unclassified")] + acceptable = [r for r in out_rows if r["class"] == "acceptable"] + # blocked: a blocker touches this partition in either direction (a tangle has no side). + # costly: something going OUT needs a published action, REST, a write, a deep link, or carries + # an aggregate. clean: nothing goes out, or only external-entity reads/types/copies do. + if blockers: + verdict = "blocked" + elif costly: + verdict = "costly" + else: + verdict = "clean" + parts[name] = { + "modules": mods, + "out_edges": len({(r["from"], r["to"]) for r in out_rows}), "out_weight": sum(r["weight"] for r in out_rows), + "in_edges": len({(r["from"], r["to"]) for r in in_rows}), "in_weight": sum(r["weight"] for r in in_rows), + "lanes": dict(collections.Counter(r["lane"] for r in out_rows)), + "blockers": sorted({"%s -> %s (%s): %s" % (r["from"], r["to"], r["kind"], r["why"]) for r in blockers}), + "costly": sorted({"%s -> %s (%s)" % (r["from"], r["to"], r["kind"]) for r in costly}), + "acceptable": sorted({"%s -> %s (%s)" % (r["from"], r["to"], r["kind"]) for r in acceptable}), + "aggregates_out": [h for h in aggregate["hits"] if h["from_partition"] == name], + "verdict": verdict, + } + +overall = "blocked" if any(p["verdict"] == "blocked" for p in parts.values()) else \ + "costly" if any(p["verdict"] == "costly" for p in parts.values()) else "clean" + +summary = { + "generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "facts": F, "catalog": CATALOG, "cut_source": cut_source, + "cut": [{"name": n, "modules": m} for n, m in partitions.items()], + "own_modules": len(own), "own_edges": total_edges, "own_edge_weight": total_weight, + "crossing_edges": crossing_edges, "crossing_weight": crossing_weight, "crossing_rows": len(rows), + "denominator": "%d of %d own edges cross a partition boundary, carrying %d of %d refs" % ( + crossing_edges, total_edges, crossing_weight, total_weight), + "rows": rows, "bidirectional_partition_pairs": bidir, "aggregate": aggregate, + "partitions": parts, "verdict": overall, "gaps": gaps, + "lanes_excluded": ["business-events (no Event Broker licence)"], +} + +if JSON: + print(json.dumps(summary, indent=2, sort_keys=True)); sys.exit(0) + +# ---- HTML ------------------------------------------------------------------------------------- +E_ = html.escape +app_name = man.get("mpr") or os.path.basename(os.path.dirname(os.path.dirname(F))) or "app" + +CSS = """ +:root{--bg:#fbfbfd;--fg:#16181d;--mut:#606878;--line:#e2e5ec;--card:#fff; +--ok:#1a7f52;--warn:#a2650a;--bad:#b3261e;--crit:#7a1710;--acc:#2b5bd7} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--fg); +font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} +.wrap{max-width:1300px;margin:0 auto;padding:22px 20px 60px} +header h1{font-size:23px;margin:0 0 3px} +.sub{color:var(--mut);margin:0 0 16px;font-size:12.5px} +section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:0 0 16px} +h2{font-size:16px;margin:0 0 10px} +h3{font-size:13.5px;margin:18px 0 7px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)} +p{margin:0 0 9px} +.lede{font-size:14.5px} +.kpis{display:flex;flex-wrap:wrap;gap:9px;margin:12px 0 4px} +.kpi{border:1px solid var(--line);border-radius:8px;padding:8px 12px;min-width:112px;background:var(--card)} +.kpi b{display:block;font-size:21px;line-height:1.15} +.kpi span{color:var(--mut);font-size:11.5px} +.kpi.k-bad b{color:var(--bad)} .kpi.k-ok b{color:var(--ok)} .kpi.k-warn b{color:var(--warn)} +.note{border-radius:8px;padding:9px 12px;margin:10px 0;font-size:13px} +.note.fault{background:#fdecea;border:1px solid #f3b7b1;color:var(--crit)} +.note.warn{background:#fdf5e4;border:1px solid #efd9a4;color:var(--warn)} +.legend{color:var(--mut);font-size:12.2px;margin-top:10px} +.tw{overflow-x:auto;margin:6px 0} +table{border-collapse:collapse;width:100%;font-size:12.8px} +th,td{text-align:left;padding:5px 9px;border-bottom:1px solid var(--line);vertical-align:top} +th{color:var(--mut);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em} +td.why{white-space:normal;color:var(--mut);font-size:12px} +code{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f1f2f6;padding:1px 4px;border-radius:4px} +.v{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11.5px;font-weight:600;color:#fff} +.v.clean{background:var(--ok)} .v.costly{background:var(--warn)} .v.blocked{background:var(--bad)} +.c-blocker{color:var(--bad);font-weight:600} .c-costly{color:var(--warn);font-weight:600} .c-acceptable{color:var(--ok)} .c-unclassified{color:var(--bad)} +.empty{color:var(--mut);font-style:italic} +@media(prefers-color-scheme:dark){ +:root{--bg:#131519;--fg:#e8eaee;--mut:#98a0b0;--line:#2b2f38;--card:#191c21} +.note.fault{background:#33191a;border-color:#6b2b26;color:#ffb3a8} +.note.warn{background:#2e2716;border-color:#6a5527;color:#f0cf8a} +code{background:#23262d}} +""" + +def table(headers, body_rows, cls=None): + if not body_rows: + return '

none

' + out = ['
%s' % "".join("" % E_(h) for h in headers)] + for r in body_rows: + cells = [] + for i, c in enumerate(r): + if isinstance(c, tuple): + cells.append('' % (c[1], c[0])) + else: + cells.append("%s" % (' class="why"' if cls and cls[i] == "why" else "", E_(str(c)))) + out.append("%s" % "".join(cells)) + out.append("
%s%s
") + return "".join(out) + +def vbadge(v): + return '%s' % (v, v) + +p = [] +p.append('

Cut score: %s

%s%s · facts %s · rendered %s · ' + 'the judgement for this page is skills/layering-review.md and the Stage 3 interview

' % ( + E_(app_name), E_(man.get("mpr") or "mpr not named"), + (" · " + E_(man.get("mxcli"))) if man.get("mxcli") else "", + E_(man.get("generated") or "collection date unknown"), E_(summary["generated"]))) + +for g in gaps: + p.append('
%s
' % E_(g)) + +p.append('

Verdict %s

' % vbadge(overall)) +p.append('

Cut (%s): %s.

' % (E_(cut_source), " | ".join( + "%s = %s" % (E_(n), E_(", ".join(m))) for n, m in partitions.items()))) +p.append('

%s. Lanes available: OData external entity, published OData action or REST. ' + 'Excluded: Business Events (no Event Broker licence).

' % E_(summary["denominator"])) +kp = [("%d" % len(partitions), "partitions", ""), + ("%d / %d" % (crossing_edges, total_edges), "crossing edges / own edges", ""), + ("%d / %d" % (crossing_weight, total_weight), "crossing refs / own refs", ""), + ("%d" % len(bidir), "bidirectional call pairs", "k-bad" if bidir else "k-ok"), + ("%d" % sum(1 for r in rows if r["class"] == "blocker"), "blocker rows", "k-bad" if any(r["class"] == "blocker" for r in rows) else "k-ok"), + (("%d" % len(aggregate["hits"])) if aggregate["status"] != "NOT DETECTED" else "n/d", + "cross-partition aggregates" + ("" if aggregate["status"] != "NOT DETECTED" else " (NOT DETECTED)"), + "k-warn" if aggregate["hits"] or aggregate["status"] == "NOT DETECTED" else "k-ok")] +p.append('
%s
' % "".join('
%s%s
' % (c, E_(v), E_(l)) for v, l, c in kp)) +p.append('
') + +p.append('

Per partition

') +p.append(table(["Partition", "Modules", "Out edges (refs)", "In edges (refs)", "Lanes out", "Blockers", "Verdict"], + [[n, ", ".join(d["modules"]), "%d (%d)" % (d["out_edges"], d["out_weight"]), + "%d (%d)" % (d["in_edges"], d["in_weight"]), + ", ".join("%s x%d" % (k, v) for k, v in sorted(d["lanes"].items())) or "-", + ("
".join(E_(b) for b in d["blockers"]) or "-", "why"), + (vbadge(d["verdict"]), "")] for n, d in parts.items()])) +if bidir: + p.append('

Bidirectional call pairs (blocker)

') + p.append(table(["A", "B", "A->B calls", "B->A calls", "Edges"], + [[b["a"], b["b"], b["a_to_b_calls"], b["b_to_a_calls"], "; ".join(b["edges"])] for b in bidir])) +p.append('
') + +p.append('

Every crossing edge (%d rows over %d module edges)

' % (len(rows), crossing_edges)) +p.append(table(["From", "To", "Partitions", "Ref kind", "Refs", "Lane", "Cost", "Class", "Aggregate", "Why"], + [[r["from"], r["to"], "%s -> %s" % (r["from_partition"], r["to_partition"]), r["kind"], r["weight"], + r["lane"], r["cost"], (E_(r["class"]), "c-" + r["class"]), r["aggregate"], r["why"]] for r in + sorted(rows, key=lambda r: (-COST_RANK.get(r["cost"], 0), r["from"], r["to"], r["kind"]))], + cls=[None, None, None, None, None, None, None, None, None, "why"])) +p.append('
') + +p.append('

Aggregates over foreign entities: %s

' % E_(aggregate["status"])) +p.append('

%s

' % E_(aggregate["reason"])) +p.append('

catalog: %s

' % E_(CATALOG)) +if aggregate["hits"]: + p.append(table(["Microflow", "From", "Retrieves", "Aggregates in microflow", "Partitions"], + [[h["microflow"], h["from"], h["entity"], h["aggregates"], + "%s -> %s" % (h["from_partition"], h["to_partition"])] for h in aggregate["hits"]])) +p.append('
') + +p.append('

Method, and what this page cannot see

    ') +for line in [ + "Source: analysis/app-facts/dependencies.json (edges with per-kind counts, scope.own_modules) and, for " + "aggregates, the mxcli catalog beside the .mpr. Nothing here re-reads the model.", + "A crossing edge is a module-to-module edge whose endpoints sit in different partitions. Edges inside a " + "partition are not listed; they are in the denominator above.", + "Lanes are platform facts, not judgement: an external entity cannot own an association, be specialised, " + "be aggregated server-side, or be the subject of a workflow; a call across apps is a published action or " + "REST operation with no shared transaction. Business Events is excluded by licence.", + "Blocker = bidirectional call pair between two partitions, or a generalize/workflow/security-role crossing. " + "Costly = a call, a write to a foreign object, a page shown across apps, or a retrieve that feeds an aggregate. " + "Acceptable = association, read, type or copy over an external entity (the owner keeps the data). " + "A partition is blocked if a blocker touches it in either direction, costly if anything costly leaves it, " + "clean if nothing or only acceptable edges leave it.", + "Aggregate detection attributes an AggregateListAction to a RetrieveAction in the same microflow. It cannot " + "see an aggregate over a list returned by a cross-partition call, nor how many objects an OData hop would move.", + "Not visible at all: Java and JavaScript actions, published REST called by name, workflow references, page URLs. " + "An edge that only exists through one of those is missing here, which is why a cut is confirmed in Studio Pro " + "before it becomes a slice.", +]: + p.append("
  • %s
  • " % E_(line)) +p.append('

An agent reading this later should take the JSON, not the page: ' + 'bin/cut-score.sh <project> --cut "..." --json emits every row with its lane, cost and aggregate flag.

') +p.append('
') + +doc = ("" + "" + "Cut score: %s
%s
" + % (E_(app_name), CSS, "\n".join(p))) + +d = os.path.dirname(OUT) +if d and not os.path.isdir(d): + os.makedirs(d) +open(OUT, "w", encoding="utf-8").write(doc) +json_out = os.path.splitext(OUT)[0] + ".json" +open(json_out, "w", encoding="utf-8").write(json.dumps(summary, indent=2, sort_keys=True)) +print("cut-score: %s" % OUT) +print("cut-score: %s" % json_out) +print(" verdict %s; %s; %d bidirectional call pair(s); aggregates %s (%d)" % ( + overall, summary["denominator"], len(bidir), aggregate["status"], len(aggregate["hits"]))) +for n, d_ in parts.items(): + print(" %-28s %-8s out %d(%d) in %d(%d)%s" % (n, d_["verdict"], d_["out_edges"], d_["out_weight"], + d_["in_edges"], d_["in_weight"], + (" blockers: " + "; ".join(d_["blockers"])) if d_["blockers"] else "")) +for g in gaps: + print(" gap: %s" % g) +PY diff --git a/tests/wave2/fixtures/cut-score/CAPTURE.txt b/tests/wave2/fixtures/cut-score/CAPTURE.txt new file mode 100644 index 00000000..6bfc4d5a --- /dev/null +++ b/tests/wave2/fixtures/cut-score/CAPTURE.txt @@ -0,0 +1,23 @@ +Captured 2026-10-12 from the lab checkout lab/decompose (CutFixture, Mendix 10.24.0.73019, mxcli v0.26.0), +a four-module fixture built by MDL with a planted structure; the MDL is in the field-run notes +(lab/decompose/mdl/*.mdl in that checkout, not shipped here). + + Core, Util Customer, Product; SUB_Log + non-persistent LogEntry + Orders Order ->Core.Customer, OrderLine ->Core.Product; ACT_Order_Create calls Util.SUB_Log; + ACT_Order_Close calls Billing.ACT_Invoice_Create (deliberate back-edge) + Billing Invoice ->Orders.Order; ACT_Invoice_Create calls Orders.SUB_Order_GetLines + Util.SUB_Log + Reporting page over Core.Customer + Billing.Invoice; CAL_Invoice_Totals retrieves Billing.Invoice + and runs COUNT + SUM over it + MyFirstModule the mxcli new scaffold, left in place (an own module the cut does not name) + +facts/dependencies.json verbatim output of project-bin/app-facts.sh --skip-loops +facts/manifest.json verbatim +app-layer-map.json verbatim output of bin/app-layer-map.sh (the default-cut producer) +catalog.db .mxcli/catalog.db reduced to the two tables cut-score.sh reads + (activities_data 279 rows, catalog_meta), rows copied verbatim, nothing edited + +Known right answer for --cut "Core,Util|Orders|Billing|Reporting": + Orders <-> Billing bidirectional call pair -> blocker, both partitions blocked + Reporting -> Billing retrieve feeding 2 aggregates -> costly (DETECTED from the catalog) + Orders -> Core associate + parameter only -> acceptable (external entity, Core owns the data) + Core+Util nothing leaves -> clean diff --git a/tests/wave2/fixtures/cut-score/app-layer-map.json b/tests/wave2/fixtures/cut-score/app-layer-map.json new file mode 100644 index 00000000..0343aa8b --- /dev/null +++ b/tests/wave2/fixtures/cut-score/app-layer-map.json @@ -0,0 +1,56 @@ +{ + "back_edge_sources": 1, + "back_edge_weight": 1, + "back_edges": 1, + "back_edges_list": [ + { + "from": "Orders", + "kinds": { + "call": 1 + }, + "to": "Billing", + "weight": 1 + } + ], + "cut_ladder": [ + { + "edges": 1, + "largest_tangle_cumulative": 0, + "largest_tangle_if_only_this": 0, + "module": "Orders", + "tangles_cumulative": 0, + "weight": 1 + } + ], + "facts_generated": "2026-10-12T05:18:05+00:00", + "gaps": [], + "generated": "2026-10-12 05:18", + "largest_tangle": 2, + "layered_weight_pct": 91.7, + "levels": 4, + "levels_by_module": { + "Billing": 2, + "Core": 0, + "MyFirstModule": 0, + "Orders": 1, + "Reporting": 3, + "Util": 0 + }, + "mismatch": null, + "modules_in_tangles": 2, + "mpr": "CutFixture.mpr", + "mxcli": "mxcli version v0.26.0 (2026-10-11T18:37:07Z)", + "order": [ + "Reporting", + "Billing", + "Orders", + "Util", + "MyFirstModule", + "Core" + ], + "own_edge_weight": 12, + "own_edges": 7, + "own_modules": 6, + "self_loops_ignored": 0, + "tangles": 1 +} \ No newline at end of file diff --git a/tests/wave2/fixtures/cut-score/catalog.db b/tests/wave2/fixtures/cut-score/catalog.db new file mode 100644 index 0000000000000000000000000000000000000000..a42dde618c8351f70cf39b34a0bc8d38b08800ce GIT binary patch literal 110592 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 literal 0 HcmV?d00001 diff --git a/tests/wave2/fixtures/cut-score/facts/dependencies.json b/tests/wave2/fixtures/cut-score/facts/dependencies.json new file mode 100644 index 00000000..7a20fb9b --- /dev/null +++ b/tests/wave2/fixtures/cut-score/facts/dependencies.json @@ -0,0 +1,363 @@ +{ + "bidirectional_pairs": [ + { + "a": "Billing", + "a_to_b": 3, + "a_to_b_kinds": { + "associate": 1, + "call": 1, + "parameter": 1 + }, + "b": "Orders", + "b_to_a": 1, + "b_to_a_kinds": { + "call": 1 + } + } + ], + "cohesion": [ + { + "cohesion_pct": 0.0, + "inbound_edges": 0, + "intra_edges": 0, + "module": "MyFirstModule", + "outbound_edges": 1 + }, + { + "cohesion_pct": 0.0, + "inbound_edges": 0, + "intra_edges": 0, + "module": "Reporting", + "outbound_edges": 4 + }, + { + "cohesion_pct": 55.6, + "inbound_edges": 3, + "intra_edges": 5, + "module": "Billing", + "outbound_edges": 4 + }, + { + "cohesion_pct": 72.7, + "inbound_edges": 3, + "intra_edges": 16, + "module": "Orders", + "outbound_edges": 6 + }, + { + "cohesion_pct": 100.0, + "inbound_edges": 2, + "intra_edges": 2, + "module": "Util", + "outbound_edges": 0 + }, + { + "cohesion_pct": null, + "inbound_edges": 4, + "intra_edges": 0, + "module": "Core", + "outbound_edges": 0 + } + ], + "counts": { + "bidirectional_pairs": 1, + "dead_assets_own": 6, + "largest_tangle": 2, + "modules_in_tangles": 2, + "own_edge_weight": 12, + "own_edges": 7, + "tangles": 1, + "three_cycles": 0 + }, + "dead_assets_own": [ + { + "ModuleName": "MyFirstModule", + "ObjectType": "MICROFLOW", + "QualifiedName": "MyFirstModule.MyFirstLogic" + }, + { + "ModuleName": "Orders", + "ObjectType": "MICROFLOW", + "QualifiedName": "Orders.ACT_Order_Close" + }, + { + "ModuleName": "Orders", + "ObjectType": "MICROFLOW", + "QualifiedName": "Orders.ACT_Order_Create" + }, + { + "ModuleName": "Orders", + "ObjectType": "PAGE", + "QualifiedName": "Orders.Order_Overview" + }, + { + "ModuleName": "Reporting", + "ObjectType": "MICROFLOW", + "QualifiedName": "Reporting.CAL_Invoice_Totals" + }, + { + "ModuleName": "Reporting", + "ObjectType": "PAGE", + "QualifiedName": "Reporting.Report_Overview" + } + ], + "edges": [ + { + "edges": 3, + "from": "Billing", + "kinds": { + "associate": 1, + "call": 1, + "parameter": 1 + }, + "to": "Orders" + }, + { + "edges": 3, + "from": "Orders", + "kinds": { + "associate": 2, + "parameter": 1 + }, + "to": "Core" + }, + { + "edges": 2, + "from": "Reporting", + "kinds": { + "datasource": 1, + "retrieve": 1 + }, + "to": "Billing" + }, + { + "edges": 1, + "from": "Billing", + "kinds": { + "call": 1 + }, + "to": "Util" + }, + { + "edges": 1, + "from": "Orders", + "kinds": { + "call": 1 + }, + "to": "Billing" + }, + { + "edges": 1, + "from": "Orders", + "kinds": { + "call": 1 + }, + "to": "Util" + }, + { + "edges": 1, + "from": "Reporting", + "kinds": { + "datasource": 1 + }, + "to": "Core" + } + ], + "edges_to_marketplace": [ + { + "edges": 1, + "from": "MyFirstModule", + "to": "Atlas_Core" + }, + { + "edges": 1, + "from": "Orders", + "to": "Atlas_Core" + }, + { + "edges": 1, + "from": "Reporting", + "to": "Atlas_Core" + } + ], + "god_nodes": [ + { + "asset": "Orders.Order", + "degree": 12, + "in_degree": 12, + "module": "Orders", + "out_degree": 0, + "type": "ENTITY" + }, + { + "asset": "Billing.ACT_Invoice_Create", + "degree": 11, + "in_degree": 1, + "module": "Billing", + "out_degree": 10, + "type": "MICROFLOW" + }, + { + "asset": "Reporting.Report_Overview", + "degree": 8, + "in_degree": 0, + "module": "Reporting", + "out_degree": 8, + "type": "PAGE" + }, + { + "asset": "Orders.ACT_Order_Create", + "degree": 8, + "in_degree": 0, + "module": "Orders", + "out_degree": 8, + "type": "MICROFLOW" + }, + { + "asset": "Util.SUB_Log", + "degree": 7, + "in_degree": 2, + "module": "Util", + "out_degree": 5, + "type": "MICROFLOW" + }, + { + "asset": "Orders.Order_Overview", + "degree": 6, + "in_degree": 0, + "module": "Orders", + "out_degree": 6, + "type": "PAGE" + }, + { + "asset": "Billing.Invoice", + "degree": 6, + "in_degree": 6, + "module": "Billing", + "out_degree": 0, + "type": "ENTITY" + }, + { + "asset": "Orders.SUB_Order_GetLines", + "degree": 5, + "in_degree": 1, + "module": "Orders", + "out_degree": 4, + "type": "MICROFLOW" + }, + { + "asset": "Orders.ACT_Order_Close", + "degree": 5, + "in_degree": 0, + "module": "Orders", + "out_degree": 5, + "type": "MICROFLOW" + }, + { + "asset": "Orders.OrderLine_Order", + "degree": 4, + "in_degree": 2, + "module": "Orders", + "out_degree": 2, + "type": "ASSOCIATION" + }, + { + "asset": "Reporting.CAL_Invoice_Totals", + "degree": 3, + "in_degree": 0, + "module": "Reporting", + "out_degree": 3, + "type": "MICROFLOW" + }, + { + "asset": "Orders.Order_Customer", + "degree": 3, + "in_degree": 1, + "module": "Orders", + "out_degree": 2, + "type": "ASSOCIATION" + }, + { + "asset": "Orders.OrderLine", + "degree": 3, + "in_degree": 3, + "module": "Orders", + "out_degree": 0, + "type": "ENTITY" + }, + { + "asset": "MyFirstModule.Home_Web", + "degree": 3, + "in_degree": 2, + "module": "MyFirstModule", + "out_degree": 1, + "type": "PAGE" + }, + { + "asset": "Core.Customer", + "degree": 3, + "in_degree": 3, + "module": "Core", + "out_degree": 0, + "type": "ENTITY" + }, + { + "asset": "Billing.Invoice_Order", + "degree": 3, + "in_degree": 1, + "module": "Billing", + "out_degree": 2, + "type": "ASSOCIATION" + }, + { + "asset": "Util.LogEntry", + "degree": 2, + "in_degree": 2, + "module": "Util", + "out_degree": 0, + "type": "ENTITY" + }, + { + "asset": "Orders.OrderLine_Product", + "degree": 2, + "in_degree": 0, + "module": "Orders", + "out_degree": 2, + "type": "ASSOCIATION" + }, + { + "asset": "Core.Product", + "degree": 1, + "in_degree": 1, + "module": "Core", + "out_degree": 0, + "type": "ENTITY" + } + ], + "scope": { + "own_modules": [ + "Billing", + "Core", + "MyFirstModule", + "Orders", + "Reporting", + "Util" + ], + "rule": "own = Source blank and not System/Atlas_*; marketplace = Source set; framework = System, Atlas_*" + }, + "tangles": { + "count": 1, + "list": [ + [ + "Billing", + "Orders" + ] + ], + "modules_in_tangles": 2, + "note": "strongly connected components of the own-module graph with >1 module; every module in one can reach every other" + }, + "three_cycles": { + "count": 0, + "list": [] + } +} \ No newline at end of file diff --git a/tests/wave2/fixtures/cut-score/facts/manifest.json b/tests/wave2/fixtures/cut-score/facts/manifest.json new file mode 100644 index 00000000..d0772e22 --- /dev/null +++ b/tests/wave2/fixtures/cut-score/facts/manifest.json @@ -0,0 +1,52 @@ +{ + "catalog": { + "activities": 279, + "build_mode": "full", + "modules": 15, + "refs": 525 + }, + "generated": "2026-10-12T05:18:05+00:00", + "mpr": "CutFixture.mpr", + "mpr_sha256_16": "5df24c3a06f60a3a", + "mxcli": "mxcli version v0.26.0 (2026-10-11T18:37:07Z)", + "options": { + "max_loops": 0, + "workers": 4 + }, + "schema": "app-facts/1", + "sections": { + "dependencies": { + "file": "dependencies.json", + "status": "pass" + }, + "inventory": { + "file": "inventory.json", + "status": "pass" + }, + "lint_baseline": { + "reason": "run project-bin/lint-gate.sh separately; not folded in yet", + "status": "fault" + }, + "loops": { + "candidates": 0, + "catalog_undercount": 0, + "described": 0, + "failed": 0, + "file": "loops.json", + "parse_mismatch": 0, + "reason": "--skip-loops", + "rules_not_describable": 0, + "status": "skipped" + }, + "security": { + "reason": "not collected by this version of app-facts.sh", + "status": "fault" + } + }, + "timings_s": { + "catalog_and_graph_report": 2, + "inventory_and_dependencies": 0, + "loops": 0, + "total": 2 + } +} \ No newline at end of file diff --git a/tests/wave2/test-cut-score.sh b/tests/wave2/test-cut-score.sh new file mode 100644 index 00000000..53a4d01d --- /dev/null +++ b/tests/wave2/test-cut-score.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# test-cut-score.sh — pin cut-score.sh against a fixture with a KNOWN right answer. +# +# Runs with no mxcli and no .mpr: entirely against fixtures/cut-score/ (captured 2026-10-12 from a +# four-module Mendix 10.24 app built by MDL with a planted structure — see CAPTURE.txt there). The +# planted answer for --cut "Core,Util|Orders|Billing|Reporting" is what is worth pinning: +# +# 1. THE TANGLE IS A BLOCKER. Orders.ACT_Order_Close calls Billing.ACT_Invoice_Create and +# Billing.ACT_Invoice_Create calls Orders.SUB_Order_GetLines. At partition level that is one +# bidirectional call pair; both call rows must read class=blocker and BOTH partitions must +# read verdict=blocked — a tangle has no side. +# 2. THE AGGREGATE IS FOUND FROM THE CATALOG, AND SAID TO BE MISSING WITHOUT IT. +# Reporting.CAL_Invoice_Totals retrieves Billing.Invoice and runs COUNT and SUM over it. +# dependencies.json cannot show that; the catalog's activities_data can. With the catalog the +# Reporting->Billing retrieve row carries aggregate "yes (...)" and Reporting is costly; with +# --catalog pointing nowhere every row must read "NOT DETECTED" and the gap must be named, +# never left blank. +# 3. ASSOCIATION-ONLY IS ACCEPTABLE, NOTHING-OUT IS CLEAN. Orders->Core is associate+parameter +# only (Core owns the data); those rows are class=acceptable. Core+Util has no outgoing +# crossing edge and must be clean even though four edges point INTO it. +# 4. THE DENOMINATOR IS PRINTED. 7 own edges carrying 12 refs; all 7 cross on this cut. The +# rows must cover every crossing module edge, and the count must say "7 of 7". +# 5. NOTHING IS INFERRED. MyFirstModule (the scaffold) is an own module the cut does not name: +# it lands in "_unassigned" and is reported as a gap, never folded into a neighbour. A module +# named in the cut that does not exist is a gap too. +# 6. THE DEFAULT CUT COMES FROM THE LAYER MAP. With no --cut, levels_by_module of +# app-layer-map.json gives four partitions L0..L3 and the same blocker; without that file and +# without --cut the script exits 2 rather than guessing. +# 7. DETERMINISTIC. Two runs on the same inputs are byte-identical bar the timestamp. +# +# Usage: bash test-cut-score.sh [path-to-cut-score.sh] + +set -u + +TOOLKIT="$(cd "$(dirname "$0")/../.." && pwd)" +CS="${1:-$TOOLKIT/bin/cut-score.sh}" +FIX="$TOOLKIT/tests/wave2/fixtures/cut-score" +# shellcheck disable=SC1091 +. "$TOOLKIT/bin/lib/portable.sh" +require_py + +PASS=0; FAIL=0 +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "${2:-}"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected '$3', got '$2'"; fi; } +jq_() { "$PY" -c 'import json,sys; d=json.load(open(sys.argv[1])) +for k in sys.argv[2].split("/"): + d = d[int(k)] if isinstance(d, list) else d.get(k, "MISSING") + if d == "MISSING": break +print("null" if d is None else (json.dumps(d) if isinstance(d,(dict,list)) else d))' "$1" "$2"; } +# rows_() FILE FROM TO KIND FIELD -> the field of that one row +rows_(){ "$PY" -c 'import json,sys; d=json.load(open(sys.argv[1])) +r=[r for r in d["rows"] if r["from"]==sys.argv[2] and r["to"]==sys.argv[3] and r["kind"]==sys.argv[4]] +print(r[0][sys.argv[5]] if len(r)==1 else "ROWS=%d" % len(r))' "$@"; } + +CUT="Core,Util|Orders|Billing|Reporting" + +echo "== cut-score.sh --json, four-way cut, with catalog ==" +bash "$CS" --facts "$FIX/facts" --catalog "$FIX/catalog.db" --cut "$CUT" --json >"$TMP/run1.json" 2>"$TMP/run1.err"; RC=$? +check "exits 0" "$RC" "0" +[ -s "$TMP/run1.json" ] || { bad "produced JSON" "$(cat "$TMP/run1.err")"; echo; echo "$PASS passed, $FAIL failed"; exit 1; } + +echo "== 1. the Orders<->Billing call tangle is a blocker on both sides ==" +check "one bidirectional partition pair" \ + "$("$PY" -c 'import json;print(len(json.load(open("'"$TMP/run1.json"'"))["bidirectional_partition_pairs"]))')" "1" +check "pair is Billing<->Orders" "$(jq_ "$TMP/run1.json" bidirectional_partition_pairs/0/a)-$(jq_ "$TMP/run1.json" bidirectional_partition_pairs/0/b)" "Billing-Orders" +check "Orders -> Billing call row is class blocker" "$(rows_ "$TMP/run1.json" Orders Billing call class)" "blocker" +check "Billing -> Orders call row is class blocker" "$(rows_ "$TMP/run1.json" Billing Orders call class)" "blocker" +check "Orders verdict blocked" "$(jq_ "$TMP/run1.json" partitions/Orders/verdict)" "blocked" +check "Billing verdict blocked" "$(jq_ "$TMP/run1.json" partitions/Billing/verdict)" "blocked" +check "overall verdict blocked" "$(jq_ "$TMP/run1.json" verdict)" "blocked" +check "Orders lists both tangle edges as blockers" \ + "$("$PY" -c 'import json;print(len(json.load(open("'"$TMP/run1.json"'"))["partitions"]["Orders"]["blockers"]))')" "2" + +echo "== 2. the Reporting->Billing aggregate is detected from the catalog ==" +check "aggregate status DETECTED" "$(jq_ "$TMP/run1.json" aggregate/status)" "DETECTED" +check "one aggregate hit" "$("$PY" -c 'import json;print(len(json.load(open("'"$TMP/run1.json"'"))["aggregate"]["hits"]))')" "1" +check "hit names Reporting.CAL_Invoice_Totals over Billing.Invoice" \ + "$(jq_ "$TMP/run1.json" aggregate/hits/0/microflow)|$(jq_ "$TMP/run1.json" aggregate/hits/0/entity)" \ + "Reporting.CAL_Invoice_Totals|Billing.Invoice" +check "hit counts the two aggregates (COUNT + SUM)" "$(jq_ "$TMP/run1.json" aggregate/hits/0/aggregates)" "2" +case "$(rows_ "$TMP/run1.json" Reporting Billing retrieve aggregate)" in + "yes ("*) ok "Reporting -> Billing retrieve row carries the aggregate flag" ;; + *) bad "Reporting -> Billing retrieve row carries the aggregate flag" "$(rows_ "$TMP/run1.json" Reporting Billing retrieve aggregate)" ;; +esac +check "that row is class costly" "$(rows_ "$TMP/run1.json" Reporting Billing retrieve class)" "costly" +check "Reporting verdict costly (aggregate, no blocker)" "$(jq_ "$TMP/run1.json" partitions/Reporting/verdict)" "costly" + +echo "== 2b. without a catalog the aggregate column says NOT DETECTED, never blank ==" +bash "$CS" --facts "$FIX/facts" --catalog "$TMP/no-such-catalog.db" --cut "$CUT" --json >"$TMP/nocat.json" 2>/dev/null; RC=$? +check "exits 0 without a catalog" "$RC" "0" +check "aggregate status NOT DETECTED" "$(jq_ "$TMP/nocat.json" aggregate/status)" "NOT DETECTED" +case "$(jq_ "$TMP/nocat.json" aggregate/reason)" in + *"no catalog at"*) ok "reason names the missing catalog" ;; + *) bad "reason names the missing catalog" "$(jq_ "$TMP/nocat.json" aggregate/reason)" ;; +esac +"$PY" -c 'import json,sys +d=json.load(open(sys.argv[1])) +blank=[r for r in d["rows"] if not str(r.get("aggregate","")).startswith("NOT DETECTED")] +sys.exit(1 if blank else 0)' "$TMP/nocat.json" \ + && ok "every row reads NOT DETECTED (none blank, none n/a)" || bad "every row reads NOT DETECTED" "some rows were not marked" +case "$(jq_ "$TMP/nocat.json" gaps)" in + *"aggregate detection: NOT DETECTED"*) ok "the gap is listed in gaps" ;; + *) bad "the gap is listed in gaps" "$(jq_ "$TMP/nocat.json" gaps)" ;; +esac +check "the verdict still finds the blocker without a catalog" "$(jq_ "$TMP/nocat.json" verdict)" "blocked" + +echo "== 3. association-only is acceptable; nothing-out is clean ==" +check "Orders -> Core associate is acceptable" "$(rows_ "$TMP/run1.json" Orders Core associate class)" "acceptable" +check "Orders -> Core associate lane is odata-external-entity" "$(rows_ "$TMP/run1.json" Orders Core associate lane)" "odata-external-entity" +check "Orders -> Core parameter is acceptable" "$(rows_ "$TMP/run1.json" Orders Core parameter class)" "acceptable" +check "Core+Util verdict clean" "$(jq_ "$TMP/run1.json" partitions/Core+Util/verdict)" "clean" +check "Core+Util has 4 edges pointing in" "$(jq_ "$TMP/run1.json" partitions/Core+Util/in_edges)" "4" +check "Core+Util has 0 edges going out" "$(jq_ "$TMP/run1.json" partitions/Core+Util/out_edges)" "0" +check "Orders -> Util call is odata-action-or-rest, costly" \ + "$(rows_ "$TMP/run1.json" Orders Util call lane)/$(rows_ "$TMP/run1.json" Orders Util call class)" "odata-action-or-rest/costly" + +echo "== 4. the denominator is printed ==" +check "own_edges 7" "$(jq_ "$TMP/run1.json" own_edges)" "7" +check "own_edge_weight 12" "$(jq_ "$TMP/run1.json" own_edge_weight)" "12" +check "crossing_edges 7" "$(jq_ "$TMP/run1.json" crossing_edges)" "7" +case "$(jq_ "$TMP/run1.json" denominator)" in + "7 of 7 own edges"*"12 of 12 refs") ok "denominator sentence reads '7 of 7 ... 12 of 12'" ;; + *) bad "denominator sentence" "$(jq_ "$TMP/run1.json" denominator)" ;; +esac +check "rows cover every crossing module edge (distinct from/to pairs)" \ + "$("$PY" -c 'import json;d=json.load(open("'"$TMP/run1.json"'"));print(len({(r["from"],r["to"]) for r in d["rows"]}))')" "7" +check "row weights sum to the crossing weight" \ + "$("$PY" -c 'import json;d=json.load(open("'"$TMP/run1.json"'"));print(sum(r["weight"] for r in d["rows"]))')" \ + "$(jq_ "$TMP/run1.json" crossing_weight)" +check "every row has a lane, cost, class and aggregate field" \ + "$("$PY" -c 'import json;d=json.load(open("'"$TMP/run1.json"'"));print(all(r.get(k) for r in d["rows"] for k in ("lane","cost","class","aggregate","why")))')" "True" +check "business events is listed as excluded" "$(jq_ "$TMP/run1.json" lanes_excluded/0)" "business-events (no Event Broker licence)" + +echo "== 5. nothing is inferred ==" +check "MyFirstModule lands in _unassigned" "$(jq_ "$TMP/run1.json" partitions/_unassigned/modules)" '["MyFirstModule"]' +case "$(jq_ "$TMP/run1.json" gaps)" in + *"own modules the cut does not place: MyFirstModule"*) ok "the unplaced module is a named gap" ;; + *) bad "the unplaced module is a named gap" "$(jq_ "$TMP/run1.json" gaps)" ;; +esac +bash "$CS" --facts "$FIX/facts" --catalog "$FIX/catalog.db" --cut "Core,Util,Nonesuch|Orders|Billing|Reporting" --json >"$TMP/typo.json" 2>/dev/null +case "$(jq_ "$TMP/typo.json" gaps)" in + *"not an own module"*"Nonesuch"*) ok "a cut naming a module that does not exist is a named gap" ;; + *) bad "a cut naming a module that does not exist is a named gap" "$(jq_ "$TMP/typo.json" gaps)" ;; +esac +bash "$CS" --facts "$FIX/facts" --cut "Core||Orders" --json >/dev/null 2>&1; RC=$? +check "an empty partition in --cut is exit 2" "$RC" "2" + +echo "== 6. the default cut comes from the layer map ==" +bash "$CS" --facts "$FIX/facts" --catalog "$FIX/catalog.db" --layer-map "$FIX/app-layer-map.json" --json >"$TMP/default.json" 2>"$TMP/default.err"; RC=$? +check "default cut exits 0" "$RC" "0" +check "four level partitions L0..L3" \ + "$("$PY" -c 'import json;print([c["name"] for c in json.load(open("'"$TMP/default.json"'"))["cut"]])')" "['L0', 'L1', 'L2', 'L3']" +check "L0 holds Core, MyFirstModule, Util" "$(jq_ "$TMP/default.json" partitions/L0/modules)" '["Core", "MyFirstModule", "Util"]' +check "default cut finds the same blocker" "$(jq_ "$TMP/default.json" verdict)" "blocked" +bash "$CS" --facts "$FIX/facts" --layer-map "$TMP/no-such-layer-map.json" --json >/dev/null 2>"$TMP/nolm.err"; RC=$? +check "no --cut and no layer map: exit 2" "$RC" "2" +case "$(cat "$TMP/nolm.err")" in + *"--cut"*) ok "the refusal says how to supply a cut" ;; + *) bad "the refusal says how to supply a cut" "$(cat "$TMP/nolm.err")" ;; +esac + +echo "== 7. deterministic, and the HTML page is written ==" +bash "$CS" --facts "$FIX/facts" --catalog "$FIX/catalog.db" --cut "$CUT" --json >"$TMP/run2.json" 2>/dev/null +STRIP='import json,sys +d = json.load(open(sys.argv[1])); d.pop("generated", None); print(json.dumps(d, sort_keys=True))' +"$PY" -c "$STRIP" "$TMP/run1.json" >"$TMP/run1.norm"; "$PY" -c "$STRIP" "$TMP/run2.json" >"$TMP/run2.norm" +if diff -q "$TMP/run1.norm" "$TMP/run2.norm" >/dev/null 2>&1; then + ok "two runs are byte-identical (generated excluded)" +else + bad "two runs are byte-identical" "$(diff "$TMP/run1.norm" "$TMP/run2.norm" | head -5)" +fi +bash "$CS" --facts "$FIX/facts" --catalog "$FIX/catalog.db" --cut "$CUT" -o "$TMP/out/cut-score.html" >"$TMP/html.out" 2>&1; RC=$? +check "HTML run exits 0" "$RC" "0" +[ -f "$TMP/out/cut-score.html" ] && ok "HTML written to the -o path" || bad "HTML written to the -o path" "$(cat "$TMP/html.out")" +[ -f "$TMP/out/cut-score.json" ] && ok "JSON sidecar written beside it" || bad "JSON sidecar written beside it" +case "$(cat "$TMP/out/cut-score.html" 2>/dev/null)" in + *"7 of 7 own edges"*) ok "the page prints the denominator" ;; + *) bad "the page prints the denominator" ;; +esac +case "$(cat "$TMP/out/cut-score.html" 2>/dev/null)" in + *"bidirectional call pair between"*) ok "the page names the blocker" ;; + *) bad "the page names the blocker" ;; +esac +case "$(cat "$TMP/out/cut-score.html" 2>/dev/null)" in + *"MyFirstModule"*"_unassigned"*) ok "the page shows the unplaced module as a gap" ;; + *) bad "the page shows the unplaced module as a gap" ;; +esac +case "$(cat "$TMP/html.out")" in + *"verdict blocked"*"7 of 7"*) ok "stdout summary carries verdict and denominator" ;; + *) bad "stdout summary carries verdict and denominator" "$(cat "$TMP/html.out")" ;; +esac + +echo "== degrades ==" +mkdir -p "$TMP/no-deps" +bash "$CS" --facts "$TMP/no-deps" --cut "$CUT" --json >/dev/null 2>&1; RC=$? +check "no dependencies.json: exit 2" "$RC" "2" + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From db2e1008aa44d2f5790380c3c2a73b035bacd73d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:37:44 +0000 Subject: [PATCH 03/53] Rehost: pipelines/mendix-mpr extractor, FSM Mendix 9 read probe, increment-1 findings rehost-extract.sh reads an .mpr, emits each own module via the MDL statement 'describe module with all', reorders flows into call order (describe output is catalog-ordered and mxcli's own check refuses it), appends the image collections 'with all' omits, replays into a fresh target and writes a fidelity ledger with its denominator. CRLF inside string literals is kept byte-for-byte. Field run 2026-10-12, ako/mxcli-banking (Mendix 11.13.0): 155/155 documents, 154 identical, 1 doc-comment drift, mx check 0 errors, mxbuild green, struct diff empty. Read probe on the Marketplace FSM template (Mendix 9.24.2, MPR v1) passed; verbatim list-modules and graph-report captures under samples/. Plan doc updated (Event Broker: no; increment 1 results). The CRLF rule is promoted into learned-mdl-preflight.md; mxcli defects and MDL learnings from both increments go to contrib/inbox for triage. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016n4dpM66droAmJMuW4YwhV --- CHANGELOG.md | 2 + ...mpose-rehost-increment-1-mxcli-findings.md | 30 +++ pipelines/mendix-mpr/README.md | 50 +++++ pipelines/mendix-mpr/lib/compare-mdl.py | 49 +++++ pipelines/mendix-mpr/lib/topo-order-mdl.py | 49 +++++ pipelines/mendix-mpr/rehost-extract.sh | 134 +++++++++++++ .../samples/fsm-template-9.24.2/CAPTURE.txt | 18 ++ .../fsm-template-9.24.2/graph-report.md | 184 ++++++++++++++++++ .../fsm-template-9.24.2/list-modules.md | 63 ++++++ .../decompose-and-rehost-plan-2026-10-12.md | 22 ++- skills/learned-mdl-preflight.md | 1 + 11 files changed, 593 insertions(+), 9 deletions(-) create mode 100644 contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md create mode 100644 pipelines/mendix-mpr/README.md create mode 100755 pipelines/mendix-mpr/lib/compare-mdl.py create mode 100755 pipelines/mendix-mpr/lib/topo-order-mdl.py create mode 100755 pipelines/mendix-mpr/rehost-extract.sh create mode 100644 pipelines/mendix-mpr/samples/fsm-template-9.24.2/CAPTURE.txt create mode 100644 pipelines/mendix-mpr/samples/fsm-template-9.24.2/graph-report.md create mode 100644 pipelines/mendix-mpr/samples/fsm-template-9.24.2/list-modules.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d70c86e5..589b3d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. ## Unreleased +- new(rehost): **`pipelines/mendix-mpr/` — the Rehost extractor (increment 1a of `process/decompose-and-rehost-plan-2026-10-12.md`): read an existing `.mpr`, emit each own module as MDL, replay it into a fresh target of the same Mendix version, write a fidelity ledger.** `rehost-extract.sh` uses the MDL statement `describe module with all` (the `describe module` subcommand emits only module + roles on mxcli v0.26.0), reorders flows into call order with `lib/topo-order-mdl.py` because describe output is in catalog order and mxcli's own `check` refuses it (MDL-ORDER01), appends the image collections `with all` omits, and keeps CRLF inside string literals byte-for-byte (a normalised annotation makes `exec` refuse the splice, MDL-V1-REBUILD). Ledger: one row per catalog document, denominator in the header; demo-user passwords (`'***'`) and project security level are hand-off rows. Field run 2026-10-12 on ako/mxcli-banking (Mendix 11.13.0): 155/155 documents, 154 identical on target describe, 1 doc-comment drift, `mx check` 0 errors, mxbuild green, `diff --format struct` empty, 35 s. Same day the Stage 0 read probe passed on the Marketplace FSM template (Mendix 9.24.2, MPR v1, 64 MB, 45 own modules): verbatim captures in `samples/fsm-template-9.24.2/`. mxcli findings and MDL learnings from both increments in `contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md`; the CRLF rule is promoted into `skills/learned-mdl-preflight.md`. Credit: ako/mxcli-banking (Apache-2.0) as the round-trip fixture; Siemens FSM template as the Mendix 9 probe. — MendixMau +- new(decompose): **`bin/cut-score.sh` — Stage 3 cut scoring for the Decompose track (increment 1b of `process/decompose-and-rehost-plan-2026-10-12.md`).** Takes `analysis/app-facts/dependencies.json` and a `--cut "Core,Util|Orders|Billing|Reporting"` (default: one app per `app-layer-map.json` level), lists every module edge that would cross an app boundary with the lane its ref kind must become — OData external entity (acceptable), published OData action / REST (costly), none (blocker: bidirectional call pairs, generalize/workflow/role crossings); Business Events is excluded by licence. Aggregates over a foreign entity are read from the mxcli catalog (`activities_data`: a RetrieveAction on a foreign entity plus an AggregateListAction in the same microflow) and reported NOT DETECTED with the reason when there is no catalog, never blank. Per partition: totals, blockers, verdict clean/costly/blocked; the denominator ("7 of 7 own edges, 12 of 12 refs") is always printed, unplaced modules land in `_unassigned` as a named gap. Writes `analysis/cut-score.html` + `.json`. Field run 2026-10-12 on a Mendix 10.24.0 fixture built by MDL (mxcli v0.26.0, Linux, no Studio Pro) with a planted Orders<->Billing tangle and a Reporting->Billing COUNT/SUM: both found. Fixture `tests/wave2/fixtures/cut-score/` (verbatim dependencies.json, layer map, reduced catalog) and `tests/wave2/test-cut-score.sh` (56 checks). Not yet wired into a skill or the routing table — that is the plan's next increment. — MendixMau - process(plan): **`process/decompose-and-rehost-plan-2026-10-12.md` — two new pipelines proposed: Decompose (split a Mendix monolith by scored cut edges, strangler per slice) and Rehost (read a Mendix 9/10 `.mpr` with `describe module`, regenerate as MDL into Mendix 11, with a fidelity ledger).** Research-backed: Mendix multi-app constraints, mxcli v0.26.0 capabilities probed in a cloud container, candidate public targets (FSM template 9.24.2, GenAI Showcase 48 modules). Nothing built yet; increments and open questions listed. — 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 diff --git a/contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md b/contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md new file mode 100644 index 00000000..db07db2e --- /dev/null +++ b/contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md @@ -0,0 +1,30 @@ +**From:** toolkit lab runs for `process/decompose-and-rehost-plan-2026-10-12.md`, increments 1a (Rehost, ako/mxcli-banking 11.13.0) and 1b (Decompose fixture, Mendix 10.24.0) +**Date:** 2026-10-12 +**Kind:** bug / learning candidates +**Field evidence:** verbatim captures in the lab trees (`lab/rehost/captures`, `lab/decompose/captures`, not shipped); each item below quotes the message seen +**Proposed target:** `bug-logs/mxcli-bugs.md` (items 1–8), `skills/learned-mdl-preflight.md` (items 9–11; item 9 already promoted in the same commit) + +mxcli v0.26.0 (2026-10-11), Linux cloud container, no Studio Pro. + +## mxcli defects / doc gaps (report upstream to mendixlabs/mxcli) + +1. **`mxcli describe module ` subcommand emits only the module and its roles.** Help text says "Describe a module (all contents)". Output is `mdl 1; create or modify module Banking; create or modify module role …` and nothing else, for `--mdl 0` and `--format json` too. The whole-module emitter is the MDL statement `describe module with all`, reachable only via `-c`, and documented only in `mxcli changelog`. +2. **`describe module … with all` emits flows in catalog order, and `mxcli check` refuses its own output**: 14× `MDL-ORDER01 … references microflow Banking.SUB_SeedBillers before it is created (statement 145)`; `exec` then `Refusing to execute: 14 unresolved reference(s). Nothing was written.` (correct refusal). mxcli knows the call graph (`callers`/`callees`) and could emit in call order. Workaround shipped: `pipelines/mendix-mpr/lib/topo-order-mdl.py`. +3. **`with all` omits image collections** — `MyFirstModule.Images` is `describable` in `project-tree --json` and `describe imagecollection` emits it, but the module-level emitter leaves it out. +4. **`describe userrole` drops `CheckSecurity: false`** — replaying the output re-creates the role with the default, a silent security change. +5. **`describe` of a doc comment loses a markdown `* ` bullet** (the one drift row of 155 on the banking round trip). +6. **Set-mode `check` prints "Check passed!" after an earlier failure in the same invocation.** +7. **`mxcli new --version 10.24.0` ships a layout that cannot build**: `App_Default` uses Atlas design properties (`Flex container`, `Align items Y`, `Disable row wrap`, `Grow / shrink (self)`) that the bundled Atlas_Core 3.17.0 does not know; mxbuild in step 6/7 ends `BUILD FAILED` but `new` still prints "Project created" and exits 0. Workaround: `ALTER PAGES SET LAYOUT = Atlas_Core.Atlas_Default WHERE LAYOUT = MyFirstModule.App_Default; DROP LAYOUT MyFirstModule.App_Default;`. Fine on 11.13.0. +8. **CE0463 on every mxcli-written DATAGRID on a 10.24 target** ("The definition of this widget has changed. Update this widget…"): mxcli writes Data grid 2 against a newer DataWidgets definition than the 2.31.0 the 10.24 scaffold carries. `mxcli fix widgets -p .mpr` resolves it (15 units changed, MPR v2 preserved). Rule for 10.x targets: after any page exec, `fix widgets` before mxbuild. `bin/bug-lookup.sh CE0463` currently returns nothing. + +## MDL learnings (preflight candidates) + +9. **CRLF inside string literals is load-bearing.** Studio Pro stores annotation text with `\r\n`; `describe` emits it verbatim; a tool that reads the file in text mode and writes `\n` changes the flow, and `check` says `this change cannot be spliced into the stored flow: the free annotations change … [MDL-V1-REBUILD]`. Byte-preserving I/O (`newline=''`) only. +10. **`RETURNS Boolean AS $Closed` does not declare `$Closed`.** `$Closed = true;` → "a reassignment says 'set' under mdl 1"; `SET $Closed = true;` alone → "variable 'Closed' is not declared". An aggregate or a retrieve declares; a plain return variable needs `DECLARE $Closed Boolean = false;` first. +11. **`LOG INFO NODE 'Util' $Message;` is not grammar.** The message is a template: `LOG INFO NODE 'Util' '{1}' WITH ({1} = $Message);`, and a non-String argument must be wrapped (`toString($Count)`). + +## Toolkit items (ours, not mxcli's) + +12. `project-bin/app-facts.sh` hard-requires the `sqlite3` CLI (`command -v sqlite3 || exit 2`) for seven one-column guard queries while the facts come from Python's `sqlite3` module. The CLI is not installable in the cloud container (`apt-get`: `Unable to locate package sqlite3`). Route the guards through `$PY`. +13. `project-bin/app-facts.sh` joins `refs` to `objects` by qualified name, so attribute-level refs (`member`, `xpath`) never reach `dependencies.json`; `bin/cut-score.sh`'s `member`/`xpath` lanes cannot fire until the producer carries them. +14. `mxcli marketplace download --output` takes a file path; a directory fails with `rename … file exists`. diff --git a/pipelines/mendix-mpr/README.md b/pipelines/mendix-mpr/README.md new file mode 100644 index 00000000..613eacc8 --- /dev/null +++ b/pipelines/mendix-mpr/README.md @@ -0,0 +1,50 @@ +# Mendix MPR pipeline — the Rehost extractor + +**Source platform:** an existing Mendix app (`.mpr`, Mendix 9 and above, MPR v1 or v2). +**Plan:** `process/decompose-and-rehost-plan-2026-10-12.md` §4 (Rehost) — read a model with +`mxcli describe`, regenerate it as MDL into a fresh target app, and write a **fidelity ledger** +(one row per source document; the denominator in the header) instead of claiming 100 %. + +This is the only pipeline whose extractor reads a Mendix model. Downstream of the ledger nothing +here is Mendix-source-specific: the slice loop of the Decompose track reuses it to move a +module into a new app, and Stages 2–6 of the runbook apply unchanged. + +## Run + +``` +MXCLI=/path/to/mxcli pipelines/mendix-mpr/rehost-extract.sh [Module ...] +``` + +- The target must exist: `mxcli new --version --theme none`. The script + writes model content only; run the mxbuild gate (`project-bin/exec.sh`, or + `~/.mxcli/mxbuild//modeler/mx check`) afterwards. +- Modules default to every own module (empty Source column in `list modules`, System excluded). + Marketplace modules are recorded by name and version on the ledger, never regenerated. +- Output: `captures/` (verbatim mxcli output — the golden fixtures), `mdl/` (describe output, + the topologically ordered file that was executed, the target's describe for the diff), + `fidelity-ledger.tsv`. + +`lib/topo-order-mdl.py` reorders the flow statements of a `describe module … with all` script so +every callee is created before its caller (a flow and its `grant execute` lines move as one unit). +`lib/compare-mdl.py` normalises two describe outputs for the per-document identical/drift verdict. +Both read and write with `newline=''`: Studio Pro stores annotation text with CRLF inside MDL string +literals, and a tool that normalises it changes the flow and `exec` refuses the splice. + +## Field runs + +| Date | Source | Result | +|---|---|---| +| 2026-10-12 | ako/mxcli-banking `RRNetBanking.mpr` (Mendix 11.13.0, Banking 151 docs + MyFirstModule 4) → `mxcli new --version 11.13.0` | 155/155 documents emitted, 154 identical on target describe, 1 doc-comment drift; `mx check` 0 errors, mxbuild BUILD SUCCEEDED, `diff --format struct` empty. 35 s end to end | +| 2026-10-12 | FSM template v1.1.1 (Mendix 9.24.2, MPR v1, 64 MB) — **read probe only** | `list modules` 1.6 s, `graph-report` 18 s. Captures in `samples/fsm-template-9.24.2/` | + +Not yet exercised: nanoflows, Java/JavaScript actions, snippets, workflows, REST/OData services, +`marketplace install` on a target that lacks the source's modules, and the regenerate step on a +Mendix 9/10 source (the read works; the write into Mendix 11 is increment 2). + +## mxcli findings from the first run (filed in `contrib/inbox/2026-10-12-decompose-rehost-increment-1-mxcli-findings.md`) + +The subcommand `mxcli describe module ` emits only the module and its roles; the whole-module +emitter is the MDL statement `describe module with all` via `-c`. Describe output is in catalog +order and the tool's own `check` refuses it (MDL-ORDER01). Image collections are omitted by +`with all`. `describe userrole` drops `CheckSecurity: false`. Demo-user passwords describe as +`'***'` and project security level describes as a report, not MDL — both are ledger hand-off rows. diff --git a/pipelines/mendix-mpr/lib/compare-mdl.py b/pipelines/mendix-mpr/lib/compare-mdl.py new file mode 100755 index 00000000..f4134d08 --- /dev/null +++ b/pipelines/mendix-mpr/lib/compare-mdl.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Per-document comparison of two `describe module … with all` scripts (source, target). +Statements are grouped per document (a create plus the grant/index statements that +name the same document), keyed by (type, qualified name); order is ignored. +Prints TSV rows: module, document, type, verdict, evidence.""" +import re, sys +def units(path): + stmts, cur = [], [] + for ln in open(path, encoding='utf-8', newline='').read().split('\n'): + cur.append(ln) + if ln and not ln[0].isspace() and ln.rstrip().endswith(';'): + stmts.append('\n'.join(cur)); cur = [] + head = re.compile(r'^(?:@[^\n]*\n)*create or modify (module role|non-persistent entity|persistent entity|view entity|entity|association|enumeration|microflow|nanoflow|page|snippet|layout|image collection|module) ([A-Za-z_]+(?:\.[A-Za-z_]+)?)', re.M) + grant = re.compile(r'^grant .*? on (?:entity|page|microflow|nanoflow) ([A-Za-z_]+\.[A-Za-z_]+)', re.M) + docs, last = {}, None + for s in stmts: + body = re.sub(r'/\*\*.*?\*/\s*', '', s, flags=re.S) # doc comment compared separately + doc = re.search(r'/\*\*(.*?)\*/', s, re.S) + m = head.search(body) + if m: + t = m.group(1).replace('non-persistent entity','entity').replace('persistent entity','entity').replace('view entity','entity') + last = (t, m.group(2)); docs[last] = {'body': body.strip(), 'doc': (doc.group(1).strip() if doc else ''), 'grants': []} + continue + g = grant.search(body) + if g and last and g.group(1) == last[1]: docs[last]['grants'].append(body.strip()); continue + if g: + key = next((k for k in docs if k[1] == g.group(1)), None) + if key: docs[key]['grants'].append(body.strip()); continue + sys.stderr.write('unattached statement in %s: %s\n' % (path, body.strip()[:80])) + return docs +src, tgt = units(sys.argv[1]), units(sys.argv[2]) +module = sys.argv[3] +def firstdiff(a, b): + al, bl = a.split('\n'), b.split('\n') + for i, (x, y) in enumerate(zip(al, bl)): + if x != y: return 'line %d: %r -> %r' % (i+1, x.strip()[:70], y.strip()[:70]) + return 'length %d -> %d lines' % (len(al), len(bl)) +for k in sorted(src, key=lambda k: (k[0], k[1])): + t, name = k + if k not in tgt: + print('\t'.join([module, name, t, 'failed-exec', 'absent from target describe'])); continue + s, g = src[k], tgt[k] + ev = [] + if s['body'] != g['body']: ev.append('body: ' + firstdiff(s['body'], g['body'])) + if sorted(s['grants']) != sorted(g['grants']): ev.append('grants: %d -> %d' % (len(s['grants']), len(g['grants']))) + if s['doc'] != g['doc']: ev.append('documentation: ' + firstdiff(s['doc'], g['doc'])) + print('\t'.join([module, name, t, 'emitted-drift' if ev else 'emitted-identical', '; '.join(ev) if ev else 'describe on target identical (body, grants, documentation)'])) +for k in sorted(set(tgt) - set(src)): + print('\t'.join([module, k[1], k[0], 'target-extra', 'present in target only (not in source describe)'])) diff --git a/pipelines/mendix-mpr/lib/topo-order-mdl.py b/pipelines/mendix-mpr/lib/topo-order-mdl.py new file mode 100755 index 00000000..07027525 --- /dev/null +++ b/pipelines/mendix-mpr/lib/topo-order-mdl.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Reorder the microflow/nanoflow statements of a `describe module … with all` +script so every flow is created before any statement that calls it. +Everything else keeps its relative position. Usage: topo-order-mdl.py in.mdl out.mdl""" +import re, sys +src = open(sys.argv[1], encoding='utf-8', newline='').read().split('\n') # newline='': annotation text carries CRLF inside string literals; translating it changes the flow and exec refuses the splice +# split into top-level statements: a statement ends at a column-0 line ending in ';' +stmts, cur = [], [] +for ln in src: + cur.append(ln) + if ln and not ln[0].isspace() and ln.rstrip().endswith(';'): + stmts.append('\n'.join(cur)); cur = [] +tail = '\n'.join(cur) +head_re = re.compile(r'^create or modify (microflow|nanoflow) ([A-Za-z_]+\.[A-Za-z_]+)', re.M) +# a flow unit = its create statement + the `grant execute on microflow ` statements right after it +units, i = [], 0 +while i < len(stmts): + m = head_re.search(stmts[i]) + if m: + name, j = m.group(2), i + 1 + while j < len(stmts) and re.match(r'^(\s*)grant execute on (microflow|nanoflow) ' + re.escape(name) + r'\b', stmts[j].strip()): + j += 1 + units.append(('\n'.join(stmts[i:j]), name)); i = j + else: + units.append((stmts[i], None)); i += 1 +stmts = [u[0] for u in units] +flows = {u[1]: k for k, u in enumerate(units) if u[1]} +flow_idx = sorted(flows.values()) +# dependencies: a flow statement mentioning another flow's qualified name +deps = {} +for name, i in flows.items(): + body = stmts[i] + deps[name] = {o for o in flows if o != name and re.search(r'\b' + re.escape(o) + r'\b', body)} +# Kahn, stable on original order +order, placed = [], set() +remaining = [n for n in sorted(flows, key=flows.get)] +while remaining: + progressed = False + for n in list(remaining): + if deps[n] <= placed: + order.append(n); placed.add(n); remaining.remove(n); progressed = True + if not progressed: # cycle (recursion): emit the rest in original order, say so + sys.stderr.write('cycle among: %s\n' % ', '.join(remaining)); order += remaining; break +moved = sum(1 for k, n in enumerate(order) if flows[n] != flow_idx[k]) +out = list(stmts) +for k, n in enumerate(order): + out[flow_idx[k]] = stmts[flows[n]] +open(sys.argv[2], 'w', encoding='utf-8', newline='').write('\n'.join(out) + ('\n' + tail if tail.strip() else '\n')) +sys.stderr.write('statements=%d flows=%d repositioned=%d\n' % (len(stmts), len(flows), moved)) diff --git a/pipelines/mendix-mpr/rehost-extract.sh b/pipelines/mendix-mpr/rehost-extract.sh new file mode 100755 index 00000000..fcbfd11c --- /dev/null +++ b/pipelines/mendix-mpr/rehost-extract.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# rehost-extract.sh — read an existing Mendix .mpr, emit each own module as MDL, replay it into a +# fresh target .mpr of the same Mendix version, and write the fidelity ledger. +# +# pipelines/mendix-mpr/rehost-extract.sh [Module ...] +# +# /captures/ verbatim mxcli output (golden fixtures: list modules, show , +# project-tree.json, check/exec logs) +# /mdl/ .mdl (source describe), .ordered.mdl (what was executed), +# target-.mdl (describe of the target afterwards), _settings/_userroles +# /fidelity-ledger.tsv one row per source document (denominator in the header) +# +# Modules default to every module `list modules` shows with an empty Source column except System. +# The target must already exist (`mxcli new --version --theme none`); this script +# creates nothing but model content. It does NOT run the build gate — run project-bin/exec.sh's +# mxbuild gate or `~/.mxcli/mxbuild//modeler/mx check ` afterwards. +# +# Field run (2026-10-12, mxcli v0.26.0, Linux container): ako/mxcli-banking RRNetBanking.mpr +# (Mendix 11.13.0, modules Banking 151 documents + MyFirstModule 4) -> `mxcli new --version 11.13.0`. +# 155/155 catalog documents emitted, 154 identical on target describe, 1 documentation drift, +# `mx check` and `mxbuild --target=deploy` clean. Everything in here was exercised on that run: +# - the whole-module emitter is the MDL statement `describe module with all` run through -c; +# the `mxcli describe module ` SUBCOMMAND emits only the module and its roles (0.26.0). +# - describe emits microflows in catalog order, so `check` reported 14 MDL-ORDER01 (callee +# created after caller) and exec refused the file; lib/topo-order-mdl.py reorders flow units +# (create + its grant execute statements) and the reordered file checked clean. +# - `describe module … with all` omits image collections (MyFirstModule.Images) — described +# separately below so the ledger has the row. +# - demo users describe with Password: '***' and exec refuses the placeholder; project security +# level describes as a report, not MDL. Both are ledger hand-off rows, never executed here. +# Not exercised: nanoflows, Java/JavaScript actions, snippets, workflows, REST/OData, marketplace +# install (the blank template already carried the source's seven modules at the same versions). +set -euo pipefail + +SRC="${1:?usage: rehost-extract.sh [Module ...]}" +TGT="${2:?target.mpr}" +OUT="${3:?out-dir}" +shift 3 +HERE="$(cd "$(dirname "$0")" && pwd)" +MXCLI="${MXCLI:-$(command -v mxcli || true)}" +[ -x "$MXCLI" ] || { echo "mxcli not found: set MXCLI=/path/to/mxcli" >&2; exit 2; } +PY="${PYTHON:-python3}" +mkdir -p "$OUT/captures" "$OUT/mdl" +mx() { "$MXCLI" "$@" 2>&1 | grep -v 'vibe-coded PoC'; } # one warning line per call, drop it +log() { printf '%s\n' "$*" >&2; } + +# ---- 1. inventory (golden captures) --------------------------------------------------------- +mx -p "$SRC" list modules > "$OUT/captures/list-modules.txt" +"$MXCLI" -p "$SRC" project-tree --json 2>/dev/null > "$OUT/captures/project-tree.json" +if [ $# -eq 0 ]; then + # own module = empty Source column, not System + mapfile -t MODULES < <(awk -F'|' 'NR>3 && NF>3 { gsub(/ /,"",$2); gsub(/ /,"",$3); if ($3=="" && $2!="System" && $2!="") print $2 }' "$OUT/captures/list-modules.txt") +else + MODULES=("$@") +fi +log "own modules: ${MODULES[*]}" +for M in "${MODULES[@]}"; do + for t in entities associations enumerations microflows nanoflows pages snippets; do + mx -p "$SRC" show "$t" "$M" | grep -v '^Connected to' > "$OUT/captures/show-$t-$M.txt" + done +done + +# ---- 2. extract ------------------------------------------------------------------------------- +# describe_module : `describe module … with all` plus the image collections +# it omits (0.26.0), from the project tree. Used for the source and, in step 4, for the target. +describe_module() { + local mpr="$1" M="$2" out="$3" + "$MXCLI" -p "$mpr" -c "describe module $M with all" 2>/dev/null | grep -v 'vibe-coded PoC' > "$out" + "$MXCLI" -p "$mpr" project-tree --json 2>/dev/null | "$PY" -I -c ' +import json,sys +def walk(n): + for x in n: + if x.get("type")=="imagecollection" and x.get("qualifiedName","").startswith(sys.argv[1]+"."): print(x["qualifiedName"]) + walk(x.get("children",[])) +walk(json.load(sys.stdin))' "$M" | while read -r ic; do + "$MXCLI" -p "$mpr" describe imagecollection "$ic" 2>/dev/null | grep -v 'vibe-coded PoC' | sed '1{/^mdl 1;$/d}' >> "$out" + done +} +for M in "${MODULES[@]}"; do + describe_module "$SRC" "$M" "$OUT/mdl/$M.mdl" + "$PY" -I "$HERE/lib/topo-order-mdl.py" "$OUT/mdl/$M.mdl" "$OUT/mdl/$M.ordered.mdl" 2>>"$OUT/captures/describe-$M.stderr" +done +"$MXCLI" -p "$SRC" describe settings Settings 2>/dev/null | grep -v 'vibe-coded PoC' > "$OUT/mdl/_settings.mdl" +mx -p "$SRC" -c "show user roles" > "$OUT/captures/show-userroles.txt" +: > "$OUT/mdl/_userroles.mdl" +awk -F'|' 'NR>3 && NF>3 { gsub(/ /,"",$2); if ($2!="") print $2 }' "$OUT/captures/show-userroles.txt" | while read -r r; do + "$MXCLI" -p "$SRC" describe userrole "$r" 2>/dev/null | grep -v 'vibe-coded PoC' >> "$OUT/mdl/_userroles.mdl" +done + +# ---- 3. check, then exec into the target (modules in the order given) --------------------------- +status=0 +for M in "${MODULES[@]}"; do + if mx check -p "$TGT" "$OUT/mdl/$M.ordered.mdl" > "$OUT/captures/check-$M.txt"; then + mx exec -p "$TGT" "$OUT/mdl/$M.ordered.mdl" > "$OUT/captures/exec-$M.txt" || { log "exec $M FAILED — see captures/exec-$M.txt"; status=1; } + else + log "check $M FAILED — see captures/check-$M.txt (nothing executed for $M)"; status=1 + fi +done +mx exec -p "$TGT" "$OUT/mdl/_settings.mdl" > "$OUT/captures/exec-_settings.txt" || status=1 +mx exec -p "$TGT" "$OUT/mdl/_userroles.mdl" > "$OUT/captures/exec-_userroles.txt" || status=1 + +# ---- 4. compare and ledger --------------------------------------------------------------------- +{ + echo "# Fidelity ledger — $(basename "$SRC") -> $(basename "$TGT"), $(mx --version | head -1), $(date +%F)" + echo "# verdicts: emitted-identical | emitted-drift | failed-exec | not-emitted | target-extra | not-expressible (app-level hand-offs)" + echo "module document type verdict evidence" +} > "$OUT/fidelity-ledger.tsv" +for M in "${MODULES[@]}"; do + describe_module "$TGT" "$M" "$OUT/mdl/target-$M.mdl" + "$PY" -I "$HERE/lib/compare-mdl.py" "$OUT/mdl/$M.mdl" "$OUT/mdl/target-$M.mdl" "$M" 2>/dev/null >> "$OUT/fidelity-ledger.tsv" +done +# app-level rows the model export cannot carry — always hand-off, never silent +printf '(app)\tDemoUsers\tdemouser\tnot-expressible\tdescribe prints Password: '"'"'***'"'"' and exec refuses it — recreate by hand with real passwords (describe demouser for the role/entity)\n' >> "$OUT/fidelity-ledger.tsv" +printf '(app)\tProjectSecurity\tprojectsecurity\tnot-expressible\tdescribe projectsecurity is a report; write `alter app security (SecurityLevel: …)` by hand and drop the blank template'"'"'s user role User + demo users before mx check under Production\n' >> "$OUT/fidelity-ledger.tsv" +printf '(app)\ttheme\ttheme\tnot-expressible\tnot in the model: copy theme/ and themesource// by hand\n' >> "$OUT/fidelity-ledger.tsv" +# denominator: describable documents of the own modules in the source tree +"$PY" -I - "$OUT/captures/project-tree.json" "$OUT/fidelity-ledger.tsv" "${MODULES[@]}" <<'PYEOF' +import json,sys +tree=json.load(open(sys.argv[1])); mods=sys.argv[3:] +def walk(n): + for x in n: + if x.get('describable') and any(x.get('qualifiedName','').startswith(m+'.') for m in mods): yield x['qualifiedName'] + yield from walk(x.get('children',[])) +cat=set(walk(tree)) +rows=[l.split('\t') for l in open(sys.argv[2]) if not l.startswith('#') and not l.startswith('module\t')] +covered={r[1] for r in rows} +missing=sorted(cat-covered) +with open(sys.argv[2],'a') as f: + for q in missing: f.write('%s\t%s\t?\tnot-emitted\tin source catalog, no describe statement on either side\n'%(q.split('.')[0],q)) + f.write('# denominator: %d describable documents in the source catalog for %s; %d covered by rows, %d listed as not-emitted\n'%(len(cat),','.join(mods),len(cat&covered),len(missing))) +print('catalog=%d covered=%d not-emitted=%d'%(len(cat),len(cat&covered),len(missing))) +PYEOF +log "ledger: $OUT/fidelity-ledger.tsv" +exit $status diff --git a/pipelines/mendix-mpr/samples/fsm-template-9.24.2/CAPTURE.txt b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/CAPTURE.txt new file mode 100644 index 00000000..4f582f2c --- /dev/null +++ b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/CAPTURE.txt @@ -0,0 +1,18 @@ +Captured 2026-10-12 — the Rehost/Decompose Stage 0 read probe on a real Mendix 9 model. + +Source Marketplace 204718 "Maintenance & Field Service Management template" v1.1.1 (public, free, + Siemens). Downloaded with `mxcli marketplace download 204718 --output /fsm-1.1.1.mpk` + (--output takes a FILE path; a directory fails with "rename ... file exists"). 103 MB .mpk, + unzipped: a 64 MB single-SQLite .mpr, MPR v1, Mendix 9.24.2, 4783 units. + Not shipped here: the .mpk/.mpr are large and the Marketplace page is the source of record. + Mendix SSO, which it depends on, leaves the Marketplace 1 Nov 2026 — download before then. +mxcli v0.26.0, Linux cloud container, no Studio Pro. + +list-modules.md `mxcli -p .mpr -c "list modules"` verbatim, 1.6 s -> READ PROBE PASSED +graph-report.md `mxcli -p .mpr graph-report` verbatim, 18 s (catalog built) + the PopupMenu.mpk "zip: checksum error" warning is benign (one widget skipped) + +What the two captures establish: 59 modules of which 45 own; 299 entities, 1896 microflows, +675 nanoflows, 253 pages, ~19.9k refs. Two sides — the web planner (TicketManagement, Technician, +Scheduler, MasterData, Part, Form, AssetManagement) and NativeMobile* — joined through the +Synchronization module. That seam is the first cut candidate for bin/cut-score.sh (increment 2). diff --git a/pipelines/mendix-mpr/samples/fsm-template-9.24.2/graph-report.md b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/graph-report.md new file mode 100644 index 00000000..546d5b95 --- /dev/null +++ b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/graph-report.md @@ -0,0 +1,184 @@ +2026/10/12 05:11:24 warning: skipping com.mendix.widget.web.PopupMenu.mpk: package.xml: zip: checksum error +Widget definitions: 58 new, 3 refreshed +2026/10/12 05:11:24 warning: skipping com.mendix.widget.web.PopupMenu.mpk: package.xml: zip: checksum error +✓ Modules: 59 +✓ Entities: 299 +✓ Attributes: 1426 +✓ Entity Event Handlers: 6 +✓ Associations: 294 +✓ Enumerations: 85 +✓ Enumeration values: 448 +✓ Constants: 49 +✓ ConstantValues: 0 +✓ Java Actions: 162 +✓ Java Action Parameters: 311 +✓ JavaScript Actions: 80 +✓ Image Collections: 19 +✓ Icon Collections: 0 +✓ Menus: 2 +✓ Page Templates: 71 +✓ Data Transformers: 0 +✓ Scheduled Events: 9 +✓ Task Queues: 0 +✓ Regular Expressions: 5 +✓ Agent-editor documents: 0 +✓ Microflows: 1896 +✓ Nanoflows: 675 +✓ Rules: 63 +✓ Flow parameters: 3170 +✓ Activities: 19905 +✓ Pages: 253 +✓ Widgets: 5033 +✓ Snippets: 107 +✓ Snippet widgets: 1904 +✓ Building blocks: 163 +✓ Layouts: 40 +✓ OData Clients: 0 +✓ OData Services: 0 +✓ Business Event Services: 0 +✓ widget_definitions: 70 +✓ Database Connections: 0 +✓ JAR Dependencies: 0 +✓ REST Clients: 0 +✓ Published REST Services: 4 +✓ Published REST Operations: 6 +✓ External Entities: 0 +✓ External Actions: 0 +✓ Business Events: 0 +✓ Contract Entities: 0 +✓ Contract Messages: 0 +✓ Navigation profiles: 2 +✓ Navigation menu items: 42 +✓ Navigation role homes: 9 +✓ RoleMappings: 316 +✓ JSON Structures: 1 +✓ Import Mappings: 4 +✓ Export Mappings: 3 +✓ XPath Expressions: 772 +✓ References: 19868 +✓ Permissions: 6650 +✓ strings: 14281 +✓ Catalog cached to .mxcli/catalog.db +# Graph report — Siemens FSM powered by Mendix.mpr + +Architecture map from `CATALOG.graph_*` (top 15 per section, framework modules excluded). + +## God nodes — most depended-upon / highest fan-out + +| Asset | ObjectType | InDegree | OutDegree | Degree | +|---|---|---|---|---| +| MasterData.Masterdata_Overview_MxAdmin | PAGE | 1 | 303 | 304 | +| TicketManagement.WorkOrder | ENTITY | 275 | 3 | 278 | +| Technician.Technician | ENTITY | 228 | 0 | 228 | +| NativeMobile.NativeAppointment | ENTITY | 205 | 6 | 211 | +| TicketManagement.Ticket | ENTITY | 169 | 2 | 171 | +| TicketManagement.Appointment | ENTITY | 158 | 1 | 159 | +| TicketManagement.WorkOrder_Ticket | ASSOCIATION | 132 | 2 | 134 | +| NativeModelCheck.NativeModelCheck | ENTITY | 119 | 1 | 120 | +| NativeModelCheck.LogMessage_Create | MICROFLOW | 110 | 9 | 119 | +| NativeMobile.NativeWorkOrder | ENTITY | 112 | 5 | 117 | +| AssetManagement.Asset | ENTITY | 109 | 0 | 109 | +| Synchronization.OfflineAction_AppendReviewMessage | MICROFLOW | 104 | 3 | 107 | +| NativeMobile.DS_GetObjectCounts | NANOFLOW | 1 | 104 | 105 | +| Administration.Account | ENTITY | 99 | 2 | 101 | +| Form.FormVersion | ENTITY | 98 | 2 | 100 | + +## Module coupling — cross-module edges (surprise edges) + +| SourceModule | TargetModule | Edges | RefKinds | +|---|---|---|---| +| MasterData | TicketManagement | 295 | parameter,retrieve,call,delete,datasource,member | +| NativeMobileRelated | NativeMobile | 251 | parameter,return,create,retrieve,call,change,delete,datasource,action,show_page,member,xpath | +| Synchronization | NativeMobile | 236 | parameter,retrieve,return,delete,call,member,xpath | +| Synchronization | TicketManagement | 185 | parameter,retrieve,return,call,change,associate,member,value,xpath | +| Scheduler | TicketManagement | 172 | call,parameter,return,retrieve,show_page,associate,datasource,action,member,value,xpath | +| Synchronization | Form | 148 | call,retrieve,parameter,return,change,associate,datasource,member,xpath | +| TicketManagement | Technician | 147 | retrieve,parameter,return,call,associate,datasource,show_page,member,xpath | +| Synchronization | NativeMobileForm | 125 | retrieve,parameter,call,return,change,member,xpath | +| Dashboard | TicketManagement | 119 | call,retrieve,parameter,return,datasource,action,show_page,member,xpath | +| Technician | TicketManagement | 112 | return,show_page,retrieve,call,datasource,member,xpath | +| MasterData | Form | 110 | parameter,call,datasource,show_page,member | +| Synchronization | Part | 109 | call,parameter,return,retrieve,change,member,xpath | +| Synchronization | NativeMobilePart | 105 | parameter,retrieve,call,change,commit,member,xpath | +| MasterData | Part | 101 | parameter,call,datasource,member | +| MasterData | NativeMobile | 99 | retrieve,call,return,parameter,member,xpath | + +## Module cohesion — lowest first (most entangled) + +| ModuleName | IntraEdges | InterEdges | CohesionPct | +|---|---|---|---| +| Navigation | 0 | 103 | 0 | +| NativeRestAPI | 4 | 52 | 7.1 | +| General | 11 | 107 | 9.3 | +| MasterData | 138 | 1156 | 10.7 | +| ModuleIntegration_Administration_Impl | 17 | 99 | 14.7 | +| ModuleIntegration_Logging_Impl | 3 | 17 | 15 | +| ModuleIntegration_DeepLink_Impl | 9 | 41 | 18 | +| NativeMobileRelated | 187 | 548 | 25.4 | +| CommunityCommons | 5 | 12 | 29.4 | +| Visualizations | 33 | 59 | 35.9 | +| Synchronization | 881 | 1494 | 37.1 | +| AppSettings | 84 | 138 | 37.8 | +| Utils | 17 | 27 | 38.6 | +| IDCreation | 9 | 12 | 42.9 | +| FSM_Specific | 12 | 15 | 44.4 | + +## Dead documents — referenceable but no inbound edge + +| QualifiedName | ObjectType | ModuleName | +|---|---|---| +| CommunityCommons.ImageDimensions | ENTITY | CommunityCommons | +| CommunityCommons.SplitItem | ENTITY | CommunityCommons | +| DeepLink.Microflow_Select_DataGrid | ENTITY | DeepLink | +| JWT.JWTPlainText | ENTITY | JWT | +| JWT.PublicClaimResponse | ENTITY | JWT | +| MendixSSO.AdditionalScopes | ENTITY | MendixSSO | +| MendixSSO.AuthRequest | ENTITY | MendixSSO | +| MendixSSO.AuthenticateEnvironmentRequest | ENTITY | MendixSSO | +| MendixSSO.MendixSSOConfigurationView | ENTITY | MendixSSO | +| NanoflowCommons.Geolocation | ENTITY | NanoflowCommons | +| NanoflowCommons.Position | ENTITY | NanoflowCommons | +| NativeMobileResources.DeviceInfo | ENTITY | NativeMobileResources | +| NativeMobileResources.ImageMetaData | ENTITY | NativeMobileResources | +| ObjectHandling.ListObject | ENTITY | ObjectHandling | +| Scheduler.PageContext_View_DataGrid | ENTITY | Scheduler | + +## Reference kinds — edge vocabulary + +| RefKind | SourceType | TargetType | Count | Pct | +|---|---|---|---|---| +| call | MICROFLOW | MICROFLOW | 2322 | 11.7 | +| parameter | MICROFLOW | ENTITY | 1788 | 9 | +| member | PAGE | ATTRIBUTE | 1517 | 7.6 | +| member | MICROFLOW | ATTRIBUTE | 1336 | 6.7 | +| call | NANOFLOW | NANOFLOW | 890 | 4.5 | +| retrieve | MICROFLOW | ENTITY | 731 | 3.7 | +| member | MICROFLOW | ASSOCIATION | 660 | 3.3 | +| parameter | NANOFLOW | ENTITY | 632 | 3.2 | +| associate | ASSOCIATION | ENTITY | 588 | 3 | +| member | SNIPPET | ATTRIBUTE | 582 | 2.9 | +| member | NANOFLOW | ATTRIBUTE | 516 | 2.6 | +| xpath | MICROFLOW | ASSOCIATION | 499 | 2.5 | +| xpath | MICROFLOW | ATTRIBUTE | 480 | 2.4 | +| return | MICROFLOW | ENTITY | 460 | 2.3 | +| change | MICROFLOW | ENTITY | 423 | 2.1 | + +## Entity hotspots — used by the most flows + +| Entity | UsedByFlows | AcrossModules | +|---|---|---| +| TicketManagement.WorkOrder | 162 | Technician,LoadTest,General,AppSettings,MasterData,Form,Skill,Scheduler,NativeMobileAsset,Tool,NativeMobileRelated,NativeModelCheck,Part,IDCreation,Synchronization,TicketManagement,Visualizations | +| Technician.Technician | 145 | Technician,LoadTest,RosterManagement,MasterData,Skill,AssetManagement,NativeMobile,FSMPublishedAPI,Scheduler,Dashboard,NativeModelCheck,Synchronization,TicketManagement | +| NativeMobile.NativeAppointment | 136 | AppSettings,MasterData,NativeMobile,NativeMobileRelated,NativeModelCheck,Synchronization,NativeMobileTimeRegistration,NativeMobileAsset,NativeReason,NativeMobilePart,NativeTechnician | +| TicketManagement.Ticket | 106 | Technician,LoadTest,General,AppSettings,MasterData,Skill,FSMPublishedAPI,Dashboard,NativeModelCheck,IDCreation,Synchronization,TicketManagement,Visualizations | +| TicketManagement.Appointment | 97 | Technician,LoadTest,General,AppSettings,MasterData,Scheduler,NativeMobileAsset,Dashboard,NativeMobileRelated,NativeModelCheck,IDCreation,Synchronization,TicketManagement,Visualizations | +| NativeMobile.NativeWorkOrder | 71 | AppSettings,MasterData,NativeMobile,NativeMobileAsset,NativeMobileRelated,NativeModelCheck,Synchronization,NativeNote | +| Form.FormVersion | 64 | LoadTest,Form,Synchronization,TicketManagement | +| AssetManagement.Asset | 61 | LoadTest,MasterData,AssetManagement,FSMPublishedAPI,NativeMobileAsset,Dashboard,NativeModelCheck,Part,Synchronization,TicketManagement | +| NativeMobile.NativeWorkOrderAssignment | 59 | AppSettings,MasterData,NativeMobile,NativeMobileRelated,NativeModelCheck,Synchronization,NativeTechnician | +| NativeMobilePart.NativeUsedPart | 53 | AppSettings,MasterData,NativeMobile,NativeMobileRelated,NativeModelCheck,NativeMobilePart,Synchronization | +| NativeMobile.NativeTicket | 52 | AppSettings,MasterData,NativeMobile,NativeMobileAsset,NativeModelCheck,Synchronization,TicketManagement | +| NativeNote.NativeNote | 51 | MasterData,NativeNote,NativeMobileRelated,NativeModelCheck,Synchronization,NativeMobile | +| Administration.Account | 49 | Technician,DemoUserSwitcher,General,Administration,NativeRestAPI,Scheduler,Utils,Synchronization,ModuleIntegration_Administration_Impl,TicketManagement,NativeMobile | +| NativeMobileTimeRegistration.NativeWorkBreak | 42 | MasterData,NativeMobileTimeRegistration,NativeModelCheck,Synchronization,NativeTechnician,NativeMobile | +| NativeMobileForm.NativeInstanceItem | 41 | AppSettings,MasterData,NativeMobileForm,NativeMobileRelated,NativeModelCheck,Synchronization,NativeMobile | diff --git a/pipelines/mendix-mpr/samples/fsm-template-9.24.2/list-modules.md b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/list-modules.md new file mode 100644 index 00000000..d93d9f42 --- /dev/null +++ b/pipelines/mendix-mpr/samples/fsm-template-9.24.2/list-modules.md @@ -0,0 +1,63 @@ +| Module | Source | Entities | Enums | Pages | Snippets | Microflows | Nanoflows | Workflows | Constants | JavaActions | PubREST | PubOData | ConOData | BizEvents | ExtDB | +|---------------------------------------|--------------------|----------|-------|-------|----------|------------|-----------|-----------|-----------|-------------|---------|----------|----------|-----------|-------| +| Administration | Marketplace v2.1.0 | 3 | 0 | 11 | 1 | 18 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| AppSettings | | 1 | 0 | 3 | 2 | 52 | 2 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | +| AssetManagement | | 5 | 0 | 10 | 3 | 24 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Atlas_Core | Marketplace v3.8.1 | 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Atlas_NativeMobile_Content | Marketplace v3.0.2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | +| Atlas_Web_Content | Marketplace v3.1.2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| BizzomateScheduler | Marketplace v1.4.0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | +| CommunityCommons | Marketplace v9.0.2 | 2 | 5 | 0 | 0 | 4 | 0 | 0 | 1 | 92 | 0 | 0 | 0 | 0 | 0 | +| CustomerManagement | | 4 | 1 | 18 | 4 | 44 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Dashboard | | 6 | 1 | 5 | 4 | 31 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| DataWidgets | Marketplace v2.5.6 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| DeepLink | Marketplace v9.0.5 | 9 | 1 | 8 | 5 | 24 | 5 | 0 | 5 | 10 | 0 | 0 | 0 | 0 | 0 | +| DemoUserSwitcher | Marketplace v1.1.0 | 1 | 0 | 2 | 0 | 6 | 1 | 0 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | +| FeatureToggle | | 1 | 1 | 1 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Form | | 25 | 5 | 7 | 4 | 178 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| FSM_Specific | | 0 | 0 | 4 | 12 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| FSMPublishedAPI | | 5 | 0 | 0 | 0 | 21 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | +| General | | 0 | 0 | 1 | 0 | 12 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| IDCreation | | 3 | 0 | 0 | 0 | 6 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| JWT | Marketplace v3.2.2 | 15 | 2 | 0 | 0 | 9 | 0 | 0 | 1 | 10 | 0 | 0 | 0 | 0 | 0 | +| LoadTest | | 2 | 0 | 1 | 0 | 24 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | +| Logging | Marketplace v1.9.0 | 1 | 2 | 1 | 2 | 5 | 0 | 0 | 2 | 2 | 0 | 0 | 0 | 0 | 0 | +| MasterData | | 0 | 0 | 29 | 0 | 88 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MendixSSO | Marketplace v4.1.0 | 14 | 2 | 0 | 0 | 43 | 0 | 0 | 23 | 16 | 2 | 0 | 0 | 0 | 0 | +| ModuleIntegration_Administration_Impl | | 0 | 0 | 5 | 0 | 7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| ModuleIntegration_DeepLink_Impl | | 0 | 0 | 4 | 3 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| ModuleIntegration_Logging_Impl | | 0 | 0 | 2 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NanoflowCommons | Marketplace v3.0.0 | 2 | 3 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobile | | 28 | 4 | 26 | 20 | 63 | 279 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileAsset | | 5 | 0 | 6 | 1 | 15 | 38 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileForm | | 10 | 0 | 3 | 1 | 43 | 33 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobilePart | | 11 | 1 | 10 | 2 | 40 | 73 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileRelated | | 5 | 0 | 11 | 8 | 19 | 47 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileResources | Marketplace v4.0.1 | 2 | 7 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileTimeRegistration | | 4 | 0 | 3 | 0 | 25 | 40 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeMobileWarehouse | | 4 | 0 | 0 | 0 | 21 | 5 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeModelCheck | | 2 | 1 | 1 | 0 | 43 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | +| NativeNote | | 3 | 2 | 2 | 0 | 16 | 35 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeReason | | 4 | 0 | 0 | 0 | 27 | 6 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| NativeRestAPI | | 0 | 0 | 0 | 0 | 5 | 1 | 0 | 3 | 0 | 1 | 0 | 0 | 0 | 0 | +| NativeTechnician | | 1 | 0 | 0 | 0 | 9 | 2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Note | | 2 | 0 | 1 | 1 | 15 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| ObjectHandling | Marketplace v3.2.0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 20 | 0 | 0 | 0 | 0 | 0 | +| Part | | 7 | 0 | 7 | 3 | 63 | 10 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Reason | | 4 | 1 | 3 | 1 | 27 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| RosterManagement | | 3 | 1 | 3 | 1 | 30 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | +| Scheduler | | 8 | 2 | 4 | 4 | 72 | 11 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | +| Skill | | 2 | 0 | 4 | 1 | 20 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Synchronization | | 23 | 11 | 2 | 0 | 245 | 69 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| System | | 39 | 15 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | +| Technician | | 3 | 0 | 14 | 4 | 78 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| TEST_NativeLocalLogin | | 1 | 0 | 1 | 1 | 0 | 2 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | +| TicketManagement | | 15 | 11 | 27 | 9 | 308 | 4 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | +| TimeRegistration | | 5 | 4 | 3 | 1 | 40 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Tool | | 2 | 0 | 4 | 1 | 24 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| Utils | | 1 | 0 | 0 | 0 | 9 | 7 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | +| ValidationFeedback | Marketplace v1.2.0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 0 | 0 | 0 | 0 | 0 | +| Visualizations | | 2 | 0 | 1 | 1 | 12 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | +| WarehouseManagement | | 2 | 0 | 5 | 2 | 26 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | + +(59 modules) diff --git a/process/decompose-and-rehost-plan-2026-10-12.md b/process/decompose-and-rehost-plan-2026-10-12.md index 7b5c05cb..494c94ed 100644 --- a/process/decompose-and-rehost-plan-2026-10-12.md +++ b/process/decompose-and-rehost-plan-2026-10-12.md @@ -1,6 +1,8 @@ # Plan: monolith decomposition and MPR-to-MDL rehost — two new pipelines -**Status:** draft for discussion, 2026-10-12. Nothing in this plan is built yet. +**Status:** 2026-10-12, increment 1 done on both tracks (results in §5); increment 2 next. +**Decisions since the draft:** no Event Broker licence → Business Events is **not** an integration lane, +a crossing edge becomes OData or REST or it blocks. The FSM template reads on Mendix 9.24.2 (MPR v1). **Decisions already taken in the conversation that produced it:** Mendix version upgrades are out of scope (apps on Mendix 9 and above only; the upgrade hop itself is the customer's job). Two tracks remain and run in parallel: **Decompose** (split one app into several) and @@ -17,7 +19,7 @@ Two tracks remain and run in parallel: **Decompose** (split one app into several | Mendix apps cannot share a database; integration is OData (external entities), REST or Business Events | Every edge across the cut becomes an HTTP call. Edge weight is the cost of the cut | | External entities cannot own associations, cannot aggregate, and every *Send External Object* is its own transaction | An edge carrying an association, an aggregate, or a shared transaction scores as a bad cut | | No cross-app workflow; no shared user table (OIDC Provider module brokers identity; Mendix SSO deprecated May 2026) | A workflow or a security role that spans the cut is a blocker, not a cost | -| Business Events need a paid Event Broker licence and carry flat payloads | Async edges are an option only when the licence exists (open question §6) | +| Business Events need a paid Event Broker licence and carry flat payloads | **Not a lane here** (decided 2026-10-12: no licence). Async edges are out of the table | | Every app is a cloud node; pricing is per app | The number of target apps is a licensing decision before an architecture one | | Module export to `.mpk` works only for a self-contained module and the same Studio Pro version; no tool moves a module with its transitive closure | The cut has to be executed by regeneration (MDL), not by export/import | @@ -30,7 +32,9 @@ cut ladder). The missing half is **scoring a proposed cut and executing it**. ### Rehost — what mxcli can and cannot do today (v0.26.0, probed in a cloud container 2026-10-12) -- `mxcli describe module ` emits a whole module as re-executable MDL (`mdl 1;` header). 25 +- The MDL statement `describe module with all` (via `-c`) emits a whole module as re-executable MDL + (`mdl 1;` header); the *subcommand* `mxcli describe module ` emits only the module and its roles + (found in increment 1a, `pipelines/mendix-mpr/README.md`). 25 document types are covered, including nanoflows, Java actions (signature only — the body is a stub, `migrate-general.md`), JavaScript actions, scheduled events, queues, REST and OData services, mappings, JSON structures. @@ -79,7 +83,7 @@ mode with a new Stage 3 instrument and a per-slice Stage 5 loop**. | Stage | Reuses | New | |---|---|---| | 0a Map | `app-facts.sh`, `app-report.sh`, dossier §3, layer map | — | -| 0b Scope ✋ | interview protocol | The three questions: why split (team, cadence, licence, risk), how many apps may exist (licensing), which integration lanes are allowed (OData/REST; Business Events only with licence) | +| 0b Scope ✋ | interview protocol | The three questions: why split (team, cadence, licence, risk), how many apps may exist (licensing), which integration lanes are allowed (OData/REST only — no Event Broker licence) | | 3 Cut scoring ✋ | `layering-review.md` cut ladder | `bin/cut-score.sh`: for each candidate partition (from the layer map's levels, or user-named), score every crossing edge by ref kind: `associate` (external-entity rewrite, no aggregates), `call` (OData action or REST), workflow crossing (blocker), security role crossing (blocker), shared `System.FileDocument` (REST, binaries), scheduled event ownership. Output: `analysis/cut-score.html|json`, one row per edge with a lane and a cost class; a partition's total is the sum. User picks or amends; decision in `PROJECT.md` | | 4 Landscape blueprint | `architecture-blueprint.md` | A target-landscape section: apps, published services per edge, identity broker, the data ownership per entity | | 5 Slice loop (strangler) | `iterative-build-loop.md`, `existing-app-change.md` | Per slice: (1) regenerate the slice's module(s) into the new app with the Rehost extractor; (2) replace each crossing edge in the monolith by a consumed service and in the new app by a published one; (3) regression baseline from Track B on both sides; (4) data copy per owned entity; (5) retire the module in the monolith. The monolith stays live throughout — the "keep the core and phase out" answer | @@ -112,10 +116,10 @@ silent. | # | Track | Target | Deliverable | Runs where | |---|---|---|---|---| -| 1 | Rehost | ako/mxcli-banking (11.x, one module) | round-trip: describe → new app → exec → struct diff; first ledger format | cloud, now | -| 1 | Decompose | Tier 0 synthetic: 4 modules, one tangle, built in Mendix **10.24** with `mxcli new` | `cut-score.sh` v0 over `dependencies.json`; proves the edge classifier on a known answer | cloud, now | +| 1 | Rehost | ako/mxcli-banking (11.x, one module) | **DONE 2026-10-12** — `pipelines/mendix-mpr/rehost-extract.sh`: 155/155 docs, 154 identical, mx check 0, mxbuild green, struct diff empty; ledger format set | cloud | +| 1 | Decompose | Tier 0 synthetic: 4 modules, one tangle, built in Mendix **10.24** with `mxcli new` | **DONE 2026-10-12** — `bin/cut-score.sh` + `tests/wave2/test-cut-score.sh` (56 checks): tangle → blocker, foreign aggregate → costly, association-only → acceptable, all found. Not yet in a skill or the routing table | cloud | | 2 | Rehost | GenAI Showcase (48 modules, 11.12) | scale: time per module, ledger over a real widget mix | cloud | -| 2 | Decompose | FSM template (9.24.2) | **the Mendix 9 read probe**, then cut scoring on a real tangle | cloud, needs user PAT for `mxcli auth login` + `marketplace download` | +| 2 | Decompose | FSM template (9.24.2) | **Read probe PASSED 2026-10-12** (MPR v1, 45 own modules, 1896 microflows; captures in `pipelines/mendix-mpr/samples/fsm-template-9.24.2/`). Next: `cut-score.sh` on the planner-vs-NativeMobile seam through `Synchronization` | cloud | | 3 | both | FSM | first actual slice: one module out, two edges rewired, journeys across the cut | cloud | | 4 | both | user's Mendix 11 project | fidelity ledger and cut score on a real customer-shaped app; read-only | user's container | @@ -127,7 +131,7 @@ layouts, a cited field run per instrument, a producer for every artifact consume ## 6. Open questions (answers go to `PROJECT.md` of the first field project) 1. Target landscape: Mendix apps only, or may a slice leave Mendix? (changes the lane table in cut scoring) -2. Is the Event Broker licence available to the customers this is for? If not, Business Events is not a lane. +2. ~~Event Broker licence?~~ **Answered 2026-10-12: no.** Business Events is not a lane. 3. Rehost done-criterion: model builds clean, or parity proven by the Track B regression baseline? -4. FSM download: the user's Mendix PAT is needed in the cloud container; pull before 1 Nov 2026. +4. ~~FSM download~~ **Done in the cloud container 2026-10-12** (`MENDIX_PAT`). Still: keep a copy on a user machine before 1 Nov 2026 — the container is ephemeral. 5. Data migration during a slice (copy per owned entity): in scope of the toolkit, or always a customer job? diff --git a/skills/learned-mdl-preflight.md b/skills/learned-mdl-preflight.md index 69993014..410c060d 100644 --- a/skills/learned-mdl-preflight.md +++ b/skills/learned-mdl-preflight.md @@ -75,6 +75,7 @@ None of these needs a STOP, but each passes `mxcli check` and fails later — at - **No `elseif` in page/widget expressions** (DynamicClasses, Visible, ContentParams, …): nest `else if … then … else …`. Microflow `IF/ELSEIF` compiles into decision splits; a page expression passes through to Mendix's parser, which has no `elseif` token. v0.24.0: `check` and `exec` pass it, `mx check` gives `[CE0117] Error(s) in expression at Container '…'`; lint does not flag it. - **`mxcli check` does not catch corruption.** Every STOP row above is grammatically valid MDL that `check` passes; only mxbuild or SP open reveals the damage. Run the real mxbuild gate after every exec (`iterative-build-loop.md`). - **mxcli v0.25 — keep the skills' spellings, never add `mdl 1;`.** The spellings in these skills (`count($L)`, `on error { … }`, `show page M.P($Obj = $Obj)`, `create or replace`) parse on v0.24.0 *and* v0.25.0; v0.25 only warns (`MDL-DEPR*`, "refused from `mdl 2`"). The new spellings (`count $L`, `on error begin … end error`, `P(Obj = $Obj)`) fail `check` on v0.24 — don't "modernise" a script the team may still run there. The hazard is the dialect header: v0.24 rejects it (`line 1:4 extraneous input '1'`), and under it v0.25 changes meanings — `show entity M.E` becomes an error (use `describe entity` / `list entities`; `-c` and the REPL already read this way), and `retrieve … limit 1` returns a *list* (use `first`) while `check` still passes. So strip the `mdl 1;` line from `describe` output before pasting it into a script, and never mix dialects in one file. Moving a whole file to mdl 1 on purpose: `mxcli fmt --upgrade -w -p .mpr