@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.
- package/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- 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)
|