From 3edf954e4e7891ebb17340fbbfeecf9f25a2caaf Mon Sep 17 00:00:00 2001 From: saingge <262679532+saingge@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:20:35 +0200 Subject: [PATCH] new(canvas): mf-canvas-facts.py prints how a microflow lies on the Studio Pro canvas A flow can build, pass its tests and still be unreadable in Studio Pro: labels on top of each other, a note line leaving the side that faces away, a decision whose true branch answers "no". mxbuild, check and lint see none of it. - project-bin/mf-canvas-facts.py prints the facts (OVERLAP, LINE, NOTE-SIDE, LEFTWARD, TURN, CAPTION, SENSE), read-only, exit 2 when a flow could not be read. Calibration is from Studio Pro 10.24, not verified on Studio Pro 11. - verify-module.sh runs it per module as an info rung (1c, 14-canvas-facts.log). - install manifest, fixture test on a model mxcli v0.27.0 wrote, V8 in test-verify-module-446.sh, CHANGELOG. --- CHANGELOG.md | 1 + bin/lib/install-manifest.sh | 5 +- project-bin/mf-canvas-facts.py | 396 ++++++++++++++++++ project-bin/verify-module.sh | 17 + tests/wave2/fixtures/canvas-facts/CAPTURE.txt | 32 ++ .../fixtures/canvas-facts/capture-canvas.mdl | 37 ++ .../fixtures/canvas-facts/exec-v0.27.0.out | 6 + .../fixtures/canvas-facts/facts-v0.27.0.exit | 1 + .../fixtures/canvas-facts/facts-v0.27.0.out | 20 + .../canvas-facts/overlay/TestApp.mpr.sql | 52 +++ ...28b31b1-5993-442e-92a1-56a1c48639db.mxunit | Bin 0 -> 4272 bytes ...369509b-1e82-4919-981b-1df462fd7c02.mxunit | Bin 0 -> 2135 bytes ...5101964-9b5f-41b0-90fc-95c8eb46b10c.mxunit | Bin 0 -> 2142 bytes ...4776610-13b2-48cb-a265-d5e984d63129.mxunit | Bin 0 -> 5065 bytes ...147be93-5865-4fe3-86d4-c36f70428add.mxunit | Bin 0 -> 4605 bytes .../fixtures/verify-446/clean.summary.tsv | 1 + .../fixtures/verify-446/finding.summary.tsv | 1 + .../verify-446/skip-journeys.summary.tsv | 1 + tests/wave2/test-mf-canvas-facts.sh | 91 ++++ tests/wave2/test-verify-module-446.sh | 15 + 20 files changed, 675 insertions(+), 1 deletion(-) create mode 100755 project-bin/mf-canvas-facts.py create mode 100644 tests/wave2/fixtures/canvas-facts/CAPTURE.txt create mode 100644 tests/wave2/fixtures/canvas-facts/capture-canvas.mdl create mode 100644 tests/wave2/fixtures/canvas-facts/exec-v0.27.0.out create mode 100644 tests/wave2/fixtures/canvas-facts/facts-v0.27.0.exit create mode 100644 tests/wave2/fixtures/canvas-facts/facts-v0.27.0.out create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/TestApp.mpr.sql create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/mprcontents/73/69/7369509b-1e82-4919-981b-1df462fd7c02.mxunit create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/mprcontents/75/10/75101964-9b5f-41b0-90fc-95c8eb46b10c.mxunit create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit create mode 100644 tests/wave2/fixtures/canvas-facts/overlay/mprcontents/91/47/9147be93-5865-4fe3-86d4-c36f70428add.mxunit create mode 100755 tests/wave2/test-mf-canvas-facts.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ee82ab78..15676061 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(canvas): **`project-bin/mf-canvas-facts.py` prints how a microflow or nanoflow lies on the Studio Pro canvas: labels on top of each other, lines through objects, note lines leaving the side that faces away, flows that run right-to-left or curl, captions wider than their shape, and decision captions that ask the opposite of their true branch (SENSE).** It prints facts, one line each, and no verdicts; read-only (it reads units through `mxpatch.py`), exit 2 when a flow could not be read. `verify-module.sh` runs it per module as an info rung (1c, log `14-canvas-facts.log`; a missing script is a FAULT, not a skip). Fixture `tests/wave2/test-mf-canvas-facts.sh` runs on a model mxcli v0.27.0 wrote (units and `.mpr` captured); `test-verify-module-446.sh` gains V8 for the rung. Calibration is from Studio Pro 10.24 screenshots, not verified on Studio Pro 11. Field evidence, measured 2026-10-21 on one Mendix 10.24 app: 1,588 flows read in one pass; on the 36 flows an mxcli-built branch added or changed in one module, OVERLAP 19, LINE 27, NOTE-SIDE 17, LEFTWARD 3, TURN 4, SENSE 9 before repair — saingge - fix(progress-report, build-plan-status): **HARNESS rows are no longer counted as built rows, and a module's `verify-module.sh` row shows once, as that module's sign-off status on its phase card, instead of as a row.** A HARNESS step is a module's sign-off, not a build, so it is out of every "x of y built" count: the phase cards, the plan rollup and the Stage 5 card in `progress-report.html`, and the lede of `build-plan-status.sh --html` ("N build rows", plus a "Not counted: N HARNESS rows" note). The verify row's evidence is read exactly as #501 reads it (`harness_verify`'s rule, now `verify_evidence` + `module_status` in `progress-report.py`; nothing new measured, no extra script run, no mxcli or mxbuild call) and shown as one line per module: `Verified ✓ (LOOK n/n)`, `Verify owed - LOOK + CONFIRM`, `Verify stale (N builds since)` (or `(a script was marked done- after it)`), `Verify not run`, `built? no verify summary`, `Verify not clean ()` for a summary with a FAULT, FINDING or SKIPPED row, or `Verify clean , LOOK n/n - held: State says ` when State is blocked or pending a person (held is not signed off, as #501 never promoted it); a State cell that disagrees is flagged under it, the rollup says how many modules are verified, and the Stage 5 card, KPI and owed text add ", k of n modules signed off". The Phase state badge is read from the build rows only (a phase of HARNESS rows alone says "sign-off only" and its card "Sign-off phase: no build rows"). A module with no verify row shows no status. Other HARNESS rows (a coherence pass, a harness run) stay in the row list, marked "(not counted)". `build-plan.md`, `build-plan.json` and the text output are unchanged, so agents still see every HARNESS step. **Behaviour change to check in your project:** the progress report's built counts drop the HARNESS rows (a plan of 10 rows with 2 HARNESS reads "x of 8"), and the verify rows leave the row tables. Fixture: `test-progress-harness-verify.sh` (H1-H10, N1-N6, both layouts). — MendixMau (decision 2026-10-21) - fix(exec): **a script restated from a stale DESCRIBE now WARNs instead of silently reverting an earlier exec** (#261). A script drafted from a DESCRIBE taken before an earlier script of the batch ran put four old messages back, with check, mxbuild and lint green. New `project-bin/describe-baseline.sh stamp` writes `-- baseline: sha256=` (plus `-- baseline-mxcli: `) from a DESCRIBE at draft time; `exec.sh` runs `verify` only for a script that carries the header, so other scripts cost one grep and no mxcli call. It reports DRIFT with the first lines of the diff, STALEHEADER, and RESTAMP after an mxcli upgrade (DESCRIBE text may change, so that is not called drift). A re-run by the script's own last recorded write (the #269 ledger) and a fresh model are not drift. Nothing is refused and the exit codes do not change. Preflight STOP 21 gains one clause (`-- supersedes:`, `stamp`, exec.sh only warns) and its trigger names `create or modify` (+24 words). Not-found DESCRIBE and `--version` output are captured from mxcli v0.26.0. Lands after the #269 PR; with it, supersedes draft #311. **Behaviour change to check in your project:** `exec.sh` output can carry `WARN: stale draft: …` lines, and the BUILD-LOG detail cell `WARN stale baseline: `; `MXTK_RESTATE_GUARD=off` skips the check. — AlvinnYeo (#261) - fix(exec): **re-running an older `mdlsource/` script over a newer owner's live work now WARNs instead of passing silently** (#269). Three field apps had 99/579, 130/357 and 38/159 documents created by more than one script, and a replay of an older one reverted the newer with exit 0 and every gate green. New `project-bin/script-owners.sh` maps each microflow, nanoflow, page and snippet to the scripts that create or restate it, in run order (digit runs compared as numbers, so `2-core/` runs before `11-fixes/`; `done-` ignored), and reads `-- supersedes:` / `-- superseded-by:` headers. `exec.sh` records every kept write in a machine-local last-writer ledger (`.claude/loop/script-owners/`, self-gitignored, compacted per write); `restore-mpr.sh` marks it on a manual restore. A replay WARNs only when the newer owner's write is live: ledger first, else one DESCRIBE per document, at most 5 per exec (the rest print `CONFLICT (not checked)`). A new owner without a supersedes header gets one `NOTE:` line on its first run, kept out of BUILD-LOG. Nothing is refused and the exit codes do not change. With no co-owner, `exec.sh` runs one grep over `mdlsource/` and no mxcli call. `gate-check.sh` prints the co-owned count when it is above 0. Part of the rebuilt draft #311 (with the #261 PR). **Behaviour change to check in your project:** `exec.sh` output can carry `WARN: restatement: …` and `NOTE: new owner of …` lines; the BUILD-LOG detail cell can carry `WARN reverts: `. Set `MXTK_RESTATE_GUARD=off` to skip the check. — MendixMau (#269) diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index f674fcba..a37181af 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -123,7 +123,10 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # write (co-owned documents) and records each kept write in its ledger; restore-mpr.sh marks it. # describe-baseline.sh joined the same day (#261): exec.sh runs its verify for a script with a # `-- baseline:` header, and it reads script-owners.sh's ledger as ITS sibling. -MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh script-owners.sh describe-baseline.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh progress-report.py done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" +# mf-canvas-facts.py joined on 2026-10-21 and needs mxpatch.py and mxunit_bson.py beside it (the one +# BSON reader). A project runs it after every microflow or nanoflow script, and verify-module.sh +# runs it per module (skills/canvas-readability.md). +MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh script-owners.sh describe-baseline.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mf-canvas-facts.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh progress-report.py done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" # Files in project-bin/ that are deliberately NOT installed into projects. The reverse check # below flags anything named by NEITHER list, so a new file in project-bin/ has to be either diff --git a/project-bin/mf-canvas-facts.py b/project-bin/mf-canvas-facts.py new file mode 100755 index 00000000..73593008 --- /dev/null +++ b/project-bin/mf-canvas-facts.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""mf-canvas-facts.py -- what Studio Pro will draw on top of what, per microflow and nanoflow. + +An INSTRUMENT (skills/skills-over-scripts.md): it prints facts, one line each, and no verdicts. +Which facts are defects, which are the norm in hand-drawn flows, and how to avoid or repair each +one is in skills/canvas-readability.md -- read that before acting on this output. + + mf-canvas-facts.py --all every microflow and nanoflow + mf-canvas-facts.py --module Shop [--module M] the flows of these modules + mf-canvas-facts.py Shop.ACT_Order_Save NAME these flows (qualified or plain names) + mf-canvas-facts.py --names-file flows.txt one name per line, # for comments + + is the .mpr, its directory, or its mprcontents/ (MPR v2 only). Units are read through +mxpatch.py, the toolkit's one BSON reader; nothing is written. + +FACTS (geometry in canvas units; Studio Pro draws 1 unit = 1 px at 100%): + OVERLAP two drawn things share space (object box, the variable label under it, note box) + LINE a flow or note line passes through an object or label that is not one of its ends + NOTE-SIDE a note line leaves the note on a side that faces away from what it points at + LEFTWARD a flow ends left of where it starts (the reader follows it backwards) + TURN a flow leaves or enters an object on a side facing away from the other end (it curls) + CAPTION a caption has a word wider than its shape (Studio Pro breaks it mid-word), or more + lines than fit + SENSE a decision's whole expression is negated (not(...), $x = empty) but its caption asks + the positive question, so the line marked true answers "no" to the caption + +CALIBRATION, measured from Studio Pro 10.24 screenshots: a variable label is ~5.5 units per +character, two lines (name, type) under the shape; an activity fits 3 caption lines of ~16 +characters and no word over 14; a decision diamond fits 2 lines of ~10 characters and no word +over 10. Connection sides are stored as indexes: an annotation (note) numbers its sides top 0, +bottom 1, left 2, right 3; every other object top 0, right 1, bottom 2, left 3. The note is +always the origin of a note line. A flow line is the cubic Bezier through the two stored control +vectors, sampled at 48 points. SENSE is a prompt to read the true branch, not a verdict: on one +hand-drawn corpus 15 of its 18 hits were fine (`$Message = empty` really means "valid"), while all +9 hits in the mxcli-written flows were real. + +OUTPUT: per flow `# Module.Name (n)` then one indented fact line each; last line +`# facts: flows, OVERLAP a, LINE b, ...`. Field run, measured 2026-10-21 on one Mendix 10.24 app (1,588 flows, read in one pass): +on the 36 flows one mxcli-built branch added or changed in a single module, before any repair, OVERLAP 19, +LINE 27, NOTE-SIDE 17, LEFTWARD 3, TURN 4, SENSE 9, CAPTION 148; after the branch's later repair +commits, OVERLAP 3, LINE 7, TURN 4, CAPTION 113 and the rest 0. + +Exit: 0 measured (facts or none), 2 could not measure (bad model path, a named flow not found, +a unit that does not decode -- the flows that did decode are still printed). +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mxpatch # noqa: E402 +from mxunit_bson import Bin # noqa: E402 + +FLOW_TYPES = ("Microflows$Microflow", "Microflows$Nanoflow") +KINDS = ("OVERLAP", "LINE", "NOTE-SIDE", "LEFTWARD", "TURN", "CAPTION", "SENSE") +CH = 5.5 +ACT_SIDES = {0: (0, -1), 1: (1, 0), 2: (0, 1), 3: (-1, 0)} +NOTE_SIDES = {0: (0, -1), 1: (0, 1), 2: (-1, 0), 3: (1, 0)} +SIDE_NAME = {(0, -1): "top", (0, 1): "bottom", (-1, 0): "left", (1, 0): "right"} +NEG = re.compile(r"\b(not|no|non|none|never|missing|wrong|without|empty|invalid|inactive|incomplete|" + r"incorrect|disabled|absent|expired|refused|failed|lacks?)\b|n't\b|" + # un- words that are negatives; an explicit set, because \bun[a-z]+ also takes + # unit, unique, under, until, union -- common caption words with no negative sense + r"\bun(?:able|assigned|available|changed|checked|defined|filled|finished|" + r"known|matched|paid|read|ready|resolved|saved|selected|sent|set|used|" + r"validated|verified)\b", re.I) + + +# -- the unit as plain data ---------------------------------------------------------------------- +def plain(t, v): + """A mxpatch (type, key, value) value as dicts and lists; arrays keep their version marker.""" + if t == 0x03: + return {k: plain(tt, vv) for tt, k, vv in v} + if t == 0x04: + return [plain(tt, vv) for tt, _k, vv in v] + if isinstance(v, Bin): + return v.data.hex() + return v + + +def items(v): + """A stored list: its first element is a version marker, not an item.""" + return [x for x in (v or []) if isinstance(x, dict)] + + +def pt(s): + x, y = (s or "0;0").split(";") + return float(x), float(y) + + +def short(q): + return (q or "").split(".")[-1] + + +def objects(mf): + """Every drawn object as an absolute box: id, type, x, y, w, h, parent loop id, raw.""" + out = [] + + def add(coll, ox, oy, parent): + for o in items((coll or {}).get("Objects")): + x, y = pt(o.get("RelativeMiddlePoint")) + w, h = pt(o.get("Size")) + t = o["$Type"].split("$")[-1] + if parent is not None: # inside a loop: relative to the loop's top-left + x, y = ox + x, oy + y + out.append(dict(id=o["$ID"], type=t, x=x, y=y, w=w, h=h, parent=parent, raw=o)) + if t == "LoopedActivity": + add(o.get("ObjectCollection"), x - w / 2, y - h / 2, o["$ID"]) + add(mf.get("ObjectCollection"), 0, 0, None) + return out + + +def edges(mf): + """All sequence and annotation flows, those stored inside loops included.""" + out = list(items(mf.get("Flows"))) + + def walk(coll): + for o in items((coll or {}).get("Objects")): + if o["$Type"].endswith("LoopedActivity"): + out.extend(items((o.get("ObjectCollection") or {}).get("Flows"))) + walk(o.get("ObjectCollection")) + walk(mf.get("ObjectCollection")) + return out + + +# -- what Studio Pro writes under and inside each shape ------------------------------------------ +def type_label(vt): + t = (vt or {}).get("$Type", "").split("$")[-1] + if t in ("ObjectType", "ListType"): + e = short(vt.get("Entity")) + return ("List of " + e) if t == "ListType" else e + if t == "EnumerationType": + return short(vt.get("Enumeration")) + return t.replace("Type", "") or "Object" + + +def label_and_caption(o, exposed): + """(variable label lines, the caption Studio Pro shows -- None when not known).""" + r, t = o["raw"], o["type"] + if "Parameter" in t: + return [r.get("Name", ""), type_label(r.get("VariableType"))], None + if t == "EndEvent": + rv = r.get("ReturnValue") or "" + return ([rv.split("\n")[0][:40], "x" * 8] if rv else []), None + if t == "ExclusiveSplit": + return [], r.get("Caption") or "" + if t != "ActionActivity": + return [], None + a = r.get("Action") if isinstance(r.get("Action"), dict) else {} + at = a.get("$Type", "").split("$")[-1] + var = a.get("ResultVariableName") or a.get("VariableName") or a.get("OutputVariableName") or "" + if at == "MicroflowCallAction" and not a.get("UseReturnVariable"): + var = "" + members = ", ".join(short(i.get("Attribute") or i.get("Association")) for i in items(a.get("Items"))) + src = a.get("RetrieveSource") if isinstance(a.get("RetrieveSource"), dict) else {} + ent = short(a.get("Entity") or src.get("Entity") + or short(src.get("AssociationId") or src.get("Association")).split("_")[0]) + typ = {"CreateVariableAction": type_label(a.get("VariableType")), "CreateChangeAction": ent, + "RetrieveAction": "List of " + ent, "CreateListAction": "List of " + ent}.get(at, "x" * 12) + cap = None + if r.get("AutoGenerateCaption") is False: + cap = r.get("Caption") or "" + elif at == "MicroflowCallAction": + called = (a.get("MicroflowCall") or {}).get("Microflow") or "" + cap = exposed.get(called) or exposed.get(short(called)) or short(called) + elif at == "CreateChangeAction": + cap = "Create " + ent + (" (" + members + ")" if members else "") + elif at == "ChangeAction": + cap = "Change " + (a.get("ChangeVariableName") or "") + (" (" + members + ")" if members else "") + elif at == "RetrieveAction": + cap = "Retrieve list of " + ent + (" from database" if "Database" in src.get("$Type", "") else " by association") + elif at == "ChangeVariableAction": + cap = "Change variable " + (a.get("VariableName") or a.get("ChangeVariableName") or "") + elif at == "CreateListAction": + cap = "Create list of " + ent + return ([var, typ] if var else []), cap + + +def closes_at_end(s): + """s starts with '(': does that parenthesis close at the very end of s?""" + depth, q = 0, False + for i, c in enumerate(s): + if c == "'": + q = not q + if q: + continue + depth += (c == "(") - (c == ")") + if depth == 0: + return i == len(s) - 1 + return False + + +def wrap(text, width): + lines, cur = [], "" + for w in text.split(): + if cur and len(cur) + 1 + len(w) > width: + lines.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + return lines + ([cur] if cur else []) + + +def box(x, y, w, h): + return (x - w / 2, y - h / 2, x + w / 2, y + h / 2) + + +def hit(a, b, pad=0): + return a[0] + pad < b[2] and b[0] + pad < a[2] and a[1] + pad < b[3] and b[1] + pad < a[3] + + +def end_point(o, idx): + dx, dy = (NOTE_SIDES if o["type"] == "Annotation" else ACT_SIDES).get(idx, (0, 0)) + return o["x"] + dx * o["w"] / 2, o["y"] + dy * o["h"] / 2, (dx, dy) + + +# -- the facts ------------------------------------------------------------------------------------ +def check(mf, exposed): + objs = objects(mf) + by = {o["id"]: o for o in objs} + out, things = [], [] # things: (kind, owner id, box, text) + for o in objs: + lab, cap = label_and_caption(o, exposed) + o["what"] = "%s@%d,%d" % (o["type"].replace("Activity", "").replace("Exclusive", "")[:10], o["x"], o["y"]) + things.append(("object" if o["type"] != "Annotation" else "note", o["id"], + box(o["x"], o["y"], o["w"], o["h"]), o["what"])) + if lab: + w = CH * max(len(s) for s in lab) + 4 + top = o["y"] + (18 if "Parameter" in o["type"] else o["h"] / 2 + 4) + things.append(("label", o["id"], (o["x"] - w / 2, top, o["x"] + w / 2, top + 38), + 'label "%s" of %s' % (lab[0], o["what"]))) + if o["type"] == "ExclusiveSplit" and cap: + sc = o["raw"].get("SplitCondition") or {} + ex = " ".join((sc.get("Expression") or "").split()) + negated = ((ex.startswith("not(") and ex.endswith(")") and closes_at_end(ex[3:])) + or re.fullmatch(r"\$[\w/]+\s*=\s*empty", ex)) + if negated and not NEG.search(cap): + out.append('SENSE %s "%s" asks the positive question; its true branch is %s' + % (o["what"], cap[:60], ex[:60])) + if cap is not None: + word, width, maxl = (10, 10, 2) if o["type"] == "ExclusiveSplit" else (14, 16, 3) + long_ = [w for w in cap.split() if len(w) > word] + n = len(wrap(cap, width)) + if long_ or n > maxl: + out.append('CAPTION %s "%s": %s' % (o["what"], cap[:60], ", ".join( + (["breaks %s" % " ".join(long_)] if long_ else []) + + (["%d lines, %d fit" % (n, maxl)] if n > maxl else [])))) + + def inside(a, b): # a is drawn inside loop b (any depth) + p = by[a]["parent"] if a in by else None + while p: + if p == b: + return True + p = by[p]["parent"] + return False + + for i, (ka, ia, ba, ta) in enumerate(things): + for kb, ib, bb, tb in things[i + 1:]: + if ia == ib or inside(ia, ib) or inside(ib, ia): + continue + if "Parameter" in by[ia]["type"] and "Parameter" in by[ib]["type"] and ka == kb == "object": + continue + if hit(ba, bb, -6 if ka == kb == "label" else 1): + out.append("OVERLAP %s x %s" % (ta, tb)) + for e in edges(mf): + a, b = by.get(e.get("OriginPointer")), by.get(e.get("DestinationPointer")) + if not a or not b: + continue + ax, ay, da = end_point(a, e.get("OriginConnectionIndex")) + bx, by_, db = end_point(b, e.get("DestinationConnectionIndex")) + line = e.get("Line") or {} + c1, c2 = pt(line.get("OriginControlVector")), pt(line.get("DestinationControlVector")) + p1, p2 = (ax + c1[0], ay + c1[1]), (bx + c2[0], by_ + c2[1]) + pts = [] + for k in range(1, 48): + s = k / 48.0 + u = 1 - s + pts.append((u ** 3 * ax + 3 * u * u * s * p1[0] + 3 * u * s * s * p2[0] + s ** 3 * bx, + u ** 3 * ay + 3 * u * u * s * p1[1] + 3 * u * s * s * p2[1] + s ** 3 * by_)) + is_note = e["$Type"].endswith("AnnotationFlow") + desc = "%s %s -> %s" % ("note line" if is_note else "flow", a["what"], b["what"]) + crossed = [] + for k, owner, bx_, t in things: + if owner in (a["id"], b["id"]) and k == "object" and by[owner]["type"] != "ActionActivity": + continue + if k == "object" and by[owner]["type"] == "LoopedActivity" and (inside(a["id"], owner) or inside(b["id"], owner)): + continue + sb = (bx_[0] + 3, bx_[1] + 3, bx_[2] - 3, bx_[3] - 3) + if any(sb[0] < x < sb[2] and sb[1] < y < sb[3] for x, y in pts[2:-2]): + crossed.append(t) + if crossed: + out.append("LINE %s crosses %s" % (desc, "; ".join(crossed))) + if is_note: + n, t, side = (a, b, da) if a["type"] == "Annotation" else (b, a, db) + vx, vy = t["x"] - n["x"], t["y"] - n["y"] + if side[0] * vx + side[1] * vy <= 0: + out.append("NOTE-SIDE %s leaves the note %s, away from %s" + % (desc, SIDE_NAME.get(side, "?"), t["what"])) + else: + if bx < ax - 10 and not e.get("IsErrorHandler"): + out.append("LEFTWARD %s runs %d units right-to-left" % (desc, ax - bx)) + vx, vy = b["x"] - a["x"], b["y"] - a["y"] + if abs(vx) + abs(vy) > 30 and a["parent"] == b["parent"]: + if da[0] * vx + da[1] * vy <= 0: + out.append("TURN %s leaves on the %s, away from its target" % (desc, SIDE_NAME.get(da, "?"))) + if -(db[0] * vx + db[1] * vy) <= 0: + out.append("TURN %s enters on the %s, the side away from its source" % (desc, SIDE_NAME.get(db, "?"))) + return out + + +# -- driver --------------------------------------------------------------------------------------- +def open_model(path): + p = os.path.abspath(path) + if os.path.isdir(p) and os.path.basename(p.rstrip(os.sep)) == "mprcontents": + p = os.path.dirname(p.rstrip(os.sep)) + return mxpatch.Model(p) + + +def main(argv): + if len(argv) < 2 or argv[1] in ("-h", "--help"): + sys.stdout.write(__doc__) + return 2 + names, modules, every, i = [], [], False, 2 + while i < len(argv): + a = argv[i] + if a == "--all": + every = True + elif a in ("--module", "--names-file") and i + 1 < len(argv): + if a == "--module": + modules.append(argv[i + 1]) + else: + names += [l.split("#", 1)[0].strip() for l in open(argv[i + 1])] + i += 1 + elif a.startswith("--"): + sys.stderr.write("mf-canvas-facts: unknown option %s\n" % a) + return 2 + else: + names.append(a) + i += 1 + names = [n for n in names if n] + if not (every or names or modules): + sys.stderr.write("mf-canvas-facts: name flows, --module M, --names-file F or --all\n") + return 2 + try: + model = open_model(argv[1]) + except mxpatch.PatchError as e: + sys.stderr.write("mf-canvas-facts: %s\n" % e) + return 2 + # model.units() would stop at the first unit that does not decode; read each candidate on + # its own so one bad unit is a named fault and every other flow is still measured. + needles = [mxpatch._bson_str(0x02, "$Type", t) for t in FLOW_TYPES] + flows, faults = [], [] + for p in model.unit_files(): + raw = open(p, "rb").read() + if not any(n in raw for n in needles): + continue + try: + u = mxpatch.Unit(p, raw) + except Exception as e: # any decode failure is a named fault, never a crash + faults.append("%s: %s" % (os.path.basename(p), e)) + continue + if u.type in FLOW_TYPES: + flows.append(u) + picked, exposed = [], {} + for u in flows: + mf = plain(0x03, u.doc) + mod = model.module_of(u) or "?" + q = "%s.%s" % (mod, u.name) + info = mf.get("MicroflowActionInfo") + if isinstance(info, dict) and info.get("Caption"): + exposed[q] = info["Caption"] + exposed.setdefault(u.name, info["Caption"]) + if every or mod in modules or q in names or u.name in names: + picked.append((q, mf)) + found = {q for q, _ in picked} | {q.split(".", 1)[1] for q, _ in picked} + missing = [n for n in names if n not in found] + totals = dict.fromkeys(KINDS, 0) + for q, mf in sorted(picked, key=lambda x: x[0]): + res = check(mf, exposed) + print("# %s (%d)" % (q, len(res))) + for line in res: + print(" " + line) + totals[line.split()[0]] += 1 + print("# facts: %d flows, %s" % (len(picked), ", ".join("%s %d" % (k, totals[k]) for k in KINDS))) + for n in missing: + sys.stderr.write("mf-canvas-facts: FAULT no microflow or nanoflow named %s\n" % n) + for f in faults: + sys.stderr.write("mf-canvas-facts: FAULT could not read %s\n" % f) + for m in modules: + if not any(q.startswith(m + ".") for q, _ in picked): + sys.stderr.write("mf-canvas-facts: note: module %s has no microflows or nanoflows\n" % m) + return 2 if (missing or faults) else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/project-bin/verify-module.sh b/project-bin/verify-module.sh index ef770018..641bbb38 100755 --- a/project-bin/verify-module.sh +++ b/project-bin/verify-module.sh @@ -551,6 +551,23 @@ else "No structured report.json for this module; the summary below is the only artefact." fi +# ── 1c. Canvas facts: how this module's microflows read in Studio Pro ─────── +# INFO, never a gate (skills/canvas-readability.md). The instrument prints facts, not verdicts: +# in the one app it was measured on, LEFTWARD and TURN occurred in 5% of hand-drawn flows and in +# 6% of the generated ones, and a SENSE fact is a prompt to read the true branch. The +# reviewer judges the log against the skill's table. rc 2 (a flow it could not read, no model) +# still lands in FAULT: unmeasured, not clean. +_vm_canvas="$(_tool mf-canvas-facts.py)" +_vm_py="$(resolve_py 2>/dev/null)" || _vm_py="" +if [ -x "$_vm_canvas" ] && [ -n "$_vm_py" ] && [ -n "$_vm_mpr" ]; then + run "canvas facts (mf-canvas-facts — informational)" "$OUTDIR/14-canvas-facts.log" info 300 -- \ + "$_vm_py" -I "$_vm_canvas" "$_vm_mpr" --module "$MODULE" +else + fault "canvas facts (mf-canvas-facts — informational)" \ + "needs bin/mf-canvas-facts.py (+ mxpatch.py, mxunit_bson.py), Python 3 and the .mpr" \ + "This module's canvas readability is UNMEASURED. Run bin/sync-project.sh to install the scripts." +fi + # ── 2. Runtime instruments (need the app) ─────────────────────────────────── # F-042 (MarkUseCase, 2026-08-28): design-audit.js reads .claude/loop/page-scope.json for # its page denominator, but nothing in any mandatory chain ever GENERATED that file — its diff --git a/tests/wave2/fixtures/canvas-facts/CAPTURE.txt b/tests/wave2/fixtures/canvas-facts/CAPTURE.txt new file mode 100644 index 00000000..8ce98935 --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/CAPTURE.txt @@ -0,0 +1,32 @@ +tests/wave2/fixtures/canvas-facts/ — provenance (captured 2026-10-16, macOS arm64) + +Every file here is captured output or captured model units. Nothing was hand-written except this +file and capture-canvas.mdl, the script that told mxcli what to create. + +BINARY: "mxcli version v0.27.0 (2026-10-15T13:45:04Z)", Mach-O arm64, + sha256 3650571f1e71dc4905033e07cbb6d72877ca56ec14cfbbd94428d872cde3d563 + +THE MODEL. Base: tests/wave2/fixtures/mxpatch/model/ (see its CAPTURE.txt), rebuilt from its +sqlite dump. On that copy: + mxcli-v0.27.0 check capture-canvas.mdl -p TestApp.mpr # Check passed! + mxcli-v0.27.0 exec capture-canvas.mdl -p TestApp.mpr # exec-v0.27.0.out, exit 0 +The exec changed five units (the domain model and four new microflows); only those five are +stored, under overlay/mprcontents/, plus the whole .mpr as its sqlite dump +(overlay/TestApp.mpr.sql; *.mpr is gitignored). The fixture rebuilds the model as: the mxpatch +model's mprcontents/, these five units over it, the .mpr from overlay/TestApp.mpr.sql. Rebuilt, +`mxpatch.py verify` is clean (27 unit rows plus the new ones, no mismatch, no orphan). + +WHAT THE FOUR MICROFLOWS CARRY (as mxcli v0.27.0 wrote them, no hand edits): + ACT_Customer_Rename an @annotation note: mxcli stores its line with connection indexes + (2, 0), out of the note's LEFT side, while the note sits above the + activity -> NOTE-SIDE (BUG-DRAFT-note-line-side), and the line runs + back over the note's own box -> LINE + ACT_Customer_Merge two parameters with long names and no @position: mxcli lays them out + 100 units apart in a row, so their labels overlap -> OVERLAP + ACT_Customer_MergeColumn the same parameters with @position in a column 80 apart -> no fact + (the negative control for OVERLAP) + ACT_Customer_Check `if $Customer = empty` captioned 'Customer found?' -> SENSE; the same + test captioned 'No customer?' -> no fact (the negative control) + +facts-v0.27.0.out / .exit: `mf-canvas-facts.py --module MyFirstModule` on the result, as +first run (2026-10-16). The fixture compares the fact lines, not the whole file. diff --git a/tests/wave2/fixtures/canvas-facts/capture-canvas.mdl b/tests/wave2/fixtures/canvas-facts/capture-canvas.mdl new file mode 100644 index 00000000..f6085ec0 --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/capture-canvas.mdl @@ -0,0 +1,37 @@ +mdl 1; +create persistent entity MyFirstModule."Customer" ( + "Name": String(200) +); +create microflow MyFirstModule."ACT_Customer_Rename" ($Customer: MyFirstModule.Customer) +begin + @annotation 'Keeps the old name in the log before it changes.' + log info node 'Canvas' 'renaming'; + change $Customer ("Name" = 'Renamed'); + return; +end; +create microflow MyFirstModule."ACT_Customer_Merge" ( + $PrimaryCustomerRecord: MyFirstModule.Customer, + $SecondaryCustomerRecord: MyFirstModule.Customer +) +begin + return; +end; +create microflow MyFirstModule."ACT_Customer_MergeColumn" ( + @position(200, 53) $PrimaryCustomerRecord: MyFirstModule.Customer, + @position(200, -27) $SecondaryCustomerRecord: MyFirstModule.Customer +) +begin + return; +end; +create microflow MyFirstModule."ACT_Customer_Check" ($Customer: MyFirstModule.Customer) +begin + @caption 'Customer found?' + if $Customer = empty then + return; + end if; + @caption 'No customer?' + if $Customer = empty then + return; + end if; + return; +end; diff --git a/tests/wave2/fixtures/canvas-facts/exec-v0.27.0.out b/tests/wave2/fixtures/canvas-facts/exec-v0.27.0.out new file mode 100644 index 00000000..29fdc369 --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/exec-v0.27.0.out @@ -0,0 +1,6 @@ +Connected to: /path/to/copy/TestApp.mpr (Mendix 11.14.0) +Created entity: MyFirstModule.Customer +Created microflow: MyFirstModule.ACT_Customer_Rename +Created microflow: MyFirstModule.ACT_Customer_Merge +Created microflow: MyFirstModule.ACT_Customer_MergeColumn +Created microflow: MyFirstModule.ACT_Customer_Check diff --git a/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.exit b/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.exit @@ -0,0 +1 @@ +0 diff --git a/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.out b/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.out new file mode 100644 index 00000000..c8586c41 --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/facts-v0.27.0.out @@ -0,0 +1,20 @@ +# MyFirstModule.ACT_Approval_Notify (0) +# MyFirstModule.ACT_Approval_Review (0) +# MyFirstModule.ACT_Customer_Check (1) + SENSE Split@360,200 "Customer found?" asks the positive question; its true branch is $Customer = empty +# MyFirstModule.ACT_Customer_Merge (1) + OVERLAP label "PrimaryCustomerRecord" of MicroflowP@200,53 x label "SecondaryCustomerRecord" of MicroflowP@300,53 +# MyFirstModule.ACT_Customer_MergeColumn (0) +# MyFirstModule.ACT_Customer_Rename (2) + LINE note line Annotation@360,100 -> Action@360,200 crosses Annotation@360,100 + NOTE-SIDE note line Annotation@360,100 -> Action@360,200 leaves the note left, away from Action@360,200 +# MyFirstModule.MyFirstLogic (0) +# MyFirstModule.Nanoflow (0) +# MyFirstModule.RetrieveExamples (0) +# MyFirstModule.SE_Cleanup (0) +# MyFirstModule.ZzMxcliProbe_Boolean (0) +# MyFirstModule.ZzMxcliProbe_Entity (0) +# MyFirstModule.ZzMxcliProbe_List (0) +# MyFirstModule.ZzMxcliProbe_LoopSplit (0) +# MyFirstModule.ZzMxcliProbe_Trivial (0) +# facts: 15 flows, OVERLAP 1, LINE 1, NOTE-SIDE 1, LEFTWARD 0, TURN 0, CAPTION 0, SENSE 1 diff --git a/tests/wave2/fixtures/canvas-facts/overlay/TestApp.mpr.sql b/tests/wave2/fixtures/canvas-facts/overlay/TestApp.mpr.sql new file mode 100644 index 00000000..c777f272 --- /dev/null +++ b/tests/wave2/fixtures/canvas-facts/overlay/TestApp.mpr.sql @@ -0,0 +1,52 @@ +BEGIN TRANSACTION; +CREATE TABLE Unit + ( + UnitID BLOB PRIMARY KEY NOT NULL, + ContainerID BLOB, + ContainmentName TEXT, + TreeConflict LONG, + ContentsHash TEXT, + ContentsConflicts TEXT + ); +INSERT INTO "Unit" VALUES(X'52F04478EFE13543A8334FE39C4B63A5',X'52F04478EFE13543A8334FE39C4B63A5','',0,'1P8B8tZuK5hIRdTwEndlcERwqbt6eN6MCgMueh/qg9U=',''); +INSERT INTO "Unit" VALUES(X'3D06704EE1537A47AB926B763330C293',X'52F04478EFE13543A8334FE39C4B63A5','ProjectDocuments',0,'VrvS4YNiyxgp52vATpJ8mD7MRUed7rO1uWLGJrHCe4M=',''); +INSERT INTO "Unit" VALUES(X'AB8629B6C91E845C9D331F0829FFCE9B',X'52F04478EFE13543A8334FE39C4B63A5','ProjectDocuments',0,'CQW8g4stdHD3myfUj1t55R6glTPOaexTBbw4UABJ5IY=',''); +INSERT INTO "Unit" VALUES(X'B5ECD2572F6CFA4FA0B3A37A40DA3ADF',X'52F04478EFE13543A8334FE39C4B63A5','ProjectDocuments',0,'q2Ut4uuc1d9aLWNnsLEKrYiDAEPyZDPMtJWjs/E5HfI=',''); +INSERT INTO "Unit" VALUES(X'BD656FBD93239D5393884684A54D0646',X'52F04478EFE13543A8334FE39C4B63A5','ProjectDocuments',0,'VGLSsTp4/eturnjDqpHoM7PTbTOYHIyo/2VPX6/pqSE=',''); +INSERT INTO "Unit" VALUES(X'C67D7416B7B6AE42AABF255DCA2AEEAF',X'52F04478EFE13543A8334FE39C4B63A5','Modules',0,'RcBxkbGB/tK5dH1fTyFD01HFb73PRmtvZ16Rjm4biTY=',''); +INSERT INTO "Unit" VALUES(X'B375844ACB140041896358F2FD3A0BE8',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'V7yfLmuC/7BpAZpws/8y8gwe7I50gvOLNGGE/McILeA=',''); +INSERT INTO "Unit" VALUES(X'E9062165383FC746BCC42B4E4AD39160',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'38VTr29tSOGyWX/lt3RiOSq7QQa0jQbs8ZJ2E62YCNY=',''); +INSERT INTO "Unit" VALUES(X'FC05685CDFCA724DA5EFF25DA7CF4C97',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'BhMyTju/Rcl2MctB67zt+opPQO9Z+2qQL72RrAuAdfQ=',''); +INSERT INTO "Unit" VALUES(X'3521F934655CC1409AF9166186ECF501',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'wsCVL2KDvUOIPIeunloMOIbGmY/JQc7L/NVeFAxeT98=',''); +INSERT INTO "Unit" VALUES(X'30B1E8E1BA26BD43B2E70F6B61A61008',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'8OydqzRphNoPJR7gThBXnL9jfK1KBaSfhUsIv4I5XKY=',''); +INSERT INTO "Unit" VALUES(X'10BB28F93218C84994157E24495EC970',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'fg5TRQPPXcJl0T2VGKQ9C4trQ+0rQLdlgKVwStzWTRI=',''); +INSERT INTO "Unit" VALUES(X'F7F1166E0712704D8847C369A7DD360A',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'hfM1rUN9AgT3QqkZjG80Uo1ei49uDR4qAVB1QJmDg3A=',''); +INSERT INTO "Unit" VALUES(X'10667784B213CB48A265D5E984D63129',X'C67D7416B7B6AE42AABF255DCA2AEEAF','DomainModel',0,'Nckz3qoQ1xA39vc5BB/GDPY+Hes6srwAkk5Y1sMoA0I=',''); +INSERT INTO "Unit" VALUES(X'37EE21D102F1AE56B644160F1D340296',X'C67D7416B7B6AE42AABF255DCA2AEEAF','ModuleSettings',0,'KYFxFtYHsM09/IpsXqRyxqMOeRhNe+vDbUwNNf2VpTQ=',''); +INSERT INTO "Unit" VALUES(X'A2BA643F5CB899499A3C3CC38382EB54',X'C67D7416B7B6AE42AABF255DCA2AEEAF','ModuleSecurity',0,'gaKtd2z3+JUzt0UL2ThGw0i51m6DwUGJwktA0h727tc=',''); +INSERT INTO "Unit" VALUES(X'2328CC1F03C3275AB6C778B04C96C88F',X'52F04478EFE13543A8334FE39C4B63A5','ProjectConversion',0,'Ach7bT0Xt73Hw9ArP9izw30C89fTuCrhIJMjYmXtt4A=',''); +INSERT INTO "Unit" VALUES(X'A7EB1C6CE0344C4DB6AEC54B487A3562',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'SItwWFUvsSV4dVzpliS/O9c7MqrwoKrUEAdfc0s7288=',''); +INSERT INTO "Unit" VALUES(X'73AF86552C67184A9CE48A9C0F092EF3',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'cDRtdwtcwTmrrv6RmrM68bL3oLxOrGLkHbIZUykNxac=',''); +INSERT INTO "Unit" VALUES(X'15FF067DEF6F8748A15A4230D851A87D',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'BAZHNfXIMeCgZMn8nP9592rOFYear/tz1l9BbrqqiN4=',''); +INSERT INTO "Unit" VALUES(X'60E4D3253DC59E41A2A9E1428F40B14F',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'ugRNWwcTrOfJ7Dp13o/vxoJD3lJBtEQ2s1eD/dUJ5D4=',''); +INSERT INTO "Unit" VALUES(X'2E6F275BD5EEA94F903B3483792CBEA7',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'MWNqVaPXrQYZ82Hu6APzlO//oCzKOyhN2dCxEP73OU8=',''); +INSERT INTO "Unit" VALUES(X'7990D298071E3541BB38598E72EB0A0D',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'CCmrWhJ6VT29aa4x6I8Dc3hAvB7mkohqzIkL3GhxaLw=',''); +INSERT INTO "Unit" VALUES(X'B708DDC4C02DC94C823C3E612EB3A281',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'a+rm/AKPx584tpcvBBRHsdyBe4861T6Wdxmi3S1Boys=',''); +INSERT INTO "Unit" VALUES(X'36646D7ABEA015419B12ABDB05CFE1CC',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'ySC1oRci5dMZBAyD+gRHutR3AGzVoQM/T5KWocVkn04=',''); +INSERT INTO "Unit" VALUES(X'EA396B05142E124D8AE187FA4B3C038A',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'Ux6EeBbxCPMJjiYdq0Sn0OlMgYWnqkD+d9j4yO9t3Zg=',''); +INSERT INTO "Unit" VALUES(X'47834DC5ECA8BA40B88E873BFEC0A493',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'cDw61j1j1+oCzSVuMI4E6gSiFRP7VPfTB2Gc0zM8xKo=',''); +INSERT INTO "Unit" VALUES(X'B1318B4293592E4492A156A1C48639DB',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'s28serUQiKcWxGgPneBee6CE4p1MqN+bKKQtxxxTCmQ=',''); +INSERT INTO "Unit" VALUES(X'9B506973821E1949981B1DF462FD7C02',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'HAd7cH2gz0RoebciOappX8HEDgRFfG/0f6Ay1o4GnHE=',''); +INSERT INTO "Unit" VALUES(X'641910755F9BB04190FC95C8EB46B10C',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'aiTMiLpqxm0wEVHWK4py3Lo0Ln+EIkQ0XxvMt/CZTjI=',''); +INSERT INTO "Unit" VALUES(X'93BE47916558E34F86D4C36F70428ADD',X'C67D7416B7B6AE42AABF255DCA2AEEAF','Documents',0,'Ch3pkVZn3Mrrk/+JR79YhQv0XNFfEB4X9yVml9WEpvg=',''); +CREATE TABLE _MetaData + ( + _FormatVersion INTEGER, + _ProductVersion TEXT, + _BuildVersion TEXT, + _SchemaHash TEXT + ); +INSERT INTO "_MetaData" VALUES(2,'11.14.0','11.14.0','{SHA256}o9B9S8lorV9RD5gY9B6j1bJp4ALW87u4newnreIbRAg='); +CREATE TABLE _Transaction (LastTransactionID TEXT); +INSERT INTO "_Transaction" VALUES('6be4d0e4-3040-4160-b709-616b7dca371d'); +COMMIT; diff --git a/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..26015354b0d09aefc44acd8a9591292c31462a8a GIT binary patch literal 4272 zcmd5=drVbz9RGPAMu?NCm1P!AglUdlKv;?z_XXUzh~0~rErr85KQ2ekJ?DBJSI{&? zBg?4+b&=0d*94aj(sH5FYfEP{{llD*PMgc-Ag5NY*XsK_=Wt(Md~CYOCfxgbd|#i> z_wjRIGD7B5mjl6Hg!X5yvaOq);b?9xZ~eM)Qnw+stUiFz6R=gn(xOn!2@9mu&_`sL zPWdq!4%=3HS%qDZWWkTc3J>PVy1^mPiXZbbC9?vL1`rJi&N`Y?7|bAKaMlF`QFh}X z=FnrXSHv<5=8>_qY96L#yTEbqhur)WcEd*Q9_sWCZ~n`=v;1n;vd+#@-N>;ZA!J#E zk+(@RX)6+{0I|syWQ2>xOymap!b+BIJu&-2>?M1I`e&0MqSMppxN@h?Zcr0U4 z56R; zlb2b!9z8`2sV`tfNiGo>g~J&kb;#hs9E3cGOCX>eo*}TjtOhvCV#&+NMg}jt5Tk)0 zA={FdZHbRl5heARO+dC`%-d@y-bav2a7i9KUtvY~GQi-#)s(`?b_Rhl@qI{uyGjH4_x)0(~?IXiOA; zzHb<3`p!72blb($$$9BF&g#majaKd!d?i?tC?D2^7@VRgh(#37a4hdr5hqmgKrU2K zbS*My*z4#5;n&noZz!pS_u|R*Z2#N|9@Dioh`` zqzb06y!xFJd?{79S`cAHPNCIQk}`m{IcQoGZ6}>?nbyV}E?je?Ytzjm`@f72(7n<& zN6n7DbcsXT$|&0Qyk5O67#v;L_EXx=f3BT;O;dgsKE}&+(3drki_D~ex{5jvOZh7-`@s6f~E{o~hh4_5)LbJg< zjfp&DE)`iH%d1@t`mH**#I_f+14<9et8hmhjO_`@nlQV-^V+Q8;u&10cCq9L8L|7S zFK(8{Xesy^Ci0kE!I6DoQT{vY>#mn}9q{PxsLeXu=4^N&i$%L427#C%17uO)%E1Vz zbA*+aJV@GT6XKjeCOaiz6$%Lz!?kdVO^LGX;FXTxk=t zbb*M2HR$l+P6SbgI+cZ@p)()6HMsE;-nMu7{5`Xm%`J62d* zJJWK#Uj!#bg>ob0Yi7=(y(KTf16Ug!#Vedlh2>(d32!jO!%Uq=jL_y zmV(~D**|ukyMO2=B2QGow3%KQKd1KJ{Px_0l_wXpH|Y{%huY9U({IU)os9YgFE&B@ z0gnuScPg2%jf`Wa3|I2Pf$#Ef zd{tv#?^=C0_{+#7-K=47DEuxf7g$EUixNsGvDPZhl+@X_uR&_BK6L;A5TUAC0|BmH zd%;<08YX{el2#778fLUt91Qw6G*9by$UZb6S`lIRmYsE&R~!Oj)xCR1yAHi_ LX3DqY119u8MrZd) literal 0 HcmV?d00001 diff --git a/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/73/69/7369509b-1e82-4919-981b-1df462fd7c02.mxunit b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/73/69/7369509b-1e82-4919-981b-1df462fd7c02.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..446696a32df949ff9e9f760b254aafa12ee4d728 GIT binary patch literal 2135 zcmcgtZD?C%6h6I4v~(&(_uv)J?M_HEEl)G;O9RE4TNab~n9wZ~NZ& zwn>pP3sdTkef$u_G5lgW*c^6Ju@w9v%uyH=lp^}a!IXk9>JLGj4n6lH$xV&OerO=M zH}8AibDs0O=RD_7Ge9VuPCzTr=X9TD&hFZtesf3LFT=N9mZb1Nc^qL2ZDllt8zZ_g zWrnM-AZ6tu!hN(Iiw_LNEmIgpqiQc(qvlBIEj ziyoXr9qy#Pl%}f4AkaHpKt;q2U8jeA^6{V_}t<+=ex^~Ja+S&DP~a`8A8L`q)p% zLbgwsVHdq~T1qmZi85@nQI$J2ZiEOoK=E{-kz^eLkvS_I?9&<)-nvEFH na>(Fg_T=}ig-AeXh^1Ax8-9CG?2c_+=$SnqIzDsiR2lvP))sa6 literal 0 HcmV?d00001 diff --git a/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/75/10/75101964-9b5f-41b0-90fc-95c8eb46b10c.mxunit b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/75/10/75101964-9b5f-41b0-90fc-95c8eb46b10c.mxunit new file mode 100644 index 0000000000000000000000000000000000000000..136e1cf92e5a405331ab94e9194be7072a5b69ce GIT binary patch literal 2142 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 MuDQ;cIvq>UKRKIPApigX literal 0 HcmV?d00001 diff --git a/tests/wave2/fixtures/verify-446/clean.summary.tsv b/tests/wave2/fixtures/verify-446/clean.summary.tsv index 78056146..fe77d2cd 100644 --- a/tests/wave2/fixtures/verify-446/clean.summary.tsv +++ b/tests/wave2/fixtures/verify-446/clean.summary.tsv @@ -4,6 +4,7 @@ conformance (ledger claims vs live model) PASS 0 1s .claude/loop/verify/MyFirstM graph sweep (orphans + wiring shape) PASS 0 0s .claude/loop/verify/MyFirstModule/11-graph.log coverage (BRD leaves: UNCLAIMED/PHANTOM/DOUBLE) PASS 0 0s .claude/loop/verify/MyFirstModule/12-coverage.log review (model vs decided — report.json) INFO 0 0s .claude/loop/verify/MyFirstModule/13-review.log +canvas facts (mf-canvas-facts — informational) INFO 0 0s .claude/loop/verify/MyFirstModule/14-canvas-facts.log page scope (denominator for the design audit) INFO 0 0s .claude/loop/verify/MyFirstModule/39-page-scope.log journeys (UI + ordered spans + data effects) PASS 0 0s .claude/loop/verify/MyFirstModule/20-journeys.log journeys (positive control — MUST detect a broken precondition) PASS 0 0s .claude/loop/verify/MyFirstModule/21-journeys-control.log diff --git a/tests/wave2/fixtures/verify-446/finding.summary.tsv b/tests/wave2/fixtures/verify-446/finding.summary.tsv index 76522a5f..74d2a4ce 100644 --- a/tests/wave2/fixtures/verify-446/finding.summary.tsv +++ b/tests/wave2/fixtures/verify-446/finding.summary.tsv @@ -4,6 +4,7 @@ conformance (ledger claims vs live model) FINDING 1 0s .claude/loop/verify/MyFir graph sweep (orphans + wiring shape) PASS 0 0s .claude/loop/verify/MyFirstModule/11-graph.log coverage (BRD leaves: UNCLAIMED/PHANTOM/DOUBLE) PASS 0 0s .claude/loop/verify/MyFirstModule/12-coverage.log review (model vs decided — report.json) INFO 0 0s .claude/loop/verify/MyFirstModule/13-review.log +canvas facts (mf-canvas-facts — informational) INFO 0 0s .claude/loop/verify/MyFirstModule/14-canvas-facts.log page scope (denominator for the design audit) INFO 0 0s .claude/loop/verify/MyFirstModule/39-page-scope.log journeys (UI + ordered spans + data effects) PASS 0 0s .claude/loop/verify/MyFirstModule/20-journeys.log journeys (positive control — MUST detect a broken precondition) PASS 0 0s .claude/loop/verify/MyFirstModule/21-journeys-control.log diff --git a/tests/wave2/fixtures/verify-446/skip-journeys.summary.tsv b/tests/wave2/fixtures/verify-446/skip-journeys.summary.tsv index 9c8f68e8..fd2d3045 100644 --- a/tests/wave2/fixtures/verify-446/skip-journeys.summary.tsv +++ b/tests/wave2/fixtures/verify-446/skip-journeys.summary.tsv @@ -4,6 +4,7 @@ conformance (ledger claims vs live model) PASS 0 0s .claude/loop/verify/MyFirstM graph sweep (orphans + wiring shape) PASS 0 0s .claude/loop/verify/MyFirstModule/11-graph.log coverage (BRD leaves: UNCLAIMED/PHANTOM/DOUBLE) PASS 0 0s .claude/loop/verify/MyFirstModule/12-coverage.log review (model vs decided — report.json) INFO 0 0s .claude/loop/verify/MyFirstModule/13-review.log +canvas facts (mf-canvas-facts — informational) INFO 0 0s .claude/loop/verify/MyFirstModule/14-canvas-facts.log page scope (denominator for the design audit) INFO 0 0s .claude/loop/verify/MyFirstModule/39-page-scope.log journeys SKIPPED 0 - (--skip-journeys) monkey (crash net — informational) INFO 0 0s .claude/loop/verify/MyFirstModule/30-monkey.log diff --git a/tests/wave2/test-mf-canvas-facts.sh b/tests/wave2/test-mf-canvas-facts.sh new file mode 100755 index 00000000..7285d949 --- /dev/null +++ b/tests/wave2/test-mf-canvas-facts.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Fixture for project-bin/mf-canvas-facts.py (the canvas-readability instrument), on a model +# mxcli v0.27.0 wrote. +# +# usage: bash tests/wave2/test-mf-canvas-facts.sh /path/to/project-bin/mf-canvas-facts.py +# (the fixture uses that file's directory: mxpatch.py and mxunit_bson.py beside it) +# +# Golden input is captured, never written: fixtures/canvas-facts/ holds the five units and the +# .mpr dump `mxcli v0.27.0 exec capture-canvas.mdl` produced on a copy of fixtures/mxpatch/model +# (see its CAPTURE.txt). Each fact has a negative control in the same model: a parameter column +# (no OVERLAP), a decision captioned to match its true branch (no SENSE). +# +# Positive control: against a project-bin without the script every case fails -- it does not +# exist. [guard] marks regression guards. +set -uo pipefail +SUBJ="${1:?usage: test-mf-canvas-facts.sh /path/to/project-bin/mf-canvas-facts.py}" +case "$SUBJ" in /*) ;; *) SUBJ="$PWD/$SUBJ" ;; esac +[ -f "$SUBJ" ] && BIN="$(dirname "$SUBJ")" || BIN="$SUBJ" +TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FX="$TK/tests/wave2/fixtures/canvas-facts" +BASE="$TK/tests/wave2/fixtures/mxpatch/model" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/canvasfacts.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ /'; return 0; } +. "$TK/bin/lib/portable.sh"; require_py +export MXPATCH_BACKUP_DIR="$WORK/bak" + +# model -- the base model's units, the captured five over them, the captured .mpr. +model() { + rm -rf "$1"; mkdir -p "$1"; cp -r "$BASE/mprcontents" "$1/"; cp -r "$FX/overlay/mprcontents" "$1/" + "$PY" -c 'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.executescript(open(sys.argv[2]).read()); c.commit()' \ + "$1/TestApp.mpr" "$FX/overlay/TestApp.mpr.sql" +} +FACTS() { "$PY" -I "$BIN/mf-canvas-facts.py" "$@"; } + +M="$WORK/m"; model "$M" +OUT="$("$PY" -I "$BIN/mxpatch.py" verify "$M/TestApp.mpr" 2>&1)" +printf '%s' "$OUT" | grep -q '^0 mismatch(es), 0 missing file(s), 0 orphan file(s)\.$' \ + && ok "[guard] rebuilt model verifies clean (overlay matches its .mpr dump)" || bad "rebuilt model" "$OUT" + +echo "== F: the facts on the captured model ==" +OUT="$(FACTS "$M" --module MyFirstModule 2>&1)"; RC=$? +[ "$RC" -eq 0 ] && ok "exit 0: measured" || bad "exit $RC" "$OUT" +WANT="$(grep '^ ' "$FX/facts-v0.27.0.out")" +[ "$(printf '%s\n' "$OUT" | grep '^ ')" = "$WANT" ] \ + && ok "[guard] fact lines equal the first run's capture (facts-v0.27.0.out)" || bad "fact lines drifted" "$(printf '%s\n' "$OUT" | grep '^ ')" +printf '%s' "$OUT" | grep -q '^ NOTE-SIDE note line Annotation@360,100 -> Action@360,200 leaves the note left' \ + && ok "NOTE-SIDE: mxcli's (2,0) line leaves the left of a note that sits above its activity" || bad "NOTE-SIDE" "$OUT" +printf '%s' "$OUT" | grep -q '^ OVERLAP label "PrimaryCustomerRecord" of MicroflowP@200,53 x label "SecondaryCustomerRecord" of MicroflowP@300,53$' \ + && ok "OVERLAP: two long parameter labels on mxcli's 100-apart row" || bad "OVERLAP" "$OUT" +printf '%s' "$OUT" | grep -A1 '^# MyFirstModule.ACT_Customer_MergeColumn (0)$' | grep -q '^# ' \ + && ok "no OVERLAP for the same parameters in a column 80 apart (negative control)" || bad "column control" "$OUT" +printf '%s' "$OUT" | grep -q '^ SENSE Split@360,200 "Customer found?" asks the positive question; its true branch is \$Customer = empty$' \ + && ok "SENSE: 'Customer found?' over \$Customer = empty" || bad "SENSE" "$OUT" +printf '%s' "$OUT" | grep -q 'No customer?' \ + && bad "SENSE fired on 'No customer?' (negative control)" "$OUT" || ok "no SENSE for 'No customer?' (negative control)" +# The negative-word list, on captions the captured model does not carry: a caption that asks the +# negated question (here with a word no un-/not- rule catches) must not read as the positive one. +OUT2="$("$PY" -I -c 'import importlib.util,sys +s=importlib.util.spec_from_file_location("f",sys.argv[1]); f=importlib.util.module_from_spec(s); s.loader.exec_module(f) +for c in sys.argv[2:]: print("%s=%s" % (c, bool(f.NEG.search(c))))' "$BIN/mf-canvas-facts.py" \ + "Customer inactive?" "Order incomplete?" "Link expired?" "Customer active?" "Customer found?" \ + "Unknown?" "Unsaved?" "Unset?" "Unit found?" "Unique?" "Under limit?" "Until done?" 2>&1)" +[ "$OUT2" = "$(printf '%s\n' 'Customer inactive?=True' 'Order incomplete?=True' 'Link expired?=True' 'Customer active?=False' 'Customer found?=False' \ + 'Unknown?=True' 'Unsaved?=True' 'Unset?=True' 'Unit found?=False' 'Unique?=False' 'Under limit?=False' 'Until done?=False')" ] \ + && ok "[guard] 'inactive?', 'incomplete?', 'expired?', 'unknown?', 'unsaved?' read as negated questions; 'active?', 'found?', 'unit', 'unique', 'under', 'until' do not" || bad "negative-word list" "$OUT2" +printf '%s' "$OUT" | tail -1 | grep -q '^# facts: 15 flows, OVERLAP 1, LINE 1, NOTE-SIDE 1, LEFTWARD 0, TURN 0, CAPTION 0, SENSE 1$' \ + && ok "summary line counts every fact kind" || bad "summary" "$(printf '%s' "$OUT" | tail -1)" + +echo "== S: selection and faults ==" +OUT="$(FACTS "$M/mprcontents" MyFirstModule.ACT_Customer_Check ACT_Customer_Merge 2>&1)"; RC=$? +[ "$RC" -eq 0 ] && [ "$(printf '%s\n' "$OUT" | grep -c '^# MyFirstModule\.')" = 2 ] \ + && ok "mprcontents/ path; a qualified and a plain name pick two flows" || bad "selection rc=$RC" "$OUT" +OUT="$(FACTS "$M" MyFirstModule.NoSuchFlow 2>&1)"; RC=$? +[ "$RC" -eq 2 ] && printf '%s' "$OUT" | grep -q 'FAULT no microflow or nanoflow named MyFirstModule.NoSuchFlow' \ + && ok "[guard] a named flow that is not there is a FAULT (exit 2), never a clean run" || bad "missing name rc=$RC" "$OUT" +OUT="$(FACTS "$WORK/nowhere" --all 2>&1)"; RC=$? +[ "$RC" -eq 2 ] && ok "no model: exit 2" || bad "no model rc=$RC" "$OUT" +cp "$M/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit" "$WORK/keep.mxunit" +"$PY" -c 'import sys; p=sys.argv[1]; b=open(p,"rb").read(); open(p,"wb").write(b[:-1])' \ + "$M/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit" +OUT="$(FACTS "$M" --all 2>&1)"; RC=$? +[ "$RC" -eq 2 ] && printf '%s' "$OUT" | grep -q 'FAULT could not read' && printf '%s' "$OUT" | grep -q '^# facts: 14 flows' \ + && ok "[guard] a unit that does not decode is a FAULT, and every other flow is still measured" || bad "bad unit rc=$RC" "$OUT" +cp "$WORK/keep.mxunit" "$M/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit" + +echo "" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-verify-module-446.sh b/tests/wave2/test-verify-module-446.sh index 0ab4dda6..e38781d7 100644 --- a/tests/wave2/test-verify-module-446.sh +++ b/tests/wave2/test-verify-module-446.sh @@ -18,6 +18,8 @@ # V6 the summary's last row is `verdict` (a run cut short has none) # V7 JOURNEY_RUNNER set: its own folder is probed; axe-core alone missing faults only the # rendered design audit (journeys and monkey run) +# V8 the canvas-facts rung (1c, 14-canvas-facts.log) is an INFO row when mf-canvas-facts.py is +# installed and a FAULT row naming the install when it is not set -uo pipefail TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SUT="${1:-$TK/project-bin/verify-module.sh}" @@ -50,6 +52,8 @@ fake_mod() { # $1 node_modules dir, $2 package name: a resolvable stand-in pack printf 'module.exports = {};\n' > "$1/$2/index.js" } fake_mod "$P/node_modules" playwright; fake_mod "$P/node_modules" axe-core +# The canvas-facts rung runs the instrument with python3 -I: a stub that prints its summary line. +printf 'print("# facts: 0 flows, OVERLAP 0")\n' > "$P/bin/mf-canvas-facts.py"; chmod +x "$P/bin/mf-canvas-facts.py" # Sleeping processes left behind with their cwd in the project (Linux /proc only; else "skip"). leftover() { @@ -117,6 +121,17 @@ JOURNEY_RUNNER="$WORK/alt/journey-runner.js" vm MyFirstModule && ok "V7: JOURNEY_RUNNER's own folder is probed; axe-core missing faults the rendered audit only" \ || bad "V7: JOURNEY_RUNNER / axe-core probe" "$sum" +# V8: the canvas-facts rung, installed and then missing. +vm MyFirstModule +{ row '^canvas facts' | grep -q '^INFO .claude/loop/verify/MyFirstModule/14-canvas-facts.log$' \ + && grep -q '^# facts: 0 flows' "$P/.claude/loop/verify/MyFirstModule/14-canvas-facts.log"; } \ + && ok "V8: mf-canvas-facts installed: an INFO row, log 14-canvas-facts.log" || bad "V8: canvas facts row" "$(row '^canvas facts')" +mv "$P/bin/mf-canvas-facts.py" "$WORK/stub/mf-canvas-facts.py" +vm MyFirstModule +{ row '^canvas facts' | grep -q '^FAULT ' && printf '%s' "$out" | grep -q 'bin/sync-project.sh to install the scripts'; } \ + && ok "V8: mf-canvas-facts not installed: a FAULT row that says how to install it" || bad "V8: missing instrument" "$(row '^canvas facts')" +mv "$WORK/stub/mf-canvas-facts.py" "$P/bin/mf-canvas-facts.py" + # Golden summaries for test-progress-harness-verify.sh, written by this subject (CAPTURE=). if [ -n "${CAPTURE:-}" ]; then fake_mod "$P/node_modules" playwright; fake_mod "$P/node_modules" axe-core