Skip to content

Commit 1c25150

Browse files
committed
Merge origin/main into stable-main-7.71.0
2 parents e7ebdc1 + d6fd3c4 commit 1c25150

File tree

1,533 files changed

+89028
-29754
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

1,533 files changed

+89028
-29754
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
name: ab-testing-implementation
3+
description: Implement and review MetaMask Mobile A/B tests using the canonical repository standard. Use for any task that adds or modifies A/B test flags, variant configs, useABTest usage, analytics payloads, or A/B-test-related tests and docs.
4+
---
5+
6+
# A/B Testing Implementation
7+
8+
`docs/ab-testing.md` is the single source of truth.
9+
10+
Follow `docs/ab-testing.md` section `Agent Execution Standard (SSOT)` for:
11+
12+
- workflow
13+
- analytics rules
14+
- risk-based testing policy
15+
- required response sections
16+
- compliance command
17+
18+
Run and report:
19+
20+
```bash
21+
bash .agents/skills/ab-testing-implementation/scripts/check-ab-testing-compliance.sh --staged
22+
```
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "A/B Test Implementation"
3+
short_description: "Implement and validate MetaMask A/B tests."
4+
default_prompt: "Use $ab-testing-implementation to implement or review an A/B test with our canonical useABTest and active_ab_tests standards."
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'USAGE'
6+
Usage:
7+
check-ab-testing-compliance.sh --staged
8+
check-ab-testing-compliance.sh --files <file1,file2,...> [--base <git-ref>]
9+
10+
Checks changed files for A/B testing implementation compliance.
11+
12+
Rules:
13+
- Fail: New ab_tests payload additions in checked code diffs
14+
- Fail: Malformed literal active_ab_tests objects missing key/value
15+
- Fail: Inline useABTest variants object missing control
16+
- Warn: Flag key naming mismatch for Abtest keys
17+
- Warn: Risky A/B integration changes without test-file updates
18+
USAGE
19+
}
20+
21+
MODE=""
22+
FILES_ARG=""
23+
BASE_REF=""
24+
FALLBACK_TO_WORKTREE=0
25+
FALLBACK_NOTE=""
26+
27+
set_mode() {
28+
local new_mode="$1"
29+
if [[ -n "$MODE" ]]; then
30+
echo "ERROR: Choose exactly one mode: --staged or --files."
31+
usage
32+
exit 2
33+
fi
34+
MODE="$new_mode"
35+
}
36+
37+
while [[ $# -gt 0 ]]; do
38+
case "$1" in
39+
--staged)
40+
set_mode "staged"
41+
shift
42+
;;
43+
--files)
44+
set_mode "files"
45+
FILES_ARG="${2:-}"
46+
if [[ -z "$FILES_ARG" ]]; then
47+
echo "ERROR: --files requires a comma-separated value."
48+
exit 2
49+
fi
50+
shift 2
51+
;;
52+
--base)
53+
BASE_REF="${2:-}"
54+
if [[ -z "$BASE_REF" ]]; then
55+
echo "ERROR: --base requires a git ref (for example origin/main)."
56+
exit 2
57+
fi
58+
shift 2
59+
;;
60+
-h|--help)
61+
usage
62+
exit 0
63+
;;
64+
*)
65+
echo "ERROR: Unknown argument: $1"
66+
usage
67+
exit 2
68+
;;
69+
esac
70+
done
71+
72+
if [[ -z "$MODE" ]]; then
73+
echo "ERROR: Choose exactly one mode: --staged or --files."
74+
usage
75+
exit 2
76+
fi
77+
78+
resolve_default_base_ref() {
79+
if [[ "$MODE" != "files" || -n "$BASE_REF" ]]; then
80+
return
81+
fi
82+
83+
local candidate
84+
for candidate in "origin/main" "main" "HEAD~1"; do
85+
if git rev-parse --verify "$candidate" >/dev/null 2>&1; then
86+
BASE_REF="$candidate"
87+
return
88+
fi
89+
done
90+
}
91+
92+
trim() {
93+
local value="$1"
94+
value="${value#"${value%%[![:space:]]*}"}"
95+
value="${value%"${value##*[![:space:]]}"}"
96+
printf '%s' "$value"
97+
}
98+
99+
is_code_file() {
100+
local file="$1"
101+
[[ "$file" =~ \.(ts|tsx|js|jsx)$ ]]
102+
}
103+
104+
is_test_file() {
105+
local file="$1"
106+
[[ "$file" =~ \.test\.(ts|tsx|js|jsx)$ ]] || [[ "$file" =~ /__tests__/ ]]
107+
}
108+
109+
is_valid_flag_key() {
110+
local key="$1"
111+
[[ "$key" =~ ^[a-z][A-Za-z0-9]*[A-Z]{2,}[0-9]+Abtest[A-Z][A-Za-z0-9]*$ ]]
112+
}
113+
114+
collect_staged_files() {
115+
git diff --cached --name-only --diff-filter=ACMR | awk 'NF && !seen[$0]++'
116+
}
117+
118+
collect_worktree_files() {
119+
{
120+
git diff --name-only --diff-filter=ACMR
121+
git ls-files --others --exclude-standard
122+
} | awk 'NF && !seen[$0]++'
123+
}
124+
125+
collect_explicit_files() {
126+
local raw
127+
local item
128+
129+
IFS=',' read -r -a raw <<< "$FILES_ARG"
130+
for item in "${raw[@]}"; do
131+
item="$(trim "$item")"
132+
[[ -n "$item" ]] && printf '%s\n' "$item"
133+
done | awk 'NF && !seen[$0]++'
134+
}
135+
136+
extract_added_lines_from_diff() {
137+
awk '
138+
/^\+\+\+ / { next }
139+
/^\+/ { sub(/^\+/, ""); print }
140+
' || true
141+
}
142+
143+
get_added_lines() {
144+
local file="$1"
145+
local base_ref="${2:-}"
146+
147+
if [[ "$MODE" == "staged" ]]; then
148+
if [[ "$FALLBACK_TO_WORKTREE" -eq 1 ]]; then
149+
if [[ -f "$file" ]] && ! git ls-files --error-unmatch "$file" >/dev/null 2>&1; then
150+
cat "$file"
151+
return
152+
fi
153+
154+
git diff --unified=0 -- "$file" | extract_added_lines_from_diff
155+
return
156+
fi
157+
158+
git diff --cached --unified=0 -- "$file" | extract_added_lines_from_diff
159+
return
160+
fi
161+
162+
if [[ -f "$file" ]] && ! git cat-file -e "HEAD:$file" >/dev/null 2>&1; then
163+
cat "$file"
164+
return
165+
fi
166+
167+
if [[ -n "$base_ref" ]] && git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
168+
git diff --unified=0 "$base_ref"...HEAD -- "$file" | extract_added_lines_from_diff
169+
return
170+
fi
171+
172+
if git ls-files --error-unmatch "$file" >/dev/null 2>&1; then
173+
git diff --unified=0 HEAD -- "$file" | extract_added_lines_from_diff
174+
fi
175+
}
176+
177+
FAILURES=()
178+
WARNINGS=()
179+
AB_RISKY_CHANGE_FILES=()
180+
TEST_CHANGED=0
181+
182+
CHANGED_FILES=()
183+
if [[ "$MODE" == "staged" ]]; then
184+
while IFS= read -r file; do
185+
[[ -n "$file" ]] && CHANGED_FILES+=("$file")
186+
done < <(collect_staged_files)
187+
188+
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
189+
FALLBACK_TO_WORKTREE=1
190+
FALLBACK_NOTE="Info: no staged files found; falling back to working-tree changed files."
191+
while IFS= read -r file; do
192+
[[ -n "$file" ]] && CHANGED_FILES+=("$file")
193+
done < <(collect_worktree_files)
194+
fi
195+
else
196+
while IFS= read -r file; do
197+
[[ -n "$file" ]] && CHANGED_FILES+=("$file")
198+
done < <(collect_explicit_files)
199+
fi
200+
201+
resolve_default_base_ref
202+
203+
if [[ ${#CHANGED_FILES[@]} -eq 0 || ( ${#CHANGED_FILES[@]} -eq 1 && -z "${CHANGED_FILES[0]}" ) ]]; then
204+
if [[ "$MODE" == "staged" ]]; then
205+
echo "A/B compliance check: no staged files and no working-tree changed files to inspect."
206+
else
207+
echo "A/B compliance check: no files to inspect from --files input."
208+
fi
209+
exit 0
210+
fi
211+
212+
for file in "${CHANGED_FILES[@]}"; do
213+
[[ -z "$file" ]] && continue
214+
215+
if is_test_file "$file"; then
216+
TEST_CHANGED=1
217+
fi
218+
219+
if ! is_code_file "$file"; then
220+
continue
221+
fi
222+
223+
added="$(get_added_lines "$file" "$BASE_REF")"
224+
[[ -z "$added" ]] && continue
225+
226+
if grep -Eq 'useABTest\(|active_ab_tests[[:space:]]*:|ab_tests[[:space:]]*:|trackEvent\(|createEventBuilder\(|MetaMetricsEvents\.|EXPERIMENT_VIEWED|Experiment Viewed' <<< "$added"; then
227+
AB_RISKY_CHANGE_FILES+=("$file")
228+
fi
229+
230+
# Rule: strict ban on new ab_tests payload additions.
231+
while IFS= read -r line; do
232+
if [[ "$line" =~ active_ab_tests[[:space:]]*: ]]; then
233+
continue
234+
fi
235+
if [[ "$line" =~ (^|[^A-Za-z0-9_])ab_tests[[:space:]]*: ]] && [[ ! "$line" =~ LEGACY_AB_TEST_ALLOWED ]]; then
236+
FAILURES+=("$file: added 'ab_tests' payload. New ab_tests payloads are forbidden.")
237+
fi
238+
done <<< "$added"
239+
240+
added_lines=()
241+
while IFS= read -r added_line; do
242+
added_lines+=("$added_line")
243+
done <<< "$added"
244+
line_count="${#added_lines[@]}"
245+
246+
for ((i=0; i<line_count; i++)); do
247+
line="${added_lines[$i]}"
248+
249+
# Rule: validate literal active_ab_tests payloads include both key and value.
250+
if [[ "$line" =~ active_ab_tests[[:space:]]*: ]]; then
251+
if [[ "$line" =~ active_ab_tests[[:space:]]*:[[:space:]]*(\[|\{) ]]; then
252+
window="$line"
253+
for ((j=i+1; j<line_count && j<=i+8; j++)); do
254+
window+=$'\n'"${added_lines[$j]}"
255+
done
256+
if ! grep -Eq 'key[[:space:]]*:' <<< "$window" || ! grep -Eq 'value[[:space:]]*:' <<< "$window"; then
257+
FAILURES+=("$file: malformed literal active_ab_tests object (expected key and value).")
258+
fi
259+
fi
260+
fi
261+
262+
# Rule: inline useABTest variants object must include control.
263+
if [[ "$line" =~ useABTest[[:space:]]*\( ]]; then
264+
call_window=""
265+
paren_depth=0
266+
for ((j=i; j<line_count; j++)); do
267+
segment="${added_lines[$j]}"
268+
if (( j == i )); then
269+
segment="useABTest${segment#*useABTest}"
270+
fi
271+
272+
call_window+="${call_window:+$'\n'}${segment}"
273+
274+
open_count="$(printf '%s' "$segment" | tr -cd '(' | wc -c | tr -d ' ')"
275+
close_count="$(printf '%s' "$segment" | tr -cd ')' | wc -c | tr -d ' ')"
276+
paren_depth=$((paren_depth + open_count - close_count))
277+
278+
if (( paren_depth <= 0 )); then
279+
break
280+
fi
281+
done
282+
283+
normalized_call="$(printf '%s' "$call_window" | tr '\n' ' ')"
284+
if grep -Eq 'useABTest[[:space:]]*\([^,]+,[[:space:]]*\{' <<< "$normalized_call"; then
285+
if ! grep -Eq 'control[[:space:]]*:' <<< "$call_window"; then
286+
FAILURES+=("$file: inline useABTest variants object is missing control.")
287+
fi
288+
fi
289+
fi
290+
291+
# Rule: warn on useABTest literal flag keys that do not follow naming convention.
292+
use_abtest_literal_key="$(sed -nE "s/.*useABTest[[:space:]]*\\([[:space:]]*['\"]([^'\"]+)['\"].*/\\1/p" <<< "$line")"
293+
if [[ -n "$use_abtest_literal_key" ]]; then
294+
if ! is_valid_flag_key "$use_abtest_literal_key"; then
295+
WARNINGS+=("$file: flag key '$use_abtest_literal_key' does not match {team}{TICKET}Abtest{Name}.")
296+
fi
297+
fi
298+
299+
# Rule: warn for explicit Abtest keys that do not match naming convention.
300+
while IFS= read -r quoted; do
301+
[[ -z "$quoted" ]] && continue
302+
key="${quoted:1:${#quoted}-2}"
303+
if [[ -n "$use_abtest_literal_key" && "$key" == "$use_abtest_literal_key" ]]; then
304+
continue
305+
fi
306+
if ! is_valid_flag_key "$key"; then
307+
WARNINGS+=("$file: Abtest key '$key' does not match {team}{TICKET}Abtest{Name}.")
308+
fi
309+
done < <(grep -oE "['\"][^'\"]*Abtest[^'\"]*['\"]" <<< "$line" || true)
310+
done
311+
done
312+
313+
if [[ ${#AB_RISKY_CHANGE_FILES[@]} -gt 0 && "$TEST_CHANGED" -eq 0 ]]; then
314+
WARNINGS+=("Risky A/B integration changes were detected without any test-file updates. For copy/config-only changes, document rationale in your response.")
315+
fi
316+
317+
echo "A/B compliance check summary"
318+
echo "Mode: $MODE"
319+
if [[ -n "$FALLBACK_NOTE" ]]; then
320+
echo "$FALLBACK_NOTE"
321+
fi
322+
if [[ "$MODE" == "files" && -n "$BASE_REF" ]]; then
323+
echo "Base ref: $BASE_REF"
324+
fi
325+
echo "Files inspected: ${#CHANGED_FILES[@]}"
326+
327+
if [[ ${#FAILURES[@]} -gt 0 ]]; then
328+
echo ""
329+
echo "Failures:"
330+
printf '%s\n' "${FAILURES[@]}" | awk '!seen[$0]++' | sed 's/^/- /'
331+
fi
332+
333+
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
334+
echo ""
335+
echo "Warnings:"
336+
printf '%s\n' "${WARNINGS[@]}" | awk '!seen[$0]++' | sed 's/^/- /'
337+
fi
338+
339+
if [[ ${#FAILURES[@]} -gt 0 ]]; then
340+
exit 1
341+
fi
342+
343+
exit 0

0 commit comments

Comments
 (0)