From 91c957c5fd2ff86646425a84b40c35f65f1566ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:52:34 +0000 Subject: [PATCH 1/2] decompose: cut-score + app-facts fix (split from #293, 1/6) bin/cut-score.sh, its fixture (tests/wave2/test-cut-score.sh + captured fixtures/cut-score/) and the app-facts.sh sqlite3-CLI fallback, copied from #293's head and adapted to master: the catalog build_mode label is recorded, not tested (#259), and an empty activities_data reads NOT DETECTED. Fixture 56 -> 64 checks (label, empty table, both layouts). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + bin/cut-score.sh | 583 ++++++++++++++++++ project-bin/app-facts.sh | 13 +- 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 | 233 +++++++ 9 files changed, 1322 insertions(+), 2 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 59bca1b3..2a95316b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- new(cut-score), fix(app-facts): **`bin/cut-score.sh` scores a proposed split of an existing app into several apps; `app-facts.sh` no longer needs the sqlite3 CLI** (split from [#293](https://github.com/MendixMau/mxcli-project-toolkit/pull/293), 1/6). Given `--cut "A,B|C|D"` (or one app per level of `app-layer-map.json`) it lists every module edge that crosses an app boundary from `analysis/app-facts/dependencies.json`, names the lane each ref kind would need (OData external entity, published action or REST, or none), and sums a clean / costly / blocked verdict per partition with the denominator printed ("7 of 7 own edges"); an aggregate over a foreign entity is read from `.mxcli/catalog.db` (single-tree or `app/`), and reads NOT DETECTED with a reason when it cannot be. Adapted to master: the catalog's `build_mode` label is recorded, not tested (#259 — v0.24+ labels a full catalog `source`), and an empty `activities_data` reads NOT DETECTED instead of "no aggregates". `app-facts.sh` runs its guard queries through Python's sqlite3 when the CLI is missing (absent on some cloud containers). `tests/wave2/test-cut-score.sh` 64/64 on a captured four-module fixture (8 new: label, empty table, both layouts; 4 of them fail on #293's copy); `test-app-report.sh` 109/109; `test-catalog-edge-guard.sh` 20/20, and 20/20 with the sqlite3 CLI removed from PATH for app-facts (master's app-facts: 14/20). — MendixMau (#293 field runs on public template apps) - process(docs): **1.0 release notes, a versioning rule, and a README for adopters.** New `docs/releases/v1.0.md`: the first named milestone, covering 2026-09-30 to 2026-10-14 by theme (independent review before merge, baseline words 79,997 → 76,259, exec.sh lock and restart verdicts, lint batches 1–4 with SEC014 and the stock-revert guard, journey/e2e fixes, the Stage 3 interaction model, delivery rounds), with upgrade steps, known gaps and credits. `docs/RELEASE-PROCESS.md` §8 is the single source for versioning: a milestone (1.0, 1.1, 2.0) is a train release the maintainer also names, with curated notes, a GitHub Release and a CHANGELOG pointer; major = a consuming project breaks without action (needs a migration section and a sync path), minor = no action needed; deprecations are announced in one milestone and removed no earlier than the next major; the `vYYYY.MM.DD` train and `bin/cut-release.sh` are unchanged; plus how a consumer checks, upgrades and pins a version. The header above now defers to it. README rewritten for newcomers: what it is, how it works, ways to use it, quick start, Make it yours (company brain, CLAUDE.local.md, lint opt-outs, waivers and --adopt, interview mode, exec approval and permission profiles, agent stubs, other harnesses), testing, a call for input, releases. The earlier material sits under Reference with the generated routing blocks untouched. Corrected: the Windows `pgrep` hazard (replaced by the per-project exec lock in #304), the hand-edit steps in "How to add a new skill" (tables are generated), and the stale Stage 6 and `examples/` notes. — MendixMau - fix(interview-mode): **`attended` and `unattended` are now read as aliases for `steering` and `auto` — a project that opted into unattended as documented was silently run as steering.** CLAUDE.md, the runbook and intake Q9 taught `Interview mode: unattended`, but `bin/interview-mode.sh` knew only `steering | assist | auto` and fell back to steering. The aliases are accepted in every source (env, `.interview-mode`, `PROJECT.md`, `--set`) under the same parse rules, with one stderr note. The `PROJECT.md` value is read after stripping bold/backticks: a bare mode or alias (`**auto**`, `` `auto` ``, `auto.`) resolves, a note may follow `steering`, but `assist`/`auto` with any trailing text falls back to steering with a "must stand alone on the line" message — so an annotated or hedged line never resolves quieter than before (two hedge word lists were tried in review and both leaked); env, session file and `--set` stay exact. Output stays canonical so `interview-gate.sh` is unchanged, and any other value still falls back to steering. Docs now name the canonical values (vocabulary in `interview-protocol.md`); intake Q9's body offers the three modes with steering the default (heading kept: it is sync's locator), and `sync-project.sh` reads an earlier unanswered Q9 body as unanswered rather than "edited". `test-interview-mode.sh` +57 checks (19 → 76) — MendixMau (found in the 1.0 README review) - fix(dispatching, sync-project, obligation-check): **review follow-ups for the 2026-10-14 integration batch.** `dispatching.md` names app-restart exits 4 and 5 beside 3 and 1. `test-test-scratch.sh` drops `ls | grep -q` under pipefail for here-strings; sync step 4d no longer aborts on empty doctor output; `_ob_has_denominator` strips NUL bytes so bash stops warning. — integration 2026-10-14 reviews diff --git a/bin/cut-score.sh b/bin/cut-score.sh new file mode 100755 index 00000000..fbfe1be7 --- /dev/null +++ b/bin/cut-score.sh @@ -0,0 +1,583 @@ +#!/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 for scoring a decomposition (the decompose-and-rehost plan; +# split out of #293 and landing separately — skills/layering-review.md judges the result). 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 project 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: + # The build_mode LABEL is recorded, never tested (master #259): mxcli v0.24+ labels a + # complete catalog 'source', so `!= full` would read every current catalog as untrustworthy. + # What this needs is checked instead: activities_data has rows. + mode = db.execute("select Value from catalog_meta where Key='build_mode'").fetchone() \ + if "catalog_meta" in names else None + aggregate["build_mode"] = mode[0] if mode else None + n_acts = db.execute("select count(*) from activities_data").fetchone()[0] + if n_acts == 0: + aggregate["reason"] = ("catalog activities_data is empty (build_mode %r); an empty table is not a " + "clean app. Rerun app-facts.sh" % (mode[0] if mode else None)) + 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) + +# The per-partition cell names each blocking edge once, without its "why": that sentence is on the +# edge's own row below. On a real monolith (FSM 9.24.2, 2026-10-12: 80 blocking rows on one cut) the +# full text made one cell of 10 KB and the stdout summary a 16 KB line nobody could read. +def edge_only(b): + return b.split(": ", 1)[0] + +def blockers_cell(blockers, cap=12): + if not blockers: + return "-" + out = ["%d blocking edge%s" % (len(blockers), "" if len(blockers) == 1 else "s")] + out += [E_(edge_only(b)) for b in blockers[:cap]] + if len(blockers) > cap: + out.append("… %d more, all in the rows below" % (len(blockers) - cap)) + return "
".join(out) + +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 "-", + (blockers_cell(d["blockers"]), "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: %d (%s%s)" % (len(d_["blockers"]), + "; ".join(edge_only(b) for b in d_["blockers"][:3]), + " …" if len(d_["blockers"]) > 3 else "")) if d_["blockers"] else "")) +for g in gaps: + print(" gap: %s" % g) +PY diff --git a/project-bin/app-facts.sh b/project-bin/app-facts.sh index 534d3263..aa0fea4c 100755 --- a/project-bin/app-facts.sh +++ b/project-bin/app-facts.sh @@ -216,7 +216,6 @@ if [ -z "$MXCLI" ]; then elif command -v mxcli >/dev/null 2>&1; then MXCLI="mxcli" else echo "app-facts: mxcli not found (./mxcli or on PATH); set MXCLI=/path/to/mxcli" >&2; exit 2; fi fi -command -v sqlite3 >/dev/null || { echo "app-facts: sqlite3 not on PATH" >&2; exit 2; } MX_VERSION="$("$MXCLI" --version 2>/dev/null | head -1)" # A project-local ./mxcli is often an older pin; its catalog can lack whole tables (seen 2026-09-15: # a v0.17 pin had no scheduled_events_data and the guard below exits 2). Say so up front. @@ -244,7 +243,17 @@ if [ $RC -ne 0 ] || [ ! -s "$FACTS_DIR/graph-report.json" ]; then fi [ -f "$DB" ] || { echo "FAULT: $DB not written by graph-report" >&2; exit 2; } -q() { sqlite3 -noheader "$DB" "$1"; } +# One-column guard queries. The sqlite3 CLI when it is there (unchanged behaviour); otherwise the +# same query through Python's sqlite3 module, which step 2 needs anyway. The CLI is absent on +# cloud containers where apt has no sqlite3 package (field run, FSM 9.24.2, 2026-10-12), and a +# missing CLI must not stop a script whose facts never came from the CLI. +if command -v sqlite3 >/dev/null 2>&1; then + q() { sqlite3 -noheader "$DB" "$1"; } +else + q() { DB="$DB" "$PY" -c 'import os, sqlite3, sys +c = sqlite3.connect("file:%s?mode=ro" % os.environ["DB"], uri=True) +for r in c.execute(sys.argv[1]): print(r[0])' "$1"; } +fi BUILD_MODE="$(q "SELECT Value FROM catalog_meta WHERE Key='build_mode';")" # The build_mode LABEL is not checked (fixed 2026-10-13, field report 2026-10-11): mxcli v0.24.0 # labels a complete catalog 'source', and the old `!= full` test refused it on every run. What 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..376eb824 --- /dev/null +++ b/tests/wave2/test-cut-score.sh @@ -0,0 +1,233 @@ +#!/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 "== 2c. the build_mode LABEL is not a gate (master #259: mxcli v0.24+ labels a full catalog 'source') ==" +# Copies of the captured catalog with ONLY the label changed / ONLY the activity rows removed. +cp "$FIX/catalog.db" "$TMP/source-label.db" +"$PY" -c 'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute("update catalog_meta set Value=? where Key=?", ("source","build_mode")); c.commit()' "$TMP/source-label.db" +bash "$CS" --facts "$FIX/facts" --catalog "$TMP/source-label.db" --cut "$CUT" --json >"$TMP/srclabel.json" 2>/dev/null +check "a catalog labelled 'source' still DETECTS the aggregate" "$(jq_ "$TMP/srclabel.json" aggregate/status)" "DETECTED" +check "the label is recorded as found" "$(jq_ "$TMP/srclabel.json" aggregate/build_mode)" "source" +cp "$FIX/catalog.db" "$TMP/empty-acts.db" +"$PY" -c 'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute("delete from activities_data"); c.commit()' "$TMP/empty-acts.db" +bash "$CS" --facts "$FIX/facts" --catalog "$TMP/empty-acts.db" --cut "$CUT" --json >"$TMP/emptyacts.json" 2>/dev/null +check "an empty activities_data reads NOT DETECTED" "$(jq_ "$TMP/emptyacts.json" aggregate/status)" "NOT DETECTED" +case "$(jq_ "$TMP/emptyacts.json" aggregate/reason)" in + *"activities_data is empty"*) ok "the reason names the empty table" ;; + *) bad "the reason names the empty table" "$(jq_ "$TMP/emptyacts.json" aggregate/reason)" ;; +esac + +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 "== 8. layouts: the catalog is found beside the .mpr, single-tree and two-tree (synthetic trees) ==" +for layout in single two; do + P="$TMP/proj-$layout"; mkdir -p "$P/analysis/app-facts" + cp "$FIX/facts/"*.json "$P/analysis/app-facts/" + if [ "$layout" = single ]; then CDIR="$P/.mxcli"; else CDIR="$P/app/.mxcli"; fi + mkdir -p "$CDIR"; cp "$FIX/catalog.db" "$CDIR/catalog.db" + bash "$CS" "$P" --cut "$CUT" --json >"$TMP/layout-$layout.json" 2>/dev/null + check "$layout-tree: catalog auto-found, aggregate DETECTED" "$(jq_ "$TMP/layout-$layout.json" aggregate/status)" "DETECTED" + bash "$CS" "$P" --cut "$CUT" >/dev/null 2>&1 + [ -f "$P/analysis/cut-score.html" ] && ok "$layout-tree: default -o is /analysis/cut-score.html" \ + || bad "$layout-tree: default -o is /analysis/cut-score.html" +done + +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 c55182f6f764a2d857db3473cea0bc936a619aab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:07:55 +0000 Subject: [PATCH 2/2] decompose: cut-score review fixes (#341) - app-facts: the Python sqlite3 fallback strips \r (native Windows Python under Git Bash, which has no sqlite3 CLI); APPFACTS_NO_SQLITE3_CLI=1 forces that path. test-catalog-edge-guard gains a CRLF case (22/22). - cut-score: rows name the actual NOT DETECTED reason; parenthesise the own-module set difference; header states the template-app field run. - test-cut-score 64 -> 70. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 +- bin/cut-score.sh | 19 +++++++++++++++++-- project-bin/app-facts.sh | 9 ++++++--- tests/wave2/test-catalog-edge-guard.sh | 24 +++++++++++++++++++++--- tests/wave2/test-cut-score.sh | 20 ++++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a95316b..92298a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- new(cut-score), fix(app-facts): **`bin/cut-score.sh` scores a proposed split of an existing app into several apps; `app-facts.sh` no longer needs the sqlite3 CLI** (split from [#293](https://github.com/MendixMau/mxcli-project-toolkit/pull/293), 1/6). Given `--cut "A,B|C|D"` (or one app per level of `app-layer-map.json`) it lists every module edge that crosses an app boundary from `analysis/app-facts/dependencies.json`, names the lane each ref kind would need (OData external entity, published action or REST, or none), and sums a clean / costly / blocked verdict per partition with the denominator printed ("7 of 7 own edges"); an aggregate over a foreign entity is read from `.mxcli/catalog.db` (single-tree or `app/`), and reads NOT DETECTED with a reason when it cannot be. Adapted to master: the catalog's `build_mode` label is recorded, not tested (#259 — v0.24+ labels a full catalog `source`), and an empty `activities_data` reads NOT DETECTED instead of "no aggregates". `app-facts.sh` runs its guard queries through Python's sqlite3 when the CLI is missing (absent on some cloud containers). `tests/wave2/test-cut-score.sh` 64/64 on a captured four-module fixture (8 new: label, empty table, both layouts; 4 of them fail on #293's copy); `test-app-report.sh` 109/109; `test-catalog-edge-guard.sh` 20/20, and 20/20 with the sqlite3 CLI removed from PATH for app-facts (master's app-facts: 14/20). — MendixMau (#293 field runs on public template apps) +- new(cut-score), fix(app-facts): **`bin/cut-score.sh` scores a proposed split of an existing app into several apps; `app-facts.sh` no longer needs the sqlite3 CLI** (split from [#293](https://github.com/MendixMau/mxcli-project-toolkit/pull/293), 1/6). Given `--cut "A,B|C|D"` (or one app per level of `app-layer-map.json`) it lists every module edge that crosses an app boundary from `analysis/app-facts/dependencies.json`, names the lane each ref kind would need (OData external entity, published action or REST, or none), and sums a clean / costly / blocked verdict per partition with the denominator printed ("7 of 7 own edges"); an aggregate over a foreign entity is read from `.mxcli/catalog.db` (single-tree or `app/`), and reads NOT DETECTED with a reason when it cannot be. Adapted to master: the catalog's `build_mode` label is recorded, not tested (#259 — v0.24+ labels a full catalog `source`), and an empty `activities_data` reads NOT DETECTED instead of "no aggregates". `app-facts.sh` runs its guard queries through Python's sqlite3 when the CLI is missing (absent on some cloud containers and always on Git Bash), stripping the `\r` native Windows Python prints. Rows whose aggregate cannot be detected name the actual reason (no catalog / empty table / unreadable). `tests/wave2/test-cut-score.sh` 70/70 on a captured four-module fixture (14 new: label, empty table, row reasons, own modules derived from edges, both layouts); `test-app-report.sh` 109/109; `test-catalog-edge-guard.sh` 22/22, new case forcing the Python path with CRLF output (fails without the `tr`). — MendixMau (#293 field runs on public template apps) - process(docs): **1.0 release notes, a versioning rule, and a README for adopters.** New `docs/releases/v1.0.md`: the first named milestone, covering 2026-09-30 to 2026-10-14 by theme (independent review before merge, baseline words 79,997 → 76,259, exec.sh lock and restart verdicts, lint batches 1–4 with SEC014 and the stock-revert guard, journey/e2e fixes, the Stage 3 interaction model, delivery rounds), with upgrade steps, known gaps and credits. `docs/RELEASE-PROCESS.md` §8 is the single source for versioning: a milestone (1.0, 1.1, 2.0) is a train release the maintainer also names, with curated notes, a GitHub Release and a CHANGELOG pointer; major = a consuming project breaks without action (needs a migration section and a sync path), minor = no action needed; deprecations are announced in one milestone and removed no earlier than the next major; the `vYYYY.MM.DD` train and `bin/cut-release.sh` are unchanged; plus how a consumer checks, upgrades and pins a version. The header above now defers to it. README rewritten for newcomers: what it is, how it works, ways to use it, quick start, Make it yours (company brain, CLAUDE.local.md, lint opt-outs, waivers and --adopt, interview mode, exec approval and permission profiles, agent stubs, other harnesses), testing, a call for input, releases. The earlier material sits under Reference with the generated routing blocks untouched. Corrected: the Windows `pgrep` hazard (replaced by the per-project exec lock in #304), the hand-edit steps in "How to add a new skill" (tables are generated), and the stale Stage 6 and `examples/` notes. — MendixMau - fix(interview-mode): **`attended` and `unattended` are now read as aliases for `steering` and `auto` — a project that opted into unattended as documented was silently run as steering.** CLAUDE.md, the runbook and intake Q9 taught `Interview mode: unattended`, but `bin/interview-mode.sh` knew only `steering | assist | auto` and fell back to steering. The aliases are accepted in every source (env, `.interview-mode`, `PROJECT.md`, `--set`) under the same parse rules, with one stderr note. The `PROJECT.md` value is read after stripping bold/backticks: a bare mode or alias (`**auto**`, `` `auto` ``, `auto.`) resolves, a note may follow `steering`, but `assist`/`auto` with any trailing text falls back to steering with a "must stand alone on the line" message — so an annotated or hedged line never resolves quieter than before (two hedge word lists were tried in review and both leaked); env, session file and `--set` stay exact. Output stays canonical so `interview-gate.sh` is unchanged, and any other value still falls back to steering. Docs now name the canonical values (vocabulary in `interview-protocol.md`); intake Q9's body offers the three modes with steering the default (heading kept: it is sync's locator), and `sync-project.sh` reads an earlier unanswered Q9 body as unanswered rather than "edited". `test-interview-mode.sh` +57 checks (19 → 76) — MendixMau (found in the 1.0 README review) - fix(dispatching, sync-project, obligation-check): **review follow-ups for the 2026-10-14 integration batch.** `dispatching.md` names app-restart exits 4 and 5 beside 3 and 1. `test-test-scratch.sh` drops `ls | grep -q` under pipefail for here-strings; sync step 4d no longer aborts on empty doctor output; `_ob_has_denominator` strips NUL bytes so bash stops warning. — integration 2026-10-14 reviews diff --git a/bin/cut-score.sh b/bin/cut-score.sh index fbfe1be7..133631c1 100755 --- a/bin/cut-score.sh +++ b/bin/cut-score.sh @@ -48,6 +48,16 @@ # 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. +# Field run: public field-service template app (Mendix Marketplace, 9.24.2, MPR v1; mxcli v0.26.0, +# 2026-10-12) — 42 own modules, 312 own edges, 4,508 refs; app-facts 25 s, each cut-score run <= 1 s. +# Seven cuts scored, all `blocked`: one-app-per-level 312/4,508 crossing (meaningless on its +# 36-module tangle); best web | mobile landscape found 132 edges / 1,442 refs, 2 bidirectional pairs. +# Six blocker rows spot-checked with `describe`: all six real references, two of them mis-laned. +# Known from that run, not fixed here: `call` mixes client-side nanoflow/JavaScript calls (lane: copy) +# with server-side ones (~30 % of the main seam's blocker mass); the catalog kinds sync, widget, +# schedule, event and publish never reach dependencies.json (a synced foreign entity is a blocker this +# cannot see); nanoflows aggregating the device store are false aggregate hits. The run also caused +# the per-partition blocker cap (one cell was 10 KB) and app-facts' sqlite3-CLI fallback. # # portability-ok: bash 3.2, Python 3 resolved through portable.sh @@ -126,7 +136,7 @@ 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}) + 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") @@ -277,6 +287,7 @@ for r in rows: # ---- aggregate detection from the catalog ----------------------------------------------------- aggregate = {"status": "NOT DETECTED", "reason": "", "catalog": CATALOG, "hits": []} if not os.path.exists(CATALOG): + aggregate["short"] = 'no 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: @@ -284,11 +295,13 @@ else: 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["short"] = 'catalog has no activities_data table' 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["short"] = 'catalog activities_data lacks columns' aggregate["reason"] = "activities_data lacks columns %s" % ", ".join(sorted(need - cols)) else: # The build_mode LABEL is recorded, never tested (master #259): mxcli v0.24+ labels a @@ -299,6 +312,7 @@ else: aggregate["build_mode"] = mode[0] if mode else None n_acts = db.execute("select count(*) from activities_data").fetchone()[0] if n_acts == 0: + aggregate["short"] = 'catalog activities_data is empty' aggregate["reason"] = ("catalog activities_data is empty (build_mode %r); an empty table is not a " "clean app. Rerun app-facts.sh" % (mode[0] if mode else None)) else: @@ -339,13 +353,14 @@ else: % h["microflow"]) db.close() except sqlite3.Error as e: + aggregate["short"] = 'catalog unreadable' 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)" + r["aggregate"] = "NOT DETECTED (%s)" % aggregate.get("short", "see gaps") elif r["kind"] in ("retrieve", "datasource"): r["aggregate"] = "no" if r["kind"] == "retrieve" else "n/a (page)" else: diff --git a/project-bin/app-facts.sh b/project-bin/app-facts.sh index aa0fea4c..aa2f725d 100755 --- a/project-bin/app-facts.sh +++ b/project-bin/app-facts.sh @@ -246,13 +246,16 @@ fi # One-column guard queries. The sqlite3 CLI when it is there (unchanged behaviour); otherwise the # same query through Python's sqlite3 module, which step 2 needs anyway. The CLI is absent on # cloud containers where apt has no sqlite3 package (field run, FSM 9.24.2, 2026-10-12), and a -# missing CLI must not stop a script whose facts never came from the CLI. -if command -v sqlite3 >/dev/null 2>&1; then +# missing CLI must not stop a script whose facts never came from the CLI. Git Bash has no sqlite3, +# so Windows always takes the Python path, and native Windows Python prints CRLF: the `tr` keeps +# every `[ "$(q …)" = "1" ]` below from failing on a trailing \r (as _common.sh does for its probe). +# APPFACTS_NO_SQLITE3_CLI=1 forces the Python path (the fixture uses it to prove this branch). +if [ -z "${APPFACTS_NO_SQLITE3_CLI:-}" ] && command -v sqlite3 >/dev/null 2>&1; then q() { sqlite3 -noheader "$DB" "$1"; } else q() { DB="$DB" "$PY" -c 'import os, sqlite3, sys c = sqlite3.connect("file:%s?mode=ro" % os.environ["DB"], uri=True) -for r in c.execute(sys.argv[1]): print(r[0])' "$1"; } +for r in c.execute(sys.argv[1]): print(r[0])' "$1" | tr -d '\r'; } fi BUILD_MODE="$(q "SELECT Value FROM catalog_meta WHERE Key='build_mode';")" # The build_mode LABEL is not checked (fixed 2026-10-13, field report 2026-10-11): mxcli v0.24.0 diff --git a/tests/wave2/test-catalog-edge-guard.sh b/tests/wave2/test-catalog-edge-guard.sh index 0c276b13..2206710a 100755 --- a/tests/wave2/test-catalog-edge-guard.sh +++ b/tests/wave2/test-catalog-edge-guard.sh @@ -166,10 +166,10 @@ for layout in single two; do # --- 5. app-facts: same guard, through a stub mxcli that drops a prepared catalog ------------- if [ -f "$AF" ]; then - for variant in source-calls full-actions full-kindless; do + for variant in source-calls full-actions full-kindless py-crlf; do P="$(proj $layout)" case "$variant" in - source-calls) mkdb "$WORK/prep.db" source calls ;; + source-calls|py-crlf) mkdb "$WORK/prep.db" source calls ;; full-actions) mkdb "$WORK/prep.db" full actions ;; full-kindless) mkdb "$WORK/prep.db" full kindless ;; esac @@ -180,7 +180,17 @@ o=""; while [ \$# -gt 0 ]; do [ "\$1" = -o ] && o="\$2"; shift; done mkdir -p .mxcli && cp "$WORK/prep.db" .mxcli/catalog.db && echo '{}' > "\$o" EOF chmod +x "$WORK/mxcli-stub" - ( cd "$P" && PROJECT_ROOT="$P" MXCLI="$WORK/mxcli-stub" bash "$AF" --skip-loops --facts-dir "$P/facts" ) > "$WORK/out" 2>&1 + if [ "$variant" = py-crlf ]; then + # The sqlite3-CLI-less path with a Python that ends every line in CRLF, as native Windows + # Python does under Git Bash (which ships no sqlite3). The real interpreter does the work. + _real_py="$(command -v python3 || command -v python)" # portability-ok: fixture picks the real interpreter to wrap + printf '#!/usr/bin/env bash\nset -o pipefail\n"%s" "$@" | awk '"'"'{printf "%%s\\r\\n", $0}'"'"'\n' "$_real_py" > "$WORK/py-crlf" + chmod +x "$WORK/py-crlf" + ( cd "$P" && PROJECT_ROOT="$P" MXCLI="$WORK/mxcli-stub" APPFACTS_NO_SQLITE3_CLI=1 PYTHON="$WORK/py-crlf" \ + bash "$AF" --skip-loops --facts-dir "$P/facts" ) > "$WORK/out" 2>&1 + else + ( cd "$P" && PROJECT_ROOT="$P" MXCLI="$WORK/mxcli-stub" bash "$AF" --skip-loops --facts-dir "$P/facts" ) > "$WORK/out" 2>&1 + fi rc=$? case "$variant" in source-calls) @@ -195,6 +205,14 @@ EOF else bad "[$layout] app-facts refused a model with no calls" "rc=$rc $(out)" fi ;; + py-crlf) + # The guard is what is under test (as for the variants above); step 2 needs columns this + # synthetic catalog does not carry. + if grep -q "catalog ok: build_mode=source modules=2 refs=3" "$WORK/out" && ! grep -q "schema is missing" "$WORK/out"; then + ok "[$layout] app-facts without the sqlite3 CLI, CRLF Python: the guard reads the catalog clean" + else + bad "[$layout] app-facts failed on the sqlite3-less CRLF path" "rc=$rc $(out)" + fi ;; full-kindless) if [ "$rc" = 2 ] && grep -q "carry no RefKind" "$WORK/out" && grep -q "build_mode 'full'" "$WORK/out" \ && grep -q "(none)=2" "$WORK/out"; then diff --git a/tests/wave2/test-cut-score.sh b/tests/wave2/test-cut-score.sh index 376eb824..6e59fbf6 100644 --- a/tests/wave2/test-cut-score.sh +++ b/tests/wave2/test-cut-score.sh @@ -124,6 +124,26 @@ case "$(jq_ "$TMP/emptyacts.json" aggregate/reason)" in *) bad "the reason names the empty table" "$(jq_ "$TMP/emptyacts.json" aggregate/reason)" ;; esac +echo "== 2d. rows carry the actual reason, not always 'no catalog' (review of #341) ==" +check "no catalog: rows read NOT DETECTED (no catalog)" "$(rows_ "$TMP/nocat.json" Reporting Billing retrieve aggregate)" "NOT DETECTED (no catalog)" +check "empty activities_data: rows say so" "$(rows_ "$TMP/emptyacts.json" Reporting Billing retrieve aggregate)" "NOT DETECTED (catalog activities_data is empty)" +printf 'not a database\n' >"$TMP/garbage.db" +bash "$CS" --facts "$FIX/facts" --catalog "$TMP/garbage.db" --cut "$CUT" --json >"$TMP/garbage.json" 2>/dev/null; RC=$? +check "an unreadable catalog: exit 0" "$RC" "0" +check "an unreadable catalog: rows say so" "$(rows_ "$TMP/garbage.json" Reporting Billing retrieve aggregate)" "NOT DETECTED (catalog unreadable)" + +echo "== 2e. own modules derived from edges never include a missing endpoint (review of #341) ==" +mkdir -p "$TMP/noscope"; cp "$FIX/facts/manifest.json" "$TMP/noscope/" +"$PY" -c 'import json,sys +d=json.load(open(sys.argv[1])); d.pop("scope", None); d["edges"][0].pop("from", None) +json.dump(d, open(sys.argv[2], "w"))' "$FIX/facts/dependencies.json" "$TMP/noscope/dependencies.json" +bash "$CS" --facts "$TMP/noscope" --cut "$CUT" --json >"$TMP/noscope.json" 2>"$TMP/noscope.err"; RC=$? +check "no scope + an edge without 'from': exit 0" "$RC" "0" +case "$(jq_ "$TMP/noscope.json" gaps)" in + *"derived from the edges"*) ok "the derived module set is named as a gap" ;; + *) bad "the derived module set is named as a gap" "$(cat "$TMP/noscope.err")" ;; +esac + 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"