diff --git a/CHANGELOG.md b/CHANGELOG.md index 606b9593..5842181a 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, Python or `.mpr`, an exit 2 or a timeout is an INFO row reading NOT MEASURED, never a gate). 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 - learn(preflight, teamserver-alignment): **a flow's return-type change and its callers' update go in one script (STOP row 33), and Studio Pro 10.24.18's "Merge feature branch" merges `origin/`, not the local branch.** On a copy of a Mendix 10.24.18 app with mxcli v0.27.0, the first script alone left `mx check` with CE0112 at a caller stored the way Studio Pro stores a call, while `check` and `exec` stayed silent; Studio Pro's own git log shows the merge of the remote branch. Text only; baseline-routed, +101 words. — saingge - fix(doctor, preflight, platform-link): **a clean `fix property-sets --dry-run` on Mendix below 10.24.24 is no longer reported as "every unit has the property names Studio Pro writes".** mxcli's property table starts at 10.24.24, so on 10.24.18 (mxcli v0.27.0) the dry-run printed "Every unit already has the property names Studio Pro writes" while Mendix's `mx diff` exited 129 on a unit mxcli had written (mendixlabs/mxcli#1595). `doctor.sh --property-sets`, `--if-stale` and `--quick` now WARN "NOT PROOF" and name `mx diff .mpr .mpr out.json` (exit 0) as the proof; the receipt records `unproven`; exit stays 0. `learned-detection-gaps.md` and `platform-link.md` said v0.26.0 closed the gap. Fixture P2b (11 cases). Baseline-routed, +18 words. — saingge - new(model-scope): **a write's whitespace-only units can now be found and put back.** mxcli v0.27.0's `alter entity … rename attribute` rewrote 135 units in 20 modules of a Mendix 10.24.18 app that never referenced the attribute; the only change in each was the line breaks between bracketed XPath conditions, and `mxcli diff-local` showed nothing (mendixlabs/mxcli#1596). New `project-bin/model-scope.py`: `report` sorts each changed unit into real or noise (every differing field is an XPath constraint that is equal once whitespace outside string literals is removed) and prints the real changes per module; `restore` checks the noise units out of `HEAD` and updates their ContentsHash rows through `mxpatch.py`; `check --staged` exits 1 when noise is staged (`MODEL_SCOPE_OK=1` lets one commit through). Not wired into `exec.sh` or the pre-commit hook yet. Fixture `tests/wave2/test-model-scope.sh`, with a golden before/after capture of a real v0.27.0 rename on a public model (`fixtures/model-scope/golden-v0.27.0/`) — saingge diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index ba30cdd1..18b58358 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -123,9 +123,12 @@ 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. +# mf-canvas-facts.py joined on 2026-10-21 and needs mxpatch.py and mxunit_bson.py beside it (the one +# BSON reader). Run it by hand on any flow; verify-module.sh also +# runs it per module (informational; see project-bin/mf-canvas-facts.py header). # model-scope.py joined on 2026-10-21: it imports mxpatch.py (and through it mxunit_bson.py) from # its own directory. It puts back units a write changed only in XPath whitespace (mxcli#1596). -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 model-scope.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" +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 model-scope.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..0667a089 --- /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 the reviewer's call -- the FACTS list below is the reference; read it before acting. + + 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..296531e8 100755 --- a/project-bin/verify-module.sh +++ b/project-bin/verify-module.sh @@ -229,7 +229,13 @@ _report_result() { # failure — the app was never measured, so it is a FAULT, not a FINDING. Scoped to the stack # rung's kind on purpose: exit 4 means something else elsewhere (conformance-check.sh: NOT # APPLICABLE), so it is not a global remap. + # `note:*` is an informational rung that is never a gate even when it could not measure + # (canvas facts): checked before `*:2` so its rc 2 is an INFO row, not a FAULT that would + # turn the module verdict INCOMPLETE. The log says it was unmeasured. + [ "$kind" = note ] && [ "$rc" -ne 0 ] && \ + echo "NOT MEASURED (exit $rc) — informational rung, does not gate the verdict." >> "$log" case "$kind:$rc" in + note:*) verdict=INFO ;; *:2) verdict=FAULT ;; stack:4) verdict=FAULT ;; info:*) verdict=INFO ;; @@ -250,6 +256,7 @@ _report_result() { # run -- # kind=gate exit 1 => finding, exit 2 => instrument fault # kind=info never fails the pass; recorded for the trend line +# kind=note as info, and even exit 2 / timeout stays INFO (never a gate, never a fault) # kind=stack as gate, plus exit 4 (test-stack-up: port held by another process) => fault # Blocks until the instrument finishes. run() { @@ -551,6 +558,26 @@ 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 (project-bin/mf-canvas-facts.py header documents the facts). 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. Because it is never a gate, nothing here touches FAULTED: a flow it could not read +# (rc 2), a timeout, no Python (Git Bash) or no .mpr is an INFO row saying NOT MEASURED. +_vm_canvas="$(_tool mf-canvas-facts.py)" +_vm_py="$(resolve_py 2>/dev/null)" || _vm_py="" +_vm_canvas_name="canvas facts (mf-canvas-facts — informational)" +if [ -x "$_vm_canvas" ] && [ -n "$_vm_py" ] && [ -n "$_vm_mpr" ]; then + run "$_vm_canvas_name" "$OUTDIR/14-canvas-facts.log" note 300 -- \ + "$_vm_py" -I "$_vm_canvas" "$_vm_mpr" --module "$MODULE" +else + _vm_canvas_why="needs bin/mf-canvas-facts.py (+ mxpatch.py, mxunit_bson.py), Python 3 and the .mpr" + printf '\n\033[1m── %s\033[0m\n' "$_vm_canvas_name" + c_warn " · info"; echo " — NOT MEASURED: $_vm_canvas_why (informational, does not gate)" + echo " Run bin/sync-project.sh to install the scripts." + printf '%s\tINFO\t-\t-\t(NOT MEASURED: %s)\n' "$_vm_canvas_name" "$_vm_canvas_why" >> "$SUMMARY" +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 00000000..26015354 Binary files /dev/null and b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/42/8b/428b31b1-5993-442e-92a1-56a1c48639db.mxunit differ 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 00000000..446696a3 Binary files /dev/null and b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/73/69/7369509b-1e82-4919-981b-1df462fd7c02.mxunit differ 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 00000000..136e1cf9 Binary files /dev/null and b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/75/10/75101964-9b5f-41b0-90fc-95c8eb46b10c.mxunit differ diff --git a/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit new file mode 100644 index 00000000..4819645a Binary files /dev/null and b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/84/77/84776610-13b2-48cb-a265-d5e984d63129.mxunit differ diff --git a/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/91/47/9147be93-5865-4fe3-86d4-c36f70428add.mxunit b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/91/47/9147be93-5865-4fe3-86d4-c36f70428add.mxunit new file mode 100644 index 00000000..dbc5722c Binary files /dev/null and b/tests/wave2/fixtures/canvas-facts/overlay/mprcontents/91/47/9147be93-5865-4fe3-86d4-c36f70428add.mxunit differ 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..dca467a6 100644 --- a/tests/wave2/test-verify-module-446.sh +++ b/tests/wave2/test-verify-module-446.sh @@ -18,6 +18,10 @@ # 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; when it is missing (as with no Python on Git Bash) or exits 2 (a flow it +# could not decode) the row is still INFO, says NOT MEASURED, and the module verdict is +# the same as the installed run's -- the rung is never a gate set -uo pipefail TK="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SUT="${1:-$TK/project-bin/verify-module.sh}" @@ -50,6 +54,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 +123,26 @@ 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')" +_v8_verdict="$(row '^verdict')"; _v8_rc=$rc +mv "$P/bin/mf-canvas-facts.py" "$WORK/stub/mf-canvas-facts.py" +vm MyFirstModule +{ row '^canvas facts' | grep -q '^INFO (NOT MEASURED: ' && printf '%s' "$out" | grep -q 'bin/sync-project.sh to install the scripts' \ + && [ "$(row '^verdict')" = "$_v8_verdict" ] && [ "$rc" = "$_v8_rc" ]; } \ + && ok "V8: mf-canvas-facts not installed: INFO NOT MEASURED row, verdict and exit unchanged" \ + || bad "V8: missing instrument must not gate" "$(row '^canvas facts') / $(row '^verdict') rc=$rc (was $_v8_verdict rc=$_v8_rc)" +printf 'import sys\nprint("cannot decode flow")\nsys.exit(2)\n' > "$P/bin/mf-canvas-facts.py"; chmod +x "$P/bin/mf-canvas-facts.py" +vm MyFirstModule +{ row '^canvas facts' | grep -q '^INFO ' && grep -q 'NOT MEASURED (exit 2)' "$P/.claude/loop/verify/MyFirstModule/14-canvas-facts.log" \ + && [ "$(row '^verdict')" = "$_v8_verdict" ] && [ "$rc" = "$_v8_rc" ]; } \ + && ok "V8: mf-canvas-facts exit 2 (undecodable flow): INFO row, verdict and exit unchanged" \ + || bad "V8: undecodable flow must not gate" "$(row '^canvas facts') / $(row '^verdict') rc=$rc (was $_v8_verdict rc=$_v8_rc)" +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