@aarwitz/tapp 0.15.0 → 0.16.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 (48) hide show
  1. package/AGENTS.md +4 -4
  2. package/Harness/OCQAHarness/AppDelegate.swift +1 -1
  3. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +2 -2
  4. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +2 -2
  5. package/Harness/generate-harness-xcodeproj.rb +3 -3
  6. package/README.md +17 -15
  7. package/bin/tapp.js +28 -28
  8. package/browser/app.js +2 -2
  9. package/browser/index.html +1 -1
  10. package/docs/BROWSER-PRODUCT.md +2 -2
  11. package/docs/PRODUCT-ENGINE.md +7 -6
  12. package/docs/application-model.md +20 -25
  13. package/docs/scenarios.md +7 -7
  14. package/mcp-server/src/application-model.js +32 -26
  15. package/mcp-server/src/browser-product.js +1 -1
  16. package/mcp-server/src/ci-report.js +5 -4
  17. package/mcp-server/src/ci-setup.js +16 -7
  18. package/mcp-server/src/enrich.js +1 -1
  19. package/mcp-server/src/index.js +63 -62
  20. package/mcp-server/src/maintenance-proposal.js +4 -4
  21. package/mcp-server/src/pr-selection.js +9 -8
  22. package/mcp-server/src/product-execution.js +1 -1
  23. package/mcp-server/src/product-operations.js +20 -18
  24. package/mcp-server/src/project-config.js +8 -5
  25. package/mcp-server/src/project-paths.js +32 -0
  26. package/mcp-server/src/report.js +3 -3
  27. package/mcp-server/src/task-runtime.js +14 -10
  28. package/package.json +15 -3
  29. package/scripts/ci-gate.sh +15 -10
  30. package/scripts/flow_ai_judge.py +1 -1
  31. package/scripts/flow_lib.py +1 -1
  32. package/scripts/quick-capture.sh +15 -14
  33. package/scripts/run-flow.sh +6 -6
  34. package/scripts/android-corpus-e2e.sh +0 -30
  35. package/scripts/cleanup-xcode.sh +0 -157
  36. package/scripts/corpus-apps.txt +0 -9
  37. package/scripts/corpus-sweep.sh +0 -121
  38. package/scripts/coverage-eval.sh +0 -92
  39. package/scripts/coverage_eval_parse.py +0 -95
  40. package/scripts/deploy-and-build.sh +0 -99
  41. package/scripts/mutation-recall-desktop.sh +0 -186
  42. package/scripts/mutation-recall.sh +0 -121
  43. package/scripts/mutation_lib.py +0 -128
  44. package/scripts/mutation_operators.py +0 -144
  45. package/scripts/validation-matrix.sh +0 -146
  46. package/scripts/vision-fp-eval.sh +0 -206
  47. package/scripts/vision_escalation_responder.py +0 -147
  48. package/scripts/vision_fp_probe.py +0 -221
