diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a3e5a4..070884e0 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 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) - new(sync-project): **`bin/sync-project.sh --only-bin [--only-bin …]` installs only the named `project-bin/` scripts into the project's `bin/` and touches nothing else; `--upgrade-bin` is now documented as what it is — a full sync that may also overwrite a modified copy.** A field run used `--upgrade-bin exec.sh --upgrade-bin _common.sh --upgrade-bin test-scratch.sh` as "install just these scripts" and got a full sync: 244 tracked files changed, one deleted, ~30 added, git hooks reinstalled. `--only-bin` decides everything before writing anything (a name outside the install manifest, NOINSTALL scripts included, a path-like or empty name, a locally modified copy without `--upgrade-bin `/`--force`, or an older copy the full sync's `_common.sh` refresh hold would keep, exits 1 with the project untouched; names are never glob-expanded), backs up an overwritten copy, and prints every file it wrote. The remedy lines in `app-restart.sh`, `test-scratch.sh`, `test-stack-up.sh`, `doctor.sh` and `harvest-learnings.sh` now name `--only-bin`. Fixture `tests/wave2/test-sync-only-bin.sh` (40 checks, single- and two-tree): one `git status` change, hooks and skills untouched, while a full `--upgrade-bin` sync of the same project installs other scripts and the hooks. — field run, issue #390 - new(bug-lookup): **`bin/bug-lookup.sh` ranks the ledger against a whole pasted error message.** One word (a CE code, a BUG id, a keyword) keeps the full-entry path unchanged; several words print the top 3 entries, each with header, score and one excerpt line, and `--full N` prints the N-th in full. Scoring is one POSIX awk pass: distinct query terms weighted by IDF, CE codes and BUG ids x3, header terms x1.5, an exact BUG id +100. A "no confident match" banner is printed below 40% term-weight coverage or for a CE code no entry mentions. `--exact` keeps the old literal phrase match for a quoted multi-word keyword. Measured on 40 labelled dev queries (the set the weights were tuned on): top-3 9% before (the literal match finds almost nothing in a pasted message) vs 94% now, top-1 89%, confident-wrong 8%. On 18 held-out queries written before the ranker ran on them: top-1 92%, top-3 100%, confident-wrong 17%. All 3 wrong answers are no-match rows without a CE code: the 40% coverage floor caught none of the 4 such rows across both sets, and coverage does not separate them from correct answers. Only the unknown-CE rule makes "no match" work today. The fixture skips, with a note, any no-match row whose CE code has since entered the ledger. About 45 ms per query vs about 950 ms (mawk; about 300 ms on BWK awk); at most 179 words printed. The banner also fires on 3 correct dev answers. Identical output under gawk, mawk and BWK awk 20121220/20180827/20231127; macOS's 20200816 build itself is not tested. All arguments are joined (`"$*"`; it read only `$1` before), so an unquoted multi-word query is ranked too. The banner exits 0 (it still lists the nearest 3); 1 is no match. The script header no longer caches the ledger's size. The routing row and CLAUDE.md mention the pasted message (+9 words on the mdl and gate stubs), and the `mxcli-bugs` row drops its stale "32k words". Fixture `tests/wave2/test-bug-lookup.sh` (floors on both sets; the PASS count depends on the awks installed; 9 checks fail on master). Field evidence: the 2026-10-15 golden-set run. — maintainer request, MendixMau - chore(migration skills): **duplicated migration and build-loop rules become pointers to their single source** (CLAUDE.md: "single source of the rule; every other mention defers here"). `migrate-general.md` §9 (nine OS 11 → Mendix mapping tables, about 115 lines) becomes a pointer to `source-os11.md` § Concept Mapping, after its lines not found there (the Data/Client Action implication, the sequence-from-10000 rationale, the Permission row, "Mendix does not enforce the layering", the ODC reasons) move into `source-os11.md`; its own OS 11 scope note goes with it. `migrate-general.md` §4 NPE lifecycle points at `learned-microflow-patterns.md` § NPE as Form Backing Object (the NPE-associations paragraph, found only here, stays). `migration-pipeline.md`'s restated extractor loop and validator checklist point at `extractor-quality-loop.md` § The Loop. The five "BUG-02 fixed in v0.13.0" notes in `iterative-build-loop.md`, `migrate-general.md` and `migration-pipeline.md` become `bin/bug-lookup.sh BUG-02` pointers (the struck-through handoff row in `migrate-general.md` §7 is deleted as history; the `iterative-build-loop.md` note stays because `test-bug09-13.sh` T6 keeps it on purpose); `migrate-outsystems.md` stops calling `migrate-general.md` the concept mapping. Same commit, to keep the companion PR under the 10-file cap: `iterative-build-loop.md`'s step-13 navigation and "every displayed field" bullets point at `module-review.md` § 4c, its wireframe drift-sync paragraph at `conversion-runbook.md` § 3b, and `checkpoint-cutover.md`'s What's Next at the runbook's Stage 7 row. References to the removed text are re-pointed: the audit-field standard (`migrate-outsystems.md`) and module layering (`migration-pipeline.md`) at `source-os11.md`, and `source-triage.md`'s "Phase 2's quality checklist" at `extractor-quality-loop.md` Validator Requirements; `source-os11.md`'s DAO and UI tables gain the remaining §9 details. Only always-loaded change: that `source-triage.md` line (−2 words); baseline 73,522 → 73,520. — authoring audit, maintainer request diff --git a/bin/cut-score.sh b/bin/cut-score.sh new file mode 100755 index 00000000..133631c1 --- /dev/null +++ b/bin/cut-score.sh @@ -0,0 +1,598 @@ +#!/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. +# 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 + +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["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: + 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["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 + # 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["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: + 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["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 (%s)" % aggregate.get("short", "see gaps") + 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 770fef79..24db2d49 100755 --- a/project-bin/app-facts.sh +++ b/project-bin/app-facts.sh @@ -272,7 +272,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. @@ -301,7 +300,20 @@ 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. 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" | 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 # 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 00000000..a42dde61 Binary files /dev/null and b/tests/wave2/fixtures/cut-score/catalog.db differ 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-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 new file mode 100644 index 00000000..6e59fbf6 --- /dev/null +++ b/tests/wave2/test-cut-score.sh @@ -0,0 +1,253 @@ +#!/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 "== 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" +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 ]