@aarwitz/tapp 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """Flow helpers for run-flow.sh: convert a .yml/.json Flow to the harness's OCQA_FLOW_JSON, and
3
+ parse the harness's OCQA_FLOW_STEP / OCQA_FLOW_RESULT markers into a scannable pass/fail report.
4
+
5
+ Usage:
6
+ flow_lib.py to-json <flow.yml|flow.json> # prints {steps:[...], name, vars, ...} JSON
7
+ flow_lib.py raw-json <flow.yml|flow.json> # prints the complete repository spec as JSON
8
+ flow_lib.py to-yaml '<flow-json-string>' # prints tidy YAML (for saving a recorded flow)
9
+ flow_lib.py report <harness.log> # prints a human-readable pass/fail report
10
+ flow_lib.py report --json <harness.log> # prints machine JSON {passed,total,failed,steps}
11
+ """
12
+ import json
13
+ import re
14
+ import sys
15
+
16
+
17
+ def load_flow(path):
18
+ raw = open(path, encoding="utf-8").read()
19
+ if path.endswith(".json"):
20
+ return json.loads(raw)
21
+ import yaml
22
+ return yaml.safe_load(raw)
23
+
24
+
25
+ def to_yaml(json_str):
26
+ """Emit a recorded/inline Flow (JSON string) as tidy YAML for saving to .autotap/flows/*.yml."""
27
+ import yaml
28
+ flow = json.loads(json_str)
29
+ # Order keys for readability. `platform` + `url` make the same repository-native
30
+ # Flow format portable across the XCUITest, Playwright, and Android drivers.
31
+ ordered = {}
32
+ for k in ("name", "platform", "app", "url", "vars", "reset"):
33
+ if flow.get(k):
34
+ ordered[k] = flow[k]
35
+ ordered["steps"] = flow.get("steps", [])
36
+ print(yaml.safe_dump(ordered, sort_keys=False, default_flow_style=False, allow_unicode=True, width=100).rstrip())
37
+
38
+
39
+ def to_json(path):
40
+ flow = load_flow(path) or {}
41
+ # Steps pass through as-is — the harness normalizes both sugar ({tap: X}) and explicit
42
+ # ({action: tap, target: X}) forms, so no rewriting is needed here.
43
+ out = {
44
+ "name": flow.get("name", "flow"),
45
+ "kind": flow.get("kind", "flow"),
46
+ "platform": flow.get("platform", ""),
47
+ "app": flow.get("app", ""),
48
+ "url": flow.get("url", ""),
49
+ "steps": flow.get("steps", []),
50
+ "vars": flow.get("vars", {}),
51
+ "reset": flow.get("reset", "launch"),
52
+ "continueOnFailure": bool(flow.get("continueOnFailure", False)),
53
+ }
54
+ # Multi-actor Scenarios deliberately reuse the Flow step language and marker
55
+ # protocol, while preserving actor/session and lifecycle orchestration.
56
+ for key in ("actors", "setup", "teardown", "timeoutMs", "taskPlan", "releaseContract"):
57
+ if key in flow:
58
+ out[key] = flow[key]
59
+ print(json.dumps(out))
60
+
61
+
62
+ def raw_json(path):
63
+ print(json.dumps(load_flow(path) or {}))
64
+
65
+
66
+ def report(path, as_json=False):
67
+ log = open(path, encoding="utf-8", errors="replace").read()
68
+ steps = []
69
+ result = None
70
+ name = "flow"
71
+ kind = "flow"
72
+ for line in log.splitlines():
73
+ line = line.strip()
74
+ if line.startswith("OCQA_FLOW_STEP:"):
75
+ try:
76
+ steps.append(json.loads(line[len("OCQA_FLOW_STEP:"):]))
77
+ except Exception:
78
+ pass
79
+ elif line.startswith("OCQA_FLOW_RESULT:started"):
80
+ m = re.search(r"name=(.*)$", line)
81
+ if m:
82
+ name = m.group(1).split(" kind=", 1)[0]
83
+ km = re.search(r"\bkind=([^ ]+)", line)
84
+ if km:
85
+ kind = km.group(1)
86
+ elif line.startswith("OCQA_FLOW_RESULT:{"):
87
+ try:
88
+ result = json.loads(line[len("OCQA_FLOW_RESULT:"):])
89
+ kind = result.get("kind", kind)
90
+ except Exception:
91
+ pass
92
+
93
+ total = (result or {}).get("total", len(steps))
94
+ failed = (result or {}).get("failed", sum(1 for s in steps if s.get("status") == "fail"))
95
+ passed = (result or {}).get("passed", failed == 0 and bool(steps))
96
+
97
+ executed = (result or {}).get("executed", len(steps))
98
+ passed_steps = sum(1 for s in steps if s.get("status") == "pass")
99
+
100
+ if as_json:
101
+ print(json.dumps({"name": name, "kind": kind, "passed": passed, "total": total, "executed": executed, "failed": failed, "steps": steps}))
102
+ return 0 if passed else 1
103
+
104
+ icon = {"pass": "✅", "fail": "❌", "skip": "⚪️"}
105
+ verb = {"tap": "👆 tap", "type": "⌨️ type", "swipe": "↔️ swipe", "back": "◀️ back",
106
+ "wait": "⏳ wait", "wait_for": "⏳ wait for", "assert_screen": "🔎 screen is",
107
+ "assert_exists": "🔎 exists", "assert_absent": "🔎 absent", "assert_text": "🔎 text",
108
+ "assert_ai": "🤖 ai"}
109
+ label = "RELEASE CONTRACT" if kind == "release-contract" else ("SCENARIO" if kind == "scenario" else "FLOW")
110
+ progress = f"{passed_steps}/{total} steps" if passed else f"{passed_steps} passed · {failed} failed · {executed}/{total} executed"
111
+ head = f"### {'🟢 ' + label + ' PASSED' if passed else '🔴 ' + label + ' FAILED'} — {name} · {progress}"
112
+ print(head)
113
+ print("")
114
+ for s in steps:
115
+ st = s.get("status", "?")
116
+ label = verb.get(s.get("action", ""), s.get("action", ""))
117
+ tgt = s.get("target", "")
118
+ actor = s.get("actor", "")
119
+ line = f"{icon.get(st, '•')}" + (f" **{actor}**" if actor else "") + f" {label}" + (f" `{tgt}`" if tgt else "")
120
+ if st != "pass" and s.get("detail"):
121
+ line += f" — {s['detail']}"
122
+ print(line)
123
+ if not passed:
124
+ print("")
125
+ bad = next((s for s in steps if s.get("status") == "fail"), None)
126
+ if bad:
127
+ print(f"**First failure:** step {bad.get('index')} ({bad.get('action')}) — {bad.get('detail','')}")
128
+ return 0 if passed else 1
129
+
130
+
131
+ def main():
132
+ if len(sys.argv) < 3:
133
+ print(__doc__)
134
+ return 2
135
+ cmd = sys.argv[1]
136
+ if cmd == "to-json":
137
+ to_json(sys.argv[2])
138
+ return 0
139
+ if cmd == "raw-json":
140
+ raw_json(sys.argv[2])
141
+ return 0
142
+ if cmd == "to-yaml":
143
+ to_yaml(sys.argv[2])
144
+ return 0
145
+ if cmd == "report":
146
+ as_json = "--json" in sys.argv
147
+ path = [a for a in sys.argv[2:] if not a.startswith("--")][0]
148
+ return report(path, as_json=as_json)
149
+ print(__doc__)
150
+ return 2
151
+
152
+
153
+ if __name__ == "__main__":
154
+ sys.exit(main())
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bash
2
+ # Mutation-recall benchmark — DESKTOP APP EDITION.
3
+ #
4
+ # Same idea as mutation-recall.sh (seeded faults, differential catch, coverage-vs-detection
5
+ # decomposition) but the system under test is the REAL desktop pipeline: each run goes through
6
+ # `AutoTap.app --verify-run` (OrchestratorService.executeReleaseCheck — the exact code the Run
7
+ # button calls: build → install → explore → host-side crash cross-check → vision/enrichment →
8
+ # verdict). That matters because the Swift interpretation layer is a SEPARATE implementation
9
+ # from the CLI/MCP marker parsing (see CLAUDE.md) — a recall number measured only via
10
+ # quick-capture logs says nothing about what the product actually reports.
11
+ #
12
+ # Scoring is on the verify-run findings JSON: `type` is FindingCategory.rawValue (so harness
13
+ # issue types are mapped, e.g. error_surface -> network_error_surface), `screen` names come from
14
+ # the run's screensVisited, and each catching finding records its flow so advisory
15
+ # ("Vision review") catches are reported separately from deterministic ones.
16
+ #
17
+ # K repeats (RUNS, default 2): exploration is nondeterministic, so the baseline is the UNION of
18
+ # K clean runs (kills false-differentials — a flaky low-severity finding that happens to appear
19
+ # only in a mutant run would otherwise read as a catch) and a mutant scores reached/caught if
20
+ # ANY of its K runs did. Runs keep their artifacts (--keep-artifacts) so any surprising row can
21
+ # be verified against the actual screenshots afterwards — never trust the marker stream alone.
22
+ #
23
+ # Prereqs: booted simulator; Release AutoTap.app at build/Build/Products/Release/AutoTap.app.
24
+ # The curated mutant set below was compile-smoked; a build failure mid-run is recorded, not fatal.
25
+ # Ends by rebuilding + reinstalling CLEAN DemoApp so the simulator isn't left with a mutant.
26
+ set -uo pipefail
27
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
28
+ APP_BIN="$ROOT/build/Build/Products/Release/AutoTap.app"
29
+ ACTIONS="${ACTIONS:-45}"
30
+ RUNS="${RUNS:-2}"
31
+ RUN_TIMEOUT="${RUN_TIMEOUT:-1080}" # seconds per verify-run (build + explore + AI passes)
32
+
33
+ [[ -d "$APP_BIN" ]] || { echo "❌ build AutoTap first ($APP_BIN missing)"; exit 2; }
34
+ UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys,json;d=json.load(sys.stdin);print(next((x["udid"] for v in d["devices"].values() for x in v if x.get("state")=="Booted"),""))')"
35
+ [[ -z "$UDID" ]] && { echo "❌ boot a simulator first"; exit 2; }
36
+
37
+ WORK="${WORK_DIR:-$(mktemp -d /tmp/mutation-recall-desktop.XXXXXX)}"
38
+ echo "▶ work dir: $WORK (runs per variant: $RUNS, actions: $ACTIONS)"
39
+
40
+ # Curated mutants: file<TAB>op<TAB>screen<TAB>expected_harness_type_or_-<TAB>in_taxonomy
41
+ # (screen = the harness-visible nav title of the mutated site, from mutation_operators.py list)
42
+ MUTANTS="$WORK/mutants.tsv"
43
+ cat > "$MUTANTS" <<'EOF'
44
+ DemoApp/Sources/CounterView.swift dead_button Counter unresponsive_element 1
45
+ DemoApp/Sources/DashboardHomeView.swift infinite_spinner Dashboard app_hang 1
46
+ DemoApp/Sources/DashboardHomeView.swift crash_on_appear Dashboard crash 1
47
+ DemoApp/Sources/SettingsView.swift inject_error Settings error_surface 1
48
+ DemoApp/Sources/TodoView.swift off_by_one Todo List - 0
49
+ DemoApp/Sources/CounterView.swift mislabel_button Counter - 0
50
+ EOF
51
+
52
+ # One verify-run of the CURRENT DemoApp source tree; waits for the output JSON.
53
+ run_verify() { # $1 = out json path
54
+ local out="$1"
55
+ rm -f "$out"
56
+ pkill -f -- "--verify-run" 2>/dev/null && sleep 3
57
+ open -n "$APP_BIN" --args --verify-run "$ROOT/DemoApp" com.autotap.demoapp \
58
+ --scheme DemoApp --project "$ROOT/DemoApp/DemoApp.xcodeproj" \
59
+ --actions "$ACTIONS" --keep-artifacts --out "$out"
60
+ local waited=0
61
+ while [[ ! -s "$out" && $waited -lt $RUN_TIMEOUT ]]; do sleep 15; waited=$((waited+15)); done
62
+ if [[ ! -s "$out" ]]; then
63
+ echo '{"error":"timeout"}' > "$out"
64
+ pkill -f -- "--verify-run" 2>/dev/null
65
+ fi
66
+ sleep 5 # let the instance fully exit before the next launch
67
+ }
68
+
69
+ run_k() { # $1 = tag; produces $WORK/<tag>_r{1..RUNS}.json
70
+ for r in $(seq 1 "$RUNS"); do
71
+ SECONDS=0
72
+ run_verify "$WORK/${1}_r${r}.json"
73
+ echo " ${1} run $r/$RUNS done in ${SECONDS}s"
74
+ done
75
+ }
76
+
77
+ echo "▶ baseline: $RUNS verify-run(s) of clean DemoApp…"
78
+ run_k baseline
79
+
80
+ i=0
81
+ while IFS=$'\t' read -r FILE OP SCREEN EXPECTED INTAX; do
82
+ i=$((i+1))
83
+ echo "▶ mutant $i: $OP @ $(basename "$FILE") [$SCREEN]"
84
+ cp "$ROOT/$FILE" "$ROOT/$FILE.bak"
85
+ if python3 "$ROOT/scripts/mutation_operators.py" apply "$ROOT/$FILE" "$OP"; then
86
+ run_k "mut_${i}_${OP}"
87
+ else
88
+ echo " (operator no-op — skipped)"
89
+ for r in $(seq 1 "$RUNS"); do echo '{"error":"noop"}' > "$WORK/mut_${i}_${OP}_r${r}.json"; done
90
+ fi
91
+ mv "$ROOT/$FILE.bak" "$ROOT/$FILE" # ALWAYS restore
92
+ done < "$MUTANTS"
93
+
94
+ # Leave the simulator holding a CLEAN build, not the last mutant.
95
+ echo "▶ restoring clean DemoApp on the simulator…"
96
+ xcodebuild -project "$ROOT/DemoApp/DemoApp.xcodeproj" -scheme DemoApp -configuration Debug \
97
+ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$WORK/clean-build" build >/dev/null 2>&1 \
98
+ && xcrun simctl install "$UDID" \
99
+ "$(find "$WORK/clean-build/Build/Products/Debug-iphonesimulator" -maxdepth 1 -name '*.app' | head -1)" \
100
+ && echo " clean DemoApp reinstalled" || echo " ⚠️ clean reinstall failed — run generate/build manually"
101
+
102
+ echo
103
+ python3 - "$WORK" "$MUTANTS" "$RUNS" <<'PY'
104
+ import sys, json, os, glob
105
+
106
+ work, mutants_path, K = sys.argv[1], sys.argv[2], int(sys.argv[3])
107
+ # Harness issue type -> FindingCategory.rawValue (ExplorationService's OCQA_ISSUE switch).
108
+ CAT = {"unresponsive_element": "unresponsive_element", "app_hang": "app_hang",
109
+ "error_surface": "network_error_surface", "blank_screen": "blank_screen", "crash": "crash"}
110
+
111
+ def load(p):
112
+ d = json.load(open(p))
113
+ finds = [(f["type"], f.get("screen") or "", f.get("flow") or "", f["title"], f["severity"])
114
+ for f in d.get("findings", [])]
115
+ return d, set(d.get("screens", [])), finds
116
+
117
+ # Baseline = union across K clean runs.
118
+ base_keys, base_screens, base_ok = set(), set(), 0
119
+ for r in range(1, K + 1):
120
+ d, screens, finds = load(os.path.join(work, f"baseline_r{r}.json"))
121
+ if "error" in d:
122
+ print(f"⚠️ baseline run {r} failed: {d['error']}")
123
+ continue
124
+ base_ok += 1
125
+ base_screens |= screens
126
+ base_keys |= {(t, s) for t, s, *_ in finds}
127
+ if base_ok == 0:
128
+ print("❌ no successful baseline run — cannot score"); sys.exit(1)
129
+
130
+ rows = []
131
+ for i, line in enumerate(open(mutants_path).read().splitlines(), 1):
132
+ file, op, screen, expected, intax = line.split("\t")
133
+ per_run, reached_any, det_any, vis_any, catchers_all, dirs = [], False, False, False, [], []
134
+ verdicts = []
135
+ for r in range(1, K + 1):
136
+ p = os.path.join(work, f"mut_{i}_{op}_r{r}.json")
137
+ d, screens, finds = load(p)
138
+ if "error" in d:
139
+ per_run.append({"run": r, "error": d["error"]}); continue
140
+ reached = screen in screens
141
+ if expected != "-":
142
+ want = CAT[expected]
143
+ if want == "crash": # crash screen attribution is fuzzy — category match anywhere
144
+ catchers = [f for f in finds if f[0] == "crash" and ("crash", f[1]) not in base_keys]
145
+ else:
146
+ catchers = [f for f in finds if f[0] == want and f[1] == screen
147
+ and (f[0], f[1]) not in base_keys]
148
+ else: # out-of-taxonomy: ANY new finding at the injected screen
149
+ catchers = [f for f in finds if f[1] == screen and (f[0], f[1]) not in base_keys]
150
+ det = [c for c in catchers if c[2] != "Vision review"]
151
+ vis = [c for c in catchers if c[2] == "Vision review"]
152
+ reached_any |= reached; det_any |= bool(det); vis_any |= bool(vis)
153
+ catchers_all += [c for c in catchers if c not in catchers_all]
154
+ verdicts.append(f"{d.get('verdict')}({d.get('confidence')})")
155
+ if d.get("supportDir"): dirs.append(d["supportDir"])
156
+ per_run.append({"run": r, "reached": reached, "det": bool(det), "vis": bool(vis)})
157
+ rows.append({"op": op, "screen": screen, "in_tax": intax == "1",
158
+ "reached": reached_any, "caught_det": det_any, "caught_vis": vis_any,
159
+ "verdicts": verdicts, "per_run": per_run, "artifact_dirs": dirs,
160
+ "catchers": [{"type": c[0], "screen": c[1], "flow": c[2], "title": c[3][:80],
161
+ "severity": c[4]} for c in catchers_all]})
162
+
163
+ print("=" * 76)
164
+ print(f"MUTATION-RECALL — DESKTOP APP (--verify-run), K={K}, union baseline ({base_ok} ok)")
165
+ print("=" * 76)
166
+ print(f"baseline screens: {sorted(base_screens)}")
167
+ print(f"baseline finding keys: {sorted(base_keys)}")
168
+ for r in rows:
169
+ tag = "IN " if r["in_tax"] else "OUT"
170
+ catch = ("DET" if r["caught_det"] else ("VIS" if r["caught_vis"] else
171
+ ("miss(detect)" if r["reached"] else "miss(coverage)")))
172
+ runs_s = " ".join(("E" if "error" in p else f"{'R' if p['reached'] else '-'}{'D' if p['det'] else ''}{'V' if p['vis'] else ''}") for p in r["per_run"])
173
+ print(f" [{tag}] {r['op']:18s} {r['screen']:12s} catch={catch:14s} runs[{runs_s}] verdicts={','.join(r['verdicts'])}")
174
+ for c in r["catchers"]:
175
+ print(f" └─ {c['severity']}/{c['type']} [{c['flow']}] {c['title']}")
176
+
177
+ def rate(xs): return f"{sum(xs)}/{len(xs)}" if xs else "n/a"
178
+ ok = rows
179
+ it = [r for r in ok if r["in_tax"]]; ot = [r for r in ok if not r["in_tax"]]
180
+ print(f"\nIN-TAXONOMY deterministic recall: {rate([r['caught_det'] for r in it])} "
181
+ f"reach: {rate([r['reached'] for r in it])}")
182
+ print(f"OUT-OF-TAXONOMY deterministic recall: {rate([r['caught_det'] for r in ot])} "
183
+ f"(+vision-advisory: {rate([r['caught_vis'] for r in ot])})")
184
+ json.dump(rows, open(os.path.join(work, "records.json"), "w"), indent=2)
185
+ print(f"\nrecords: {work}/records.json (artifact dirs preserved per run for visual verification)")
186
+ PY
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ # Mutation-recall benchmark — the HONEST recall scoreboard (docs/COMPETITIVE-MAP.md §0).
3
+ #
4
+ # Unlike validation-matrix.sh (which only tests bug classes we built detectors for, so it scores
5
+ # near-100% and flatters us), this INJECTS seeded faults — including out-of-taxonomy ones we have
6
+ # no detector for — and measures what fraction the harness actually catches, decomposed into
7
+ # coverage (did we reach the screen?) vs detection (did we flag it once reached?).
8
+ #
9
+ # Loop per mutant: back up source -> apply ONE mutation -> regenerate xcodeproj -> build ->
10
+ # install -> run `explore` K times -> parse markers -> restore source.
11
+ # Then mutation_lib.py rolls the records into the recall table.
12
+ #
13
+ # Cost note: one xcodebuild per mutant (~30-90s). Start SMALL: --app DemoApp --per-op 1 --runs 2.
14
+ # Mutants are independent -> the full version fans out across simulators; this sketch is 1 sim.
15
+ #
16
+ # SKETCH / WIP: the source backup/restore + per-app regenerate seams are marked TODO where a
17
+ # corpus app needs its own generator. Prereqs mirror validation-matrix.sh (booted sim + built
18
+ # harness). Exits 0 always (it's a measurement, not a gate) unless setup is missing.
19
+ set -uo pipefail
20
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
21
+
22
+ APP="DemoApp"; RUNS=2; PER_OP=1; ACTIONS=70; OPS_FILTER=""
23
+ while [[ $# -gt 0 ]]; do case "$1" in
24
+ --app) APP="$2"; shift 2;;
25
+ --runs) RUNS="$2"; shift 2;; # K repeats per mutant (non-determinism)
26
+ --per-op) PER_OP="$2"; shift 2;; # sites per operator (sketch uses first match => 1)
27
+ --actions) ACTIONS="$2"; shift 2;;
28
+ --ops) OPS_FILTER="$2"; shift 2;; # comma list, e.g. dead_button,off_by_one
29
+ *) echo "unknown arg $1"; exit 2;;
30
+ esac; done
31
+
32
+ SRC_DIR="$ROOT/$APP/Sources"
33
+ BUNDLE="com.autotap.$(echo "$APP" | tr '[:upper:]' '[:lower:]')"
34
+ [[ -d "$SRC_DIR" ]] || { echo "❌ no $SRC_DIR"; exit 2; }
35
+
36
+ UDID="$(xcrun simctl list devices booted -j 2>/dev/null | python3 -c 'import sys,json;d=json.load(sys.stdin);print(next((x["udid"] for v in d["devices"].values() for x in v if x.get("state")=="Booted"),""))')"
37
+ [[ -z "$UDID" ]] && { echo "❌ boot a simulator first"; exit 2; }
38
+
39
+ WORK="$(mktemp -d)"; RECORDS="$WORK/records.json"; echo "[]" > "$RECORDS"
40
+ trap 'rm -rf "$WORK"' EXIT
41
+
42
+ # --- regenerate + build + install the CURRENT source of $APP, return 0 on success ------------
43
+ build_install() {
44
+ case "$APP" in
45
+ DemoApp) ( cd "$ROOT" && ruby generate-demoapp-xcodeproj.rb ) >/dev/null 2>&1 ;;
46
+ *) ( cd "$ROOT" && ruby generate-demo-named.rb "$APP" ) >/dev/null 2>&1 ;; # LoginDemo/WizardDemo
47
+ # TODO: ShopDemo/RestaurantDemo have bespoke generators — add cases as needed.
48
+ esac
49
+ local proj="$ROOT/$APP/$APP.xcodeproj"
50
+ xcodebuild -project "$proj" -scheme "$APP" -configuration Debug \
51
+ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$WORK/build" build >/dev/null 2>&1 || return 1
52
+ local appdir; appdir="$(find "$WORK/build/Build/Products/Debug-iphonesimulator" -maxdepth 1 -name '*.app' | head -1)"
53
+ [[ -n "$appdir" ]] && xcrun simctl install "$UDID" "$appdir" >/dev/null 2>&1
54
+ }
55
+
56
+ # --- run explore K times against the installed build, echo the K log paths -------------------
57
+ explore_k() {
58
+ local tag="$1" logs=()
59
+ for i in $(seq 1 "$RUNS"); do
60
+ local log="$WORK/${tag}_run$i.log"
61
+ "$ROOT/scripts/quick-capture.sh" explore "$BUNDLE" --actions "$ACTIONS" >/dev/null 2>&1
62
+ cp "$(ls -td "$ROOT"/captures/*/harness-output.txt 2>/dev/null | head -1)" "$log" 2>/dev/null || echo "" > "$log"
63
+ logs+=("$log")
64
+ done
65
+ echo "${logs[@]}"
66
+ }
67
+
68
+ # 1) BASELINE — pristine build, K runs, union of findings = "pre-existing" (never counts as caught)
69
+ echo "▶ baseline: build + $RUNS explore run(s) of clean $APP…"
70
+ build_install || { echo "❌ baseline build failed"; exit 2; }
71
+ BASE_LOGS=($(explore_k baseline))
72
+ BASE_JSON="$WORK/baseline.json"
73
+ python3 "$ROOT/scripts/mutation_lib.py" parse "${BASE_LOGS[0]}" > "$BASE_JSON" # sketch: 1st run; full: union K
74
+
75
+ # 2) enumerate applicable mutation sites
76
+ SITES="$WORK/sites.json"
77
+ python3 "$ROOT/scripts/mutation_operators.py" list "$SRC_DIR" > "$SITES"
78
+ COUNT="$(python3 -c "import json;print(len(json.load(open('$SITES'))))")"
79
+ echo "▶ $COUNT mutation site(s) in $APP"
80
+
81
+ # 3) per-mutant loop
82
+ python3 - "$SITES" "$OPS_FILTER" <<'PY' | while IFS=$'\t' read -r FILE OP SCREEN; do
83
+ import sys, json
84
+ sites = json.load(open(sys.argv[1]))
85
+ flt = set(filter(None, sys.argv[2].split(",")))
86
+ for s in sites:
87
+ if flt and s["op"] not in flt: continue
88
+ print(f"{s['file']}\t{s['op']}\t{s['screen']}")
89
+ PY
90
+ echo " ↳ mutate $OP @ $(basename "$FILE") [$SCREEN]"
91
+ cp "$FILE" "$FILE.bak" # back up
92
+ if python3 "$ROOT/scripts/mutation_operators.py" apply "$FILE" "$OP"; then
93
+ if build_install; then
94
+ MUT_LOGS=($(explore_k "mut_${OP}"))
95
+ # append a record: baseline + this mutant's K logs -> mutation_lib.score_mutant
96
+ python3 - "$ROOT/scripts" "$RECORDS" "$SITES" "$OP" "$FILE" "$BASE_JSON" "${MUT_LOGS[@]}" <<'PY'
97
+ import sys, json
98
+ sys.path.insert(0, sys.argv[1]) # scripts/ dir, so `import mutation_lib` resolves
99
+ from mutation_lib import parse_log, score_mutant
100
+ records_path, sites_path, op, file, base_path = sys.argv[2:7]
101
+ mut_logs = sys.argv[7:]
102
+ sites = json.load(open(sites_path))
103
+ inj = next(s for s in sites if s["op"] == op and s["file"] == file)
104
+ base = json.load(open(base_path))
105
+ base_findings = set(tuple(x) for x in base["findings"])
106
+ runs = [parse_log(p) for p in mut_logs]
107
+ rec = score_mutant(inj, base_findings, runs)
108
+ recs = json.load(open(records_path)); recs.append(rec); json.dump(recs, open(records_path, "w"))
109
+ PY
110
+ else
111
+ echo " (build failed — mutant likely uncompilable; recorded as skipped)"
112
+ fi
113
+ else
114
+ echo " (operator no-op here — skipped)"
115
+ fi
116
+ mv "$FILE.bak" "$FILE" # ALWAYS restore
117
+ done
118
+
119
+ # 4) restore pristine build + report
120
+ build_install >/dev/null 2>&1
121
+ echo; python3 "$ROOT/scripts/mutation_lib.py" report "$RECORDS"
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Parse harness logs and score mutation-recall (companion to mutation_operators.py).
3
+
4
+ SKETCH / WIP. Reuses the exact marker-parse shape as validation-matrix.sh / coverage_eval_parse.py
5
+ so "caught" means the same thing the product's regression gate means: a finding matched by
6
+ `type|screen` (see mcp-server/src/report.js computeRegression, matched by type|screen).
7
+
8
+ Two subcommands:
9
+ parse <log> -> {"screens":[...], "findings":[["type","screen"],...]}
10
+ report <records.json> -> human table + machine JSON of the recall decomposition
11
+ """
12
+ import sys, re, json
13
+
14
+
15
+ def parse_log(path):
16
+ """-> (set of reached screen names, set of (type, screen) finding keys)."""
17
+ screens, findings = set(), set()
18
+ for line in open(path, encoding="utf-8", errors="replace").read().splitlines():
19
+ m = re.search(r"OCQA_STATE:(\{.*\})", line)
20
+ if m:
21
+ try:
22
+ s = (json.loads(m.group(1)).get("screen") or "").strip()
23
+ if s and s not in ("Unknown", "pending"):
24
+ screens.add(s)
25
+ except Exception:
26
+ pass
27
+ continue
28
+ m = re.search(r"OCQA_ISSUE:(\{.*\})", line)
29
+ if m:
30
+ try:
31
+ d = json.loads(m.group(1))
32
+ findings.add((d.get("type", "unknown"), (d.get("screen") or "").strip()))
33
+ except Exception:
34
+ pass
35
+ return screens, findings
36
+
37
+
38
+ def score_mutant(injection, baseline_findings, runs):
39
+ """injection: {expected_type, screen, in_taxonomy, ...}
40
+ baseline_findings: set of (type,screen) seen on the CLEAN build (union across baseline runs)
41
+ runs: list of (screens_set, findings_set) — one per repeated run of THIS mutant
42
+
43
+ Returns a record with the coverage-vs-detection decomposition, per run and rolled up."""
44
+ scr, typ = injection["screen"], injection["expected_type"]
45
+ key = (typ, scr)
46
+
47
+ reached_any = any(scr in s for s, _ in runs)
48
+ # absolute: expected finding present on the mutant (in-taxonomy only — out-of-taxonomy has no
49
+ # expected type, so its only hope is a *differential* finding of any type at the screen).
50
+ def caught_abs(findings):
51
+ if typ is not None:
52
+ return key in findings
53
+ return any(s == scr for _, s in findings) # any finding at the injected screen
54
+ # differential: caught AND not already in the clean baseline (the real gate signal)
55
+ def caught_diff(findings):
56
+ if typ is not None:
57
+ return key in findings and key not in baseline_findings
58
+ return any(s == scr and (t, s) not in baseline_findings for t, s in findings)
59
+
60
+ abs_hits = [caught_abs(f) for _, f in runs]
61
+ diff_hits = [caught_diff(f) for _, f in runs]
62
+ return {
63
+ **{k: injection[k] for k in ("file", "op", "bug_class", "expected_type", "in_taxonomy", "screen")},
64
+ "reached": reached_any,
65
+ "caught_abs_any": any(abs_hits), "caught_abs_all": all(abs_hits),
66
+ "caught_diff_any": any(diff_hits), "caught_diff_all": all(diff_hits),
67
+ "runs": len(runs),
68
+ }
69
+
70
+
71
+ def _rate(num, den):
72
+ return f"{(100*num/den):5.1f}% ({num}/{den})" if den else " n/a"
73
+
74
+
75
+ def report(records):
76
+ def bucket(recs, label):
77
+ n = len(recs)
78
+ reached = sum(r["reached"] for r in recs)
79
+ caught = sum(r["caught_diff_any"] for r in recs) # headline = differential (gate signal)
80
+ detect = sum(r["caught_diff_any"] for r in recs if r["reached"])
81
+ print(f"\n{label} (n={n})")
82
+ print(f" recall (caught / all) {_rate(caught, n)}")
83
+ print(f" reach_rate (coverage) {_rate(reached, n)}")
84
+ print(f" detect|reached (detection) {_rate(detect, reached)}")
85
+
86
+ in_tax = [r for r in records if r["in_taxonomy"]]
87
+ out_tax = [r for r in records if not r["in_taxonomy"]]
88
+
89
+ print("=" * 64)
90
+ print("MUTATION-RECALL BENCHMARK — differential (gate-equivalent) catch")
91
+ print("=" * 64)
92
+ bucket(records, "OVERALL")
93
+ bucket(in_tax, "IN-TAXONOMY (detector exists — should be HIGH)")
94
+ bucket(out_tax, "OUT-OF-TAXONOMY (no detector — the honest CEILING)")
95
+
96
+ print("\nPer class (in-taxonomy):")
97
+ classes = {}
98
+ for r in in_tax:
99
+ classes.setdefault(r["op"], []).append(r)
100
+ for op, recs in sorted(classes.items()):
101
+ c = sum(x["caught_diff_any"] for x in recs)
102
+ print(f" {op:18s} {_rate(c, len(recs))} expects OCQA_ISSUE.type={recs[0]['expected_type']}")
103
+
104
+ print("\nMisses that REACHED the screen but weren't detected (need a detector/oracle):")
105
+ for r in records:
106
+ if r["reached"] and not r["caught_diff_any"]:
107
+ tag = "in-tax" if r["in_taxonomy"] else "OUT"
108
+ print(f" [{tag}] {r['op']:16s} {r['screen']:16s} {r['bug_class']}")
109
+
110
+ summary = {
111
+ "overall_recall": sum(r["caught_diff_any"] for r in records) / max(len(records), 1),
112
+ "in_taxonomy_recall": sum(r["caught_diff_any"] for r in in_tax) / max(len(in_tax), 1),
113
+ "out_taxonomy_recall": sum(r["caught_diff_any"] for r in out_tax) / max(len(out_tax), 1),
114
+ "n": len(records),
115
+ }
116
+ print("\nJSON:", json.dumps(summary))
117
+ return summary
118
+
119
+
120
+ if __name__ == "__main__":
121
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ""
122
+ if cmd == "parse":
123
+ scr, fnd = parse_log(sys.argv[2])
124
+ print(json.dumps({"screens": sorted(scr), "findings": sorted(map(list, fnd))}))
125
+ elif cmd == "report":
126
+ report(json.load(open(sys.argv[2])))
127
+ else:
128
+ print(__doc__); sys.exit(2)