@@ -1,186 +0,0 @@
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
@@ -1,121 +0,0 @@
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"
@@ -1,128 +0,0 @@
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)
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Mutation operators for the mutation-recall benchmark (scripts/mutation-recall.sh).
3
-
4
- SKETCH / WIP — grounded in the real corpus (SwiftUI source in <App>/Sources/*.swift) and the
5
- real detector taxonomy (the OCQA_ISSUE `type` values the harness emits). Each operator injects
6
- ONE seeded fault and declares the ground truth: which detector *class* it should trip (or None
7
- if no detector exists for it). That in/out-of-taxonomy split is the whole point — see
8
- docs/COMPETITIVE-MAP.md §0.
9
-
10
- LIMITATIONS (honest):
11
- - Regex/line transforms, not a real Swift AST. Robust version: swiftsyntax, one mutant per
12
- (operator, matched node). Here we take the FIRST matching site per (operator, file).
13
- - Screen attribution is heuristic (nearest .navigationTitle above the site). The fiddliest
14
- part of honest matching; a wrong screen name shows up as a coverage miss, not a detect miss.
15
-
16
- Usage:
17
- mutation_operators.py list <App/Sources> -> JSON array of applicable sites
18
- mutation_operators.py apply <file> <op_id> (in place; caller backs up + restores)
19
- """
20
- import sys, re, json, glob, os
21
- from dataclasses import dataclass
22
- from typing import Callable, Optional
23
-
24
-
25
- @dataclass
26
- class Op:
27
- id: str
28
- bug_class: str # human label for the fault
29
- expected_type: Optional[str] # OCQA_ISSUE `type` a detector should emit; None => out-of-taxonomy
30
- in_taxonomy: bool
31
- find: str # regex that identifies an applicable site
32
- apply: Callable[[str], str] # src -> mutated src (mutates the FIRST match only)
33
-
34
-
35
- def _sub_first(pattern, repl, src, flags=re.S):
36
- return re.sub(pattern, repl, src, count=1, flags=flags)
37
-
38
-
39
- OPS = [
40
- # ---------- IN-TAXONOMY: a detector class exists; measures "do we catch what we claim?" ----------
41
- # Skips labels the harness's isSelectionOrValueControl deliberately EXCLUDES from dead-control
42
- # detection (increment/decrement — steppers legitimately no-op at boundaries). Measured: a
43
- # gutted 'Decrement' is invisible 4/4 runs BY DESIGN (the FP guard), so mutating it measures
44
- # the documented precision/recall trade, not the detector. This operator measures what the
45
- # detector claims to catch; a future dead_stepper op can price the known gap explicitly.
46
- Op("dead_button", "button action gutted (dead control)", "unresponsive_element", True,
47
- r'Button\("(?![^"]*(?:ncrement|ecrement))([^"]+)"\)\s*\{.*?\}',
48
- lambda s: _sub_first(r'(Button\("(?![^"]*(?:ncrement|ecrement))[^"]+"\)\s*\{).*?(\})',
49
- r'\1 /* MUT: no-op */ \2', s)),
50
-
51
- Op("infinite_spinner", "loading flag never cleared (perpetual spinner)", "app_hang", True,
52
- r'(isLoading|loaded|isReady)\s*=\s*false', # the line that would END loading
53
- lambda s: _sub_first(r'((?:isLoading|loaded|isReady)\s*=\s*false)', r'/* MUT */ // \1', s)),
54
-
55
- # NOTE: must attach to a view that actually RENDERS. The first sketch inserted
56
- # `let _mut = Text(...)` after `var body {` — a local never added to the view tree, so the
57
- # "fault" was invisible to the a11y tree and the benchmark misread it as a detector miss.
58
- # Overlaying the .navigationTitle-bearing view guarantees the error text is really on screen.
59
- Op("inject_error", "always-visible error surface", "error_surface", True,
60
- r'\.navigationTitle\("[^"]+"\)',
61
- lambda s: _sub_first(r'(\.navigationTitle\("[^"]+"\))',
62
- r'\1.overlay(Text("Something went wrong. Error 500.")) /* MUT */', s)),
63
-
64
- Op("broken_nav", "NavigationLink destination blanked", "blank_screen", True,
65
- r'NavigationLink\s*\{[^}]*\}',
66
- lambda s: _sub_first(r'(NavigationLink\s*\{)[^}]*(\})', r'\1 EmptyView() /* MUT */ \2', s)),
67
-
68
- # Same inert-let lesson as inject_error: fatalError must live inside a closure that FIRES.
69
- # Injecting into an existing .task/.onAppear guarantees it runs when the screen appears.
70
- Op("crash_on_appear", "fatalError on screen appear", "crash", True,
71
- r'\.(?:task|onAppear)\s*\{',
72
- lambda s: _sub_first(r'(\.(?:task|onAppear)\s*\{)',
73
- r'\1 fatalError("MUT crash"); ', s)),
74
-
75
- # ---------- OUT-OF-TAXONOMY: no detector; measures the CEILING of deterministic detection ----------
76
- # These should score ~0. That number is the argument for regression-diff + oracle/Flows.
77
- Op("mislabel_button", "button label typo (still works, wrong text)", None, False,
78
- r'Button\("[A-Za-z]{4,}"\)',
79
- lambda s: _sub_first(r'Button\("([A-Za-z])([A-Za-z])([A-Za-z]+)"\)',
80
- r'Button("\2\1\3")', s)), # transpose first two letters
81
-
82
- Op("wrong_arithmetic", "arithmetic operator flipped (wrong number, UI fine)", None, False,
83
- r'\w+\s*\+\s*\w+',
84
- lambda s: _sub_first(r'(\w+)\s*\+\s*(\w+)', r'\1 - \2', s)),
85
-
86
- # Skip `Enum.allCases` sites (static pickers/segments) so the mutation lands on real DATA
87
- # (e.g. ForEach(filteredItems)) — dropping the last row of a data list is the silent-loss
88
- # bug this operator exists to model; dropping the last segment of a picker is much weaker.
89
- Op("off_by_one", "list drops its last row (silent data loss)", None, False,
90
- r'ForEach\((?![A-Za-z_][\w\.]*\.allCases\))([A-Za-z_][\w\.]*)\)',
91
- lambda s: _sub_first(r'ForEach\((?![A-Za-z_][\w\.]*\.allCases\))([A-Za-z_][\w\.]*)\)',
92
- r'ForEach(\1.dropLast())', s)),
93
-
94
- Op("swap_fields", "two field bindings swapped (wrong wiring)", None, False,
95
- r'TextField\(', # NOTE: real swap needs 2 sites; sketch marks it, apply is a TODO
96
- lambda s: s), # TODO: locate two adjacent TextField bindings and swap $values
97
- ]
98
-
99
- OPS_BY_ID = {o.id: o for o in OPS}
100
-
101
-
102
- def infer_screen(src: str, upto: int) -> str:
103
- """Nearest .navigationTitle("X") at or above char offset `upto` — the harness names screens
104
- by nav title, so this is our ground-truth screen for match. Fallback: last title in file."""
105
- titles = [(m.start(), m.group(1)) for m in re.finditer(r'\.navigationTitle\("([^"]+)"\)', src)]
106
- above = [t for pos, t in titles if pos <= upto] or [t for _, t in titles]
107
- return above[-1] if above else "?"
108
-
109
-
110
- def list_sites(sources_dir: str):
111
- sites = []
112
- for path in sorted(glob.glob(os.path.join(sources_dir, "*.swift"))):
113
- src = open(path, encoding="utf-8", errors="replace").read()
114
- for op in OPS:
115
- m = re.search(op.find, src, flags=re.S)
116
- if not m:
117
- continue
118
- sites.append({
119
- "file": path, "op": op.id, "bug_class": op.bug_class,
120
- "expected_type": op.expected_type, "in_taxonomy": op.in_taxonomy,
121
- "screen": infer_screen(src, m.start()),
122
- })
123
- return sites
124
-
125
-
126
- def apply(path: str, op_id: str) -> bool:
127
- op = OPS_BY_ID[op_id]
128
- src = open(path, encoding="utf-8", errors="replace").read()
129
- mutated = op.apply(src)
130
- if mutated == src:
131
- return False # no-op (e.g. swap_fields TODO) — caller skips
132
- open(path, "w", encoding="utf-8").write(mutated)
133
- return True
134
-
135
-
136
- if __name__ == "__main__":
137
- cmd = sys.argv[1] if len(sys.argv) > 1 else ""
138
- if cmd == "list":
139
- print(json.dumps(list_sites(sys.argv[2]), indent=2))
140
- elif cmd == "apply":
141
- ok = apply(sys.argv[2], sys.argv[3])
142
- sys.exit(0 if ok else 3)
143
- else:
144
- print(__doc__); sys.exit(2)