@amityco/social-plus-vise 1.0.0 → 1.1.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 (56) hide show
  1. package/CHANGELOG.md +71 -24
  2. package/LICENSE +8 -6
  3. package/README.md +168 -358
  4. package/dist/capabilities.js +19 -62
  5. package/dist/intelligence/grounding.js +0 -23
  6. package/dist/intelligence/placement.js +0 -9
  7. package/dist/outcomes.js +44 -22
  8. package/dist/server.js +75 -38
  9. package/dist/tools/ast.js +3 -209
  10. package/dist/tools/blocks.js +6 -20
  11. package/dist/tools/compliance.js +168 -43
  12. package/dist/tools/creative.js +15 -41
  13. package/dist/tools/debug.js +0 -16
  14. package/dist/tools/design.js +18 -364
  15. package/dist/tools/docs.js +53 -24
  16. package/dist/tools/experienceCompiler.js +7 -10
  17. package/dist/tools/experienceSensors.js +1 -1
  18. package/dist/tools/harness.js +2 -27
  19. package/dist/tools/integration.js +6 -38
  20. package/dist/tools/learning.js +1 -1
  21. package/dist/tools/project.js +763 -546
  22. package/dist/tools/sdkFacts.js +2 -15
  23. package/dist/tools/sdkVersion.js +3 -36
  24. package/dist/tools/sensors.js +0 -6
  25. package/dist/tools/uxHarness.js +12 -9
  26. package/package.json +8 -97
  27. package/rules/chat.yaml +225 -0
  28. package/rules/event.yaml +45 -0
  29. package/rules/feed.yaml +24 -24
  30. package/rules/invitation.yaml +58 -0
  31. package/rules/live-data.yaml +104 -2
  32. package/rules/notification-tray.yaml +106 -0
  33. package/rules/poll.yaml +71 -0
  34. package/rules/sdk-lifecycle.yaml +112 -6
  35. package/rules/search.yaml +131 -0
  36. package/rules/story.yaml +221 -0
  37. package/rules/user-blocking.yaml +71 -0
  38. package/sdk-surface/flutter.json +1 -1
  39. package/sdk-surface/ios.json +1 -1
  40. package/sdk-surface/manifest.json +12 -12
  41. package/sdk-surface/models.flutter.json +96 -96
  42. package/sdk-surface/models.ios.json +1 -1
  43. package/sdk-surface/typescript.json +4 -4
  44. package/skills/social-plus-vise/SKILL.md +25 -5
  45. package/scripts/catalog-coverage-html.mjs +0 -325
  46. package/scripts/catalog-relationships-html.mjs +0 -686
  47. package/scripts/catalog-sheets.mjs +0 -286
  48. package/scripts/dart-model-extractor/bin/extract_models.dart +0 -169
  49. package/scripts/dart-model-extractor/pubspec.lock +0 -149
  50. package/scripts/dart-model-extractor/pubspec.yaml +0 -16
  51. package/scripts/extract-sdk-models.mjs +0 -749
  52. package/scripts/import-sdk-surface.mjs +0 -161
  53. package/scripts/pilot-feedback.mjs +0 -107
  54. package/scripts/workshop-board-html.mjs +0 -1018
  55. package/scripts/workshop-kit.mjs +0 -252
  56. package/skills/vise-harness-engineer/SKILL.md +0 -35
@@ -1,4 +1,4 @@
1
- import { access, readdir, readFile } from "node:fs/promises";
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { productFindingIdentity } from "../productExpectations.js";
4
4
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
@@ -20,8 +20,21 @@ async function readIfExists(filePath) {
20
20
  return "";
21
21
  }
22
22
  }
23
+ async function assertRepoPathIsDirectory(repoRoot) {
24
+ let info;
25
+ try {
26
+ info = await stat(repoRoot);
27
+ }
28
+ catch {
29
+ throw new Error(`repoPath does not exist: ${repoRoot}`);
30
+ }
31
+ if (!info.isDirectory()) {
32
+ throw new Error(`repoPath is not a directory: ${repoRoot}`);
33
+ }
34
+ }
23
35
  export async function inspectProject(repoPath, surfacePath) {
24
36
  const repoRoot = path.resolve(repoPath);
37
+ await assertRepoPathIsDirectory(repoRoot);
25
38
  const surfaces = await detectProjectSurfaces(repoRoot);
26
39
  const effectiveRoot = resolveSurfaceRoot(repoRoot, surfacePath);
27
40
  const selectedSurface = surfacePath ? await surfaceForPath(repoRoot, effectiveRoot) : surfaceMatchingRoot(surfaces, repoRoot);
@@ -82,10 +95,6 @@ async function inspectRoot(root) {
82
95
  reason: "react-native dependency or script signal",
83
96
  });
84
97
  }
85
- // When react-native is detected alongside generic typescript signals, prefer react-native
86
- // so that platform-specific rules (react-native.*) are used for init/check/run-sensors.
87
- // Same for android: an agent may create package.json (e.g. to enable a local Vise check script) which
88
- // would normally trigger typescript detection — suppress it so only android rules apply.
89
98
  const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
90
99
  const hasRN = rawPlatforms.includes("react-native");
91
100
  const hasAndroid = rawPlatforms.includes("android");
@@ -181,9 +190,6 @@ async function validateAndroid(root) {
181
190
  const findings = [];
182
191
  const manifestPath = "app/src/main/AndroidManifest.xml";
183
192
  const manifest = await readIfExists(path.join(root, manifestPath));
184
- // Scan ALL Gradle build scripts, not just app/root — a multi-module app idiomatically declares the
185
- // SDK dependency in the consuming FEATURE module's build.gradle(.kts) (e.g. feature/community/impl).
186
- // Only checking app/build.gradle false-fired android.dependency.sdk on those (brownfield Now-in-Android).
187
193
  const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200)).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
