forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_analytics_reachability.py
More file actions
448 lines (396 loc) · 16.8 KB
/
Copy pathcheck_analytics_reachability.py
File metadata and controls
448 lines (396 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
#!/usr/bin/env python3
"""Static tripwire for analytics methods orphaned from production callers.
This would have caught the Device Connected regression in 02524b7c0b: its
qualified Flutter call sites fell from two to one. It also rejects the dead
macOS shape removed with this checker: a production-called empty
reportAllSettingsIfNeeded() plus its unreachable collectAllSettings() helper.
This is deliberately lexical, not behavioral coverage. It recognizes the
repository's three analytics access boundaries, ignores comments and strings,
and ratchets current call-site counts. It does not prove that a call executes.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
BASELINE = ROOT / ".github/scripts/analytics_reachability_baseline.json"
@dataclass(frozen=True)
class Token:
value: str
kind: str = "punct"
@dataclass(frozen=True)
class Method:
name: str
public: bool
body: tuple[Token, ...]
def lex(source: str) -> list[Token]:
"""Tokenize enough syntax to distinguish code from comments and strings."""
tokens: list[Token] = []
i = 0
while i < len(source):
char = source[i]
if char.isspace():
i += 1
continue
if source.startswith("//", i):
i = source.find("\n", i)
if i < 0:
break
continue
if source.startswith("/*", i):
depth = 1
i += 2
while i < len(source) and depth:
if source.startswith("/*", i):
depth += 1
i += 2
elif source.startswith("*/", i):
depth -= 1
i += 2
else:
i += 1
continue
if char in "'\"`":
quote = char
triple = source.startswith(char * 3, i)
delimiter = char * (3 if triple else 1)
i += len(delimiter)
value: list[str] = []
while i < len(source):
if source.startswith(delimiter, i):
i += len(delimiter)
break
if source[i] == "\\" and not triple and i + 1 < len(source):
value.append(source[i + 1])
i += 2
else:
value.append(source[i])
i += 1
tokens.append(Token("".join(value), "string"))
continue
if char.isalpha() or char in "_$":
end = i + 1
while end < len(source) and (source[end].isalnum() or source[end] in "_$"):
end += 1
tokens.append(Token(source[i:end], "ident"))
i = end
continue
if source.startswith("=>", i):
tokens.append(Token("=>"))
i += 2
continue
tokens.append(Token(char))
i += 1
return tokens
def matching(tokens: list[Token], start: int, opening: str, closing: str) -> int:
depth = 0
for index in range(start, len(tokens)):
if tokens[index].value == opening:
depth += 1
elif tokens[index].value == closing:
depth -= 1
if depth == 0:
return index
raise ValueError(f"unclosed {opening}")
def statement_end(tokens: list[Token], start: int) -> int:
"""Find a semicolon outside nested closures/collections."""
depths = {"(": 0, "[": 0, "{": 0}
pairs = {")": "(", "]": "[", "}": "{"}
for index in range(start, len(tokens)):
value = tokens[index].value
if value in depths:
depths[value] += 1
elif value in pairs:
depths[pairs[value]] -= 1
elif value == ";" and not any(depths.values()):
return index
raise ValueError("unterminated statement")
def class_body(tokens: list[Token], name: str) -> tuple[int, int]:
for index in range(len(tokens) - 2):
if tokens[index].value == "class" and tokens[index + 1].value == name:
brace = next(i for i in range(index + 2, len(tokens)) if tokens[i].value == "{")
return brace, matching(tokens, brace, "{", "}")
raise ValueError(f"class {name} not found")
def dart_methods(source: str) -> list[Method]:
tokens = lex(source)
start, end = class_body(tokens, "AnalyticsManager")
methods: list[Method] = []
depth = 1
index = start + 1
while index < end:
value = tokens[index].value
if value == "{":
depth += 1
elif value == "}":
depth -= 1
elif (
depth == 1
and tokens[index].kind == "ident"
and index + 1 < end
and tokens[index + 1].value == "("
and value != "AnalyticsManager"
):
close = matching(tokens, index + 1, "(", ")")
after = close + 1
while after < end and tokens[after].value in {"async", "sync", "*"}:
after += 1
if after < end and tokens[after].value in {"{", "=>"}:
if tokens[after].value == "{":
body_end = matching(tokens, after, "{", "}")
body = tuple(tokens[after + 1 : body_end])
else:
body_end = statement_end(tokens, after + 1)
body = tuple(tokens[after + 1 : body_end])
methods.append(Method(value, not value.startswith("_"), body))
index = body_end
index += 1
return methods
def swift_methods(source: str) -> list[Method]:
tokens = lex(source)
start, end = class_body(tokens, "AnalyticsManager")
methods: list[Method] = []
depth = 1
index = start + 1
while index < end:
value = tokens[index].value
if value == "{":
depth += 1
elif value == "}":
depth -= 1
elif depth == 1 and value == "func" and index + 1 < end:
name = tokens[index + 1].value
modifier_start = index - 1
# `var`/`let` terminate the scan too: a stored property declared just above a func
# (`private var x: T?` then `func setX(...)`) would otherwise donate its `private`.
while modifier_start > start and tokens[modifier_start].value not in {"}", "{", ";", "var", "let"}:
modifier_start -= 1
modifiers = {token.value for token in tokens[modifier_start + 1 : index]}
brace = next(i for i in range(index + 2, end) if tokens[i].value == "{")
body_end = matching(tokens, brace, "{", "}")
methods.append(
Method(
name, not modifiers.intersection({"private", "fileprivate"}), tuple(tokens[brace + 1 : body_end])
)
)
index = body_end
index += 1
return methods
def typescript_methods(source: str) -> list[Method]:
tokens = lex(source)
methods: list[Method] = []
for index in range(len(tokens) - 2):
if tokens[index].value != "export" or tokens[index + 1].value != "function":
continue
name = tokens[index + 2].value
opening = next(i for i in range(index + 3, len(tokens)) if tokens[i].value == "(")
closing = matching(tokens, opening, "(", ")")
brace = next(i for i in range(closing + 1, len(tokens)) if tokens[i].value == "{")
body_end = matching(tokens, brace, "{", "}")
methods.append(Method(name, True, tuple(tokens[brace + 1 : body_end])))
return methods
def sequence_call_counts(tokens: list[Token], prefixes: tuple[tuple[str, ...], ...]) -> dict[str, int]:
counts: dict[str, int] = {}
values = [token.value for token in tokens]
for index in range(len(values)):
for prefix in prefixes:
end = index + len(prefix)
if tuple(values[index:end]) != prefix or end + 2 >= len(values):
continue
if values[end] == "." and tokens[end + 1].kind == "ident" and values[end + 2] == "(":
name = values[end + 1]
counts[name] = counts.get(name, 0) + 1
return counts
def dart_call_counts(sources: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
prefixes = (
("AnalyticsManager", "(", ")"),
("AnalyticsManager",),
("PlatformManager", ".", "instance", ".", "analytics"),
)
for source in sources:
for name, count in sequence_call_counts(lex(source), prefixes).items():
counts[name] = counts.get(name, 0) + count
return counts
def swift_call_counts(sources: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for source in sources:
for name, count in sequence_call_counts(lex(source), (("AnalyticsManager", ".", "shared"),)).items():
counts[name] = counts.get(name, 0) + count
return counts
def windows_imports(tokens: list[Token]) -> dict[str, str]:
aliases: dict[str, str] = {}
index = 0
while index < len(tokens):
if tokens[index].value != "import":
index += 1
continue
try:
opening = next(i for i in range(index + 1, len(tokens)) if tokens[i].value in {"{", ";"})
except StopIteration:
break
if tokens[opening].value != "{":
index = opening + 1
continue
closing = matching(tokens, opening, "{", "}")
if closing + 2 >= len(tokens) or tokens[closing + 1].value != "from":
index = closing + 1
continue
path = tokens[closing + 2]
if path.kind != "string" or path.value.rsplit("/", 1)[-1] not in {"analytics", "analytics.ts"}:
index = closing + 1
continue
cursor = opening + 1
while cursor < closing:
if tokens[cursor].kind != "ident":
cursor += 1
continue
imported = tokens[cursor].value
local = imported
if cursor + 2 < closing and tokens[cursor + 1].value == "as":
local = tokens[cursor + 2].value
cursor += 3
else:
cursor += 1
aliases[local] = imported
index = closing + 3
return aliases
def windows_call_counts(sources: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for source in sources:
tokens = lex(source)
aliases = windows_imports(tokens)
for index in range(len(tokens) - 1):
imported = aliases.get(tokens[index].value)
if imported and tokens[index + 1].value == "(":
counts[imported] = counts.get(imported, 0) + 1
return counts
def is_test_seam(name: str) -> bool:
"""Return whether a method is a test-only observation hook, not an emitter.
Installing or forwarding to a scoped test capture is production-unreachable
by construction, so auditing it only forces one baseline entry per seam. The
emitters that call these hooks are still audited normally.
"""
return name.endswith("ForTests") or name.endswith("ForTesting")
def emitters(methods: list[Method]) -> list[Method]:
return [method for method in methods if not is_test_seam(method.name)]
def private_incoming(methods: list[Method]) -> dict[str, int]:
private_names = {method.name for method in methods if not method.public}
incoming = dict.fromkeys(private_names, 0)
for caller in methods:
for index in range(len(caller.body) - 1):
callee = caller.body[index].value
if callee in private_names and caller.name != callee and caller.body[index + 1].value == "(":
incoming[callee] += 1
return incoming
def audit_platform(
platform: str,
methods: list[Method],
calls: dict[str, int],
baseline: dict[str, dict[str, object]],
) -> list[str]:
errors: list[str] = []
by_name = {method.name: method for method in methods}
if len(by_name) != len(methods):
errors.append(f"{platform}: overloaded analytics methods are unsupported by this name-based tripwire")
return errors
orphans = set(baseline["public_orphans"].get(platform, []))
private_orphans = set(baseline["private_orphans"].get(platform, []))
minimums = baseline["multi_call_minimums"].get(platform, {})
for stale in sorted((orphans | private_orphans | set(minimums)) - set(by_name)):
errors.append(f"{platform}.{stale}: stale baseline entry; remove it")
incoming = private_incoming(methods)
for method in methods:
if not method.body:
errors.append(f"{platform}.{method.name}: empty analytics method")
if method.public:
count = calls.get(method.name, 0)
if count == 0:
if method.name not in orphans:
errors.append(f"{platform}.{method.name}: no production call site")
continue
if method.name in orphans:
errors.append(f"{platform}.{method.name}: now has a caller; remove it from public_orphans")
minimum = int(minimums.get(method.name, 1))
if count < minimum:
errors.append(f"{platform}.{method.name}: production call sites fell {minimum}->{count}")
elif count > minimum and count > 1:
errors.append(
f"{platform}.{method.name}: production call sites grew {minimum}->{count}; "
"raise multi_call_minimums to ratchet the gain"
)
else:
count = incoming.get(method.name, 0)
if count == 0 and method.name not in private_orphans:
errors.append(f"{platform}.{method.name}: unreachable private analytics helper")
elif count > 0 and method.name in private_orphans:
errors.append(f"{platform}.{method.name}: now reachable; remove it from private_orphans")
return errors
def load_platforms(root: Path) -> dict[str, tuple[list[Method], dict[str, int]]]:
dart_manager = root / "app/lib/utils/analytics/analytics_manager.dart"
swift_manager = root / "desktop/macos/Desktop/Sources/AnalyticsManager.swift"
windows_manager = root / "desktop/windows/src/renderer/src/lib/analytics.ts"
dart_sources = [
path.read_text(encoding="utf-8") for path in (root / "app/lib").rglob("*.dart") if path != dart_manager
]
swift_sources = [
path.read_text(encoding="utf-8")
for path in (root / "desktop/macos/Desktop/Sources").rglob("*.swift")
if path != swift_manager
]
windows_sources = [
path.read_text(encoding="utf-8")
for path in (root / "desktop/windows/src/renderer/src").rglob("*")
if path.suffix in {".ts", ".tsx"}
and ".test." not in path.name
and ".spec." not in path.name
and path != windows_manager
]
return {
"flutter": (emitters(dart_methods(dart_manager.read_text(encoding="utf-8"))), dart_call_counts(dart_sources)),
"macos": (emitters(swift_methods(swift_manager.read_text(encoding="utf-8"))), swift_call_counts(swift_sources)),
"windows": (
emitters(typescript_methods(windows_manager.read_text(encoding="utf-8"))),
windows_call_counts(windows_sources),
),
}
def suggested_baseline(platforms: dict[str, tuple[list[Method], dict[str, int]]]) -> dict[str, dict[str, object]]:
baseline: dict[str, dict[str, object]] = {
"public_orphans": {},
"private_orphans": {},
"multi_call_minimums": {},
}
for platform, (methods, calls) in platforms.items():
incoming = private_incoming(methods)
baseline["public_orphans"][platform] = sorted(
method.name for method in methods if method.public and calls.get(method.name, 0) == 0
)
baseline["private_orphans"][platform] = sorted(
method.name for method in methods if not method.public and incoming.get(method.name, 0) == 0
)
baseline["multi_call_minimums"][platform] = {
method.name: calls[method.name]
for method in sorted(methods, key=lambda item: item.name)
if method.public and calls.get(method.name, 0) > 1
}
return baseline
def main() -> int:
platforms = load_platforms(ROOT)
if "--print-baseline" in sys.argv:
print(json.dumps(suggested_baseline(platforms), indent=2, sort_keys=True))
return 0
baseline = json.loads(BASELINE.read_text(encoding="utf-8"))
errors = [
error
for platform, (methods, calls) in platforms.items()
for error in audit_platform(platform, methods, calls, baseline)
]
if errors:
print("analytics reachability static tripwire failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print("analytics reachability static tripwire passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())