188
194
  const buildFiles = [...new Set([
189
195
  ...(await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])),
@@ -222,13 +228,8 @@ async function validateAndroid(root) {
222
228
  else {
223
229
  const activitySetup = setupFiles.find((file) => {
224
230
  const c = sourceContent.get(file) ?? "";
225
- // Application-scoped setup (a Hilt @Module / @HiltAndroidApp Application / SingletonComponent
226
- // provider) is the CORRECT place — never flag it. The old marker matched the bare word
227
- // "Activity" anywhere (incl. a comment like "outlives any Activity/ViewModel" in a DI module),
228
- // false-firing on idiomatic Hilt setup (brownfield Now-in-Android).
229
231
  if (/@Module\b|@HiltAndroidApp\b|SingletonComponent\b|@ApplicationContext\b|:\s*Application\b|\bApplication\s*\(\s*\)/.test(c))
230
232
  return false;
231
- // Otherwise flag setup in an actual UI lifecycle: an Activity/Fragment class, a @Composable, or onCreateView.
232
233
  return /\bclass\s+\w*(?:Activity|Fragment)\b|:\s*(?:ComponentActivity|AppCompatActivity|Activity|Fragment)\b|@Composable\b|\bonCreateView\b/.test(c);
233
234
  });
234
235
  if (activitySetup) {
@@ -247,14 +248,11 @@ async function validateAndroid(root) {
247
248
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
248
249
  findings.push(finding("android.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
249
250
  }
250
- // Android push payload: if onMessageReceived exists, require push-specific SDK forwarding
251
- // Nexus gate (see iOS): a generic FCM handler with no social.plus push registration is the host
252
- // app's own push, not a social.plus forwarding gap — require social.plus push registration first.
253
251
  const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
254
252
  if (androidPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
255
253
  findings.push(finding("android.push.payload-contract-respected", "warning", "Push payload handler (onMessageReceived) exists but no social.plus push forwarding call was detected.", relativeFile(root, androidPayloadFiles[0]), "Forward incoming FCM payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
256
254
  }
257
- if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
255
+ if (liveDataFiles.length > 0 && !containsAnyStripped(sourceContent, "android", [/\.dispose\s*\(/, /\bdispose\s*\(\s*\)/, /\.clear\s*\(/, /\bremoveObserver\b/, /\bunsubscribe\b/, /\.cancel\s*\(/])) {
258
256
  findings.push(finding("android.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Dispose or unsubscribe observers when the Activity, Fragment, ViewModel, or composable lifecycle ends."));
259
257
  }
260
258
  findings.push(...validateLiteralGuardrails(root, "android", sourceContent));
@@ -268,6 +266,15 @@ async function validateAndroid(root) {
268
266
  findings.push(...validateComments(root, "android", sourceContent));
269
267
  findings.push(...validateModeration(root, "android", sourceContent));
270
268
  findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
269
+ findings.push(...validateStories(root, "android", sourceContent));
270
+ findings.push(...validateSearch(root, "android", sourceContent));
271
+ findings.push(...validateNotificationTray(root, "android", sourceContent));
272
+ findings.push(...validateMembershipList(root, "android", sourceContent));
273
+ findings.push(...validateEvents(root, "android", sourceContent));
274
+ findings.push(...validateBlockedUsers(root, "android", sourceContent));
275
+ findings.push(...validateInvitations(root, "android", sourceContent));
276
+ findings.push(...validatePollVoteStatusGuard(root, "android", sourceContent));
277
+ findings.push(...validateFollowStatusSubscription(root, "android", sourceContent));
271
278
  findings.push(...validatePostDataTypeHandled(root, "android", sourceContent));
272
279
  findings.push(...validateAvatarFromSdk(root, "android", sourceContent));
273
280
  findings.push(...validatePollAnswerDataShape(root, "android", sourceContent));
@@ -297,6 +304,8 @@ async function validateAndroid(root) {
297
304
  findings.push(...validateChatMessageOrderExplicit(root, "android", sourceContent));
298
305
  findings.push(...validateProfileSocialCounts(root, "android", sourceContent));
299
306
  findings.push(...validateChat(root, "android", sourceContent));
307
+ findings.push(...validateChatDeprecations(root, "android", sourceContent));
308
+ findings.push(...validateLivestreamDeprecation(root, "android", sourceContent));
300
309
  findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
301
310
  findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
302
311
  return findings;
@@ -327,13 +336,6 @@ async function validateFlutter(root) {
327
336
  if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
328
337
  findings.push(finding("flutter.binding.initialized", "warning", "SDK setup was found but WidgetsFlutterBinding.ensureInitialized was not detected.", relativeFile(root, setupFiles[0]), "Call WidgetsFlutterBinding.ensureInitialized before async SDK setup in main()."));
329
338
  }
330
- // Region is configured via AmityRegion.* OR an explicit endpoint (the idiomatic Flutter
331
- // forms — `httpEndpoint:` is not matched by `\bendpoint:` because of the camelCase boundary
332
- // + case). The real endpoint symbols are httpEndpoint/mqttEndpoint/uploadEndpoint and the
333
- // AmityRegional{Http,Mqtt}Endpoint enums (verified against the SDK surface — there is no
334
- // `socketEndpoint`). NOTE: do NOT key on AmityCoreClientOption — that builder is used by
335
- // EVERY setup regardless of whether a region is set, so it would silence this rule for any
336
- // standard setup (a false negative).
337
339
  if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/, /\bhttpEndpoint\s*:/i, /\bmqttEndpoint\s*:/i, /\buploadEndpoint\s*:/i, /\bAmityRegional(?:Http|Mqtt)Endpoint\b/, /\bAmityRegion\b/])) {
338
340
  findings.push(finding("flutter.setup.region", "warning", "SDK setup was found but no explicit endpoint/region marker was detected.", relativeFile(root, setupFiles[0]), "Set the endpoint/region explicitly to match the customer's social.plus console project."));
339
341
  }
@@ -347,9 +349,6 @@ async function validateFlutter(root) {
347
349
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
348
350
  findings.push(finding("flutter.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
349
351
  }
350
- // Flutter push payload: if FirebaseMessaging.onMessage/onBackgroundMessage exists, require push-specific SDK forwarding
351
- // Nexus gate (see iOS): require social.plus push registration before demanding forwarding, so a
352
- // host app's own FirebaseMessaging handler doesn't false-fire when push isn't a social.plus concern.
353
352
  const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
354
353
  if (flutterPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
355
354
  findings.push(finding("flutter.push.payload-contract-respected", "warning", "Push payload handler (FirebaseMessaging) exists but no social.plus push forwarding call was detected.", relativeFile(root, flutterPayloadFiles[0]), "Forward incoming Firebase push payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
@@ -368,6 +367,11 @@ async function validateFlutter(root) {
368
367
  findings.push(...validateComments(root, "flutter", dartContent));
369
368
  findings.push(...validateModeration(root, "flutter", dartContent));
370
369
  findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
370
+ findings.push(...validateStories(root, "flutter", dartContent));
371
+ findings.push(...validateSearch(root, "flutter", dartContent));
372
+ findings.push(...validateMembershipList(root, "flutter", dartContent));
373
+ findings.push(...validateBlockedUsers(root, "flutter", dartContent));
374
+ findings.push(...validatePollVoteStatusGuard(root, "flutter", dartContent));
371
375
  findings.push(...validatePostDataTypeHandled(root, "flutter", dartContent));
372
376
  findings.push(...validateAvatarFromSdk(root, "flutter", dartContent));
373
377
  findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
@@ -396,6 +400,7 @@ async function validateFlutter(root) {
396
400
  findings.push(...validateChatMessageOrderExplicit(root, "flutter", dartContent));
397
401
  findings.push(...validateProfileSocialCounts(root, "flutter", dartContent));
398
402
  findings.push(...validateChat(root, "flutter", dartContent));
403
+ findings.push(...validateChatDeprecations(root, "flutter", dartContent));
399
404
  findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
400
405
  findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
401
406
  return findings;
@@ -408,15 +413,10 @@ async function validateTypeScript(root, platform) {
408
413
  const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/]);
409
414
  const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
410
415
  const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
411
- // Skip .d.ts declarations when checking for unregister — type stubs declare both register and
412
- // unregister methods but should not suppress the "unregister missing" finding.
413
416
  const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
414
417
  const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
415
- // Only REACTIVE subscriptions need cleanup. A one-shot `await queryPosts(...)` /
416
- // `await getPost(...)` returns a Promise with nothing to unsubscribe, so bare
417
- // queryPosts(/getPost( are NOT live-data markers (they'd false-fire live.cleanup on
418
- // one-shot awaits — found on a weak-model build). The reactive forms below remain.
419
- const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/]);
418
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/,
419
+ /\bgetNotificationTrayItems\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/]);
420
420
  if (!packageJson) {
421
421
  findings.push(finding("typescript.package.present", "warning", "No package.json file was found.", "package.json", "Point repoPath at the TypeScript or React Native project root."));
422
422
  }
@@ -430,19 +430,6 @@ async function validateTypeScript(root, platform) {
430
430
  findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
431
431
  }
432
432
  else {
433
- // Region detection accepts three idioms:
434
- // (a) keyword-arg style: createClient({ region: 'sg' }) / apiRegion / apiEndpoint
435
- // (b) positional with a named variable: const region = ...; createClient(apiKey, region)
436
- // (c) env-sourced region declared anywhere in the codebase:
437
- // process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
438
- // VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
439
- // Any one of these is sufficient evidence the integration is region-aware.
440
- // `API_REGIONS` is the canonical social.plus TS SDK region enum
441
- // (API_REGIONS.SG/EU/US) — its presence is definitive region evidence. The
442
- // named-decl form is case-insensitive so `const REGION = API_REGIONS.SG` counts.
443
- // (d) positional region argument: createClient(API_KEY, AMITY_REGION) — a 2nd arg whose name
444
- // contains "region" (incl. an uppercase env const like AMITY_REGION). Idiom (b)'s regex only
445
- // caught a `const region` decl named exactly "region"; the positional call site was missed (RN build).
446
433
  const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/, /\bAPI_REGIONS\b/, /createClient\s*\([^,)]+,\s*[\w.$]*[Rr]egion/i]);
447
434
  const setupHasNamedRegionDecl = containsAnyForFiles(sourceContent, setupFiles, [/\b(?:const|let)\s+(?:region|apiRegion|endpoint|apiEndpoint)\b/i]);
448
435
  const projectHasEnvRegion = containsAny(sourceContent, [/process\.env\.[A-Z0-9_]*(?:REGION|ENDPOINT)[A-Z0-9_]*/, /import\.meta\.env\.[A-Z0-9_]*(?:REGION|ENDPOINT)[A-Z0-9_]*/]);
@@ -463,11 +450,7 @@ async function validateTypeScript(root, platform) {
463
450
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
464
451
  findings.push(finding(`${platform}.push.unregister.present`, "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens when the user logs out or the component unmounts. In React/React Native, return the unregister call from useEffect: return () => { NotificationRepository.unregisterPushNotification(deviceToken).catch(() => {}); }. Also update any local registration-state you maintain. Missing unregistration means notifications continue arriving after logout, which is a privacy and UX bug."));
465
452
  }
466
- // A function whose RETURN TYPE is Amity.Unsubscriber hands the cleanup contract to
467
- // its caller — the idiomatic repository/store pattern. Anchored on the return-type
468
- // annotation `): Unsubscriber` (not a bare mention), so a disposer captured and
469
- // dropped on the floor still fires.
470
- if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /cleanup/i, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/])) {
453
+ if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/])) {
471
454
  findings.push(finding(`${platform}.live.cleanup`, "warning", "Live Object/Collection subscription was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Return or call the unsubscribe cleanup from the owning component, route, or store lifecycle."));
472
455
  }
473
456
  findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
@@ -481,6 +464,14 @@ async function validateTypeScript(root, platform) {
481
464
  findings.push(...validateComments(root, platform, sourceContent));
482
465
  findings.push(...validateModeration(root, platform, sourceContent));
483
466
  findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
467
+ findings.push(...validateStories(root, platform, sourceContent));
468
+ findings.push(...validateSearch(root, platform, sourceContent));
469
+ findings.push(...validateNotificationTray(root, platform, sourceContent));
470
+ findings.push(...validateMembershipList(root, platform, sourceContent));
471
+ findings.push(...validateEvents(root, platform, sourceContent));
472
+ findings.push(...validateBlockedUsers(root, platform, sourceContent));
473
+ findings.push(...validateInvitations(root, platform, sourceContent));
474
+ findings.push(...validatePollVoteStatusGuard(root, platform, sourceContent));
484
475
  findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
485
476
  findings.push(...validateActivityPostsTagFilter(root, platform, sourceContent));
486
477
  findings.push(...validatePostDataTypeHandled(root, platform, sourceContent));
@@ -513,6 +504,8 @@ async function validateTypeScript(root, platform) {
513
504
  findings.push(...validateChatMessageOrderExplicit(root, platform, sourceContent));
514
505
  findings.push(...validateProfileSocialCounts(root, platform, sourceContent));
515
506
  findings.push(...validateChat(root, platform, sourceContent));
507
+ findings.push(...validateChatDeprecations(root, platform, sourceContent));
508
+ findings.push(...validateLivestreamDeprecation(root, platform, sourceContent));
516
509
  findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
517
510
  if (platform === "typescript") {
518
511
  findings.push(...validateClientNoSsrInit(root, sourceContent));
@@ -525,9 +518,6 @@ async function validateEnvSecretHygiene(root, platform, sourceContent) {
525
518
  if (platform === "typescript") {
526
519
  const envSecretFiles = await committedEnvSecretFiles(root);
527
520
  if (envSecretFiles.length > 0) {
528
- // Only fire when the secret-bearing env file is NOT gitignored. A gitignored local `.env` (the
529
- // exact practice this rule's own recommendation endorses) won't be committed, so flagging it is
530
- // a false positive — committed-env is about a `.env` that WILL be committed (no .gitignore cover).
531
521
  const gitignore = await readIfExists(path.join(root, ".gitignore"));
532
522
  if (!gitignore || !gitignoreIgnoresEnvFiles(gitignore)) {
533
523
  for (const file of envSecretFiles) {
@@ -603,10 +593,6 @@ function validateClientNoSsrInit(root, sourceContent) {
603
593
  }
604
594
  return findings;
605
595
  }
606
- // Logging hygiene: customers shipping debug logs of secret-shaped values is a
607
- // real failure mode (console.log("client", apiKey) escapes to production).
608
- // Per platform we match the relevant log function calls; the secret-shaped
609
- // regex is shared. Returns at most one finding per scan to keep noise low.
610
596
  function validateLoggingHygiene(root, platform, sourceContent) {
611
597
  const logCallPattern = LOGGING_CALL_PATTERNS_BY_PLATFORM[platform];
612
598
  if (!logCallPattern) {
@@ -620,8 +606,6 @@ function validateLoggingHygiene(root, platform, sourceContent) {
620
606
  if (!logCallPattern.test(line)) {
621
607
  continue;
622
608
  }
623
- // Allow commented-out lines (//, #, /*); rough but kills the obvious
624
- // false positives like documentation snippets.
625
609
  const trimmed = line.trimStart();
626
610
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
627
611
  continue;
@@ -639,20 +623,11 @@ function validateLoggingHygiene(root, platform, sourceContent) {
639
623
  }
640
624
  return findings;
641
625
  }
642
- // Design system reuse: when the project has detected theme / token / design-
643
- // system files (via detectDesignSignals), at least one other source file
644
- // should reference one of those files by basename or class identifier.
645
- // Tolerant heuristic — false negatives are accepted (the agent might use a
646
- // theme provider context implicitly); the goal is to catch the obvious
647
- // "agent ignored the detected design system and wrote inline styles."
648
626
  async function validateDesignReuse(root, platform, sourceContent) {
649
627
  const designSignals = await detectDesignSignals(root);
650
628
  if (designSignals.length === 0) {
651
629
  return [];
652
630
  }
653
- // Build reference markers per design file. For each design file we look for
654
- // its basename (without extension) AND its PascalCase identifier guess. A
655
- // source file that imports / mentions any marker counts as a reference.
656
631
  const markersByFile = [];
657
632
  for (const signal of designSignals) {
658
633
  const base = path.basename(signal.file).replace(/\.(ts|tsx|js|jsx|dart|kt|java|swift|xml|css)$/i, "");
@@ -663,14 +638,7 @@ async function validateDesignReuse(root, platform, sourceContent) {
663
638
  }
664
639
  markersByFile.push({ file: signal.file, markers: [...markers] });
665
640
  }
666
- // Resolve design file absolute paths so we can skip them when scanning.
667
641
  const designAbsolutePaths = new Set(designSignals.map((signal) => path.resolve(root, signal.file)));
668
- // Implicit theme-provider reuse patterns. Idiomatic UI on most platforms
669
- // pulls tokens from an ambient theme context (Theme.of in Flutter, useTheme
670
- // in MUI/Chakra, MaterialTheme.colorScheme in Compose, UIColor.systemBlue
671
- // dynamic colors in iOS). These reuse the design system without importing
672
- // the file that declared the theme — the cross-file marker heuristic misses
673
- // them. Per-platform list of additional positive signals:
674
642
  const IMPLICIT_THEME_MARKERS_BY_PLATFORM = {
675
643
  flutter: [
676
644
  /Theme\.of\s*\(\s*context\s*\)/,
@@ -678,7 +646,6 @@ async function validateDesignReuse(root, platform, sourceContent) {
678
646
  /MediaQuery\.of\s*\(\s*context\s*\)/,
679
647
  /\bcolorScheme\s*\./,
680
648
  /\btextTheme\s*\./,
681
- // Material 3 widget references — strong signal of design-system reuse.
682
649
  /\b(Card|ElevatedButton|FilledButton|OutlinedButton|TextButton|IconButton|Scaffold|AppBar|FloatingActionButton|ListTile|Chip|NavigationBar|NavigationRail)\s*\(/,
683
650
  ],
684
651
  android: [
@@ -711,11 +678,8 @@ async function validateDesignReuse(root, platform, sourceContent) {
711
678
  if (designAbsolutePaths.has(file)) {
712
679
  continue;
713
680
  }
714
- // Phase 4: For AST-supported languages, check against comment-stripped
715
- // content to avoid false passes from references only in comments.
716
681
  const astLang = astLanguageForFile(file, platform);
717
682
  const checkContent = astLang ? stripComments(astLang, content) : content;
718
- // (a) Explicit reuse — cross-file basename / PascalCase reference.
719
683
  for (const { markers } of markersByFile) {
720
684
  for (const marker of markers) {
721
685
  if (!marker || marker.length < 3) {
@@ -726,7 +690,6 @@ async function validateDesignReuse(root, platform, sourceContent) {
726
690
  }
727
691
  }
728
692
  }
729
- // (b) Implicit reuse — ambient theme-provider API or design-system widget.
730
693
  if (implicitMarkers.some((re) => re.test(checkContent))) {
731
694
  return [];
732
695
  }
@@ -742,11 +705,6 @@ function toPascalCase(snakeOrKebab) {
742
705
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
743
706
  .join("");
744
707
  }
745
- // Feed UI states: when a source file observes a live data stream/collection,
746
- // it should also render loading / empty / error states. The heuristic is
747
- // per-platform: detect a live-data observation pattern in a file, then check
748
- // that file for at least one state marker. Returns at most one finding to
749
- // keep noise low.
750
708
  function validateFeedUiStates(root, platform, sourceContent) {
751
709
  const observationPatterns = LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM[platform];
752
710
  const statePatterns = UI_STATE_PATTERNS_BY_PLATFORM[platform];
@@ -761,10 +719,7 @@ function validateFeedUiStates(root, platform, sourceContent) {
761
719
  if (!observes) {
762
720
  continue;
763
721
  }
764
- // Phase 4: For AST-supported languages, check state patterns against
765
- // comment-stripped content to avoid false passes from comment text.
766
- const astLang = astLanguageForFile(file, platform);
767
- const checkContent = astLang ? stripComments(astLang, content) : content;
722
+ const checkContent = blankDeadDeclarations(commentStripped(file, platform, content), "(?:isLoading|loading|error|empty|isEmpty)");
768
723
  const hasStates = statePatterns.some((pattern) => pattern.test(checkContent));
769
724
  if (hasStates) {
770
725
  continue;
@@ -779,10 +734,7 @@ const LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM = {
779
734
  typescript: [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
780
735
  "react-native": [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
781
736
  flutter: [/\.listen\s*\(/, /StreamSubscription/, /getStreamController\s*\(/, /AmityCollection/, /AmityObject/],
782
- // Kotlin uses trailing lambdas — `.observe { ... }` is more common than
783
- // `.observe(...)`. Match either form.
784
737
  android: [/LiveCollection/, /LiveObject/, /\.observe\s*[({]/, /\.subscribe\s*[({]/, /\.collectAsState\b/, /AmitySocialClient\.newPostRepository/, /AmityCollection/],
785
- // Swift trailing-closure form: `.observe { snapshot in ... }`.
786
738
  ios: [/AmityCollection/, /AmityObject/, /\.observe\s*[({]/, /getPosts\s*\(/, /queryPosts\s*\(/],
787
739
  };
788
740
  const UI_STATE_PATTERNS_BY_PLATFORM = {
@@ -817,14 +769,12 @@ function validateFeedPagination(root, platform, sourceContent) {
817
769
  if (!hasFeed) {
818
770
  continue;
819
771
  }
820
- // Check all source files for pagination markers (may be in a separate hook/util)
821
772
  const astLang = astLanguageForFile(file, platform);
822
773
  const checkContent = astLang ? stripComments(astLang, content) : content;
823
774
  const hasPagination = paginationPatterns.some((pattern) => pattern.test(checkContent));
824
775
  if (hasPagination) {
825
776
  return [];
826
777
  }
827
- // Also check sibling files for pagination markers
828
778
  for (const [otherFile, otherContent] of sourceContent) {
829
779
  if (otherFile === file)
830
780
  continue;
@@ -861,7 +811,6 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
861
811
  if (!hasFeedFile) {
862
812
  return [];
863
813
  }
864
- // Check all source files for any moderation marker (may be in a separate module)
865
814
  for (const [file, content] of sourceContent) {
866
815
  const astLang = astLanguageForFile(file, platform);
867
816
  const checkContent = astLang ? stripComments(astLang, content) : content;
@@ -873,11 +822,7 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
873
822
  finding(`${platform}.feed.moderation-affordance-present`, "warning", "A feed/posts UI is present but no moderation affordance (report, block, mute, hide) was detected.", undefined, "Add at least one user-visible moderation action (report, block, mute, or hide) to the feed UI or a sibling module. Attest if moderation is centralized elsewhere."),
874
823
  ];
875
824
  }
876
- // ─── Logout on user-switch ───────────────────────────────────────────────────
877
825
  const USER_SWITCH_SYMBOLS = [
878
- // setActiveUser is the real SDK user-switch. `setCurrentUser`/`setUser` were REMOVED — they collide
879
- // with ubiquitous React useState setters (`const [u, setCurrentUser] = useState(...)`) and false-fired
880
- // logout-on-user-switch on an RN feed screen that merely READ the active user into state.
881
826
  /switchUser/i, /setActiveUser/i, /authStateChanged/i, /onAuthStateChanged/i,
882
827
  /logoutAndLogin/i, /signInAs/i, /changeUser/i,
883
828
  ];
@@ -898,21 +843,18 @@ const LOGOUT_MARKERS_BY_PLATFORM = {
898
843
  function validateLogoutOnUserSwitch(root, platform, sourceContent) {
899
844
  const loginPatterns = LOGIN_PATTERNS_BY_PLATFORM[platform] ?? LOGIN_PATTERNS_BY_PLATFORM.typescript;
900
845
  const logoutMarkers = LOGOUT_MARKERS_BY_PLATFORM[platform] ?? LOGOUT_MARKERS_BY_PLATFORM.typescript;
901
- // Check: does the project have login AND user-switch symbols?
902
846
  const loginFiles = filesMatching(sourceContent, loginPatterns);
903
847
  if (loginFiles.length === 0)
904
848
  return [];
905
849
  const switchFiles = filesMatching(sourceContent, USER_SWITCH_SYMBOLS);
906
850
  if (switchFiles.length === 0)
907
851
  return [];
908
- // Require a logout marker somewhere in the source
909
852
  if (containsAny(sourceContent, logoutMarkers))
910
853
  return [];
911
854
  return [
912
855
  finding(`${platform}.auth.logout-on-user-switch`, "warning", "User-switch pattern detected with social.plus login, but no logout/disconnect call was found. Switching users without logout can leak state across identities.", relativeFile(root, switchFiles[0]), "Call logout or disconnect on the current session before logging in as a different user. Preserve the existing sessionHandler or renewal wiring on the subsequent login call, and attest only if a centralized auth coordinator owns the switch flow."),
913
856
  ];
914
857
  }
915
- // ─── No anonymous write ──────────────────────────────────────────────────────
916
858
  const WRITE_CALL_PATTERNS = [
917
859
  /createPost\s*\(/, /createComment\s*\(/, /sendMessage\s*\(/,
918
860
  /createStory\s*\(/, /addReaction\s*\(/, /\.react\s*\(/,
@@ -920,13 +862,8 @@ const WRITE_CALL_PATTERNS = [
920
862
  /\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
921
863
  ];
922
864
  const AUTH_GATE_MARKERS = [
923
- // `currentUser` prefix, NOT \bcurrentUser\b — a write gated on `currentUserId` (the
924
- // session-derived user id, idiomatically passed into a component) is a valid
925
- // anonymous-write guard, and \b after "currentUser" would not match "currentUserId".
926
865
  /\bcurrentUser\w*/, /\bisAuthenticated\b/, /\brequireAuth\b/,
927
- /\bsession\b/, /\bisLoggedIn\b/, /\bauthState\b/,
928
- // \w* not \b: `getCurrentUserId()` (the session-derived id used to gate writes) must match —
929
- // the trailing \b would stop at "getCurrentUser" and miss the camelCase suffix (recurring pattern).
866
+ /\bsession\b/, /\bis(?:User)?LoggedIn\b/, /\bauthState\b/,
930
867
  /\bgetCurrentUser\w*/, /\bgetAccessToken\b/, /\baccessToken\b/,
931
868
  /\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
932
869
  ];
@@ -934,19 +871,17 @@ function validateNoAnonymousWrite(root, platform, sourceContent) {
934
871
  const writeFiles = filesMatching(sourceContent, WRITE_CALL_PATTERNS);
935
872
  if (writeFiles.length === 0)
936
873
  return [];
937
- // For each file with write calls, check if it has auth gate markers
938
874
  for (const filePath of writeFiles) {
939
875
  const content = sourceContent.get(filePath) ?? "";
940
876
  const astLang = astLanguageForFile(filePath, platform);
941
- const checkContent = astLang ? stripComments(astLang, content) : content;
942
- if (AUTH_GATE_MARKERS.some((p) => p.test(checkContent)))
877
+ const authSurface = blankDeadDeclarations(astLang ? stripComments(astLang, content) : content, "currentUser\\w*");
878
+ if (AUTH_GATE_MARKERS.some((p) => p.test(authSurface)))
943
879
  return [];
944
880
  }
945
881
  return [
946
882
  finding(`${platform}.auth.no-anonymous-write`, "warning", "Write operations (createPost, sendMessage, etc.) found but no auth gate or user-session check was detected in the same file.", relativeFile(root, writeFiles[0]), "Ensure write operations are only reachable after authentication. Check currentUser, session, or isAuthenticated before calling create/send methods."),
947
883
  ];
948
884
  }
949
- // ─── Network error handling ──────────────────────────────────────────────────
950
885
  const SDK_CALL_PATTERNS = [
951
886
  /\.login\s*\(/, /\.setup\s*\(/, /createPost\s*\(/,
952
887
  /createComment\s*\(/, /sendMessage\s*\(/, /createStory\s*\(/,
@@ -956,15 +891,6 @@ const SDK_CALL_PATTERNS = [
956
891
  const ERROR_HANDLING_MARKERS_BY_PLATFORM = {
957
892
  android: [/try\s*\{/, /runCatching/, /\.onFailure/, /CoroutineExceptionHandler/, /\.catch\s*\{/],
958
893
  flutter: [/try\s*\{/, /\.catchError\s*\(/, /\.onError\s*\(/, /\.handleError/, /catch\s*\(/],
959
- // TypeScript / React: in addition to imperative try/catch and Promise.catch,
960
- // recognize React's idiomatic error-state pattern:
961
- // - setError(...) state setter
962
- // - useState<Error...> or useState<...Error> (error-typed state)
963
- // - ErrorBoundary component reference (React error boundary)
964
- // - error.tsx co-located file (Next.js App Router convention)
965
- // - destructured error from a callback's response object: ({ error }) or (error: foo)
966
- // These are the canonical patterns the social.plus TS SDK's Live Collection
967
- // callbacks use; flagging them would force attestation on idiomatic code.
968
894
  typescript: [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /error\.tsx\b/, /\berror\s*:\s*\w+\s*[,})]/],
969
895
  "react-native": [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /\berror\s*:\s*\w+\s*[,})]/],
970
896
  ios: [/do\s*\{/, /try\s+/, /try\?/, /catch\s*\{/, /\.failure\s*\(/, /Result\s*</, /completion.*Error/],
@@ -974,14 +900,12 @@ function validateErrorHandling(root, platform, sourceContent) {
974
900
  if (sdkCallFiles.length === 0)
975
901
  return [];
976
902
  const errorMarkers = ERROR_HANDLING_MARKERS_BY_PLATFORM[platform] ?? ERROR_HANDLING_MARKERS_BY_PLATFORM.typescript;
977
- // Check if ANY source file has error handling markers
978
903
  if (containsAny(sourceContent, errorMarkers))
979
904
  return [];
980
905
  return [
981
906
  finding(`${platform}.network.error-handling-present`, "warning", "SDK calls found but no error handling (try/catch, .catch, Result) was detected. Unhandled network errors produce broken UX or crashes.", relativeFile(root, sdkCallFiles[0]), "Wrap SDK calls in try/catch or attach .catch() handlers. Attest if error handling is centralized in a middleware layer."),
982
907
  ];
983
908
  }
984
- // ─── Comments validation ─────────────────────────────────────────────────────
985
909
  const COMMENT_PRESENCE_PATTERNS = [
986
910
  /createComment\s*\(/, /getComments\s*\(/, /commentRepository/i,
987
911
  /CommentRepository/, /AmityCommentRepository/, /commentCollection/i,
@@ -995,15 +919,16 @@ const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
995
919
  ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
996
920
  };
997
921
  const COMMENT_UI_STATE_MARKERS = [
998
- /loading/i, /empty/i, /error/i, /isLoading/, /isEmpty/, /hasError/,
922
+ /isLoading/, /isEmpty/, /hasError/, /isError\b/, /isFetching/, /isPending/,
999
923
  /\.loading\b/, /\.empty\b/, /\.error\b/, /LoadingState/, /emptyState/i,
924
+ /\bloading\s*[?:&]/i, /\berror\s*[?:&]/i, /\bempty\s*[?:&]/i, /No comments/i,
925
+ /\.status\s*===?\s*["']?(?:loading|error|empty)/i,
1000
926
  ];
1001
927
  function validateComments(root, platform, sourceContent) {
1002
928
  const findings = [];
1003
929
  const commentFiles = filesMatching(sourceContent, COMMENT_PRESENCE_PATTERNS);
1004
930
  if (commentFiles.length === 0)
1005
931
  return findings;
1006
- // target-resolved: check for hardcoded postId/commentId in comment files
1007
932
  for (const filePath of commentFiles) {
1008
933
  const content = sourceContent.get(filePath) ?? "";
1009
934
  const hardcodedTarget = /(?:postId|commentId|referenceId)\s*[:=]\s*["'`][a-z0-9-]+["'`]/i.exec(content);
@@ -1012,12 +937,6 @@ function validateComments(root, platform, sourceContent) {
1012
937
  break;
1013
938
  }
1014
939
  }
1015
- // observer-cleanup: require cleanup only for a REACTIVE comment observer. The
1016
- // distinguisher is `await`: a one-shot `await queryComments(...)` returns a Promise
1017
- // with nothing to clean up (false-fired on a weak-model build), whereas a non-awaited
1018
- // getComments/queryComments creates a live collection that must be disposed. Strip the
1019
- // awaited one-shot queries, then any remaining (bare-call, assigned, or chained) comment
1020
- // query is a live observer.
1021
940
  const hasReactiveCommentObserver = [...sourceContent.values()].some((c) => {
1022
941
  const stripped = c.replace(/\bawait\b[^\n;]*?(?:get|query)Comments\s*\(/g, "");
1023
942
  return /(?:get|query)Comments\s*\(/.test(stripped)
@@ -1027,12 +946,12 @@ function validateComments(root, platform, sourceContent) {
1027
946
  if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
1028
947
  findings.push(finding(`${platform}.comments.observer-cleanup`, "warning", "Comment observer/subscription was found but no obvious cleanup was detected.", relativeFile(root, commentFiles[0]), "Dispose or unsubscribe comment observers when the lifecycle owner is destroyed."));
1029
948
  }
1030
- // ui-states-present: require loading/empty/error states in the comment surface itself.
1031
- // Unrelated feed/auth/network state markers elsewhere in the project do not make a comment UI complete.
1032
- if (!containsAnyForFiles(sourceContent, commentFiles, COMMENT_UI_STATE_MARKERS)) {
949
+ const strippedCommentFiles = new Map();
950
+ for (const f of commentFiles)
951
+ strippedCommentFiles.set(f, commentStripped(f, platform, sourceContent.get(f) ?? ""));
952
+ if (!containsAnyForFiles(strippedCommentFiles, commentFiles, COMMENT_UI_STATE_MARKERS)) {
1033
953
  findings.push(finding(`${platform}.comments.ui-states-present`, "warning", "Comment UI is present but no loading, empty, or error state handling was detected.", relativeFile(root, commentFiles[0]), "Handle loading, empty, and error states in the comment UI for a complete user experience."));
1034
954
  }
1035
- // moderation-affordance-present: require moderation on comments (scoped to comment files)
1036
955
  const commentOnlyContent = new Map();
1037
956
  for (const f of commentFiles)
1038
957
  commentOnlyContent.set(f, sourceContent.get(f) ?? "");
@@ -1041,7 +960,6 @@ function validateComments(root, platform, sourceContent) {
1041
960
  }
1042
961
  return findings;
1043
962
  }
1044
- // --- Moderation validators ---
1045
963
  const MODERATION_PRESENCE_PATTERNS = [
1046
964
  /\bAmityModerationRepository\b/,
1047
965
  /\bModerationRepository\b/,
@@ -1086,31 +1004,22 @@ function validateModeration(root, platform, sourceContent) {
1086
1004
  const moderationFiles = filesMatching(sourceContent, MODERATION_PRESENCE_PATTERNS);
1087
1005
  if (moderationFiles.length === 0)
1088
1006
  return findings;
1089
- // report-flow-present: require report action
1090
1007
  const hasReport = containsAny(sourceContent, [/\breport\b/i, /\bflag\b/i, /\bflagContent\b/, /\breportContent\b/]);
1091
1008
  if (!hasReport) {
1092
1009
  findings.push(finding(`${platform}.moderation.report-flow-present`, "warning", "Moderation code is present but no report/flag flow was detected.", relativeFile(root, moderationFiles[0]), "Add a report action that calls the moderation API with a confirmation dialog before submission."));
1093
1010
  }
1094
- // block-or-mute-state-applied: require state rendering markers (not just the action call)
1095
- if (!containsAny(sourceContent, MODERATION_BLOCK_STATE_APPLIED_MARKERS)) {
1011
+ const blockStateContent = new Map([...sourceContent].map(([f, c]) => [f, blankDeadDeclarations(c, "isBlocked")]));
1012
+ if (!containsAny(blockStateContent, MODERATION_BLOCK_STATE_APPLIED_MARKERS)) {
1096
1013
  findings.push(finding(`${platform}.moderation.block-or-mute-state-applied`, "warning", "Moderation code is present but no block/mute state application was detected.", relativeFile(root, moderationFiles[0]), "After block/mute SDK call, update the UI to reflect the new state (hide content or show placeholder)."));
1097
1014
  }
1098
- // hidden-content-rendering-present: require rendering logic for hidden content
1099
1015
  if (!containsAny(sourceContent, MODERATION_HIDDEN_RENDERING_MARKERS)) {
1100
1016
  findings.push(finding(`${platform}.moderation.hidden-content-rendering-present`, "warning", "Moderation code is present but no hidden/blocked content rendering logic was detected.", relativeFile(root, moderationFiles[0]), "Add rendering logic for hidden/blocked content (placeholder, removed indicator, or blur)."));
1101
1017
  }
1102
- // confirmation-ux-present: require confirmation for destructive moderation actions
1103
1018
  if (!containsAny(sourceContent, MODERATION_CONFIRMATION_MARKERS)) {
1104
1019
  findings.push(finding(`${platform}.moderation.confirmation-ux-present`, "warning", "Destructive moderation actions found without confirmation UX.", relativeFile(root, moderationFiles[0]), "Add a confirmation step (dialog/alert/modal) before submitting destructive moderation actions."));
1105
1020
  }
1106
1021
  return findings;
1107
1022
  }
1108
- // --- Chat validators ---
1109
- // social.plus chat presence. NOTE: bare `sendMessage` is intentionally NOT here — it is not a
1110
- // social.plus symbol (the SDK sends via createMessage; see sdk-surface), and it false-matched
1111
- // foreign chat SDKs (e.g. Google Gemini's `chat.sendMessage`) — on the Flexify pilot smoke that
1112
- // mis-classified a feed-only app's AI-assistant store as a chat file and fired chat.* rules.
1113
- // Detection anchors on social.plus-specific message/channel symbols instead.
1114
1023
  const CHAT_PRESENCE_PATTERNS = [
1115
1024
  /\bcreateMessage\b/,
1116
1025
  /\bAmityMessageRepository\b/,
@@ -1123,7 +1032,7 @@ const CHAT_PRESENCE_PATTERNS = [
1123
1032
  const CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
1124
1033
  typescript: [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
1125
1034
  "react-native": [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
1126
- android: [/\bonCleared\b/, /\bonDestroy\b/, /\bdispose\b/, /\bremoveObserver\b/],
1035
+ android: [/\bdispose\s*\(/, /\bremoveObserver\b/, /\bunsubscribe\b/, /\bcancel\s*\(/, /\bclose\s*\(/],
1127
1036
  flutter: [/\bdispose\b/, /\bcancel\b/, /\bclose\b/],
1128
1037
  ios: [/\bdeinit\b/, /\bonDisappear\b/, /\binvalidate\b/, /\bremoveObserver\b/],
1129
1038
  };
@@ -1132,28 +1041,18 @@ function validateChat(root, platform, sourceContent) {
1132
1041
  const chatFiles = filesMatching(sourceContent, CHAT_PRESENCE_PATTERNS);
1133
1042
  if (chatFiles.length === 0)
1134
1043
  return findings;
1135
- // channel-type-dm: createChannel with type 'community' AND userIds indicates DM intent with wrong type.
1136
- // Disambiguating condition: community channels do not pass userIds in createChannel (membership is
1137
- // managed separately). If userIds appears alongside type:'community', the intent is clearly a DM
1138
- // conversation but the wrong channel type was used. Do NOT fire on community channels without userIds.
1139
1044
  for (const filePath of chatFiles) {
1140
1045
  const content = sourceContent.get(filePath) ?? "";
1141
1046
  if (/\bcreateChannel\b/.test(content)) {
1142
1047
  const hasCommunityType = /createChannel[\s\S]{0,400}type\s*:\s*['"]community['"]/.test(content);
1143
1048
  const hasUserIds = /createChannel[\s\S]{0,400}\buserIds\b/.test(content);
1144
1049
  const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
1145
- // Only fire on react-native/typescript where this rule is registered.
1146
- // Only fire when both type:'community' AND userIds are present — that combination signals
1147
- // DM intent. Community channel creation does not pass userIds to createChannel.
1148
1050
  if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
1149
1051
  findings.push(finding(`${platform}.chat.channel-type-dm`, "warning", "createChannel uses type 'community' with userIds, which indicates DM intent — but 'community' creates a discoverable group channel, not a private conversation.", relativeFile(root, filePath), "Use type: 'conversation' for 1-to-1 direct messages between known users. 'community' channels are discoverable group channels; passing userIds to createChannel with type:'community' does not create a private DM."));
1150
1052
  break;
1151
1053
  }
1152
1054
  }
1153
1055
  }
1154
- // channel-target-resolved: check for hardcoded channelId/conversationId.
1155
- // Comment-stripped so a commented-out or documented channelId can't trip this
1156
- // no-escape (exit-2) gate.
1157
1056
  for (const filePath of chatFiles) {
1158
1057
  const content = commentStripped(filePath, platform, sourceContent.get(filePath) ?? "");
1159
1058
  const hardcodedChannel = /(?:channelId|conversationId|channel_id)\b[^=\n]*=\s*["'`][a-z0-9-]+["'`]/i.exec(content);
@@ -1162,12 +1061,10 @@ function validateChat(root, platform, sourceContent) {
1162
1061
  break;
1163
1062
  }
1164
1063
  }
1165
- // message-observer-cleanup: require cleanup
1166
1064
  const chatCleanupMarkers = CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
1167
- if (!containsAny(sourceContent, chatCleanupMarkers)) {
1065
+ if (!containsAnyStripped(sourceContent, platform, chatCleanupMarkers)) {
1168
1066
  findings.push(finding(`${platform}.chat.message-observer-cleanup`, "warning", "Chat message observers were found but no obvious cleanup was detected.", relativeFile(root, chatFiles[0]), "Unsubscribe from message live collections on component unmount or view disposal."));
1169
1067
  }
1170
- // send-error-handling: require error handling around sendMessage (scoped to chat files)
1171
1068
  const chatFileContent = new Map();
1172
1069
  for (const f of chatFiles)
1173
1070
  chatFileContent.set(f, sourceContent.get(f) ?? "");
@@ -1183,13 +1080,72 @@ function validateChat(root, platform, sourceContent) {
1183
1080
  if (!hasErrorHandling) {
1184
1081
  findings.push(finding(`${platform}.chat.send-error-handling`, "warning", "Message send calls found without error handling.", relativeFile(root, chatFiles[0]), "Wrap sendMessage in try/catch or handle the error callback to show send failure state."));
1185
1082
  }
1186
- // moderation-affordance-present: require moderation on chat messages (scoped to chat files)
1187
1083
  if (!containsAny(chatFileContent, MODERATION_MARKERS)) {
1188
1084
  findings.push(finding(`${platform}.chat.moderation-affordance-present`, "warning", "Chat messages are user-generated content but no moderation affordance (report/block/hide) was detected.", relativeFile(root, chatFiles[0]), "Add a report/block/hide action on chat messages."));
1189
1085
  }
1190
1086
  return findings;
1191
1087
  }
1192
- // ─── Session Handler Retention ────────────────────────────────────────────────
1088
+ const DEPRECATED_MARKER_SYNC_PATTERNS = [
1089
+ /\bstartMessageReceiptSync\b/,
1090
+ /\bstopMessageReceiptSync\b/,
1091
+ ];
1092
+ const DEPRECATED_MARKER_SYNC_PLATFORMS = new Set(["typescript", "react-native", "android", "ios"]);
1093
+ const DEPRECATED_SUBCHANNEL_PATTERNS = [
1094
+ /\bcreateSubChannel\b/,
1095
+ /\bupdateSubChannel\b/,
1096
+ /\bdeleteSubChannel\b/,
1097
+ /\bAmitySubChannelQuery\b/,
1098
+ ];
1099
+ const DEPRECATED_MARKER_SYNC_ESCAPE = /\/\/\s*vise:\s*receipt-sync intentional/i;
1100
+ const DEPRECATED_SUBCHANNEL_ESCAPE = /\/\/\s*vise:\s*subchannel-management intentional/i;
1101
+ function validateChatDeprecations(root, platform, sourceContent) {
1102
+ const findings = [];
1103
+ if (DEPRECATED_MARKER_SYNC_PLATFORMS.has(platform)) {
1104
+ for (const [filePath, raw] of sourceContent) {
1105
+ const content = commentStripped(filePath, platform, raw);
1106
+ if (DEPRECATED_MARKER_SYNC_PATTERNS.some((pattern) => pattern.test(content))) {
1107
+ if (DEPRECATED_MARKER_SYNC_ESCAPE.test(raw))
1108
+ continue;
1109
+ findings.push(finding(`${platform}.chat.deprecated-marker-sync`, "warning", "Chat code uses the deprecated MarkerSyncEngine receipt sync API (startMessageReceiptSync/stopMessageReceiptSync), which is being sun-set.", relativeFile(root, filePath), "MarkerSyncEngine receipt sync is deprecated. Track read state with the channel unread count (channel.unreadCount / getTotalChannelUnread) and mark read via markRead() — the unread-count APIs stay fully supported. Add `// vise: receipt-sync intentional — <reason>` only if you are deliberately staying on the legacy API during migration."));
1110
+ break;
1111
+ }
1112
+ }
1113
+ }
1114
+ for (const [filePath, raw] of sourceContent) {
1115
+ const content = commentStripped(filePath, platform, raw);
1116
+ if (DEPRECATED_SUBCHANNEL_PATTERNS.some((pattern) => pattern.test(content))) {
1117
+ if (DEPRECATED_SUBCHANNEL_ESCAPE.test(raw))
1118
+ continue;
1119
+ findings.push(finding(`${platform}.chat.deprecated-subchannel`, "warning", "Chat code manually manages sub-channels (createSubChannel/updateSubChannel/deleteSubChannel), a deprecated pattern that is being sun-set.", relativeFile(root, filePath), "Manual sub-channel management is deprecated. Use the channel's default sub-channel via channel.defaultSubChannelId (channel.getDefaultSubChannelId() on Android) — you do not need to create or query sub-channels separately. Add `// vise: subchannel-management intentional — <reason>` only if a multi-sub-channel layout is deliberately required."));
1120
+ break;
1121
+ }
1122
+ }
1123
+ return findings;
1124
+ }
1125
+ const DEPRECATED_LEGACY_STREAM_PATTERNS = [
1126
+ /\bAmityStreamRepository\b/,
1127
+ /\bStreamRepository\b/,
1128
+ /\bgetStreamById\b/,
1129
+ /\bcreateLiveStreamPost\b/,
1130
+ /\bAmityLiveStreamPostCreator\b/,
1131
+ ];
1132
+ const DEPRECATED_LEGACY_STREAM_PLATFORMS = new Set(["typescript", "react-native", "android", "ios"]);
1133
+ const DEPRECATED_LEGACY_STREAM_ESCAPE = /\/\/\s*vise:\s*legacy-stream intentional/i;
1134
+ function validateLivestreamDeprecation(root, platform, sourceContent) {
1135
+ const findings = [];
1136
+ if (!DEPRECATED_LEGACY_STREAM_PLATFORMS.has(platform))
1137
+ return findings;
1138
+ for (const [filePath, raw] of sourceContent) {
1139
+ const content = commentStripped(filePath, platform, raw);
1140
+ if (DEPRECATED_LEGACY_STREAM_PATTERNS.some((pattern) => pattern.test(content))) {
1141
+ if (DEPRECATED_LEGACY_STREAM_ESCAPE.test(raw))
1142
+ continue;
1143
+ findings.push(finding(`${platform}.livestream.deprecated-stream`, "warning", "Livestream code uses the deprecated legacy Stream API (AmityStreamRepository/StreamRepository/getStreamById/createLiveStreamPost), which is being sun-set in favour of Room-based livestreaming.", relativeFile(root, filePath), "The legacy Stream feature is deprecated. Migrate to the Room API: AmityRoomRepository/RoomRepository.getRoom(roomId) (observe the returned room live object and handle all four statuses — idle/live/ended/recorded) and createRoomPost in place of createLiveStreamPost. Add `// vise: legacy-stream intentional — <reason>` only if you are deliberately staying on legacy Stream during migration."));
1144
+ break;
1145
+ }
1146
+ }
1147
+ return findings;
1148
+ }
1193
1149
  const SESSION_HANDLER_PRESENCE = {
1194
1150
  android: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
1195
1151
  flutter: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
@@ -1228,7 +1184,7 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1228
1184
  /\bListView\b/,
1229
1185
  /\bRecyclerView\b/,
1230
1186
  /\bLazyColumn\b/,
1231
- /\bLazyRow\b/, // Jetpack Compose lists
1187
+ /\bLazyRow\b/,
1232
1188
  /\bLazyVerticalGrid\b/,
1233
1189
  /\bLazyVerticalStaggeredGrid\b/,
1234
1190
  /\bForEach\b/,
@@ -1245,26 +1201,17 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1245
1201
  /\bonData\b/,
1246
1202
  /\bStreamBuilder\b/,
1247
1203
  /\bLiveData\b/,
1248
- /\bMutableLiveData\b/, // Android LiveData (modern; `\bLiveData\b` misses Mutable/Mediator forms)
1204
+ /\bMutableLiveData\b/,
1249
1205
  /\bMediatorLiveData\b/,
1250
- /collectAsState/, // Jetpack Compose: Flow/StateFlow → State (collectAsState, collectAsStateWithLifecycle)
1251
- /observeAsState/, // Jetpack Compose: LiveData → State
1206
+ /collectAsState/,
1207
+ /observeAsState/,
1252
1208
  /Amity\w*Flow\b/,
1253
1209
  /\bPagingData\b/,
1254
- /LazyPagingItems/, // Paging3 Compose: getX().collectAsLazyPagingItems() in a LazyColumn — reactive
1210
+ /LazyPagingItems/,
1255
1211
  /collectAsLazyPagingItems/,
1256
- /\.on\s*\(\s*['"]data[A-Z]/, // event-emitter SDK pattern: .on('dataUpdated', ...)
1257
- /\$snapshots\b/, // iOS Combine: AmityCollection.$snapshots.sink { ... }
1258
- /\bAmityCollection\b/, // iOS live collection type, consumed reactively (@Published / .snapshots in a List)
1259
- /\bLiveCollectionCallback\b/, // TS/RN live-collection callback type (Amity.LiveCollectionCallback)
1260
- /\bLiveObjectCallback\b/, // TS/RN live-object callback type
1261
- // TS/RN reactive form: getPosts(params, callback) / getComments(params, cb) — the
1262
- // 2-arg callback form IS the live collection (callback fires on every update). Earlier
1263
- // markers only knew getLiveCollection/observe and missed this idiomatic v6 form. The
1264
- // first arg is a flat params OBJECT or an identifier; the callback is a genuine SECOND
1265
- // argument — matching `{...},` (not a comma *inside* the object, which would falsely
1266
- // count a one-shot `queryPosts({ a, b })` as reactive).
1267
- /\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1212
+ /\.on\s*\(\s*['"]data[A-Z]/,
1213
+ /\$snapshots\b/,
1214
+ /\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions|CommunityFeed|GlobalFeed)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1268
1215
  /\/\/\s*vise:\s*one-shot query/i
1269
1216
  ];
1270
1217
  for (const [file, content] of sourceContent) {
@@ -1275,7 +1222,8 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1275
1222
  if (hasListQuery) {
1276
1223
  const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1277
1224
  if (hasListUI) {
1278
- const hasReactivity = REACTIVE_MARKERS.some((p) => p.test(content));
1225
+ const reactiveSurface = commentStripped(file, platform, content);
1226
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1279
1227
  if (!hasReactivity) {
1280
1228
  findings.push(finding(ruleId, "warning", `A list query (like getPosts) is present but no reactive markers (getLiveCollection, observe, onData) were found in the same file.`, rel, "Use the reactive LiveCollection API for lists so the UI updates when new items arrive. Preserve existing query options and pagination wiring (for example pageToken, hasMore/loadMore, useInfiniteQuery) while converting the list to live updates. If a one-shot query is truly intended, add comment // vise: one-shot query."));
1281
1229
  }
@@ -1284,23 +1232,212 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1284
1232
  }
1285
1233
  return findings;
1286
1234
  }
1235
+ function validateStories(root, platform, sourceContent) {
1236
+ const findings = [];
1237
+ const ruleId = `${platform}.story.live-collection`;
1238
+ const STORY_LIST_QUERY_PATTERNS = [
1239
+ /\bgetActiveStoriesByTarget\b/,
1240
+ /\bgetActiveStories\b/,
1241
+ /\bgetStoriesByTargets\b/,
1242
+ /\bgetStoriesByTargetIds\b/,
1243
+ /\bgetGlobalStoryTargets\b/,
1244
+ ];
1245
+ const LIST_UI_PATTERNS = [
1246
+ /\bmap\s*\(/,
1247
+ /\bFlatList\b/,
1248
+ /\bListView\b/,
1249
+ /\bRecyclerView\b/,
1250
+ /\bLazyColumn\b/,
1251
+ /\bLazyRow\b/,
1252
+ /\bLazyVerticalGrid\b/,
1253
+ /\bLazyVerticalStaggeredGrid\b/,
1254
+ /\bForEach\b/,
1255
+ /\bList\s*\(/,
1256
+ /\bsubmitList\b/,
1257
+ /\btableView\b/,
1258
+ /\bcollectionView\b/
1259
+ ];
1260
+ const REACTIVE_MARKERS = [
1261
+ /\bgetLiveCollection\b/,
1262
+ /\bobserve\b/,
1263
+ /\bobserveOnce\b/,
1264
+ /\blisten\b/,
1265
+ /\bonData\b/,
1266
+ /\bStreamBuilder\b/,
1267
+ /\bLiveData\b/,
1268
+ /\bMutableLiveData\b/,
1269
+ /\bMediatorLiveData\b/,
1270
+ /collectAsState/,
1271
+ /observeAsState/,
1272
+ /Amity\w*Flow\b/,
1273
+ /\bPagingData\b/,
1274
+ /LazyPagingItems/,
1275
+ /collectAsLazyPagingItems/,
1276
+ /\.on\s*\(\s*['"]data[A-Z]/,
1277
+ /\$snapshots\b/,
1278
+ /\bStoryLiveCollection\b/,
1279
+ /\bget(?:ActiveStoriesByTarget|StoriesByTargets|StoriesByTargetIds|GlobalStoryTargets)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1280
+ /\/\/\s*vise:\s*one-shot query/i
1281
+ ];
1282
+ for (const [file, content] of sourceContent) {
1283
+ const rel = relativeFile(root, file);
1284
+ if (path.basename(file).endsWith('.d.ts'))
1285
+ continue;
1286
+ const hasStoryQuery = STORY_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1287
+ if (hasStoryQuery) {
1288
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1289
+ if (hasListUI) {
1290
+ const reactiveSurface = commentStripped(file, platform, content);
1291
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1292
+ if (!hasReactivity) {
1293
+ findings.push(finding(ruleId, "warning", `A story query (like getActiveStoriesByTarget) renders a list but no reactive markers (observe, onData, the (params, callback) live form, StreamBuilder, PagingData) were found in the same file.`, rel, "Render story trays from the reactive Story LiveCollection so the UI updates when stories are created, viewed, or expire. Pass the live callback (TS/RN: getActiveStoriesByTarget(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter StreamBuilder). If a one-shot query is truly intended, add comment // vise: one-shot query."));
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+ return findings;
1299
+ }
1300
+ function validateSearch(root, platform, sourceContent) {
1301
+ const findings = [];
1302
+ const ruleId = `${platform}.search.live-collection`;
1303
+ const SEARCH_LIST_QUERY_PATTERNS = [
1304
+ /\bsearchCommunities\b/,
1305
+ /\bsemanticSearchCommunities\b/,
1306
+ /\bsemanticSearchPosts\b/,
1307
+ /\bsearchPostsByHashtag\b/,
1308
+ /\bsearchUsers\b/,
1309
+ /\bsearchUserByDisplayName\b/,
1310
+ /\bsearchChannels\b/,
1311
+ ];
1312
+ const LIST_UI_PATTERNS = [
1313
+ /\bmap\s*\(/,
1314
+ /\bFlatList\b/,
1315
+ /\bListView\b/,
1316
+ /\bRecyclerView\b/,
1317
+ /\bLazyColumn\b/,
1318
+ /\bLazyRow\b/,
1319
+ /\bLazyVerticalGrid\b/,
1320
+ /\bLazyVerticalStaggeredGrid\b/,
1321
+ /\bForEach\b/,
1322
+ /\bList\s*\(/,
1323
+ /\bsubmitList\b/,
1324
+ /\btableView\b/,
1325
+ /\bcollectionView\b/
1326
+ ];
1327
+ const REACTIVE_MARKERS = [
1328
+ /\bgetLiveCollection\b/,
1329
+ /\bobserve\b/,
1330
+ /\bobserveOnce\b/,
1331
+ /\blisten\b/,
1332
+ /\bonData\b/,
1333
+ /\bStreamBuilder\b/,
1334
+ /\bLiveData\b/,
1335
+ /\bMutableLiveData\b/,
1336
+ /\bMediatorLiveData\b/,
1337
+ /collectAsState/,
1338
+ /observeAsState/,
1339
+ /Amity\w*Flow\b/,
1340
+ /\bPagingData\b/,
1341
+ /LazyPagingItems/,
1342
+ /collectAsLazyPagingItems/,
1343
+ /\.on\s*\(\s*['"]data[A-Z]/,
1344
+ /\$snapshots\b/,
1345
+ /\bPagingController\b/,
1346
+ /\bgetPagingData\b/,
1347
+ /\b(?:search(?:Communities|Users|UserByDisplayName|PostsByHashtag|Channels)|semanticSearch(?:Communities|Posts))\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1348
+ /\/\/\s*vise:\s*one-shot query/i
1349
+ ];
1350
+ for (const [file, content] of sourceContent) {
1351
+ const rel = relativeFile(root, file);
1352
+ if (path.basename(file).endsWith('.d.ts'))
1353
+ continue;
1354
+ const hasSearchQuery = SEARCH_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1355
+ if (hasSearchQuery) {
1356
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1357
+ if (hasListUI) {
1358
+ const reactiveSurface = commentStripped(file, platform, content);
1359
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1360
+ if (!hasReactivity) {
1361
+ findings.push(finding(ruleId, "warning", `A search query (like searchCommunities/searchUsers) renders a list but no reactive/paginated markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render search results from the reactive, paginated Search LiveCollection so the list updates and pages past the first result set. Use the live callback (TS/RN: searchX(params, callback)), observe the collection (iOS .observe / Android PagingData), or wire a PagingController (Flutter). If a one-shot, single-page search is truly intended, add comment // vise: one-shot query."));
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ return findings;
1367
+ }
1368
+ function validateNotificationTray(root, platform, sourceContent) {
1369
+ const findings = [];
1370
+ const ruleId = `${platform}.notification-tray.live-collection`;
1371
+ const TRAY_LIST_QUERY_PATTERNS = [
1372
+ /\bgetNotificationTrayItems\b/,
1373
+ ];
1374
+ const LIST_UI_PATTERNS = [
1375
+ /\bmap\s*\(/,
1376
+ /\bFlatList\b/,
1377
+ /\bListView\b/,
1378
+ /\bRecyclerView\b/,
1379
+ /\bLazyColumn\b/,
1380
+ /\bLazyRow\b/,
1381
+ /\bLazyVerticalGrid\b/,
1382
+ /\bLazyVerticalStaggeredGrid\b/,
1383
+ /\bForEach\b/,
1384
+ /\bList\s*\(/,
1385
+ /\bsubmitList\b/,
1386
+ /\btableView\b/,
1387
+ /\bcollectionView\b/
1388
+ ];
1389
+ const REACTIVE_MARKERS = [
1390
+ /\bgetLiveCollection\b/,
1391
+ /\bobserve\b/,
1392
+ /\bobserveOnce\b/,
1393
+ /\blisten\b/,
1394
+ /\bonData\b/,
1395
+ /\bStreamBuilder\b/,
1396
+ /\bLiveData\b/,
1397
+ /\bMutableLiveData\b/,
1398
+ /\bMediatorLiveData\b/,
1399
+ /collectAsState/,
1400
+ /observeAsState/,
1401
+ /Amity\w*Flow\b/,
1402
+ /\bPagingData\b/,
1403
+ /LazyPagingItems/,
1404
+ /collectAsLazyPagingItems/,
1405
+ /\.on\s*\(\s*['"]data[A-Z]/,
1406
+ /\$snapshots\b/,
1407
+ /\bgetNotificationTrayItems\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1408
+ /\/\/\s*vise:\s*one-shot query/i
1409
+ ];
1410
+ for (const [file, content] of sourceContent) {
1411
+ const rel = relativeFile(root, file);
1412
+ if (path.basename(file).endsWith('.d.ts'))
1413
+ continue;
1414
+ const hasTrayQuery = TRAY_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1415
+ if (hasTrayQuery) {
1416
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1417
+ if (hasListUI) {
1418
+ const reactiveSurface = commentStripped(file, platform, content);
1419
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1420
+ if (!hasReactivity) {
1421
+ findings.push(finding(ruleId, "warning", `A notification tray query (getNotificationTrayItems) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData) were found in the same file.`, rel, "Render the notification tray from the reactive Tray LiveCollection so it updates as notifications arrive and seen-state changes. Use the live callback (TS/RN: getNotificationTrayItems(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot tray snapshot is truly intended, add comment // vise: one-shot query."));
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ return findings;
1427
+ }
1287
1428
  function validatePostsStatusFilter(root, platform, sourceContent) {
1288
1429
  const findings = [];
1289
1430
  const ruleId = `${platform}.posts.status-filter-applied`;
1290
1431
  const POST_QUERY_PATTERNS = [
1291
1432
  /\bgetPosts\b/,
1292
1433
  /\bqueryPosts\b/,
1293
- // getCommunityFeed intentionally NOT matched: it is also a common name for a
1294
- // user-defined wrapper, and firing on a wrapper CALL SITE (which has no query
1295
- // params to filter) is a false positive. The explicit getPosts/queryPosts cover
1296
- // the real query path where filters are actually applied.
1297
1434
  ];
1298
1435
  const FILTER_MARKERS = [
1299
- /\bfeedTypes?\b/,
1436
+ /\bfeedTypes?\s*[:(]/,
1300
1437
  /\bincludeDeleted\s*\(\s*false\s*\)/,
1301
1438
  /\bisDeleted\s*:\s*false\b/,
1302
1439
  /\bisFlagged\s*:\s*false\b/,
1303
- /\bstatuses\b/,
1440
+ /\bstatuses\s*[:(]/,
1304
1441
  /\bif\s*\([^)]*isDeleted\)/,
1305
1442
  /\bfilter\s*\([^)]*isDeleted\)/,
1306
1443
  /\bif\s*\([^)]*isFlagged\)/,
@@ -1311,7 +1448,8 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1311
1448
  const rel = relativeFile(root, file);
1312
1449
  const hasPostQuery = POST_QUERY_PATTERNS.some((p) => p.test(content));
1313
1450
  if (hasPostQuery) {
1314
- const hasFilter = FILTER_MARKERS.some((p) => p.test(content));
1451
+ const filterSurface = commentStripped(file, platform, content);
1452
+ const hasFilter = /\/\/\s*vise:\s*moderation review feed/i.test(content) || FILTER_MARKERS.some((p) => p.test(filterSurface));
1315
1453
  if (!hasFilter) {
1316
1454
  findings.push(finding(ruleId, "warning", `A post query (like getPosts) is present but no status filters (includeDeleted, statuses, feedType) were found.`, rel, "Unfiltered queries leak deleted or flagged posts to the feed. Apply a status filter."));
1317
1455
  }
@@ -1327,7 +1465,6 @@ function validateActivityPostsTagFilter(root, platform, sourceContent) {
1327
1465
  const filename = path.basename(file).toLowerCase();
1328
1466
  if (filename.endsWith('.d.ts'))
1329
1467
  continue;
1330
- // Only fire for files that are clearly activity / toast / notification hooks
1331
1468
  if (!/activity|toast|notification|event/.test(filename))
1332
1469
  continue;
1333
1470
  if (!/\bgetPosts\b/.test(content))
@@ -1347,16 +1484,12 @@ function validateReactionStalePostRef(root, platform, sourceContent) {
1347
1484
  const rel = relativeFile(root, file);
1348
1485
  if (path.basename(file).endsWith('.d.ts'))
1349
1486
  continue;
1350
- // Only check files that have reaction calls
1351
1487
  if (!/\baddReaction\b|\bremoveReaction\b/.test(content))
1352
1488
  continue;
1353
- // Only check files that use a ref (React hook — TypeScript/React Native only)
1354
1489
  if (!/\buseRef\b/.test(content))
1355
1490
  continue;
1356
- // Look for .current passed into the reaction call — the stale-ref pattern
1357
1491
  if (!/.current\b/.test(content))
1358
1492
  continue;
1359
- // If the file already reads post.postId from live state it is correct
1360
1493
  if (/\bpost\s*\??\.\s*postId\b/.test(content))
1361
1494
  continue;
1362
1495
  if (/\/\/\s*vise:\s*captured ref intentional/i.test(content))
@@ -1368,29 +1501,24 @@ function validateReactionStalePostRef(root, platform, sourceContent) {
1368
1501
  function validateFollowStatusSubscription(root, platform, sourceContent) {
1369
1502
  const findings = [];
1370
1503
  const ruleId = `${platform}.follow.status-subscription`;
1504
+ if (platform === 'flutter')
1505
+ return findings;
1371
1506
  for (const [file, content] of sourceContent) {
1372
1507
  const rel = relativeFile(root, file);
1373
1508
  if (path.basename(file).endsWith('.d.ts'))
1374
1509
  continue;
1375
- if (!/\bgetFollowStatus\b/.test(content))
1376
- continue;
1377
- // Positive markers: live object subscription or explicit escape hatch.
1378
- // The canonical "live" pattern is `getFollowStatus(userId, ({ data }) => ...)` —
1379
- // a callback that receives the live snapshot. That's also what the
1380
- // recommendation below suggests. If the call passes a function-style argument
1381
- // (arrow or function), treat it as a live subscription.
1382
- if (/\.on\s*\(\s*['"]dataUpdated['"]/.test(content) ||
1383
- /\bLiveObject\b/.test(content) ||
1384
- /\bobserve\s*\(/.test(content) ||
1385
- // `getFollowStatus(userId, callback)` — two-arg form (comma inside the
1386
- // first level of parens). Matches the recommended pattern whether the
1387
- // callback is inline `({ data }) => …` or a named reference.
1388
- // [^()]* keeps us inside the first level — disqualifies promise chains
1389
- // like `getFollowStatus(userId).then(...)` which have no comma inside.
1390
- /getFollowStatus\s*\([^()]*,/.test(content) ||
1510
+ const checkContent = commentStripped(file, platform, content);
1511
+ if (!/\bgetFollowInfo\b/.test(checkContent))
1512
+ continue;
1513
+ if (/\.on\s*\(\s*['"]dataUpdated['"]/.test(checkContent) ||
1514
+ /\bobserve\s*\(/.test(checkContent) ||
1515
+ /\bobserve\s*\{/.test(checkContent) ||
1516
+ /\bdoOnNext\b/.test(checkContent) ||
1517
+ /\.subscribe\s*\(/.test(checkContent) ||
1518
+ /getFollowInfo\s*\([^()]*,/.test(checkContent) ||
1391
1519
  /\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
1392
1520
  continue;
1393
- findings.push(finding(ruleId, "warning", "getFollowStatus is called without a live subscription. Follow state will not update when the relationship changes.", rel, "Subscribe to the live object returned by getFollowStatus and dispose on cleanup: const unsubFollow = UserRepository.getFollowStatus(userId, ({ data }) => { if (data) setFollowStatus(data.status); }); return () => { unsubFollow(); };"));
1521
+ findings.push(finding(ruleId, "warning", "getFollowInfo is called without a live subscription. Follow state will not update when the relationship changes.", rel, "Subscribe to the live object returned by getFollowInfo and dispose on cleanup: const unsubFollow = UserRepository.getFollowInfo(userId, ({ data }) => { if (data) setFollowStatus(data.status); }); return () => { unsubFollow(); };"));
1394
1522
  break;
1395
1523
  }
1396
1524
  return findings;
@@ -1398,6 +1526,8 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
1398
1526
  function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
1399
1527
  const findings = [];
1400
1528
  const ruleId = `${platform}.membership.use-live-collection`;
1529
+ if (platform !== 'react-native')
1530
+ return findings;
1401
1531
  for (const [file, content] of sourceContent) {
1402
1532
  const rel = relativeFile(root, file);
1403
1533
  if (path.basename(file).endsWith('.d.ts'))
@@ -1405,10 +1535,313 @@ function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
1405
1535
  if (!/\bsearchMembers\b/.test(content))
1406
1536
  continue;
1407
1537
  if (/\bgetMembers\b/.test(content))
1408
- continue; // already uses live collection
1538
+ continue;
1539
+ if (/searchMembers\s*\(\s*\{[\s\S]*?\}\s*,/.test(content))
1540
+ continue;
1409
1541
  if (/\/\/\s*vise:\s*one-shot membership intentional/i.test(content))
1410
1542
  continue;
1411
- findings.push(finding(ruleId, "warning", "searchMembers is a one-shot query — it fetches membership once and will not update when members join or leave.", rel, "Replace searchMembers with CommunityRepository.Membership.getMembers({ communityId }) which returns a LiveCollection. Subscribe to dataUpdated for real-time membership changes and call dispose() for cleanup: const liveCollection = CommunityRepository.Membership.getMembers({ communityId }); liveCollection.on('dataUpdated', (members) => setMembers(members)); return () => liveCollection.dispose();"));
1543
+ findings.push(finding(ruleId, "warning", "searchMembers is called as a one-shot Promise (no callback) — it fetches membership once and will not update when members join or leave.", rel, "Pass a callback so searchMembers subscribes to its LiveCollection (or use CommunityRepository.Membership.getMembers): const unsub = CommunityRepository.Membership.searchMembers({ communityId }, ({ data }) => setMembers(data)); return () => unsub();"));
1544
+ }
1545
+ return findings;
1546
+ }
1547
+ function validateMembershipList(root, platform, sourceContent) {
1548
+ const findings = [];
1549
+ if (platform === 'react-native')
1550
+ return findings;
1551
+ const ruleId = `${platform}.membership.use-live-collection`;
1552
+ const MEMBER_LIST_QUERY_PATTERNS = [
1553
+ /\bgetMembers\b/,
1554
+ /\bsearchMembers\b/,
1555
+ ];
1556
+ const LIST_UI_PATTERNS = [
1557
+ /\bmap\s*\(/,
1558
+ /\bFlatList\b/,
1559
+ /\bListView\b/,
1560
+ /\bRecyclerView\b/,
1561
+ /\bLazyColumn\b/,
1562
+ /\bLazyRow\b/,
1563
+ /\bLazyVerticalGrid\b/,
1564
+ /\bLazyVerticalStaggeredGrid\b/,
1565
+ /\bForEach\b/,
1566
+ /\bList\s*\(/,
1567
+ /\bsubmitList\b/,
1568
+ /\btableView\b/,
1569
+ /\bcollectionView\b/
1570
+ ];
1571
+ const REACTIVE_MARKERS = [
1572
+ /\bgetLiveCollection\b/,
1573
+ /\bobserve\b/,
1574
+ /\bobserveOnce\b/,
1575
+ /\blisten\b/,
1576
+ /\bonData\b/,
1577
+ /\bStreamBuilder\b/,
1578
+ /\bLiveData\b/,
1579
+ /\bMutableLiveData\b/,
1580
+ /\bMediatorLiveData\b/,
1581
+ /collectAsState/,
1582
+ /observeAsState/,
1583
+ /Amity\w*Flow\b/,
1584
+ /\bPagingData\b/,
1585
+ /LazyPagingItems/,
1586
+ /collectAsLazyPagingItems/,
1587
+ /\.on\s*\(\s*['"]data[A-Z]/,
1588
+ /\$snapshots\b/,
1589
+ /\bPagingController\b/,
1590
+ /\bgetPagingData\b/,
1591
+ /\bCommunityMemberLiveCollection\b/,
1592
+ /\b(?:getMembers|searchMembers)\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1593
+ /\/\/\s*vise:\s*one-shot query/i
1594
+ ];
1595
+ for (const [file, content] of sourceContent) {
1596
+ const rel = relativeFile(root, file);
1597
+ if (path.basename(file).endsWith('.d.ts'))
1598
+ continue;
1599
+ const hasMemberQuery = MEMBER_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1600
+ if (hasMemberQuery) {
1601
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1602
+ if (hasListUI) {
1603
+ const reactiveSurface = commentStripped(file, platform, content);
1604
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1605
+ if (!hasReactivity) {
1606
+ findings.push(finding(ruleId, "warning", `A community-member query (getMembers/searchMembers) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the member list from the reactive member LiveCollection so the roster updates as people join, leave, or change role. Use the live callback (TS/RN: getMembers(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter PagingController/CommunityMemberLiveCollection). If a one-shot member snapshot is truly intended, add comment // vise: one-shot query."));
1607
+ }
1608
+ }
1609
+ }
1610
+ }
1611
+ return findings;
1612
+ }
1613
+ function validateEvents(root, platform, sourceContent) {
1614
+ const findings = [];
1615
+ if (platform !== 'typescript' && platform !== 'android' && platform !== 'ios')
1616
+ return findings;
1617
+ const ruleId = `${platform}.event.live-collection`;
1618
+ const EVENT_LIST_QUERY_PATTERNS = [
1619
+ /\bgetEvents\b/,
1620
+ /\bgetRSVPs\b/,
1621
+ ];
1622
+ const LIST_UI_PATTERNS = [
1623
+ /\bmap\s*\(/,
1624
+ /\bFlatList\b/,
1625
+ /\bListView\b/,
1626
+ /\bRecyclerView\b/,
1627
+ /\bLazyColumn\b/,
1628
+ /\bLazyRow\b/,
1629
+ /\bLazyVerticalGrid\b/,
1630
+ /\bLazyVerticalStaggeredGrid\b/,
1631
+ /\bForEach\b/,
1632
+ /\bList\s*\(/,
1633
+ /\bsubmitList\b/,
1634
+ /\btableView\b/,
1635
+ /\bcollectionView\b/
1636
+ ];
1637
+ const REACTIVE_MARKERS = [
1638
+ /\bgetLiveCollection\b/,
1639
+ /\bobserve\b/,
1640
+ /\bobserveOnce\b/,
1641
+ /\blisten\b/,
1642
+ /\bonData\b/,
1643
+ /\bStreamBuilder\b/,
1644
+ /\bLiveData\b/,
1645
+ /\bMutableLiveData\b/,
1646
+ /\bMediatorLiveData\b/,
1647
+ /collectAsState/,
1648
+ /observeAsState/,
1649
+ /Amity\w*Flow\b/,
1650
+ /\bPagingData\b/,
1651
+ /LazyPagingItems/,
1652
+ /collectAsLazyPagingItems/,
1653
+ /\.on\s*\(\s*['"]data[A-Z]/,
1654
+ /\$snapshots\b/,
1655
+ /\bget(?:Events|RSVPs)\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1656
+ /\/\/\s*vise:\s*one-shot query/i
1657
+ ];
1658
+ for (const [file, content] of sourceContent) {
1659
+ const rel = relativeFile(root, file);
1660
+ if (path.basename(file).endsWith('.d.ts'))
1661
+ continue;
1662
+ const hasEventQuery = EVENT_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1663
+ if (hasEventQuery) {
1664
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1665
+ if (hasListUI) {
1666
+ const reactiveSurface = commentStripped(file, platform, content);
1667
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1668
+ if (!hasReactivity) {
1669
+ findings.push(finding(ruleId, "warning", `An event/RSVP query (getEvents/getRSVPs) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData) were found in the same file.`, rel, "Render event calendars and attendee lists from the reactive Event/RSVP LiveCollection so rsvpCount and the roster update as people RSVP. Use the live callback (TS: getEvents(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
1670
+ }
1671
+ }
1672
+ }
1673
+ }
1674
+ return findings;
1675
+ }
1676
+ function validateBlockedUsers(root, platform, sourceContent) {
1677
+ const findings = [];
1678
+ const ruleId = `${platform}.blocked-users.live-collection`;
1679
+ const BLOCKED_LIST_QUERY_PATTERNS = [
1680
+ /\bgetBlockedUsers\b/,
1681
+ ];
1682
+ const LIST_UI_PATTERNS = [
1683
+ /\bmap\s*\(/,
1684
+ /\bFlatList\b/,
1685
+ /\bListView\b/,
1686
+ /\bRecyclerView\b/,
1687
+ /\bLazyColumn\b/,
1688
+ /\bLazyRow\b/,
1689
+ /\bLazyVerticalGrid\b/,
1690
+ /\bLazyVerticalStaggeredGrid\b/,
1691
+ /\bForEach\b/,
1692
+ /\bList\s*\(/,
1693
+ /\bsubmitList\b/,
1694
+ /\btableView\b/,
1695
+ /\bcollectionView\b/
1696
+ ];
1697
+ const REACTIVE_MARKERS = [
1698
+ /\bgetLiveCollection\b/,
1699
+ /\bobserve\b/,
1700
+ /\bobserveOnce\b/,
1701
+ /\blisten\b/,
1702
+ /\bonData\b/,
1703
+ /\bStreamBuilder\b/,
1704
+ /\bLiveData\b/,
1705
+ /\bMutableLiveData\b/,
1706
+ /\bMediatorLiveData\b/,
1707
+ /collectAsState/,
1708
+ /observeAsState/,
1709
+ /Amity\w*Flow\b/,
1710
+ /\bPagingData\b/,
1711
+ /LazyPagingItems/,
1712
+ /collectAsLazyPagingItems/,
1713
+ /\.on\s*\(\s*['"]data[A-Z]/,
1714
+ /\$snapshots\b/,
1715
+ /\bPagingController\b/,
1716
+ /\bgetPagingData\b/,
1717
+ /\bgetBlockedUsers\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1718
+ /\/\/\s*vise:\s*one-shot query/i
1719
+ ];
1720
+ for (const [file, content] of sourceContent) {
1721
+ const rel = relativeFile(root, file);
1722
+ if (path.basename(file).endsWith('.d.ts'))
1723
+ continue;
1724
+ const hasBlockedQuery = BLOCKED_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1725
+ if (hasBlockedQuery) {
1726
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1727
+ if (hasListUI) {
1728
+ const reactiveSurface = commentStripped(file, platform, content);
1729
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1730
+ if (!hasReactivity) {
1731
+ findings.push(finding(ruleId, "warning", `A blocked-users query (getBlockedUsers) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the blocked-users list from the reactive getBlockedUsers LiveCollection so it refreshes when the user blocks/unblocks. Use the live callback (TS/RN: getBlockedUsers(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter PagingController). For a one-shot feed-filter snapshot use getAllBlockedUsers instead, or add comment // vise: one-shot query."));
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+ return findings;
1737
+ }
1738
+ function validateInvitations(root, platform, sourceContent) {
1739
+ const findings = [];
1740
+ const ruleId = `${platform}.invitation.live-collection`;
1741
+ const isNative = platform === 'android' || platform === 'ios';
1742
+ const INVITATION_LIST_QUERY_PATTERNS = isNative
1743
+ ? [/\bgetMyCommunityInvitations\b/, /\bgetMemberInvitations\b/]
1744
+ : [/\bgetMyCommunityInvitations\b/];
1745
+ const LIST_UI_PATTERNS = [
1746
+ /\bmap\s*\(/,
1747
+ /\bFlatList\b/,
1748
+ /\bListView\b/,
1749
+ /\bRecyclerView\b/,
1750
+ /\bLazyColumn\b/,
1751
+ /\bLazyRow\b/,
1752
+ /\bLazyVerticalGrid\b/,
1753
+ /\bLazyVerticalStaggeredGrid\b/,
1754
+ /\bForEach\b/,
1755
+ /\bList\s*\(/,
1756
+ /\bsubmitList\b/,
1757
+ /\btableView\b/,
1758
+ /\bcollectionView\b/
1759
+ ];
1760
+ const REACTIVE_MARKERS = [
1761
+ /\bgetLiveCollection\b/,
1762
+ /\bobserve\b/,
1763
+ /\bobserveOnce\b/,
1764
+ /\blisten\b/,
1765
+ /\bonData\b/,
1766
+ /\bStreamBuilder\b/,
1767
+ /\bLiveData\b/,
1768
+ /\bMutableLiveData\b/,
1769
+ /\bMediatorLiveData\b/,
1770
+ /collectAsState/,
1771
+ /observeAsState/,
1772
+ /Amity\w*Flow\b/,
1773
+ /\bPagingData\b/,
1774
+ /LazyPagingItems/,
1775
+ /collectAsLazyPagingItems/,
1776
+ /\.on\s*\(\s*['"]data[A-Z]/,
1777
+ /\$snapshots\b/,
1778
+ /\bPagingController\b/,
1779
+ /\bgetPagingData\b/,
1780
+ /\bgetMyCommunityInvitations\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1781
+ /\/\/\s*vise:\s*one-shot query/i
1782
+ ];
1783
+ for (const [file, content] of sourceContent) {
1784
+ const rel = relativeFile(root, file);
1785
+ if (path.basename(file).endsWith('.d.ts'))
1786
+ continue;
1787
+ const hasInvitationQuery = INVITATION_LIST_QUERY_PATTERNS.some((p) => p.test(content));
1788
+ if (hasInvitationQuery) {
1789
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1790
+ if (hasListUI) {
1791
+ const reactiveSurface = commentStripped(file, platform, content);
1792
+ const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
1793
+ if (!hasReactivity) {
1794
+ findings.push(finding(ruleId, "warning", `A community-invitations query (getMyCommunityInvitations/getMemberInvitations) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the invitations list from the reactive invitation LiveCollection so it refreshes as invitations arrive or change status. Use the live callback (TS/RN: getMyCommunityInvitations(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
1795
+ }
1796
+ }
1797
+ }
1798
+ }
1799
+ return findings;
1800
+ }
1801
+ function validatePollVoteStatusGuard(root, platform, sourceContent) {
1802
+ const findings = [];
1803
+ const ruleId = `${platform}.poll.vote-status-guard`;
1804
+ const VOTE_PATTERNS = [/\bvotePoll\b/, /\bvote\s*\(\s*pollId/];
1805
+ const ANSWER_REF_PATTERNS = [/\banswers\b/, /\bgetAnswers\b/];
1806
+ const LIST_UI_PATTERNS = [
1807
+ /\bmap\s*\(/,
1808
+ /\bforEach\b/,
1809
+ /\bForEach\b/,
1810
+ /\bLazyColumn\b/,
1811
+ /\bLazyRow\b/,
1812
+ /\bList\s*\(/,
1813
+ /\bRecyclerView\b/,
1814
+ /\bsubmitList\b/,
1815
+ /\btableView\b/,
1816
+ /\bcollectionView\b/,
1817
+ /\bFlatList\b/
1818
+ ];
1819
+ const GUARD_PATTERNS = [
1820
+ /\bisVoted/,
1821
+ /\bvoteCount\b/,
1822
+ /\bgetStatus\b/,
1823
+ /\bPollStatus\b/,
1824
+ /\bisClosed?\b/,
1825
+ /\.status\b/,
1826
+ /\bclosedAt\b/
1827
+ ];
1828
+ for (const [file, content] of sourceContent) {
1829
+ const rel = relativeFile(root, file);
1830
+ if (path.basename(file).endsWith('.d.ts'))
1831
+ continue;
1832
+ const surface = commentStripped(file, platform, content);
1833
+ const hasVote = VOTE_PATTERNS.some((p) => p.test(surface));
1834
+ if (!hasVote)
1835
+ continue;
1836
+ const hasAnswerUI = ANSWER_REF_PATTERNS.some((p) => p.test(surface)) && LIST_UI_PATTERNS.some((p) => p.test(surface));
1837
+ if (!hasAnswerUI)
1838
+ continue;
1839
+ if (/\/\/\s*vise:\s*poll status handled/i.test(content))
1840
+ continue;
1841
+ const hasGuard = GUARD_PATTERNS.some((p) => p.test(surface));
1842
+ if (!hasGuard) {
1843
+ findings.push(finding(ruleId, "warning", `A poll vote action (votePoll/vote) renders the answers but never reads poll status, voted-state, or vote counts — users can vote on a closed poll, re-vote without feedback, and see no results.`, rel, "Gate the vote affordance on poll state and render results: check poll.status / isClosed and poll.isVoted before allowing a vote, and show answer.voteCount / answer.isVotedByUser. If poll status/voted-state is handled in another file, add comment // vise: poll status handled."));
1844
+ }
1412
1845
  }
1413
1846
  return findings;
1414
1847
  }
@@ -1449,17 +1882,6 @@ function validatePaginationCursorOpaque(root, platform, sourceContent) {
1449
1882
  function validatePostsParentChild(root, platform, sourceContent) {
1450
1883
  const findings = [];
1451
1884
  const ruleId = `${platform}.posts.parent-child-rendered`;
1452
- // Direct text-field reads that signal a component is rendering the post body.
1453
- // Deliberately NOT a bare `post.data` — that also matches passing post.data as
1454
- // an argument to a content/share helper (e.g. textFromContent(post.dataType,
1455
- // post.data)) in a non-rendering file like an actions menu, which produced a
1456
- // false positive on a correctly-factored app where rendering and the actions
1457
- // menu live in separate files.
1458
- // Case-SENSITIVE on purpose: lowercase `post.data.text` is post-body rendering,
1459
- // but the case-insensitive form also matched Kotlin/Swift sealed-class type
1460
- // discriminators like `AmityComment.Data.TEXT` / `AmityMessage.Data.TEXT` —
1461
- // which are type checks in comment/message code, not post rendering. That
1462
- // produced a large false-positive cluster on real Kotlin (Android sample app).
1463
1885
  const POST_RENDER_PATTERNS = [
1464
1886
  /\.data\.text\b/,
1465
1887
  /\bpost\.text\b/
@@ -1469,29 +1891,19 @@ function validatePostsParentChild(root, platform, sourceContent) {
1469
1891
  /\.\s*childrenPosts\b/,
1470
1892
  /\bgetChildren\b/
1471
1893
  ];
1472
- // Child-typed media the parent-child model carries (TS/RN string dataTypes).
1473
1894
  const CHILD_MEDIA_TYPES = ["poll", "video", "room", "file", "audio"];
1474
1895
  for (const [filename, text] of sourceContent) {
1475
1896
  const rel = relativeFile(root, filename);
1476
- // Escape-hatch checked on RAW content (stripComments would delete the marker).
1477
1897
  if (/\/\/\s*vise:\s*child-types intentional/i.test(text))
1478
1898
  continue;
1479
1899
  const astLang = astLanguageForFile(filename, platform);
1480
1900
  const checkContent = astLang ? stripComments(astLang, text) : text;
1481
1901
  const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(checkContent));
1482
1902
  const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(checkContent));
1483
- // (1) Presence: renders post text but never inspects children at all.
1484
1903
  if (hasPostRender && !hasChildRef) {
1485
1904
  findings.push(finding(ruleId, "warning", `A component renders post text but does not appear to inspect post children for media.`, rel, "Amity models images and videos as child posts. A UI that only renders the parent post's text will silently drop attachments. Inspect post.children or call getChildren()."));
1486
1905
  continue;
1487
1906
  }
1488
- // (2) Inconsistency (TS/RN): the renderer DOES inspect children (so the
1489
- // parent-child model is in play) but gates some media types on the PARENT
1490
- // post's dataType with no matching child lookup. A feed parent is dataType
1491
- // 'text', so `post.dataType === 'poll'` never matches and that content
1492
- // silently never renders. Only fires when child handling is already
1493
- // present for something — which keeps false positives low (a feed with
1494
- // genuinely top-level posts won't be inspecting childrenPosts at all).
1495
1907
  if (hasChildRef && (platform === "typescript" || platform === "react-native")) {
1496
1908
  const missed = CHILD_MEDIA_TYPES.filter((t) => {
1497
1909
  const parentGated = new RegExp(`post\\s*\\.\\s*dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
@@ -1514,14 +1926,6 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1514
1926
  const goodPatterns = SESSION_HANDLER_GOOD_PATTERNS[platform] ?? SESSION_HANDLER_GOOD_PATTERNS.typescript;
1515
1927
  for (const file of handlerFiles) {
1516
1928
  const content = sourceContent.get(file) ?? "";
1517
- // iOS AST arm: scope-aware detection. The regex bad-pattern bridges up to 200
1518
- // chars from ANY `func … {` into the next `let/var … = …SessionHandler`, which
1519
- // false-fires on a class-scope property that merely FOLLOWS a short function
1520
- // (it cannot see brace depth). The AST asks the real question — is the binding
1521
- // function-local? — and also catches type-annotated locals
1522
- // (`let h: AmitySessionHandler = AppSessionHandler()`) the regex bridge missed.
1523
- // Same rule id / severity / message; regex stays the floor when the file can't
1524
- // be parsed (grammar unavailable, oversized).
1525
1929
  if (platform === "ios" && file.endsWith(".swift")) {
1526
1930
  const tree = tryParse("swift", content);
1527
1931
  if (tree) {
@@ -1529,11 +1933,14 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1529
1933
  if (locals.length > 0 && !containsAny(new Map([[file, content]]), goodPatterns)) {
1530
1934
  return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
1531
1935
  }
1532
- continue; // parsed: AST verdict replaces the regex bridge for this file
1936
+ continue;
1533
1937
  }
1534
1938
  }
1535
1939
  if (badPattern && badPattern.test(content)) {
1536
- if (containsAny(new Map([[file, content]]), goodPatterns)) {
1940
+ const hasGood = platform === "android"
1941
+ ? androidSessionHandlerRetained(file, content)
1942
+ : containsAny(new Map([[file, content]]), goodPatterns);
1943
+ if (hasGood) {
1537
1944
  continue;
1538
1945
  }
1539
1946
  return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
@@ -1541,6 +1948,20 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1541
1948
  }
1542
1949
  return [];
1543
1950
  }
1951
+ function androidSessionHandlerRetained(file, content) {
1952
+ if (/\/\/\s*vise:\s*handler retained/.test(content))
1953
+ return true;
1954
+ const stripped = commentStripped(file, "android", content);
1955
+ const declRe = /private\s+(?:val|var)\s+(\w+)\s*=\s*(?:object\s*:\s*)?(?:Amity)?SessionHandler/g;
1956
+ let match;
1957
+ while ((match = declRe.exec(stripped)) !== null) {
1958
+ const name = match[1];
1959
+ const uses = stripped.match(new RegExp(`\\b${escapeRegExp(name)}\\b`, "g")) ?? [];
1960
+ if (uses.length > 1)
1961
+ return true;
1962
+ }
1963
+ return false;
1964
+ }
1544
1965
  function sessionHandlerRetainedFinding(platform, file) {
1545
1966
  return finding(`${platform}.session-handler.retained`, "warning", "Session handler appears to be declared as a function-local variable, making it eligible for garbage collection. The SDK's access token renewal callback will fail to fire, causing sessions to silently expire.", file, "Declare the session handler as a class-level property, a module-scoped variable, or retain it via a state container so it outlives the setup function. Add `// vise: handler retained` if it is explicitly assigned to a long-lived store.");
1546
1967
  }
@@ -1563,56 +1984,16 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1563
1984
  findings.push(finding(`${platform}.feed.target.literal`, "warning", `A hardcoded feed target literal was found: ${feedTarget.name}.`, relativeFile(root, feedTarget.file), "Do not invent or hardcode communityId, targetId, feedId, or channelId. Ask the user for the target or use an existing app-owned selection/create flow."));
1564
1985
  }
1565
1986
  const inlineApiKey = firstLiteralAssignment(sourceContent, [
1566
- // Direct literal assignment — apiKey: "literal" or apiKey = "literal".
1567
1987
  /\bapiKey\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
1568
1988
  /\bapi_key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
1569
1989
  /\bapi-key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
1570
- // Env-fallback secret leak patterns — the literal is on the RHS of an
1571
- // env-lookup fallback, not directly assigned to apiKey. The v0.7 §14
1572
- // Antigravity benchmark surfaced this gap: Dart's
1573
- // `apiKey = String.fromEnvironment(KEY, defaultValue: 'literal')` and
1574
- // JS/TS's `apiKey: process.env.X ?? 'literal'` both shipped a hardcoded
1575
- // key while the original regex (which requires the literal immediately
1576
- // after `apiKey [:=]`) saw `String.fromEnvironment(` or `process.env.`
1577
- // first and missed the literal. The patterns below explicitly walk
1578
- // through the env-lookup wrapper to the literal default.
1579
- //
1580
- // Dart: `defaultValue:` form inside String.fromEnvironment. Uses
1581
- // non-greedy any-char so the regex can bridge a multi-line argument
1582
- // list (real Dart code commonly puts each named arg on its own line).
1583
1990
  /\bapi[-_]?key\b\s*[:=][\s\S]{0,200}?defaultValue\s*:\s*["'`]([^"'`]+)["'`]/i,
1584
- // JS/TS: process.env.X ?? 'literal' or process.env.X || 'literal'.
1585
- //
1586
- // The bridge here is `[^;,}\n]` (NOT the `[\s\S]` used by the Dart arm
1587
- // above) and that difference is load-bearing. A wide `[\s\S]` bridge is
1588
- // not statement-scoped: anchored on `apiKey`, it walks past the apiKey
1589
- // assignment into the NEXT statement/property and binds a DIFFERENT
1590
- // variable's env-fallback literal — e.g. an idiomatic
1591
- // const apiKey = process.env.API_KEY
1592
- // const region = process.env.REGION || 'sg'
1593
- // mis-reports the region default 'sg' as a hardcoded apiKey. Forbidding
1594
- // `;`, `,`, and `\n` in the bridge keeps it inside the apiKey assignment:
1595
- // `;` stops a semicolon'd next statement, `\n` stops an ASI/no-semicolon
1596
- // next statement, and `,` stops the next object property
1597
- // (`{ apiKey: env, region: env || 'sg' }`). The genuine single-expression
1598
- // leak `apiKey: process.env.X || 'realkey'` is still caught because
1599
- // nothing separates the key from its own fallback. Known residual: a real
1600
- // leak written with the env lookup on a line BELOW the key
1601
- // (`apiKey:\n process.env.X || 'realkey'`) is missed — rare; the AST pass
1602
- // (resolveLiteralValue) is the structural place to catch that.
1603
1991
  /\bapi[-_]?key\b\s*[:=][^;,}\n]{0,200}?(?:process\.env\.[A-Z0-9_]+|import\.meta\.env\.[A-Z0-9_]+)\s*(?:\?\?|\|\|)\s*["'`]([^"'`]+)["'`]/i,
1604
- // Ternary fallback: `apiKey = X ? 'literal' : ...` captures the truthy
1605
- // branch. Same statement/property-scoped bridge as the `||`/`??` arm so a
1606
- // sibling property's ternary (`{ apiKey: env, region: prod ? 'sg' : 'us' }`)
1607
- // does not bind 'sg' to apiKey.
1608
1992
  /\bapi[-_]?key\b\s*[:=][^;,}\n]{0,200}?\?\s*["'`]([^"'`]+)["'`]\s*:/i,
1609
1993
  ], platform);
1610
1994
  if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
1611
1995
  findings.push(finding(`${platform}.secret.inline-api-key`, "warning", "A social.plus API key appears to be hardcoded in source.", relativeFile(root, inlineApiKey.file), "Use the host app's environment/config pattern instead of committing API keys directly into source files. The literal is still committed even when wrapped in an env-fallback (e.g. `defaultValue:`, `??`, `||`, ternary)."));
1612
1996
  }
1613
- // Hardcoded user identity — every benchmark Pure MCP failure mode included
1614
- // `userId: "current-user"` or similar. The user identity should come from
1615
- // the host app's auth state, not a literal in source.
1616
1997
  const literalUserId = firstLiteralAssignment(sourceContent, [
1617
1998
  /\buserId\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
1618
1999
  /\buser_id\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
@@ -1622,9 +2003,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1622
2003
  if (literalUserId && !isAllowedPlaceholder(literalUserId.value)) {
1623
2004
  findings.push(finding(`${platform}.auth.no-literal-user-id`, "warning", `A hardcoded user identity literal was found: ${literalUserId.name}.`, relativeFile(root, literalUserId.file), "Do not hardcode a userId in source. Read the authenticated user from the host app's auth state (current session, route param, user-store hook, etc.)."));
1624
2005
  }
1625
- // AST pass — catches indirect literals via identifier resolution.
1626
- // Runs for TypeScript/TSX files. Covers: auth.no-literal-user-id,
1627
- // secret.inline-api-key, and feed.target.literal.
1628
2006
  if (platform === "typescript" || platform === "react-native") {
1629
2007
  for (const [file, content] of sourceContent) {
1630
2008
  if (!file.endsWith(".ts") && !file.endsWith(".tsx"))
@@ -1633,8 +2011,7 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1633
2011
  const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
1634
2012
  const tree = tryParse(lang, content);
1635
2013
  if (!tree)
1636
- continue; // oversized/unparseable file — regex passes above still apply
1637
- // ── auth.no-literal-user-id via .login({ userId: CONST }) ──
2014
+ continue;
1638
2015
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1639
2016
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
1640
2017
  const loginCalls = findCallExpressions(tree, /\.login\b/);
@@ -1652,17 +2029,14 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1652
2029
  }
1653
2030
  }
1654
2031
  }
1655
- // ── secret.inline-api-key via createClient({ apiKey: CONST }) or createClient(CONST, ...) ──
1656
2032
  const secretRule = `${platform}.secret.inline-api-key`;
1657
2033
  if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
1658
2034
  const createCalls = findCallExpressions(tree, /\bcreateClient\b/);
1659
2035
  for (const call of createCalls) {
1660
- // Object form: createClient({ apiKey: CONST })
1661
2036
  const firstArg = call.args[0];
1662
2037
  if (!firstArg)
1663
2038
  continue;
1664
2039
  let apiKeyNode = pickObjectProperty(firstArg, "apiKey");
1665
- // Positional form: createClient(KEY, region)
1666
2040
  if (!apiKeyNode && firstArg.type !== "object") {
1667
2041
  apiKeyNode = firstArg;
1668
2042
  }
@@ -1675,7 +2049,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1675
2049
  }
1676
2050
  }
1677
2051
  }
1678
- // ── feed.target.literal via getPosts({ targetId: CONST }) or similar ──
1679
2052
  const feedRule = `${platform}.feed.target.literal`;
1680
2053
  if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
1681
2054
  const feedCalls = findCallExpressions(tree, /\bgetPosts\b|\bgetComments\b|\bcreatePost\b/);
@@ -1699,7 +2072,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1699
2072
  }
1700
2073
  }
1701
2074
  }
1702
- // AST pass for Kotlin/Android — catches indirect literals via identifier resolution.
1703
2075
  if (platform === "android") {
1704
2076
  for (const [file, content] of sourceContent) {
1705
2077
  if (!file.endsWith(".kt"))
@@ -1707,8 +2079,7 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1707
2079
  const rel = relativeFile(root, file);
1708
2080
  const tree = tryParse("kotlin", content);
1709
2081
  if (!tree)
1710
- continue; // oversized/unparseable file — regex passes above still apply
1711
- // ── auth.no-literal-user-id via .login(CONST) ──
2082
+ continue;
1712
2083
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1713
2084
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
1714
2085
  const loginCalls = findCallExpressions(tree, /\.login$/);
@@ -1723,7 +2094,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1723
2094
  }
1724
2095
  }
1725
2096
  }
1726
- // ── secret.inline-api-key via .setup(CONST) ──
1727
2097
  const secretRule = `${platform}.secret.inline-api-key`;
1728
2098
  if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
1729
2099
  const setupCalls = findCallExpressions(tree, /\.setup$/);
@@ -1738,7 +2108,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1738
2108
  }
1739
2109
  }
1740
2110
  }
1741
- // ── feed.target.literal via .targetCommunity(CONST) / .targetUser(CONST) ──
1742
2111
  const feedRule = `${platform}.feed.target.literal`;
1743
2112
  if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
1744
2113
  const feedCalls = findCallExpressions(tree, /targetCommunity$|targetUser$|targetFeed$/);
@@ -1755,11 +2124,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1755
2124
  }
1756
2125
  }
1757
2126
  }
1758
- // AST pass for Swift/iOS — catches indirect literals via identifier resolution.
1759
- // Swift SDK calls are label-keyed (`login(userId:…)`, `AmityClient(apiKey:…)`,
1760
- // `getPosts(targetType:targetId:)`), so the arms resolve the labeled argument.
1761
- // tryParse returns null when tree-sitter-swift is unavailable or the file is
1762
- // oversized — the regex passes above remain the detection floor in that case.
1763
2127
  if (platform === "ios") {
1764
2128
  for (const [file, content] of sourceContent) {
1765
2129
  if (!file.endsWith(".swift"))
@@ -1768,7 +2132,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1768
2132
  const tree = tryParse("swift", content);
1769
2133
  if (!tree)
1770
2134
  continue;
1771
- // ── auth.no-literal-user-id via .login(userId: CONST) ──
1772
2135
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1773
2136
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
1774
2137
  for (const call of findCallExpressions(tree, /\.login$/)) {
@@ -1782,7 +2145,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1782
2145
  }
1783
2146
  }
1784
2147
  }
1785
- // ── secret.inline-api-key via AmityClient(apiKey: CONST) / .setup(apiKey: CONST) ──
1786
2148
  const secretRule = `${platform}.secret.inline-api-key`;
1787
2149
  if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
1788
2150
  for (const call of findCallExpressions(tree, /(?:^|\.)AmityClient$|\.setup$/)) {
@@ -1796,8 +2158,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1796
2158
  }
1797
2159
  }
1798
2160
  }
1799
- // ── feed.target.literal via any call carrying a target label (targetId:,
1800
- // communityId:, channelId:, withCommunityId:, …) resolving to a literal ──
1801
2161
  const feedRule = `${platform}.feed.target.literal`;
1802
2162
  if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
1803
2163
  outer: for (const call of findCallExpressions(tree, /\w/)) {
@@ -1927,10 +2287,6 @@ async function validateIos(root) {
1927
2287
  const swiftContent = await readMany(swiftFiles);
1928
2288
  const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
1929
2289
  const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
1930
- // social.plus push REGISTRATION only (real iOS symbol: registerPushNotification). Do NOT key
1931
- // on the generic APNs primitive `didRegisterForRemoteNotifications` — that is the host app's own
1932
- // push, present in any iOS app with notifications, and made the social.plus-push rules false-fire
1933
- // on brownfield apps whose social.plus integration never included push.
1934
2290
  const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
1935
2291
  const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
1936
2292
  const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
@@ -1950,9 +2306,6 @@ async function validateIos(root) {
1950
2306
  if (!containsAnyForFiles(swiftContent, setupFiles, [/\bregion\s*:/, /\.SG\b|\.EU\b|\.US\b/])) {
1951
2307
  findings.push(finding("ios.setup.region", "warning", "AmityClient initialization was found but no explicit region marker was detected.", relativeFile(root, setupFiles[0]), "Set the region explicitly to match the customer's social.plus console project."));
1952
2308
  }
1953
- // Setup must happen during app startup (AppDelegate.application(_:didFinishLaunchingWithOptions:),
1954
- // @main App.init, or a UIApplicationDelegate willFinishLaunching), not in
1955
- // a view/scene lifecycle method that re-runs across screens.
1956
2309
  const lifecycleSetup = setupFiles.find((file) => /(viewDidLoad|viewWillAppear|viewWillDisappear|viewDidAppear|viewDidDisappear|scene\s*\(_:willConnectTo)/.test(swiftContent.get(file) ?? ""));
1957
2310
  if (lifecycleSetup) {
1958
2311
  findings.push(finding("ios.setup.lifecycle", "warning", "AmityClient initialization appears inside a view or scene lifecycle method.", relativeFile(root, lifecycleSetup), "Run AmityClient setup during app process startup (AppDelegate.application(_:didFinishLaunchingWithOptions:) or @main App.init) so it does not re-initialize on screen transitions."));
@@ -1967,15 +2320,9 @@ async function validateIos(root) {
1967
2320
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
1968
2321
  findings.push(finding("ios.push.unregister.present", "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens on logout or user switch so notifications are not sent to the wrong user."));
1969
2322
  }
1970
- // iOS push permission: if push registration exists, require UNUserNotificationCenter.requestAuthorization
1971
2323
  if (pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/requestAuthorization/, /UNUserNotificationCenter/])) {
1972
2324
  findings.push(finding("ios.push.permission-prompt-before-register", "warning", "Push registration was found but no UNUserNotificationCenter.requestAuthorization call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Request push permission from the user via UNUserNotificationCenter before registering for remote notifications."));
1973
2325
  }
1974
- // iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
1975
- // require push-specific SDK forwarding marker.
1976
- // Nexus gate: only demand social.plus forwarding when social.plus push is actually set up
1977
- // (registration present). A host app's generic push payload handler with no social.plus push
1978
- // registration is not a social.plus concern (brownfield false-positive).
1979
2326
  const iosPayloadHandlerFiles = filesMatching(swiftContent, [/didReceiveRemoteNotification/, /userNotificationCenter\s*\(\s*_\s*,\s*didReceive/]);
1980
2327
  if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
1981
2328
  findings.push(finding("ios.push.payload-contract-respected", "warning", "Push payload handler exists but no social.plus push forwarding call was detected.", relativeFile(root, iosPayloadHandlerFiles[0]), "Forward incoming push payloads to the social.plus SDK push handler (e.g. AmityPush.handleNotification) so social notifications are routed correctly."));
@@ -1994,6 +2341,15 @@ async function validateIos(root) {
1994
2341
  findings.push(...validateComments(root, "ios", swiftContent));
1995
2342
  findings.push(...validateModeration(root, "ios", swiftContent));
1996
2343
  findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
2344
+ findings.push(...validateStories(root, "ios", swiftContent));
2345
+ findings.push(...validateSearch(root, "ios", swiftContent));
2346
+ findings.push(...validateNotificationTray(root, "ios", swiftContent));
2347
+ findings.push(...validateMembershipList(root, "ios", swiftContent));
2348
+ findings.push(...validateEvents(root, "ios", swiftContent));
2349
+ findings.push(...validateBlockedUsers(root, "ios", swiftContent));
2350
+ findings.push(...validateInvitations(root, "ios", swiftContent));
2351
+ findings.push(...validatePollVoteStatusGuard(root, "ios", swiftContent));
2352
+ findings.push(...validateFollowStatusSubscription(root, "ios", swiftContent));
1997
2353
  findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
1998
2354
  findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
1999
2355
  findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
@@ -2022,6 +2378,8 @@ async function validateIos(root) {
2022
2378
  findings.push(...validateChatMessageOrderExplicit(root, "ios", swiftContent));
2023
2379
  findings.push(...validateProfileSocialCounts(root, "ios", swiftContent));
2024
2380
  findings.push(...validateChat(root, "ios", swiftContent));
2381
+ findings.push(...validateChatDeprecations(root, "ios", swiftContent));
2382
+ findings.push(...validateLivestreamDeprecation(root, "ios", swiftContent));
2025
2383
  findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
2026
2384
  findings.push(...(await validateEnvSecretHygiene(root, "ios", swiftContent)));
2027
2385
  return findings;
@@ -2308,6 +2666,19 @@ function containsAny(contents, patterns) {
2308
2666
  }
2309
2667
  return false;
2310
2668
  }
2669
+ function containsAnyStripped(contents, platform, patterns) {
2670
+ for (const [file, content] of contents) {
2671
+ const stripped = commentStripped(file, platform, content);
2672
+ if (patterns.some((pattern) => pattern.test(stripped))) {
2673
+ return true;
2674
+ }
2675
+ }
2676
+ return false;
2677
+ }
2678
+ function blankDeadDeclarations(text, namePattern) {
2679
+ const typeToken = "(?:[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*(?:<[^>=;{}\\n]*>)?\\??\\s+)?";
2680
+ return text.replace(new RegExp(`\\b(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:late\\s+)?(?:function|fun|func|const|let|var|val|final)\\s+${typeToken}${namePattern}\\b`, "g"), " ");
2681
+ }
2311
2682
  function escapeRegExp(value) {
2312
2683
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2313
2684
  }
@@ -2320,11 +2691,6 @@ function containsAnyForFiles(contents, files, patterns) {
2320
2691
  function relativeFile(root, file) {
2321
2692
  return file ? path.relative(root, file) : undefined;
2322
2693
  }
2323
- /**
2324
- * Map a source file path + platform to the AST Language type (if supported).
2325
- * Returns undefined for languages without tree-sitter support (Dart — no npm
2326
- * grammar compatible with the pinned tree-sitter 0.21 exists; see ARCHITECTURE.md).
2327
- */
2328
2694
  function astLanguageForFile(filePath, platform) {
2329
2695
  const ext = path.extname(filePath).toLowerCase();
2330
2696
  if (ext === ".ts")
@@ -2335,26 +2701,14 @@ function astLanguageForFile(filePath, platform) {
2335
2701
  return "kotlin";
2336
2702
  if (ext === ".swift")
2337
2703
  return "swift";
2338
- // Platforms that use TS/TSX but file doesn't have extension — infer from platform
2339
2704
  if ((platform === "typescript" || platform === "react-native") && (ext === ".js" || ext === ".jsx")) {
2340
2705
  return "typescript";
2341
2706
  }
2342
2707
  return undefined;
2343
2708
  }
2344
- // Comment-aware view of a source file for the presence-of-a-bad-literal regex
2345
- // checks that GATE (channel-target-resolved, inline secrets, literal userId/feed
2346
- // target). A pattern that appears only in a commented-out or documentation line
2347
- // must not trip a gate — a hard CI failure on a comment is the worst false positive
2348
- // a compliance gate can produce. ts/tsx/kotlin/swift use the precise tree-sitter
2349
- // stripper; Dart (no compatible grammar published) uses the conservative scanner
2350
- // below. Anything else is returned unchanged.
2351
2709
  function commentStripped(filePath, platform, content) {
2352
2710
  const astLang = astLanguageForFile(filePath, platform);
2353
2711
  if (astLang === "swift") {
2354
- // Swift is gate-relevant, so it must never degrade to RAW content: when the
2355
- // tree-sitter pass is a no-op (bindings unavailable, oversized file, or simply
2356
- // no comments) chain into the conservative scanner. Both strippers only ever
2357
- // blank comment spans, so the composition stays fail-toward-firing.
2358
2712
  const stripped = stripComments("swift", content);
2359
2713
  return stripped !== content ? stripped : stripLineAndBlockComments(content);
2360
2714
  }
@@ -2365,15 +2719,6 @@ function commentStripped(filePath, platform, content) {
2365
2719
  return stripLineAndBlockComments(content);
2366
2720
  return content;
2367
2721
  }
2368
- // Conservative comment stripper for languages without a wired tree-sitter grammar
2369
- // (Swift, Dart). Blanks `//` line comments and `/* */` block comments with spaces,
2370
- // preserving newlines so offsets/line numbers are unchanged. It tracks single-line
2371
- // string state ("…" and '…') with escape handling so a `//` inside a string or a URL
2372
- // ("https://…") is not mistaken for a comment. Critically, it only ever blanks
2373
- // comment spans — never code or string text — so any mis-classification degrades
2374
- // toward a residual false-positive (a comment left un-stripped), never a silent
2375
- // false-negative on a gate. Multi-line/raw strings are not modeled precisely, but the
2376
- // same fail-toward-firing property holds (worst case: a comment is not stripped).
2377
2722
  function stripLineAndBlockComments(source) {
2378
2723
  const out = source.split("");
2379
2724
  let inString = null;
@@ -2396,8 +2741,6 @@ function stripLineAndBlockComments(source) {
2396
2741
  continue;
2397
2742
  }
2398
2743
  if (inString) {
2399
- // Escape: skip the next char — but never jump past a newline, so a stray
2400
- // trailing backslash can't swallow the following line of real code.
2401
2744
  if (c === "\\" && next !== "\n") {
2402
2745
  i += 2;
2403
2746
  continue;
@@ -2432,14 +2775,6 @@ function stripLineAndBlockComments(source) {
2432
2775
  }
2433
2776
  function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
2434
2777
  const findings = [];
2435
- // TypeScript/React Native: the SDK types referenceType as the string-literal
2436
- // union Amity.CommentReferenceType ('post' | 'story' | 'content') — there is
2437
- // no enum to use, and `referenceType: 'post'` is the documented, idiomatic
2438
- // value (this ruleset's own comment-limit / creation-affordance remediation
2439
- // strings instruct exactly that). A wrong casing like 'POST' is not assignable
2440
- // to the union, so tsc already catches it. Flagging the correct string here was
2441
- // a pure false positive; the rule remains active on android/flutter/ios where a
2442
- // real AmityCommentReferenceType enum exists.
2443
2778
  if (platform === "typescript" || platform === "react-native")
2444
2779
  return findings;
2445
2780
  const ruleId = `${platform}.comment.reference-type-enum`;
@@ -2457,7 +2792,7 @@ function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
2457
2792
  for (const pattern of REFERENCE_TYPE_PATTERNS) {
2458
2793
  if (pattern.test(text)) {
2459
2794
  findings.push(finding(ruleId, "warning", `A createComment call appears to hardcode referenceType to a raw string instead of an enum.`, rel, `Comment reference types should be strongly typed using the SDK enums (e.g. AmityCommentReferenceType.POST) to prevent casing errors. If this is intentional, add comment // vise: reference-type rationale — <reason>.`));
2460
- break; // one per file is enough for this rule
2795
+ break;
2461
2796
  }
2462
2797
  }
2463
2798
  }
@@ -2498,11 +2833,6 @@ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
2498
2833
  if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
2499
2834
  continue;
2500
2835
  }
2501
- // The reaction NAME is the tenant-specific argument. The TS/RN SDK signature is
2502
- // addReaction(referenceType, referenceId, reactionName) — the first literal is the
2503
- // referenceType ('post'/'comment'/'message'/'story'), which is LEGITIMATELY a literal
2504
- // and must not be mistaken for a hardcoded reaction name. Flag only when a string
2505
- // literal that is NOT a known reference-type appears in the call.
2506
2836
  const REF_TYPE = /^(?:post|comment|message|story|community|user|channel|content)$/i;
2507
2837
  const reactionCallRe = /\b(?:addReaction|removeReaction|flagReaction|react)\s*\(([^)]*)\)/gi;
2508
2838
  let hardcodesReactionName = false;
@@ -2525,7 +2855,6 @@ function validateImagePostChildResolutionAwaited(root, platform, sourceContent)
2525
2855
  const ruleId = platform + '.image-post.child-resolution-awaited';
2526
2856
  for (const [filename, text] of sourceContent) {
2527
2857
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2528
- // Look for createPost and immediate render/setState/display
2529
2858
  if (/createPost\s*\(/.test(text) &&
2530
2859
  /(?:render|setState|display)\s*\(/.test(text)) {
2531
2860
  const hasResolution = /whenComplete/.test(text) ||
@@ -2582,22 +2911,23 @@ function validateUnreadSubscribedNotCounted(root, platform, sourceContent) {
2582
2911
  let hasSubscribedStream = false;
2583
2912
  let firstManualCountFile = '';
2584
2913
  for (const [filename, text] of sourceContent) {
2585
- if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(text) ||
2586
- /\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(text) ||
2587
- /\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(text) ||
2588
- /unreadCount\s*=\s*[^;]*\bfilter\b/.test(text)) {
2914
+ const checkContent = commentStripped(filename, platform, text);
2915
+ if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
2916
+ /\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
2917
+ /\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(checkContent) ||
2918
+ /unreadCount\s*=\s*[^;]*\bfilter\b/.test(checkContent)) {
2589
2919
  hasManualCount = true;
2590
2920
  if (!firstManualCountFile) {
2591
2921
  firstManualCountFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2592
2922
  }
2593
2923
  }
2594
- if (/AmityCoreClient\.unreadCount/.test(text) ||
2595
- /\.unreadCount\(\)/.test(text) ||
2596
- /unreadCountObservable/.test(text) ||
2597
- /getUnreadCount/.test(text) ||
2598
- /subscribeUnread/.test(text) ||
2599
- /unreadCount\.observe/.test(text) ||
2600
- /unread\.stream/.test(text) ||
2924
+ if (/AmityCoreClient\.unreadCount/.test(checkContent) ||
2925
+ /\.unreadCount\(\)/.test(checkContent) ||
2926
+ /unreadCountObservable/.test(checkContent) ||
2927
+ /getUnreadCount\s*\(/.test(checkContent) ||
2928
+ /subscribeUnread\s*\(/.test(checkContent) ||
2929
+ /unreadCount\.observe/.test(checkContent) ||
2930
+ /unread\.stream/.test(checkContent) ||
2601
2931
  /\/\/\s*vise:\s*unread sourced from/.test(text)) {
2602
2932
  hasSubscribedStream = true;
2603
2933
  }
@@ -2620,9 +2950,10 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
2620
2950
  firstPushRegistrationFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2621
2951
  }
2622
2952
  }
2623
- if (/\.notifications\s*\(\s*\)\.(?:getSettings|setSettings)/.test(text) ||
2624
- /notificationRepository\.(?:getSettings|setSettings)/.test(text) ||
2625
- /\bNotificationSettings\b/.test(text) ||
2953
+ const astLang = astLanguageForFile(filename, platform);
2954
+ const checkContent = astLang ? stripComments(astLang, text) : text;
2955
+ if (/\.notifications\s*\(\s*\)(?:\s*\.\s*\w+\s*\([^)]*\))*\s*\.\s*(?:getSettings|setSettings)\s*[({]/.test(checkContent) ||
2956
+ /\b(?:notificationRepository|notificationManager)\s*\.\s*(?:getSettings|setSettings)\s*[({]/.test(checkContent) ||
2626
2957
  /\/\/\s*vise:\s*notification preferences synced via/.test(text)) {
2627
2958
  hasPreferences = true;
2628
2959
  }
@@ -2632,31 +2963,14 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
2632
2963
  }
2633
2964
  return findings;
2634
2965
  }
2635
- // The ban-state / role-gated / flagCount-leak rules all flag a MISSING UI guard.
2636
- // That claim only makes sense for files that render UI. Service and data layers —
2637
- // repositories, managers, datasources, API clients, stores, interactors — wrap the
2638
- // SDK call but legitimately leave the guard to the UI layer above them, so firing on
2639
- // them is a false positive. Observed on the official Amity sample apps, where
2640
- // PostFlagManager / StoryManager / MembershipListDataSource tripped all three rules
2641
- // despite the guards living correctly in their View/ViewController callers.
2642
2966
  function isNonUiSourceFile(filename) {
2643
2967
  const base = path.basename(filename).replace(/\.(kt|swift|dart|tsx?|jsx?)$/i, "");
2644
- // PascalCase class-named files (Swift, Kotlin, TS): suffix match. Note "Controller"
2645
- // and "View(Model)" are deliberately absent — iOS ViewControllers/SwiftUI Views DO
2646
- // render and must keep firing.
2647
2968
  if (/(?:Application|Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
2648
2969
  return true;
2649
- // snake_case files (Dart, sometimes RN): same non-UI layers, plus Flutter's BLoC /
2650
- // Cubit / ChangeNotifier state-management layers. "_page"/"_popup"/"_screen" are UI
2651
- // and intentionally not matched.
2652
2970
  if (/_(?:manager|repository|repo|service|data_source|datasource|provider|client|store|mapper|interactor|use_case|usecase|bloc|cubit|notifier|mock|fake|stub|test|spec)$/i.test(base))
2653
2971
  return true;
2654
- // Bare data-layer file names (store.ts, repository.ts, service.ts, datasource.ts, …) — the same
2655
- // non-UI layers as the suffix/snake forms above, just without a domain prefix. A file literally
2656
- // named `store` is a state-management/data layer (e.g. a Zustand/Redux store), not a screen.
2657
2972
  if (/^(?:store|stores|repository|repositories|repo|repos|service|services|datasource|datasources|provider|providers|client|clients|interactor|interactors|usecase|usecases|bloc|cubit|notifier|api|mapper)$/i.test(base))
2658
2973
  return true;
2659
- // dotted test/spec helpers (foo.test.ts, foo.spec.ts)
2660
2974
  if (/\.(?:test|spec)$/i.test(base))
2661
2975
  return true;
2662
2976
  return false;
@@ -2666,38 +2980,17 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
2666
2980
  const ruleId = platform + '.user.ban-state-respected';
2667
2981
  for (const [filename, text] of sourceContent) {
2668
2982
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2669
- // Skip type declaration files — they declare the method signatures but are not
2670
- // the implementation that needs the ban-state guard.
2671
2983
  if (path.basename(filename).endsWith('.d.ts'))
2672
2984
  continue;
2673
- // Skip non-UI service/data layers — the ban gate belongs in the UI that renders
2674
- // the interaction button, not in the manager/repository that wraps the SDK call.
2675
2985
  if (isNonUiSourceFile(filename))
2676
2986
  continue;
2677
- // Check if interaction methods are used in the file (includes flag actions — banned users
2678
- // should also not be able to flag content, preventing spurious moderation queue spam).
2679
- // iOS/Swift: the interaction surface is the COMMENT-STRIPPED file (tree-sitter, scanner
2680
- // fallback) — a `// TODO: wire createPost here` comment is not an interaction surface and
2681
- // used to false-fire this rule. The ban-state markers below stay on RAW text on purpose:
2682
- // the `// vise: ban state checked at` escape hatch lives in a comment.
2683
2987
  const interactionSurface = platform === "ios" && filename.endsWith(".swift") ? commentStripped(filename, platform, text) : text;
2684
2988
  if (!/\b(createPost|createComment|sendMessage|addReaction|flagComment|flagPost)\b/.test(interactionSurface)) {
2685
2989
  continue;
2686
2990
  }
2687
- // Ban-state positive markers. Accepts both member-access form
2688
- // (`user.isBanned`, `currentUser.isGlobalBan`) and bare-variable form
2689
- // (`disabled={isBanned}`, `!isBanned && ...`) — the latter is common
2690
- // when a hook destructures the flag and the screen consumes it directly.
2691
- const hasBanCheck =
2692
- // Any ban-STATE boolean: isBanned, isGlobalBan, isGlobalBanned, isUserBanned,
2693
- // currentUser.isGlobalBan(ned). The `is` prefix anchors this to ban-state flags
2694
- // and excludes the banUser/bannedUserIds ACTION names. Real SDK ships both
2695
- // isGlobalBan and isGlobalBanned; agents commonly derive a local `isGlobalBanned`.
2696
- /\bis\w*Ban(?:ned)?\b/.test(text) ||
2697
- // camelCase boolean form: `currentUserIsBanned` / `userIsBanned` — typically the ban state
2698
- // fetched in a parent and passed down as a prop, then used to gate the write. The lowercase
2699
- // `is`-anchored marker above misses it (capital `Is`, no leading word boundary mid-identifier).
2700
- /\b\w*[Ii]sBanned\b/.test(text) ||
2991
+ const banUseSurface = text.replace(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|fun|func|const|let|var|val)\s+\w*[Bb]an\w*\b/g, " ");
2992
+ const hasBanCheck = /\bis\w*Ban(?:ned)?\b/.test(banUseSurface) ||
2993
+ /\b\w*[Ii]sBanned\b/.test(banUseSurface) ||
2701
2994
  /isCurrentUserBannedFromCommunity/.test(text) ||
2702
2995
  /community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
2703
2996
  /channel\.isMuted/.test(text) ||
@@ -2715,24 +3008,22 @@ function validateFlagCountNotLeaked(root, platform, sourceContent) {
2715
3008
  const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
2716
3009
  for (const [filename, text] of sourceContent) {
2717
3010
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2718
- // Non-UI service/data layers read flagCount to pass it upward; the role gate
2719
- // that decides whether to RENDER it lives in the UI layer, so skip them.
2720
3011
  if (isNonUiSourceFile(filename))
2721
3012
  continue;
2722
- // Check if flagCount is used in the file
2723
- if (!/\bflagCount\b/.test(text)) {
3013
+ const stripped = commentStripped(filename, platform, text);
3014
+ if (!/\bflagCount\b/.test(stripped)) {
2724
3015
  continue;
2725
3016
  }
2726
- // Role-check positive markers
2727
- const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2728
- /\.hasRole\s*\(.*moderator/i.test(text) ||
2729
- /community\.hasModeratorRole/.test(text) ||
2730
- /isCommunityModerator/.test(text) ||
2731
- /isModerator\s*[(=]/.test(text) ||
2732
- /member\.isModerator/.test(text) ||
2733
- /roles\.includes\s*\(.*moderator/i.test(text) ||
2734
- /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2735
- /myReactions\.(?:contains|includes).*flag/i.test(text) ||
3017
+ const roleUseSurface = blankDeadDeclarations(stripped, "(?:isModerator|isCommunityModerator)");
3018
+ const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(stripped) ||
3019
+ /\.hasRole\s*\(.*moderator/i.test(stripped) ||
3020
+ /community\.hasModeratorRole/.test(stripped) ||
3021
+ /\bisCommunityModerator\b/.test(roleUseSurface) ||
3022
+ /\bisModerator\b/.test(roleUseSurface) ||
3023
+ /member\.isModerator/.test(stripped) ||
3024
+ /roles\.includes\s*\(.*moderator/i.test(stripped) ||
3025
+ /permissions\.(?:contains|includes).*moderation/i.test(stripped) ||
3026
+ /myReactions\.(?:contains|includes).*flag/i.test(stripped) ||
2736
3027
  /\/\/\s*vise:\s*flagCount visible only to moderators/i.test(text);
2737
3028
  if (!hasRoleCheck) {
2738
3029
  findings.push(finding(ruleId, "warning", "File " + rel + " references flagCount without a role check.", rel, "flagCount is moderator-only data. Rendering it to everyone leaks moderation signal. Ensure flagCount rendering is gated by a role check."));
@@ -2745,52 +3036,37 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2745
3036
  const ruleId = platform + '.moderation.role-gated-action';
2746
3037
  for (const [filename, text] of sourceContent) {
2747
3038
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2748
- // Skip non-UI service/data layers for BOTH branches. The ownership gate
2749
- // (edit/delete) is a UI concern; and on real code (Amity's MembershipListDataSource,
2750
- // a UITableView data source that wraps muteMembers) the mod-only ban/mute gate also
2751
- // belongs in the presenting ViewController, not the data layer — firing on the
2752
- // service reads as a false positive. The SDK enforces ban/mute server-side regardless.
2753
3039
  if (isNonUiSourceFile(filename))
2754
3040
  continue;
2755
- // Moderator-ONLY actions: only an admin/moderator can ban or mute members.
2756
- // (flagPost/flagComment/unflag are deliberately NOT here — flagging/reporting
2757
- // is a user-level action available to any authenticated member, so gating it
2758
- // on a moderator role is itself a bug.)
2759
- const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(text);
2760
- // Delete/edit: valid for the content AUTHOR (ownership) OR a moderator/admin
2761
- // — per the SDK, "only post owners and admins can delete posts".
2762
- const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(text);
3041
+ const stripped = commentStripped(filename, platform, text);
3042
+ const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(stripped);
3043
+ const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(stripped);
2763
3044
  if (!hasModOnlyAction && !hasOwnableAction) {
2764
3045
  continue;
2765
3046
  }
2766
- // Moderator role markers
2767
- const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2768
- /\.hasRole\s*\(.*moderator/i.test(text) ||
2769
- /community\.hasModeratorRole/.test(text) ||
2770
- /isCommunityModerator/.test(text) ||
2771
- /isModerator\s*[(=]/.test(text) ||
2772
- /member\.isModerator/.test(text) ||
2773
- /roles\.includes\s*\(.*moderator/i.test(text) ||
2774
- /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2775
- /\bhasPermission\s*\(/.test(text) ||
3047
+ const roleUseSurface = blankDeadDeclarations(stripped, "(?:isModerator|isCommunityModerator)");
3048
+ const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(stripped) ||
3049
+ /\.hasRole\s*\(.*moderator/i.test(stripped) ||
3050
+ /community\.hasModeratorRole/.test(stripped) ||
3051
+ /\bisCommunityModerator\b/.test(roleUseSurface) ||
3052
+ /\bisModerator\b/.test(roleUseSurface) ||
3053
+ /member\.isModerator/.test(stripped) ||
3054
+ /roles\.includes\s*\(.*moderator/i.test(stripped) ||
3055
+ /permissions\.(?:contains|includes).*moderation/i.test(stripped) ||
3056
+ /\bhasPermission\s*\(/.test(stripped) ||
2776
3057
  /\/\/\s*vise:\s*role check applied/i.test(text);
2777
- // Ownership markers (valid alternative to a role check for delete/edit).
2778
- const hasOwnershipCheck = /\bisAuthor\b/.test(text) ||
2779
- /\bisOwner\b/.test(text) ||
2780
- /postedUserId\s*===?=?\s*\w+/.test(text) ||
2781
- /\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(text) ||
2782
- /creator\s*\??\.\s*userId\s*===?=?/.test(text) ||
2783
- // Ownership by comparing the entity's userId to the current user — the
2784
- // idiomatic Swift/Kotlin form, e.g. `comment.userId == amity.currentUserId`.
2785
- /\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(text) ||
2786
- /\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(text) ||
3058
+ const hasOwnershipCheck = /\bisAuthor\b/.test(stripped) ||
3059
+ /\bisOwner\b/.test(stripped) ||
3060
+ /postedUserId\s*===?=?\s*\w+/.test(stripped) ||
3061
+ /\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(stripped) ||
3062
+ /creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
3063
+ /\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
3064
+ /\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped) ||
2787
3065
  /\/\/\s*vise:\s*owner action\b/i.test(text);
2788
- // Moderator-only actions require a role check.
2789
3066
  if (hasModOnlyAction && !hasRoleCheck) {
2790
3067
  findings.push(finding(ruleId, "warning", "File " + rel + " invokes a moderator-only action (ban/mute) without a role check.", rel, "Ban/mute require the moderator role and fail on the server otherwise. Gate the action behind a role check (e.g. currentUser.roles.includes('moderator'), or client.hasPermission(...))."));
2791
3068
  }
2792
3069
  else if (hasOwnableAction && !hasRoleCheck && !hasOwnershipCheck) {
2793
- // Delete/edit need EITHER ownership OR a moderator role.
2794
3070
  findings.push(finding(ruleId, "warning", "File " + rel + " deletes/edits content without an ownership or moderator-role check.", rel, "Only the content author or a moderator/admin can delete or edit a post/comment. Gate the action behind an ownership check (post.postedUserId === currentUserId) or a moderator role check. Add // vise: owner action if the author-ownership gate lives elsewhere."));
2795
3071
  }
2796
3072
  }
@@ -2808,9 +3084,6 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2808
3084
  if (!matches)
2809
3085
  continue;
2810
3086
  for (const match of matches) {
2811
- // A standard TEXT post (`data: { text: ... }`, incl. quoted keys for Dart/Swift) legitimately
2812
- // has no dataType — only CUSTOM data payloads need the dataType tag. Don't mistake the canonical
2813
- // text-post shape for a custom post (the weak-model bench build hit this with `data: { text }`).
2814
3087
  if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
2815
3088
  continue;
2816
3089
  if (!/dataType\s*[:=\(]/i.test(match)) {
@@ -2820,11 +3093,6 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2820
3093
  }
2821
3094
  return findings;
2822
3095
  }
2823
- // ── Post data-type check ──────────────────────────────────────────────────────
2824
- // Fires when a file renders post content but only reads the text field without
2825
- // branching on the post's data type. getGlobalFeed / getPosts return every
2826
- // post type (image, video, file, poll, liveStream…); text-only rendering leaves
2827
- // all non-text posts silently blank.
2828
3096
  function validatePostDataTypeHandled(root, platform, sourceContent) {
2829
3097
  const findings = [];
2830
3098
  const ruleId = `${platform}.feed.post-datatype-handled`;
@@ -2833,9 +3101,6 @@ function validatePostDataTypeHandled(root, platform, sourceContent) {
2833
3101
  "react-native": /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
2834
3102
  flutter: /\.getData\s*<|\bAmityTextPost\b|\bAmityPostData\b/,
2835
3103
  ios: /\.getData\(\)\s*as\?.*AmityPost\.Data\.TEXT|post\s*\.\s*text\b/,
2836
- // POST text only — a bare `.getText()` also matches a COMMENT renderer reading
2837
- // AmityComment.Data.TEXT (comments are text by nature), which is not a post
2838
- // renderer and must not trip this rule. Scope to AmityPost.Data.TEXT.
2839
3104
  android: /\.getData\(\)\s*as\??\s*AmityPost\.Data\.TEXT/,
2840
3105
  };
2841
3106
  const TYPE_GUARD = {
@@ -2926,19 +3191,10 @@ function validateRichPostComposerScope(root, platform, sourceContent) {
2926
3191
  }
2927
3192
  return findings;
2928
3193
  }
2929
- // ── Avatar / display-identity from SDK ───────────────────────────────────────
2930
- // Fires when a file renders community or user display identity using raw string
2931
- // manipulation (charAt / slice on a name or raw userId) instead of the SDK-
2932
- // provided avatar.fileUrl / avatarImage / getAvatar(). The SDK resolves the
2933
- // avatar URL on the linked object; ignoring it shows a generated initial where
2934
- // the user uploaded a real photo.
2935
3194
  function validateAvatarFromSdk(root, platform, sourceContent) {
2936
3195
  const findings = [];
2937
3196
  const ruleId = `${platform}.community.avatar-from-sdk`;
2938
3197
  const INITIAL_PAT = {
2939
- // slice(0,1) / slice(0,2) are avatar initials; slice(0,8)+ is a userId/name
2940
- // truncation used as a display fallback (e.g. userId.slice(0, 8)) and must
2941
- // NOT be flagged as a missing avatar.
2942
3198
  typescript: /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
2943
3199
  "react-native": /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
2944
3200
  flutter: /(?:displayName|name)\s*\??\.(?:substring\s*\(\s*0\s*,\s*1|characters\.first)/,
@@ -2960,11 +3216,12 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
2960
3216
  continue;
2961
3217
  if (/\/\/\s*vise:\s*avatar fallback intentional/i.test(content))
2962
3218
  continue;
2963
- if (!CTX_PAT.test(content))
3219
+ const checkContent = commentStripped(file, platform, content);
3220
+ if (!CTX_PAT.test(checkContent))
2964
3221
  continue;
2965
- if (!initPat.test(content))
3222
+ if (!initPat.test(checkContent))
2966
3223
  continue;
2967
- if (avatarPat.test(content))
3224
+ if (avatarPat.test(checkContent))
2968
3225
  continue;
2969
3226
  findings.push(finding(ruleId, "warning", "Community or user display uses a string initial instead of the SDK-provided avatar URL.", relativeFile(root, file), "Check the avatar field before falling back to initials. For TypeScript/React Native: community.avatar?.fileUrl or user.avatar?.fileUrl. For Flutter: community.avatarImage?.getUrl(AmityImageSize.MEDIUM). For iOS: community.avatar?.fileURL. For Android: community.getAvatar()?.getUrl(). Add // vise: avatar fallback intentional if no avatar field is available in your SDK version."));
2970
3227
  break;
@@ -2973,13 +3230,6 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
2973
3230
  }
2974
3231
  function validatePollAnswerDataShape(root, platform, sourceContent) {
2975
3232
  const findings = [];
2976
- // iOS has no poll-answer data-shape footgun: AmityPollAnswer exposes a typed
2977
- // `public let text: String` (and `image: AmityImageData?`) — there is no
2978
- // `.data` to mis-shape, and `answer.text` is the documented, correct accessor
2979
- // (confirmed against the SDK source; two independent agent builds both wrote
2980
- // it from the docs). The rule used to flag `answer.text` as wrong — a pure
2981
- // false positive. The TS/RN concern (`answer.data` is a string, don't read
2982
- // `.data.text`) doesn't translate to typed Swift. Rule de-registered for iOS.
2983
3233
  if (platform === "ios")
2984
3234
  return findings;
2985
3235
  const ruleId = `${platform}.feed.poll-answer-data-shape`;
@@ -3003,7 +3253,6 @@ function validatePollAnswerDataShape(root, platform, sourceContent) {
3003
3253
  for (const [file, content] of sourceContent) {
3004
3254
  if (path.basename(file).endsWith('.d.ts'))
3005
3255
  continue;
3006
- // Escape-hatch is checked on RAW content — stripComments would delete the marker.
3007
3256
  if (/\/\/\s*vise:\s*poll-answer-shape intentional/i.test(content))
3008
3257
  continue;
3009
3258
  const astLang = astLanguageForFile(file, platform);
@@ -3039,17 +3288,12 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3039
3288
  const COMMENT_CTX = /referenceType\s*[:=]\s*['"]?post['"]?|\breferenceId\b|\bCommentRepository\b|\bAmityComment\b/;
3040
3289
  const recommendation = "Pass an explicit pageSize to the getComments params (limit is deprecated). For TypeScript/React Native: CommentRepository.getComments({ referenceId, referenceType: 'post', pageSize: 20 }, cb). For Flutter: .getLiveCollection(pageSize: 20). For iOS: AmityCommentQueryOptions(..., pageSize: 20). For Android: .pageSize(20). Add // vise: comment-limit handled if you have a deliberate reason to omit it.";
3041
3290
  const message = "Comment live collection called without an explicit limit/pageSize — the SDK default may use skip/limit pagination incompatible with scrollable query types, causing a runtime 500000 error.";
3042
- // TypeScript / React Native: AST pass — inspect the actual getComments({...})
3043
- // first argument and fire only when the object literal carries neither
3044
- // pageSize nor limit. More precise than a text scan: ignores the token
3045
- // appearing in comments/strings, and won't fire when params are passed as a
3046
- // variable we can't statically read (conservative — avoids false positives).
3047
3291
  if (platform === "typescript" || platform === "react-native") {
3048
3292
  for (const [file, content] of sourceContent) {
3049
3293
  if (path.basename(file).endsWith('.d.ts'))
3050
3294
  continue;
3051
3295
  if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
3052
- continue; // escape on RAW content
3296
+ continue;
3053
3297
  const astLang = astLanguageForFile(file, platform);
3054
3298
  if (!astLang)
3055
3299
  continue;
@@ -3064,7 +3308,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3064
3308
  for (const call of calls) {
3065
3309
  const objArg = call.args[0];
3066
3310
  if (!objArg || objArg.type !== "object")
3067
- continue; // params not a literal → can't analyze, skip
3311
+ continue;
3068
3312
  if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
3069
3313
  continue;
3070
3314
  findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
@@ -3073,16 +3317,13 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3073
3317
  }
3074
3318
  return findings;
3075
3319
  }
3076
- // Flutter / iOS / Android: regex pass (builder chains and option structs do
3077
- // not fit call-argument inspection). Kotlin runs against comment-stripped
3078
- // source; Dart/Swift have no grammar and run against raw content.
3079
3320
  const getCommentsPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
3080
3321
  const limitPat = HAS_LIMIT[platform] ?? HAS_LIMIT.typescript;
3081
3322
  for (const [file, content] of sourceContent) {
3082
3323
  if (path.basename(file).endsWith('.d.ts'))
3083
3324
  continue;
3084
3325
  if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
3085
- continue; // escape on RAW content
3326
+ continue;
3086
3327
  const astLang = astLanguageForFile(file, platform);
3087
3328
  const checkContent = astLang ? stripComments(astLang, content) : content;
3088
3329
  if (!COMMENT_CTX.test(checkContent))
@@ -3096,12 +3337,6 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3096
3337
  }
3097
3338
  return findings;
3098
3339
  }
3099
- // ── Comment creation affordance ──────────────────────────────────────────────
3100
- // Fires when comments are READ (getComments) but never CREATED (createComment)
3101
- // anywhere in the codebase. A read-only comment list gives users no way to
3102
- // participate. Mirrors the moderation-affordance-present precedent: the write
3103
- // path may live in a sibling file, so detection is codebase-wide and an
3104
- // attestation escape covers deliberately read-only surfaces.
3105
3340
  function validateCommentCreationAffordance(root, platform, sourceContent) {
3106
3341
  const findings = [];
3107
3342
  const ruleId = `${platform}.comments.creation-affordance-present`;
@@ -3116,9 +3351,6 @@ function validateCommentCreationAffordance(root, platform, sourceContent) {
3116
3351
  typescript: /CommentRepository\s*\.\s*createComment\s*\(/,
3117
3352
  "react-native": /CommentRepository\s*\.\s*createComment\s*\(/,
3118
3353
  flutter: /\.createComment\s*\(/,
3119
- // Any receiver, not just a var literally named `commentRepository` — agents
3120
- // idiomatically hold the repo in `repo`/`vm`/etc. (real v8: AmityCommentRepository
3121
- // .createComment(with: AmityCommentCreateOptions(...))). Mirrors android/flutter.
3122
3354
  ios: /AmityCommentCreateOptions\s*\(|\.createComment\s*\(/,
3123
3355
  android: /\.createComment\s*\(/,
3124
3356
  };
@@ -3129,7 +3361,6 @@ function validateCommentCreationAffordance(root, platform, sourceContent) {
3129
3361
  for (const [file, content] of sourceContent) {
3130
3362
  if (path.basename(file).endsWith('.d.ts'))
3131
3363
  continue;
3132
- // Escape-hatch is checked on RAW content — stripComments would delete the marker.
3133
3364
  if (/\/\/\s*vise:\s*comments read-only intentional/i.test(content))
3134
3365
  return findings;
3135
3366
  const astLang = astLanguageForFile(file, platform);
@@ -3172,7 +3403,7 @@ function validateCommentThreadUiStates(root, platform, sourceContent) {
3172
3403
  continue;
3173
3404
  const hasLoading = /LoadState\.Loading|loadState\.refresh\s+is\s+LoadState\.Loading|loadState\.append\s+is\s+LoadState\.Loading/.test(checkContent);
3174
3405
  const hasError = /LoadState\.Error|loadState\.refresh\s+is\s+LoadState\.Error|retry\s*\(\s*\)/.test(checkContent);
3175
- const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|empty/i.test(checkContent);
3406
+ const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|EmptyState|emptyState|\bisEmpty\b/.test(checkContent);
3176
3407
  if (!(hasLoading && hasError && hasEmpty)) {
3177
3408
  findings.push(finding(ruleId, "warning", "Comment tray renders a paged comment list but does not handle loading, error, and empty states. It can show an empty tray while comments are still loading or hide failures.", relativeFile(root, file), "For collectAsLazyPagingItems(), branch on comments.loadState.refresh/append for Loading and Error, show retry for errors, and show the empty state only when refresh is NotLoading and itemCount == 0. Add // vise: comment-ui-states intentional — <reason> only when a parent component handles these states."));
3178
3409
  break;
@@ -3184,7 +3415,7 @@ function validateChatUnreadVisible(root, platform, sourceContent) {
3184
3415
  const findings = [];
3185
3416
  const ruleId = `${platform}.chat.unread-visible`;
3186
3417
  let chatListFile;
3187
- let hasUnreadSignal = false;
3418
+ let chatListContent;
3188
3419
  const channelListPat = {
3189
3420
  typescript: /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
3190
3421
  "react-native": /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
@@ -3212,16 +3443,15 @@ function validateChatUnreadVisible(root, platform, sourceContent) {
3212
3443
  for (const [file, content] of sourceContent) {
3213
3444
  if (/\/\/\s*vise:\s*chat-unread intentional/i.test(content))
3214
3445
  return findings;
3215
- const astLang = astLanguageForFile(file, platform);
3216
- const checkContent = astLang ? stripComments(astLang, content) : content;
3217
- if (unreadSignalPat.test(checkContent)) {
3218
- hasUnreadSignal = true;
3219
- }
3446
+ const checkContent = commentStripped(file, platform, content);
3220
3447
  if (!chatListFile && queryPat.test(checkContent) && uiPat.test(checkContent)) {
3221
3448
  chatListFile = file;
3449
+ chatListContent = checkContent;
3222
3450
  }
3223
3451
  }
3224
- if (chatListFile && !hasUnreadSignal) {
3452
+ if (chatListFile &&
3453
+ chatListContent !== undefined &&
3454
+ !unreadSignalPat.test(blankDeadDeclarations(chatListContent, "(?:unreadCount|totalChannelUnread|subChannelsUnreadCount)"))) {
3225
3455
  findings.push(finding(ruleId, "warning", "Chat channel list is present but no SDK unread count is rendered or subscribed. The UI will drop unread badges/counts.", relativeFile(root, chatListFile), "Render unread from the SDK, for example channel.getUnreadCount()/getSubChannelsUnreadCount() for channel rows or AmityCoreClient.observeUserUnread()/getTotalChannelUnread() for a global badge. Add // vise: chat-unread intentional — <reason> only for a deliberately unread-free chat surface."));
3226
3456
  }
3227
3457
  return findings;
@@ -3263,11 +3493,11 @@ function validateProfileSocialCounts(root, platform, sourceContent) {
3263
3493
  const findings = [];
3264
3494
  const ruleId = `${platform}.profile.social-counts-from-sdk`;
3265
3495
  const sdkCountsPat = {
3266
- typescript: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bUserRepository\b/i,
3267
- "react-native": /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bUserRepository\b/i,
3268
- flutter: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bnewUserRepository\s*\(/i,
3269
- ios: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bAmityUserRepository\b/i,
3270
- android: /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bAmityCoreClient\s*\.\s*newUserRepository\s*\(\s*\)/,
3496
+ typescript: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
3497
+ "react-native": /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
3498
+ flutter: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
3499
+ ios: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
3500
+ android: /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/,
3271
3501
  };
3272
3502
  const placeholderCountsPat = {
3273
3503
  typescript: /value\s*=\s*["'][—-]+["']|value\s*=\s*["']0["']|>\s*[—-]+\s*<|>\s*0\s*</,
@@ -3311,7 +3541,6 @@ function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
3311
3541
  for (const [file, content] of sourceContent) {
3312
3542
  if (path.basename(file).endsWith('.d.ts'))
3313
3543
  continue;
3314
- // Escape-hatch is checked on RAW content — stripComments would delete the marker.
3315
3544
  if (/\/\/\s*vise:\s*community-id display intentional/i.test(content))
3316
3545
  continue;
3317
3546
  const astLang = astLanguageForFile(file, platform);
@@ -3334,9 +3563,6 @@ function validateRoomPostFetched(root, platform, sourceContent) {
3334
3563
  typescript: /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
3335
3564
  "react-native": /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
3336
3565
  flutter: /room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
3337
- // Negative lookbehind for Swift string interpolation `\(room.roomId)`:
3338
- // that's a segment/identifier key, not a displayed name, and must not fire.
3339
- // Real display (`Text(room.roomId)`, `label.text = room.roomId`) still matches.
3340
3566
  ios: /(?<!\\\()room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\?\.?\s*roomId/,
3341
3567
  android: /room\s*\??\.\s*getRoomId\s*\(\)|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
3342
3568
  };
@@ -3353,7 +3579,6 @@ function validateRoomPostFetched(root, platform, sourceContent) {
3353
3579
  for (const [file, content] of sourceContent) {
3354
3580
  if (path.basename(file).endsWith('.d.ts'))
3355
3581
  continue;
3356
- // Escape-hatch is checked on RAW content — stripComments would delete the marker.
3357
3582
  if (/\/\/\s*vise:\s*room-display intentional/i.test(content))
3358
3583
  continue;
3359
3584
  const astLang = astLanguageForFile(file, platform);
@@ -3388,16 +3613,8 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
3388
3613
  let ttm;
3389
3614
  let flagged = false;
3390
3615
  while ((ttm = g.exec(text)) !== null) {
3391
- // A literal targetType in a READ query (getPosts/getCommunityFeed params) is correct —
3392
- // you specify targetType:'community' + targetId to read that feed. Read-query objects
3393
- // carry feedType/sortBy keys; a createPost payload carries dataType/data. Only the
3394
- // latter is the reusability concern this rule targets.
3395
3616
  const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
3396
3617
  const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
3397
- // A literal targetType is the required enum ('community'/'user'); the reusability concern is
3398
- // the TARGET id. If targetId is bound to a dynamic value (identifier/member/prop, not a
3399
- // string literal) it is the correct community-feed form — do not flag. Only flag when the
3400
- // targetId is hardcoded or absent. (Mirrors the read-query carve-out above.)
3401
3618
  const targetIdDynamic = /\btargetId\s*:\s*[A-Za-z_$]/.test(window);
3402
3619
  if (!isReadQuery && !targetIdDynamic) {
3403
3620
  flagged = true;