@amityco/social-plus-vise 0.11.0 → 0.12.2

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.
@@ -1,7 +1,7 @@
1
1
  import { access, readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
4
- import { findCallExpressions, parse, pickObjectProperty, resolveLiteralValue, stripComments } from "./ast.js";
4
+ import { findCallExpressions, parse, tryParse, pickObjectProperty, resolveLiteralValue, stripComments } from "./ast.js";
5
5
  async function exists(filePath) {
6
6
  try {
7
7
  await access(filePath);
@@ -171,7 +171,14 @@ async function validateAndroid(root) {
171
171
  const findings = [];
172
172
  const manifestPath = "app/src/main/AndroidManifest.xml";
173
173
  const manifest = await readIfExists(path.join(root, manifestPath));
174
- const buildFiles = await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]);
174
+ // Scan ALL Gradle build scripts, not just app/root — a multi-module app idiomatically declares the
175
+ // SDK dependency in the consuming FEATURE module's build.gradle(.kts) (e.g. feature/community/impl).
176
+ // Only checking app/build.gradle false-fired android.dependency.sdk on those (brownfield Now-in-Android).
177
+ const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200)).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
178
+ const buildFiles = [...new Set([
179
+ ...(await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])),
180
+ ...gradleScripts,
181
+ ])];
175
182
  const buildContent = await readMany(buildFiles);
176
183
  const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
177
184
  const sourceContent = await readMany(sourceFiles);
@@ -201,7 +208,17 @@ async function validateAndroid(root) {
201
208
  findings.push(finding("android.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in Kotlin/Java files.", undefined, "Call SDK setup once during application startup before any social.plus API usage."));
202
209
  }
203
210
  else {
204
- const activitySetup = setupFiles.find((file) => /Activity|Fragment|Composable|onCreateView/.test(sourceContent.get(file) ?? ""));
211
+ const activitySetup = setupFiles.find((file) => {
212
+ const c = sourceContent.get(file) ?? "";
213
+ // Application-scoped setup (a Hilt @Module / @HiltAndroidApp Application / SingletonComponent
214
+ // provider) is the CORRECT place — never flag it. The old marker matched the bare word
215
+ // "Activity" anywhere (incl. a comment like "outlives any Activity/ViewModel" in a DI module),
216
+ // false-firing on idiomatic Hilt setup (brownfield Now-in-Android).
217
+ if (/@Module\b|@HiltAndroidApp\b|SingletonComponent\b|@ApplicationContext\b|:\s*Application\b|\bApplication\s*\(\s*\)/.test(c))
218
+ return false;
219
+ // Otherwise flag setup in an actual UI lifecycle: an Activity/Fragment class, a @Composable, or onCreateView.
220
+ return /\bclass\s+\w*(?:Activity|Fragment)\b|:\s*(?:ComponentActivity|AppCompatActivity|Activity|Fragment)\b|@Composable\b|\bonCreateView\b/.test(c);
221
+ });
205
222
  if (activitySetup) {
206
223
  findings.push(finding("android.setup.lifecycle", "warning", "SDK setup appears near Activity/Fragment/UI lifecycle code.", relativeFile(root, activitySetup), "Prefer Application.onCreate so the SDK is initialized once and not reinitialized on screen recreation."));
207
224
  }
@@ -213,14 +230,16 @@ async function validateAndroid(root) {
213
230
  findings.push(finding("android.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and before subscribing to social.plus collections."));
214
231
  }
215
232
  else if (!containsAny(sourceContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
216
- findings.push(finding("android.session.renewal", "warning", "Login was found but no AmitySessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass an AmitySessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
233
+ findings.push(finding("android.session.renewal", "warning", "Login was found but no SessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a SessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
217
234
  }
218
235
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
219
236
  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."));
220
237
  }
221
238
  // Android push payload: if onMessageReceived exists, require push-specific SDK forwarding
239
+ // Nexus gate (see iOS): a generic FCM handler with no social.plus push registration is the host
240
+ // app's own push, not a social.plus forwarding gap — require social.plus push registration first.
222
241
  const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
223
- if (androidPayloadFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
242
+ if (androidPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
224
243
  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."));
225
244
  }
226
245
  if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
@@ -237,6 +256,13 @@ async function validateAndroid(root) {
237
256
  findings.push(...validateComments(root, "android", sourceContent));
238
257
  findings.push(...validateModeration(root, "android", sourceContent));
239
258
  findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
259
+ findings.push(...validatePostDataTypeHandled(root, "android", sourceContent));
260
+ findings.push(...validateAvatarFromSdk(root, "android", sourceContent));
261
+ findings.push(...validatePollAnswerDataShape(root, "android", sourceContent));
262
+ findings.push(...validateCommentsQueryHasLimit(root, "android", sourceContent));
263
+ findings.push(...validateCommentCreationAffordance(root, "android", sourceContent));
264
+ findings.push(...validateCommunityDisplayNameFromSdk(root, "android", sourceContent));
265
+ findings.push(...validateRoomPostFetched(root, "android", sourceContent));
240
266
  findings.push(...validatePostsStatusFilter(root, "android", sourceContent));
241
267
  findings.push(...validatePaginationCursorOpaque(root, "android", sourceContent));
242
268
  findings.push(...validatePostsParentChild(root, "android", sourceContent));
@@ -284,7 +310,14 @@ async function validateFlutter(root) {
284
310
  if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
285
311
  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()."));
286
312
  }
287
- if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/])) {
313
+ // Region is configured via AmityRegion.* OR an explicit endpoint (the idiomatic Flutter
314
+ // forms — `httpEndpoint:` is not matched by `\bendpoint:` because of the camelCase boundary
315
+ // + case). The real endpoint symbols are httpEndpoint/mqttEndpoint/uploadEndpoint and the
316
+ // AmityRegional{Http,Mqtt}Endpoint enums (verified against the SDK surface — there is no
317
+ // `socketEndpoint`). NOTE: do NOT key on AmityCoreClientOption — that builder is used by
318
+ // EVERY setup regardless of whether a region is set, so it would silence this rule for any
319
+ // standard setup (a false negative).
320
+ 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/])) {
288
321
  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."));
289
322
  }
290
323
  }
@@ -292,14 +325,16 @@ async function validateFlutter(root) {
292
325
  findings.push(finding("flutter.login.present", "warning", "No obvious AmityCoreClient.login call was found.", undefined, "Call login after the app has a known user identity and before social.plus queries/subscriptions."));
293
326
  }
294
327
  else if (!containsAny(dartContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /SessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
295
- findings.push(finding("flutter.session.renewal", "warning", "Login was found but no AmitySessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Implement an AmitySessionHandler and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
328
+ findings.push(finding("flutter.session.renewal", "warning", "Login was found but no session handler callback with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a session handler callback (AccessTokenRenewal renewal) to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
296
329
  }
297
330
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
298
331
  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."));
299
332
  }
300
333
  // Flutter push payload: if FirebaseMessaging.onMessage/onBackgroundMessage exists, require push-specific SDK forwarding
334
+ // Nexus gate (see iOS): require social.plus push registration before demanding forwarding, so a
335
+ // host app's own FirebaseMessaging handler doesn't false-fire when push isn't a social.plus concern.
301
336
  const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
302
- if (flutterPayloadFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
337
+ if (flutterPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
303
338
  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."));
304
339
  }
305
340
  if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
@@ -316,6 +351,13 @@ async function validateFlutter(root) {
316
351
  findings.push(...validateComments(root, "flutter", dartContent));
317
352
  findings.push(...validateModeration(root, "flutter", dartContent));
318
353
  findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
354
+ findings.push(...validatePostDataTypeHandled(root, "flutter", dartContent));
355
+ findings.push(...validateAvatarFromSdk(root, "flutter", dartContent));
356
+ findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
357
+ findings.push(...validateCommentsQueryHasLimit(root, "flutter", dartContent));
358
+ findings.push(...validateCommentCreationAffordance(root, "flutter", dartContent));
359
+ findings.push(...validateCommunityDisplayNameFromSdk(root, "flutter", dartContent));
360
+ findings.push(...validateRoomPostFetched(root, "flutter", dartContent));
319
361
  findings.push(...validatePostsStatusFilter(root, "flutter", dartContent));
320
362
  findings.push(...validatePaginationCursorOpaque(root, "flutter", dartContent));
321
363
  findings.push(...validatePostsParentChild(root, "flutter", dartContent));
@@ -349,7 +391,11 @@ async function validateTypeScript(root, platform) {
349
391
  // unregister methods but should not suppress the "unregister missing" finding.
350
392
  const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
351
393
  const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
352
- const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/, /onSnapshot/]);
394
+ // Only REACTIVE subscriptions need cleanup. A one-shot `await queryPosts(...)` /
395
+ // `await getPost(...)` returns a Promise with nothing to unsubscribe, so bare
396
+ // queryPosts(/getPost( are NOT live-data markers (they'd false-fire live.cleanup on
397
+ // one-shot awaits — found on a weak-model build). The reactive forms below remain.
398
+ const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/]);
353
399
  if (!packageJson) {
354
400
  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."));
355
401
  }
@@ -370,8 +416,14 @@ async function validateTypeScript(root, platform) {
370
416
  // process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
371
417
  // VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
372
418
  // Any one of these is sufficient evidence the integration is region-aware.
373
- const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/]);
374
- const setupHasNamedRegionDecl = containsAnyForFiles(sourceContent, setupFiles, [/\bconst\s+(region|apiRegion|endpoint|apiEndpoint)\b/, /\blet\s+(region|apiRegion|endpoint|apiEndpoint)\b/]);
419
+ // `API_REGIONS` is the canonical social.plus TS SDK region enum
420
+ // (API_REGIONS.SG/EU/US) its presence is definitive region evidence. The
421
+ // named-decl form is case-insensitive so `const REGION = API_REGIONS.SG` counts.
422
+ // (d) positional region argument: createClient(API_KEY, AMITY_REGION) — a 2nd arg whose name
423
+ // contains "region" (incl. an uppercase env const like AMITY_REGION). Idiom (b)'s regex only
424
+ // caught a `const region` decl named exactly "region"; the positional call site was missed (RN build).
425
+ const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/, /\bAPI_REGIONS\b/, /createClient\s*\([^,)]+,\s*[\w.$]*[Rr]egion/i]);
426
+ const setupHasNamedRegionDecl = containsAnyForFiles(sourceContent, setupFiles, [/\b(?:const|let)\s+(?:region|apiRegion|endpoint|apiEndpoint)\b/i]);
375
427
  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_]*/]);
376
428
  if (!setupHasKeywordRegion && !setupHasNamedRegionDecl && !projectHasEnvRegion) {
377
429
  findings.push(finding("typescript.client.region", "warning", "Client initialization was found but no explicit region or endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Pass the region or endpoint that matches the customer's social.plus console project. Reuse the app's existing config or environment source of truth if one exists, and do not hardcode a guessed default region just to make setup appear green."));
@@ -390,7 +442,11 @@ async function validateTypeScript(root, platform) {
390
442
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
391
443
  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."));
392
444
  }
393
- if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /cleanup/i])) {
445
+ // A function whose RETURN TYPE is Amity.Unsubscriber hands the cleanup contract to
446
+ // its caller — the idiomatic repository/store pattern. Anchored on the return-type
447
+ // annotation `): Unsubscriber` (not a bare mention), so a disposer captured and
448
+ // dropped on the floor still fires.
449
+ 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/])) {
394
450
  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."));
395
451
  }
396
452
  findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
@@ -406,6 +462,13 @@ async function validateTypeScript(root, platform) {
406
462
  findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
407
463
  findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
408
464
  findings.push(...validateActivityPostsTagFilter(root, platform, sourceContent));
465
+ findings.push(...validatePostDataTypeHandled(root, platform, sourceContent));
466
+ findings.push(...validateAvatarFromSdk(root, platform, sourceContent));
467
+ findings.push(...validatePollAnswerDataShape(root, platform, sourceContent));
468
+ findings.push(...validateCommentsQueryHasLimit(root, platform, sourceContent));
469
+ findings.push(...validateCommentCreationAffordance(root, platform, sourceContent));
470
+ findings.push(...validateCommunityDisplayNameFromSdk(root, platform, sourceContent));
471
+ findings.push(...validateRoomPostFetched(root, platform, sourceContent));
409
472
  findings.push(...validateReactionStalePostRef(root, platform, sourceContent));
410
473
  findings.push(...validateFollowStatusSubscription(root, platform, sourceContent));
411
474
  findings.push(...validateMembershipUsesLiveCollection(root, platform, sourceContent));
@@ -776,8 +839,11 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
776
839
  }
777
840
  // ─── Logout on user-switch ───────────────────────────────────────────────────
778
841
  const USER_SWITCH_SYMBOLS = [
779
- /switchUser/i, /setCurrentUser/i, /authStateChanged/i, /onAuthStateChanged/i,
780
- /logoutAndLogin/i, /signInAs/i, /changeUser/i, /setUser/i,
842
+ // setActiveUser is the real SDK user-switch. `setCurrentUser`/`setUser` were REMOVED — they collide
843
+ // with ubiquitous React useState setters (`const [u, setCurrentUser] = useState(...)`) and false-fired
844
+ // logout-on-user-switch on an RN feed screen that merely READ the active user into state.
845
+ /switchUser/i, /setActiveUser/i, /authStateChanged/i, /onAuthStateChanged/i,
846
+ /logoutAndLogin/i, /signInAs/i, /changeUser/i,
781
847
  ];
782
848
  const LOGIN_PATTERNS_BY_PLATFORM = {
783
849
  android: [/AmityCoreClient\.login/, /\.login\s*\(/],
@@ -818,9 +884,14 @@ const WRITE_CALL_PATTERNS = [
818
884
  /\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
819
885
  ];
820
886
  const AUTH_GATE_MARKERS = [
821
- /\bcurrentUser\b/, /\bisAuthenticated\b/, /\brequireAuth\b/,
887
+ // `currentUser` prefix, NOT \bcurrentUser\b a write gated on `currentUserId` (the
888
+ // session-derived user id, idiomatically passed into a component) is a valid
889
+ // anonymous-write guard, and \b after "currentUser" would not match "currentUserId".
890
+ /\bcurrentUser\w*/, /\bisAuthenticated\b/, /\brequireAuth\b/,
822
891
  /\bsession\b/, /\bisLoggedIn\b/, /\bauthState\b/,
823
- /\bgetCurrentUser\b/, /\bgetAccessToken\b/, /\baccessToken\b/,
892
+ // \w* not \b: `getCurrentUserId()` (the session-derived id used to gate writes) must match —
893
+ // the trailing \b would stop at "getCurrentUser" and miss the camelCase suffix (recurring pattern).
894
+ /\bgetCurrentUser\w*/, /\bgetAccessToken\b/, /\baccessToken\b/,
824
895
  /\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
825
896
  ];
826
897
  function validateNoAnonymousWrite(root, platform, sourceContent) {
@@ -883,8 +954,8 @@ const COMMENT_PRESENCE_PATTERNS = [
883
954
  const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
884
955
  android: [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/],
885
956
  flutter: [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/],
886
- typescript: [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/],
887
- "react-native": [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/, /useEffect.*return/],
957
+ typescript: [/unsubscribe/, /unobserve/, /\.off\s*\(/, /removeListener/, /dispose/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/, /return\s*\(\s*\)\s*=>/],
958
+ "react-native": [/unsubscribe/, /unobserve/, /\.off\s*\(/, /removeListener/, /dispose/, /useEffect.*return/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/, /return\s*\(\s*\)\s*=>/],
888
959
  ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
889
960
  };
890
961
  const COMMENT_UI_STATE_MARKERS = [
@@ -905,9 +976,19 @@ function validateComments(root, platform, sourceContent) {
905
976
  break;
906
977
  }
907
978
  }
908
- // observer-cleanup: if comment live collection exists, require cleanup
979
+ // observer-cleanup: require cleanup only for a REACTIVE comment observer. The
980
+ // distinguisher is `await`: a one-shot `await queryComments(...)` returns a Promise
981
+ // with nothing to clean up (false-fired on a weak-model build), whereas a non-awaited
982
+ // getComments/queryComments creates a live collection that must be disposed. Strip the
983
+ // awaited one-shot queries, then any remaining (bare-call, assigned, or chained) comment
984
+ // query is a live observer.
985
+ const hasReactiveCommentObserver = [...sourceContent.values()].some((c) => {
986
+ const stripped = c.replace(/\bawait\b[^\n;]*?(?:get|query)Comments\s*\(/g, "");
987
+ return /(?:get|query)Comments\s*\(/.test(stripped)
988
+ || /CommentLiveCollection|AmityCommentCollection|commentCollection/.test(c);
989
+ });
909
990
  const cleanupMarkers = COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
910
- if (!containsAny(sourceContent, cleanupMarkers)) {
991
+ if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
911
992
  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."));
912
993
  }
913
994
  // ui-states-present: require loading/empty/error states
@@ -1104,6 +1185,9 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1104
1185
  /\bListView\b/,
1105
1186
  /\bRecyclerView\b/,
1106
1187
  /\bLazyColumn\b/,
1188
+ /\bLazyRow\b/, // Jetpack Compose lists
1189
+ /\bLazyVerticalGrid\b/,
1190
+ /\bLazyVerticalStaggeredGrid\b/,
1107
1191
  /\bForEach\b/,
1108
1192
  /\bList\s*\(/,
1109
1193
  /\bsubmitList\b/,
@@ -1118,9 +1202,26 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1118
1202
  /\bonData\b/,
1119
1203
  /\bStreamBuilder\b/,
1120
1204
  /\bLiveData\b/,
1205
+ /\bMutableLiveData\b/, // Android LiveData (modern; `\bLiveData\b` misses Mutable/Mediator forms)
1206
+ /\bMediatorLiveData\b/,
1207
+ /collectAsState/, // Jetpack Compose: Flow/StateFlow → State (collectAsState, collectAsStateWithLifecycle)
1208
+ /observeAsState/, // Jetpack Compose: LiveData → State
1121
1209
  /Amity\w*Flow\b/,
1122
1210
  /\bPagingData\b/,
1211
+ /LazyPagingItems/, // Paging3 Compose: getX().collectAsLazyPagingItems() in a LazyColumn — reactive
1212
+ /collectAsLazyPagingItems/,
1123
1213
  /\.on\s*\(\s*['"]data[A-Z]/, // event-emitter SDK pattern: .on('dataUpdated', ...)
1214
+ /\$snapshots\b/, // iOS Combine: AmityCollection.$snapshots.sink { ... }
1215
+ /\bAmityCollection\b/, // iOS live collection type, consumed reactively (@Published / .snapshots in a List)
1216
+ /\bLiveCollectionCallback\b/, // TS/RN live-collection callback type (Amity.LiveCollectionCallback)
1217
+ /\bLiveObjectCallback\b/, // TS/RN live-object callback type
1218
+ // TS/RN reactive form: getPosts(params, callback) / getComments(params, cb) — the
1219
+ // 2-arg callback form IS the live collection (callback fires on every update). Earlier
1220
+ // markers only knew getLiveCollection/observe and missed this idiomatic v6 form. The
1221
+ // first arg is a flat params OBJECT or an identifier; the callback is a genuine SECOND
1222
+ // argument — matching `{...},` (not a comma *inside* the object, which would falsely
1223
+ // count a one-shot `queryPosts({ a, b })` as reactive).
1224
+ /\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
1124
1225
  /\/\/\s*vise:\s*one-shot query/i
1125
1226
  ];
1126
1227
  for (const [file, content] of sourceContent) {
@@ -1146,7 +1247,10 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1146
1247
  const POST_QUERY_PATTERNS = [
1147
1248
  /\bgetPosts\b/,
1148
1249
  /\bqueryPosts\b/,
1149
- /\bgetCommunityFeed\b/
1250
+ // getCommunityFeed intentionally NOT matched: it is also a common name for a
1251
+ // user-defined wrapper, and firing on a wrapper CALL SITE (which has no query
1252
+ // params to filter) is a false positive. The explicit getPosts/queryPosts cover
1253
+ // the real query path where filters are actually applied.
1150
1254
  ];
1151
1255
  const FILTER_MARKERS = [
1152
1256
  /\bfeedTypes?\b/,
@@ -1302,23 +1406,57 @@ function validatePaginationCursorOpaque(root, platform, sourceContent) {
1302
1406
  function validatePostsParentChild(root, platform, sourceContent) {
1303
1407
  const findings = [];
1304
1408
  const ruleId = `${platform}.posts.parent-child-rendered`;
1409
+ // Direct text-field reads that signal a component is rendering the post body.
1410
+ // Deliberately NOT a bare `post.data` — that also matches passing post.data as
1411
+ // an argument to a content/share helper (e.g. textFromContent(post.dataType,
1412
+ // post.data)) in a non-rendering file like an actions menu, which produced a
1413
+ // false positive on a correctly-factored app where rendering and the actions
1414
+ // menu live in separate files.
1415
+ // Case-SENSITIVE on purpose: lowercase `post.data.text` is post-body rendering,
1416
+ // but the case-insensitive form also matched Kotlin/Swift sealed-class type
1417
+ // discriminators like `AmityComment.Data.TEXT` / `AmityMessage.Data.TEXT` —
1418
+ // which are type checks in comment/message code, not post rendering. That
1419
+ // produced a large false-positive cluster on real Kotlin (Android sample app).
1305
1420
  const POST_RENDER_PATTERNS = [
1306
- /\.data\.text\b/i,
1307
- /\bpost\.data\b/i,
1308
- /\bpost\.text\b/i
1421
+ /\.data\.text\b/,
1422
+ /\bpost\.text\b/
1309
1423
  ];
1310
1424
  const CHILD_REFERENCE_PATTERNS = [
1311
1425
  /\.\s*children\b/,
1312
1426
  /\.\s*childrenPosts\b/,
1313
1427
  /\bgetChildren\b/
1314
1428
  ];
1429
+ // Child-typed media the parent-child model carries (TS/RN string dataTypes).
1430
+ const CHILD_MEDIA_TYPES = ["poll", "video", "room", "file", "audio"];
1315
1431
  for (const [filename, text] of sourceContent) {
1316
1432
  const rel = relativeFile(root, filename);
1317
- const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(text));
1318
- if (hasPostRender) {
1319
- const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(text));
1320
- if (!hasChildRef) {
1321
- 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()."));
1433
+ // Escape-hatch checked on RAW content (stripComments would delete the marker).
1434
+ if (/\/\/\s*vise:\s*child-types intentional/i.test(text))
1435
+ continue;
1436
+ const astLang = astLanguageForFile(filename, platform);
1437
+ const checkContent = astLang ? stripComments(astLang, text) : text;
1438
+ const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(checkContent));
1439
+ const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(checkContent));
1440
+ // (1) Presence: renders post text but never inspects children at all.
1441
+ if (hasPostRender && !hasChildRef) {
1442
+ 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()."));
1443
+ continue;
1444
+ }
1445
+ // (2) Inconsistency (TS/RN): the renderer DOES inspect children (so the
1446
+ // parent-child model is in play) but gates some media types on the PARENT
1447
+ // post's dataType with no matching child lookup. A feed parent is dataType
1448
+ // 'text', so `post.dataType === 'poll'` never matches and that content
1449
+ // silently never renders. Only fires when child handling is already
1450
+ // present for something — which keeps false positives low (a feed with
1451
+ // genuinely top-level posts won't be inspecting childrenPosts at all).
1452
+ if (hasChildRef && (platform === "typescript" || platform === "react-native")) {
1453
+ const missed = CHILD_MEDIA_TYPES.filter((t) => {
1454
+ const parentGated = new RegExp(`post\\s*\\.\\s*dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
1455
+ const childResolved = new RegExp(`childrenPosts[\\s\\S]{0,80}dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
1456
+ return parentGated && !childResolved;
1457
+ });
1458
+ if (missed.length > 0) {
1459
+ findings.push(finding(ruleId, "warning", `Post renderer resolves children for some types but gates ${missed.join("/")} on the parent post's dataType. Feed parents are dataType 'text', so these branches never match and that content silently never renders.`, rel, "Resolve every child media type from post.childrenPosts, e.g. post.childrenPosts.find(c => c.dataType === 'poll')?.getPollInfo() — the same way images are handled. Checking post.dataType === 'poll' on a feed parent never matches. Add // vise: child-types intentional if these are genuinely top-level posts."));
1322
1460
  }
1323
1461
  }
1324
1462
  }
@@ -1410,7 +1548,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1410
1548
  continue;
1411
1549
  const rel = relativeFile(root, file);
1412
1550
  const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
1413
- const tree = parse(lang, content);
1551
+ const tree = tryParse(lang, content);
1552
+ if (!tree)
1553
+ continue; // oversized/unparseable file — regex passes above still apply
1414
1554
  // ── auth.no-literal-user-id via .login({ userId: CONST }) ──
1415
1555
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1416
1556
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
@@ -1482,7 +1622,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1482
1622
  if (!file.endsWith(".kt"))
1483
1623
  continue;
1484
1624
  const rel = relativeFile(root, file);
1485
- const tree = parse("kotlin", content);
1625
+ const tree = tryParse("kotlin", content);
1626
+ if (!tree)
1627
+ continue; // oversized/unparseable file — regex passes above still apply
1486
1628
  // ── auth.no-literal-user-id via .login(CONST) ──
1487
1629
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1488
1630
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
@@ -1641,7 +1783,11 @@ async function validateIos(root) {
1641
1783
  const swiftContent = await readMany(swiftFiles);
1642
1784
  const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
1643
1785
  const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
1644
- const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/, /didRegisterForRemoteNotifications/]);
1786
+ // social.plus push REGISTRATION only (real iOS symbol: registerPushNotification). Do NOT key
1787
+ // on the generic APNs primitive `didRegisterForRemoteNotifications` — that is the host app's own
1788
+ // push, present in any iOS app with notifications, and made the social.plus-push rules false-fire
1789
+ // on brownfield apps whose social.plus integration never included push.
1790
+ const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
1645
1791
  const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
1646
1792
  const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
1647
1793
  if (manifestFiles.length === 0) {
@@ -1672,7 +1818,7 @@ async function validateIos(root) {
1672
1818
  findings.push(finding("ios.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity and handle session renewal for production auth."));
1673
1819
  }
1674
1820
  else if (!containsAny(swiftContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\.renew\s*\(/])) {
1675
- findings.push(finding("ios.session.renewal", "warning", "Login was found but no AmitySessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, loginFiles[0]), "Implement AmitySessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
1821
+ findings.push(finding("ios.session.renewal", "warning", "Login was found but no SessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, loginFiles[0]), "Implement a SessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
1676
1822
  }
1677
1823
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
1678
1824
  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."));
@@ -1683,8 +1829,11 @@ async function validateIos(root) {
1683
1829
  }
1684
1830
  // iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
1685
1831
  // require push-specific SDK forwarding marker.
1832
+ // Nexus gate: only demand social.plus forwarding when social.plus push is actually set up
1833
+ // (registration present). A host app's generic push payload handler with no social.plus push
1834
+ // registration is not a social.plus concern (brownfield false-positive).
1686
1835
  const iosPayloadHandlerFiles = filesMatching(swiftContent, [/didReceiveRemoteNotification/, /userNotificationCenter\s*\(\s*_\s*,\s*didReceive/]);
1687
- if (iosPayloadHandlerFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
1836
+ if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
1688
1837
  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."));
1689
1838
  }
1690
1839
  if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
@@ -1701,6 +1850,13 @@ async function validateIos(root) {
1701
1850
  findings.push(...validateComments(root, "ios", swiftContent));
1702
1851
  findings.push(...validateModeration(root, "ios", swiftContent));
1703
1852
  findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
1853
+ findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
1854
+ findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
1855
+ findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
1856
+ findings.push(...validateCommentsQueryHasLimit(root, "ios", swiftContent));
1857
+ findings.push(...validateCommentCreationAffordance(root, "ios", swiftContent));
1858
+ findings.push(...validateCommunityDisplayNameFromSdk(root, "ios", swiftContent));
1859
+ findings.push(...validateRoomPostFetched(root, "ios", swiftContent));
1704
1860
  findings.push(...validatePostsStatusFilter(root, "ios", swiftContent));
1705
1861
  findings.push(...validatePaginationCursorOpaque(root, "ios", swiftContent));
1706
1862
  findings.push(...validatePostsParentChild(root, "ios", swiftContent));
@@ -2000,6 +2156,16 @@ function astLanguageForFile(filePath, platform) {
2000
2156
  }
2001
2157
  function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
2002
2158
  const findings = [];
2159
+ // TypeScript/React Native: the SDK types referenceType as the string-literal
2160
+ // union Amity.CommentReferenceType ('post' | 'story' | 'content') — there is
2161
+ // no enum to use, and `referenceType: 'post'` is the documented, idiomatic
2162
+ // value (this ruleset's own comment-limit / creation-affordance remediation
2163
+ // strings instruct exactly that). A wrong casing like 'POST' is not assignable
2164
+ // to the union, so tsc already catches it. Flagging the correct string here was
2165
+ // a pure false positive; the rule remains active on android/flutter/ios where a
2166
+ // real AmityCommentReferenceType enum exists.
2167
+ if (platform === "typescript" || platform === "react-native")
2168
+ return findings;
2003
2169
  const ruleId = `${platform}.comment.reference-type-enum`;
2004
2170
  const REFERENCE_TYPE_PATTERNS = [
2005
2171
  /referenceType\s*[:=\(]\s*(['"][^'"]+['"])/i
@@ -2056,7 +2222,23 @@ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
2056
2222
  if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
2057
2223
  continue;
2058
2224
  }
2059
- if (/\b(addReaction|removeReaction|flagReaction|react)\s*\(\s*['"`][^'"`]+['"`]/i.test(text)) {
2225
+ // The reaction NAME is the tenant-specific argument. The TS/RN SDK signature is
2226
+ // addReaction(referenceType, referenceId, reactionName) — the first literal is the
2227
+ // referenceType ('post'/'comment'/'message'/'story'), which is LEGITIMATELY a literal
2228
+ // and must not be mistaken for a hardcoded reaction name. Flag only when a string
2229
+ // literal that is NOT a known reference-type appears in the call.
2230
+ const REF_TYPE = /^(?:post|comment|message|story|community|user|channel|content)$/i;
2231
+ const reactionCallRe = /\b(?:addReaction|removeReaction|flagReaction|react)\s*\(([^)]*)\)/gi;
2232
+ let hardcodesReactionName = false;
2233
+ let rcm;
2234
+ while ((rcm = reactionCallRe.exec(text)) !== null) {
2235
+ const literals = [...rcm[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((x) => x[1]);
2236
+ if (literals.some((lit) => !REF_TYPE.test(lit))) {
2237
+ hardcodesReactionName = true;
2238
+ break;
2239
+ }
2240
+ }
2241
+ if (hardcodesReactionName) {
2060
2242
  findings.push(finding(ruleId, "warning", `File ${rel} hardcodes a reaction name string literal.`, rel, `Reaction names are per-tenant. Retrieve the reaction name from a configuration file or prop instead of hardcoding it. If it matches console config, add comment // vise: reaction "<name>" matches console config.`));
2061
2243
  }
2062
2244
  }
@@ -2174,6 +2356,30 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
2174
2356
  }
2175
2357
  return findings;
2176
2358
  }
2359
+ // The ban-state / role-gated / flagCount-leak rules all flag a MISSING UI guard.
2360
+ // That claim only makes sense for files that render UI. Service and data layers —
2361
+ // repositories, managers, datasources, API clients, stores, interactors — wrap the
2362
+ // SDK call but legitimately leave the guard to the UI layer above them, so firing on
2363
+ // them is a false positive. Observed on the official Amity sample apps, where
2364
+ // PostFlagManager / StoryManager / MembershipListDataSource tripped all three rules
2365
+ // despite the guards living correctly in their View/ViewController callers.
2366
+ function isNonUiSourceFile(filename) {
2367
+ const base = path.basename(filename).replace(/\.(kt|swift|dart|tsx?|jsx?)$/i, "");
2368
+ // PascalCase class-named files (Swift, Kotlin, TS): suffix match. Note "Controller"
2369
+ // and "View(Model)" are deliberately absent — iOS ViewControllers/SwiftUI Views DO
2370
+ // render and must keep firing.
2371
+ if (/(?:Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
2372
+ return true;
2373
+ // snake_case files (Dart, sometimes RN): same non-UI layers, plus Flutter's BLoC /
2374
+ // Cubit / ChangeNotifier state-management layers. "_page"/"_popup"/"_screen" are UI
2375
+ // and intentionally not matched.
2376
+ 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))
2377
+ return true;
2378
+ // dotted test/spec helpers (foo.test.ts, foo.spec.ts)
2379
+ if (/\.(?:test|spec)$/i.test(base))
2380
+ return true;
2381
+ return false;
2382
+ }
2177
2383
  function validateUserBanStateRespected(root, platform, sourceContent) {
2178
2384
  const findings = [];
2179
2385
  const ruleId = platform + '.user.ban-state-respected';
@@ -2183,6 +2389,10 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
2183
2389
  // the implementation that needs the ban-state guard.
2184
2390
  if (path.basename(filename).endsWith('.d.ts'))
2185
2391
  continue;
2392
+ // Skip non-UI service/data layers — the ban gate belongs in the UI that renders
2393
+ // the interaction button, not in the manager/repository that wraps the SDK call.
2394
+ if (isNonUiSourceFile(filename))
2395
+ continue;
2186
2396
  // Check if interaction methods are used in the file (includes flag actions — banned users
2187
2397
  // should also not be able to flag content, preventing spurious moderation queue spam)
2188
2398
  if (!/\b(createPost|createComment|sendMessage|addReaction|flagComment|flagPost)\b/.test(text)) {
@@ -2192,14 +2402,21 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
2192
2402
  // (`user.isBanned`, `currentUser.isGlobalBan`) and bare-variable form
2193
2403
  // (`disabled={isBanned}`, `!isBanned && ...`) — the latter is common
2194
2404
  // when a hook destructures the flag and the screen consumes it directly.
2195
- const hasBanCheck = /currentUser\.isGlobalBan/.test(text) ||
2405
+ const hasBanCheck =
2406
+ // Any ban-STATE boolean: isBanned, isGlobalBan, isGlobalBanned, isUserBanned,
2407
+ // currentUser.isGlobalBan(ned). The `is` prefix anchors this to ban-state flags
2408
+ // and excludes the banUser/bannedUserIds ACTION names. Real SDK ships both
2409
+ // isGlobalBan and isGlobalBanned; agents commonly derive a local `isGlobalBanned`.
2410
+ /\bis\w*Ban(?:ned)?\b/.test(text) ||
2411
+ // camelCase boolean form: `currentUserIsBanned` / `userIsBanned` — typically the ban state
2412
+ // fetched in a parent and passed down as a prop, then used to gate the write. The lowercase
2413
+ // `is`-anchored marker above misses it (capital `Is`, no leading word boundary mid-identifier).
2414
+ /\b\w*[Ii]sBanned\b/.test(text) ||
2196
2415
  /isCurrentUserBannedFromCommunity/.test(text) ||
2197
2416
  /community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
2198
2417
  /channel\.isMuted/.test(text) ||
2199
2418
  /member\.isMuted/.test(text) ||
2200
2419
  /user\.banState/.test(text) ||
2201
- /\.isBanned\b/.test(text) ||
2202
- /\bisBanned\b/.test(text) ||
2203
2420
  /\/\/\s*vise:\s*ban state checked at/i.test(text);
2204
2421
  if (!hasBanCheck) {
2205
2422
  findings.push(finding(ruleId, "warning", "File " + rel + " renders an interaction action without checking ban state.", rel, "Banned users still see post-create, like, comment, or send-message buttons, which fail on the server. To avoid poor UX and support tickets, check the ban state before showing these actions."));
@@ -2212,6 +2429,10 @@ function validateFlagCountNotLeaked(root, platform, sourceContent) {
2212
2429
  const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
2213
2430
  for (const [filename, text] of sourceContent) {
2214
2431
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2432
+ // Non-UI service/data layers read flagCount to pass it upward; the role gate
2433
+ // that decides whether to RENDER it lives in the UI layer, so skip them.
2434
+ if (isNonUiSourceFile(filename))
2435
+ continue;
2215
2436
  // Check if flagCount is used in the file
2216
2437
  if (!/\bflagCount\b/.test(text)) {
2217
2438
  continue;
@@ -2238,11 +2459,25 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2238
2459
  const ruleId = platform + '.moderation.role-gated-action';
2239
2460
  for (const [filename, text] of sourceContent) {
2240
2461
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2241
- // Check if any of the target actions exist in the file
2242
- if (!/\b(flagPost|deletePost|banUser|muteChannelMember|unflagPost|flagComment|deleteComment|unbanUser)\b/.test(text)) {
2462
+ // Skip non-UI service/data layers for BOTH branches. The ownership gate
2463
+ // (edit/delete) is a UI concern; and on real code (Amity's MembershipListDataSource,
2464
+ // a UITableView data source that wraps muteMembers) the mod-only ban/mute gate also
2465
+ // belongs in the presenting ViewController, not the data layer — firing on the
2466
+ // service reads as a false positive. The SDK enforces ban/mute server-side regardless.
2467
+ if (isNonUiSourceFile(filename))
2468
+ continue;
2469
+ // Moderator-ONLY actions: only an admin/moderator can ban or mute members.
2470
+ // (flagPost/flagComment/unflag are deliberately NOT here — flagging/reporting
2471
+ // is a user-level action available to any authenticated member, so gating it
2472
+ // on a moderator role is itself a bug.)
2473
+ const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(text);
2474
+ // Delete/edit: valid for the content AUTHOR (ownership) OR a moderator/admin
2475
+ // — per the SDK, "only post owners and admins can delete posts".
2476
+ const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(text);
2477
+ if (!hasModOnlyAction && !hasOwnableAction) {
2243
2478
  continue;
2244
2479
  }
2245
- // Role-check positive markers
2480
+ // Moderator role markers
2246
2481
  const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2247
2482
  /\.hasRole\s*\(.*moderator/i.test(text) ||
2248
2483
  /community\.hasModeratorRole/.test(text) ||
@@ -2251,9 +2486,26 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2251
2486
  /member\.isModerator/.test(text) ||
2252
2487
  /roles\.includes\s*\(.*moderator/i.test(text) ||
2253
2488
  /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2489
+ /\bhasPermission\s*\(/.test(text) ||
2254
2490
  /\/\/\s*vise:\s*role check applied/i.test(text);
2255
- if (!hasRoleCheck) {
2256
- findings.push(finding(ruleId, "warning", "File " + rel + " invokes a moderator action without a role check.", rel, "Moderation actions (flag, delete, ban, mute) fail on the server if the user lacks the moderator role. Showing these buttons to all users causes a poor user experience. Wrap the action or the button in a role check (e.g. currentUser.roles.contains('moderator'))."));
2491
+ // Ownership markers (valid alternative to a role check for delete/edit).
2492
+ const hasOwnershipCheck = /\bisAuthor\b/.test(text) ||
2493
+ /\bisOwner\b/.test(text) ||
2494
+ /postedUserId\s*===?=?\s*\w+/.test(text) ||
2495
+ /\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(text) ||
2496
+ /creator\s*\??\.\s*userId\s*===?=?/.test(text) ||
2497
+ // Ownership by comparing the entity's userId to the current user — the
2498
+ // idiomatic Swift/Kotlin form, e.g. `comment.userId == amity.currentUserId`.
2499
+ /\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(text) ||
2500
+ /\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(text) ||
2501
+ /\/\/\s*vise:\s*owner action\b/i.test(text);
2502
+ // Moderator-only actions require a role check.
2503
+ if (hasModOnlyAction && !hasRoleCheck) {
2504
+ 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(...))."));
2505
+ }
2506
+ else if (hasOwnableAction && !hasRoleCheck && !hasOwnershipCheck) {
2507
+ // Delete/edit need EITHER ownership OR a moderator role.
2508
+ 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."));
2257
2509
  }
2258
2510
  }
2259
2511
  return findings;
@@ -2270,6 +2522,11 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2270
2522
  if (!matches)
2271
2523
  continue;
2272
2524
  for (const match of matches) {
2525
+ // A standard TEXT post (`data: { text: ... }`, incl. quoted keys for Dart/Swift) legitimately
2526
+ // has no dataType — only CUSTOM data payloads need the dataType tag. Don't mistake the canonical
2527
+ // text-post shape for a custom post (the weak-model bench build hit this with `data: { text }`).
2528
+ if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
2529
+ continue;
2273
2530
  if (!/dataType\s*[:=\(]/i.test(match)) {
2274
2531
  findings.push(finding(ruleId, "warning", `File ${rel} creates a custom data post but does not declare a dataType.`, rel, `When creating a post with a custom data payload, the 'dataType' tag must be explicitly provided so the SDK can route it correctly. If missing, it defaults to a text post, which stringifies the payload.`));
2275
2532
  }
@@ -2277,6 +2534,342 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2277
2534
  }
2278
2535
  return findings;
2279
2536
  }
2537
+ // ── Post data-type check ──────────────────────────────────────────────────────
2538
+ // Fires when a file renders post content but only reads the text field without
2539
+ // branching on the post's data type. getGlobalFeed / getPosts return every
2540
+ // post type (image, video, file, poll, liveStream…); text-only rendering leaves
2541
+ // all non-text posts silently blank.
2542
+ function validatePostDataTypeHandled(root, platform, sourceContent) {
2543
+ const findings = [];
2544
+ const ruleId = `${platform}.feed.post-datatype-handled`;
2545
+ const TEXT_ACCESS = {
2546
+ typescript: /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
2547
+ "react-native": /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
2548
+ flutter: /\.getData\s*<|\bAmityTextPost\b|\bAmityPostData\b/,
2549
+ ios: /\.getData\(\)\s*as\?.*AmityPost\.Data\.TEXT|post\s*\.\s*text\b/,
2550
+ // POST text only — a bare `.getText()` also matches a COMMENT renderer reading
2551
+ // AmityComment.Data.TEXT (comments are text by nature), which is not a post
2552
+ // renderer and must not trip this rule. Scope to AmityPost.Data.TEXT.
2553
+ android: /\.getData\(\)\s*as\??\s*AmityPost\.Data\.TEXT/,
2554
+ };
2555
+ const TYPE_GUARD = {
2556
+ typescript: /\bpost\s*\??\.\s*dataType\b|\bPostContentType\b|\bstructureType\b|\bdataType\s*===/,
2557
+ "react-native": /\bpost\s*\??\.\s*dataType\b|\bPostContentType\b|\bstructureType\b|\bdataType\s*===/,
2558
+ flutter: /post\s*\.\s*type\b|\bAmityDataType\b/,
2559
+ ios: /post\s*\.\s*dataType\b|\bAmityPostDataType\b|\bdataType\s*==/,
2560
+ android: /post\s*\.\s*getType\(\)|\bAmityPost\.Data\.IMAGE\b|\bAmityPost\.Data\.VIDEO\b|\bwhen\s*\{[\s\S]*AmityPost\.Data/,
2561
+ };
2562
+ const RENDER_CTX = /\bcommentsCount\b|\breactionsCount\b|\bpostId\b|\bAmityPost\b/;
2563
+ const textPat = TEXT_ACCESS[platform] ?? TEXT_ACCESS.typescript;
2564
+ const guardPat = TYPE_GUARD[platform] ?? TYPE_GUARD.typescript;
2565
+ for (const [file, content] of sourceContent) {
2566
+ if (path.basename(file).endsWith('.d.ts'))
2567
+ continue;
2568
+ if (/\/\/\s*vise:\s*text-only post intentional/i.test(content))
2569
+ continue;
2570
+ if (!RENDER_CTX.test(content))
2571
+ continue;
2572
+ if (!textPat.test(content))
2573
+ continue;
2574
+ if (guardPat.test(content))
2575
+ continue;
2576
+ findings.push(finding(ruleId, "warning", "Post renderer reads text data without checking post.dataType. Non-text posts (image, video, file, poll) will render as blank.", relativeFile(root, file), "Branch on the post data type before accessing content. For TypeScript/React Native: switch on post.dataType (e.g. 'text', 'image', 'video'). For Android: when (post.getData()) { is AmityPost.Data.TEXT -> … is AmityPost.Data.IMAGE -> … }. For Flutter: check post.type == AmityDataType.TEXT. For iOS: check post.dataType. Add // vise: text-only post intentional if a single datatype is intentional."));
2577
+ break;
2578
+ }
2579
+ return findings;
2580
+ }
2581
+ // ── Avatar / display-identity from SDK ───────────────────────────────────────
2582
+ // Fires when a file renders community or user display identity using raw string
2583
+ // manipulation (charAt / slice on a name or raw userId) instead of the SDK-
2584
+ // provided avatar.fileUrl / avatarImage / getAvatar(). The SDK resolves the
2585
+ // avatar URL on the linked object; ignoring it shows a generated initial where
2586
+ // the user uploaded a real photo.
2587
+ function validateAvatarFromSdk(root, platform, sourceContent) {
2588
+ const findings = [];
2589
+ const ruleId = `${platform}.community.avatar-from-sdk`;
2590
+ const INITIAL_PAT = {
2591
+ // slice(0,1) / slice(0,2) are avatar initials; slice(0,8)+ is a userId/name
2592
+ // truncation used as a display fallback (e.g. userId.slice(0, 8)) and must
2593
+ // NOT be flagged as a missing avatar.
2594
+ typescript: /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
2595
+ "react-native": /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
2596
+ flutter: /(?:displayName|name)\s*\??\.(?:substring\s*\(\s*0\s*,\s*1|characters\.first)/,
2597
+ ios: /(?:displayName|name)\s*\??\s*\.?\s*prefix\s*\(\s*1\s*\)|(?:displayName|name)\s*\[.*\.startIndex/,
2598
+ android: /(?:displayName|name)\s*\??\.\s*(?:substring\s*\(\s*0\s*,\s*1|first\(\)|take\(1\))/,
2599
+ };
2600
+ const AVATAR_PAT = {
2601
+ typescript: /\bavatar\s*\??\.\s*fileUrl\b|\bavatarFileId\b|\bavatarUrl\b/,
2602
+ "react-native": /\bavatar\s*\??\.\s*fileUrl\b|\bavatarFileId\b|\bavatarUrl\b/,
2603
+ flutter: /\bavatarImage\b|\bavatarUrl\b|\bAmityImageSize\b/,
2604
+ ios: /\bavatar\s*\?|\bavatarFileId\b|\bgetAvatarInfo\b/,
2605
+ android: /\bgetAvatar\s*\(\)|\bavatarUrl\b|\bgetAvatarFileId\b/,
2606
+ };
2607
+ const CTX_PAT = /\bdisplayName\b|\bmembersCount\b|\bpostedUserId\b|\bAmityCommunity\b|\bAmityUser\b/;
2608
+ const initPat = INITIAL_PAT[platform] ?? INITIAL_PAT.typescript;
2609
+ const avatarPat = AVATAR_PAT[platform] ?? AVATAR_PAT.typescript;
2610
+ for (const [file, content] of sourceContent) {
2611
+ if (path.basename(file).endsWith('.d.ts'))
2612
+ continue;
2613
+ if (/\/\/\s*vise:\s*avatar fallback intentional/i.test(content))
2614
+ continue;
2615
+ if (!CTX_PAT.test(content))
2616
+ continue;
2617
+ if (!initPat.test(content))
2618
+ continue;
2619
+ if (avatarPat.test(content))
2620
+ continue;
2621
+ 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."));
2622
+ break;
2623
+ }
2624
+ return findings;
2625
+ }
2626
+ function validatePollAnswerDataShape(root, platform, sourceContent) {
2627
+ const findings = [];
2628
+ // iOS has no poll-answer data-shape footgun: AmityPollAnswer exposes a typed
2629
+ // `public let text: String` (and `image: AmityImageData?`) — there is no
2630
+ // `.data` to mis-shape, and `answer.text` is the documented, correct accessor
2631
+ // (confirmed against the SDK source; two independent agent builds both wrote
2632
+ // it from the docs). The rule used to flag `answer.text` as wrong — a pure
2633
+ // false positive. The TS/RN concern (`answer.data` is a string, don't read
2634
+ // `.data.text`) doesn't translate to typed Swift. Rule de-registered for iOS.
2635
+ if (platform === "ios")
2636
+ return findings;
2637
+ const ruleId = `${platform}.feed.poll-answer-data-shape`;
2638
+ const WRONG_ACCESS = {
2639
+ typescript: /\banswer\s*\??\.\s*data\s*(?:as\s*\{|\?\.text\b|\.text\b)|\([^)]*answer[^)]*\bas\s*\{[^}]*text/,
2640
+ "react-native": /\banswer\s*\??\.\s*data\s*(?:as\s*\{|\?\.text\b|\.text\b)|\([^)]*answer[^)]*\bas\s*\{[^}]*text/,
2641
+ flutter: /answer\s*\.\s*getData\s*<[^>]*>\s*\(\)\s*\??\.\s*text\b|answer\s*\.\s*data\s*\??\.\s*text\b/,
2642
+ ios: /answer\s*\??\.\s*data\s*as\??\s*[A-Z]\w*\s*\)?\.?\s*text\b|answer\s*\??\.\s*text\b(?!\s*=)/,
2643
+ android: /answer\s*\.\s*getData\s*\(\)\s*\??\.\s*getText\(\)|answer\s*\??\.\s*getText\(\)/,
2644
+ };
2645
+ const CORRECT = {
2646
+ typescript: /\banswer\s*\.\s*data\b(?!\s*as\s*\{)(?!\s*\?\.text)(?!\s*\.text)(?!\s*\?\s*\.)/,
2647
+ "react-native": /\banswer\s*\.\s*data\b(?!\s*as\s*\{)(?!\s*\?\.text)(?!\s*\.text)(?!\s*\?\s*\.)/,
2648
+ flutter: /answer\s*\.\s*data\b(?!\s*\??\.\s*text)/,
2649
+ ios: /answer\s*\.\s*data\b(?!\s*as\?)|\banswer\.data\b/,
2650
+ android: /answer\s*\.\s*getData\s*\(\)\s*(?!\.)\b|answer\s*\.\s*data\b/,
2651
+ };
2652
+ const POLL_CTX = /\banswers\s*[\.\?][\.\?]?\s*map\b|\banswer\.id\b|\bpollId\b|\bPollAnswer\b|\bAmityPollAnswer\b|\bAmityPoll\b/;
2653
+ const wrongPat = WRONG_ACCESS[platform] ?? WRONG_ACCESS.typescript;
2654
+ const correctPat = CORRECT[platform] ?? CORRECT.typescript;
2655
+ for (const [file, content] of sourceContent) {
2656
+ if (path.basename(file).endsWith('.d.ts'))
2657
+ continue;
2658
+ // Escape-hatch is checked on RAW content — stripComments would delete the marker.
2659
+ if (/\/\/\s*vise:\s*poll-answer-shape intentional/i.test(content))
2660
+ continue;
2661
+ const astLang = astLanguageForFile(file, platform);
2662
+ const checkContent = astLang ? stripComments(astLang, content) : content;
2663
+ if (!POLL_CTX.test(checkContent))
2664
+ continue;
2665
+ if (!wrongPat.test(checkContent))
2666
+ continue;
2667
+ if (correctPat.test(checkContent))
2668
+ continue;
2669
+ findings.push(finding(ruleId, "warning", "Poll answer text is read as an object property (.data.text or .data?.text), but PollAnswer.data is a plain string.", relativeFile(root, file), "Use answer.data directly as the display text — it is already the string content. For image answers, use answer.image?.fileUrl (TypeScript/React Native) or the linked image object. Add // vise: poll-answer-shape intentional if the data field really is an object in your SDK version."));
2670
+ break;
2671
+ }
2672
+ return findings;
2673
+ }
2674
+ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
2675
+ const findings = [];
2676
+ const ruleId = `${platform}.comments.query-has-limit`;
2677
+ const GETCOMMENTS_PAT = {
2678
+ typescript: /CommentRepository\s*\.\s*getComments\s*\(/,
2679
+ "react-native": /CommentRepository\s*\.\s*getComments\s*\(/,
2680
+ flutter: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
2681
+ ios: /AmityCommentQueryOptions\s*\(|commentRepository\s*\.\s*getComments\s*\(/,
2682
+ android: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
2683
+ };
2684
+ const HAS_LIMIT = {
2685
+ typescript: /\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
2686
+ "react-native": /\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
2687
+ flutter: /\.setLimit\s*\(|\blimit\s*:\s*\d+|\bpageSize\s*:\s*\d+/,
2688
+ ios: /\.pageSize\s*=\s*\d+|\bpageSize\s*:\s*\d+|setPageSize/,
2689
+ android: /\.pageSize\s*\(\s*\d+\)|\blimit\s*\(\s*\d+\)/,
2690
+ };
2691
+ const COMMENT_CTX = /referenceType\s*[:=]\s*['"]?post['"]?|\breferenceId\b|\bCommentRepository\b|\bAmityComment\b/;
2692
+ 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.";
2693
+ 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.";
2694
+ // TypeScript / React Native: AST pass — inspect the actual getComments({...})
2695
+ // first argument and fire only when the object literal carries neither
2696
+ // pageSize nor limit. More precise than a text scan: ignores the token
2697
+ // appearing in comments/strings, and won't fire when params are passed as a
2698
+ // variable we can't statically read (conservative — avoids false positives).
2699
+ if (platform === "typescript" || platform === "react-native") {
2700
+ for (const [file, content] of sourceContent) {
2701
+ if (path.basename(file).endsWith('.d.ts'))
2702
+ continue;
2703
+ if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
2704
+ continue; // escape on RAW content
2705
+ const astLang = astLanguageForFile(file, platform);
2706
+ if (!astLang)
2707
+ continue;
2708
+ let tree;
2709
+ try {
2710
+ tree = parse(astLang, content);
2711
+ }
2712
+ catch {
2713
+ continue;
2714
+ }
2715
+ const calls = findCallExpressions(tree, /(?:^|\.)getComments$/);
2716
+ for (const call of calls) {
2717
+ const objArg = call.args[0];
2718
+ if (!objArg || objArg.type !== "object")
2719
+ continue; // params not a literal → can't analyze, skip
2720
+ if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
2721
+ continue;
2722
+ findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
2723
+ return findings;
2724
+ }
2725
+ }
2726
+ return findings;
2727
+ }
2728
+ // Flutter / iOS / Android: regex pass (builder chains and option structs do
2729
+ // not fit call-argument inspection). Kotlin runs against comment-stripped
2730
+ // source; Dart/Swift have no grammar and run against raw content.
2731
+ const getCommentsPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
2732
+ const limitPat = HAS_LIMIT[platform] ?? HAS_LIMIT.typescript;
2733
+ for (const [file, content] of sourceContent) {
2734
+ if (path.basename(file).endsWith('.d.ts'))
2735
+ continue;
2736
+ if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
2737
+ continue; // escape on RAW content
2738
+ const astLang = astLanguageForFile(file, platform);
2739
+ const checkContent = astLang ? stripComments(astLang, content) : content;
2740
+ if (!COMMENT_CTX.test(checkContent))
2741
+ continue;
2742
+ if (!getCommentsPat.test(checkContent))
2743
+ continue;
2744
+ if (limitPat.test(checkContent))
2745
+ continue;
2746
+ findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
2747
+ break;
2748
+ }
2749
+ return findings;
2750
+ }
2751
+ // ── Comment creation affordance ──────────────────────────────────────────────
2752
+ // Fires when comments are READ (getComments) but never CREATED (createComment)
2753
+ // anywhere in the codebase. A read-only comment list gives users no way to
2754
+ // participate. Mirrors the moderation-affordance-present precedent: the write
2755
+ // path may live in a sibling file, so detection is codebase-wide and an
2756
+ // attestation escape covers deliberately read-only surfaces.
2757
+ function validateCommentCreationAffordance(root, platform, sourceContent) {
2758
+ const findings = [];
2759
+ const ruleId = `${platform}.comments.creation-affordance-present`;
2760
+ const GETCOMMENTS_PAT = {
2761
+ typescript: /CommentRepository\s*\.\s*getComments\s*\(/,
2762
+ "react-native": /CommentRepository\s*\.\s*getComments\s*\(/,
2763
+ flutter: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
2764
+ ios: /AmityCommentQueryOptions\s*\(|commentRepository\s*\.\s*getComments\s*\(/,
2765
+ android: /AmitySocialClient\s*\.\s*newCommentRepository\s*\(\s*\)\s*\.getComments\b/,
2766
+ };
2767
+ const CREATECOMMENT_PAT = {
2768
+ typescript: /CommentRepository\s*\.\s*createComment\s*\(/,
2769
+ "react-native": /CommentRepository\s*\.\s*createComment\s*\(/,
2770
+ flutter: /\.createComment\s*\(/,
2771
+ // Any receiver, not just a var literally named `commentRepository` — agents
2772
+ // idiomatically hold the repo in `repo`/`vm`/etc. (real v8: AmityCommentRepository
2773
+ // .createComment(with: AmityCommentCreateOptions(...))). Mirrors android/flutter.
2774
+ ios: /AmityCommentCreateOptions\s*\(|\.createComment\s*\(/,
2775
+ android: /\.createComment\s*\(/,
2776
+ };
2777
+ const getPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
2778
+ const createPat = CREATECOMMENT_PAT[platform] ?? CREATECOMMENT_PAT.typescript;
2779
+ let readFile;
2780
+ let hasCreate = false;
2781
+ for (const [file, content] of sourceContent) {
2782
+ if (path.basename(file).endsWith('.d.ts'))
2783
+ continue;
2784
+ // Escape-hatch is checked on RAW content — stripComments would delete the marker.
2785
+ if (/\/\/\s*vise:\s*comments read-only intentional/i.test(content))
2786
+ return findings;
2787
+ const astLang = astLanguageForFile(file, platform);
2788
+ const checkContent = astLang ? stripComments(astLang, content) : content;
2789
+ if (!readFile && getPat.test(checkContent))
2790
+ readFile = file;
2791
+ if (createPat.test(checkContent))
2792
+ hasCreate = true;
2793
+ }
2794
+ if (readFile && !hasCreate) {
2795
+ findings.push(finding(ruleId, "warning", "Comments are read (getComments) but no comment creation (createComment) was found anywhere in the project. A read-only comment list gives users no way to participate.", relativeFile(root, readFile), "Add a comment composer that calls createComment. For TypeScript/React Native: CommentRepository.createComment({ data: { text }, referenceId, referenceType: 'post' }). For iOS: commentRepository.createComment(with: AmityCommentCreateOptions(...)). For Flutter/Android: AmitySocialClient.newCommentRepository().createComment().post(postId)...send(). Gate it behind the user's ban/permission state. Add // vise: comments read-only intentional if a read-only view is deliberate."));
2796
+ }
2797
+ return findings;
2798
+ }
2799
+ function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
2800
+ const findings = [];
2801
+ const ruleId = `${platform}.community.display-name-from-sdk`;
2802
+ const ID_AS_NAME = {
2803
+ typescript: /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*\?\?\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*:\s*selected\w*[Ii]d\b|\bdisplayName\s*:\s*\w+[Cc]ommunity[Ii]d\b/,
2804
+ "react-native": /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*\?\?\s*\w*[Cc]ommunity[Ii]d\b|\bdisplayName\s*:\s*selected\w*[Ii]d\b|\bdisplayName\s*:\s*\w+[Cc]ommunity[Ii]d\b/,
2805
+ flutter: /displayName\s*:\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?\?\s*\w*[Cc]ommunityId\b|Text\s*\(\s*\w*[Cc]ommunity\w*\.communityId\b/,
2806
+ ios: /displayName\s*=\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?\?\s*\w*communityId\b|\.displayName\s*\?\s*\?\s*community\.communityId\b/,
2807
+ android: /displayName\s*=\s*\w*[Cc]ommunity[Ii]d\b|displayName\s*\?:\s*\w*communityId\b|setText\s*\(\s*\w*communityId\b/,
2808
+ };
2809
+ const COMMUNITY_CTX = /\bcommunityId\b.*\b(Community|AmityCommunity|displayName)\b|\b(Community|AmityCommunity|displayName)\b.*\bcommunityId\b/s;
2810
+ const CORRECT = /CommunityRepository\s*\.\s*getCommunity\b|community\s*\??\.\s*displayName\s*(?!:)\b/;
2811
+ const idPat = ID_AS_NAME[platform] ?? ID_AS_NAME.typescript;
2812
+ for (const [file, content] of sourceContent) {
2813
+ if (path.basename(file).endsWith('.d.ts'))
2814
+ continue;
2815
+ // Escape-hatch is checked on RAW content — stripComments would delete the marker.
2816
+ if (/\/\/\s*vise:\s*community-id display intentional/i.test(content))
2817
+ continue;
2818
+ const astLang = astLanguageForFile(file, platform);
2819
+ const checkContent = astLang ? stripComments(astLang, content) : content;
2820
+ if (!COMMUNITY_CTX.test(checkContent))
2821
+ continue;
2822
+ if (!idPat.test(checkContent))
2823
+ continue;
2824
+ if (CORRECT.test(checkContent))
2825
+ continue;
2826
+ findings.push(finding(ruleId, "warning", "A community ID is used as a display name fallback. The raw communityId will be shown to users instead of the community's actual display name.", relativeFile(root, file), "Pass the full Community object (not just the ID) when navigating to a community detail screen, then use community.displayName. If you only have the ID, subscribe to CommunityRepository.getCommunity(communityId, cb) to get the live display name. Add // vise: community-id display intentional only if showing the ID is truly required."));
2827
+ break;
2828
+ }
2829
+ return findings;
2830
+ }
2831
+ function validateRoomPostFetched(root, platform, sourceContent) {
2832
+ const findings = [];
2833
+ const ruleId = `${platform}.feed.room-post-fetched`;
2834
+ const ROOM_ID_DISPLAY = {
2835
+ typescript: /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
2836
+ "react-native": /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
2837
+ flutter: /room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
2838
+ // Negative lookbehind for Swift string interpolation `\(room.roomId)`:
2839
+ // that's a segment/identifier key, not a displayed name, and must not fire.
2840
+ // Real display (`Text(room.roomId)`, `label.text = room.roomId`) still matches.
2841
+ ios: /(?<!\\\()room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\?\.?\s*roomId/,
2842
+ android: /room\s*\??\.\s*getRoomId\s*\(\)|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
2843
+ };
2844
+ const ROOM_FETCH = {
2845
+ typescript: /RoomRepository\s*\.\s*getRoom\s*\(|room\s*\??\.\s*title\b/,
2846
+ "react-native": /RoomRepository\s*\.\s*getRoom\s*\(|room\s*\??\.\s*title\b/,
2847
+ flutter: /AmityRoomRepository\s*\(\s*\)\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
2848
+ ios: /AmityRoomRepository\s*\.\s*shared\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
2849
+ android: /AmityRoomRepository\s*\(\s*\)\s*\.\s*getRoom\b|room\s*\??\.\s*title\b/,
2850
+ };
2851
+ const POST_CTX = /\bpostId\b|\bAmityPost\b|\bpost\s*\??\.\s*dataType\b|\bpost\.type\b|\bpost_id\b/;
2852
+ const roomIdPat = ROOM_ID_DISPLAY[platform] ?? ROOM_ID_DISPLAY.typescript;
2853
+ const fetchPat = ROOM_FETCH[platform] ?? ROOM_FETCH.typescript;
2854
+ for (const [file, content] of sourceContent) {
2855
+ if (path.basename(file).endsWith('.d.ts'))
2856
+ continue;
2857
+ // Escape-hatch is checked on RAW content — stripComments would delete the marker.
2858
+ if (/\/\/\s*vise:\s*room-display intentional/i.test(content))
2859
+ continue;
2860
+ const astLang = astLanguageForFile(file, platform);
2861
+ const checkContent = astLang ? stripComments(astLang, content) : content;
2862
+ if (!POST_CTX.test(checkContent))
2863
+ continue;
2864
+ if (!roomIdPat.test(checkContent))
2865
+ continue;
2866
+ if (fetchPat.test(checkContent))
2867
+ continue;
2868
+ findings.push(finding(ruleId, "warning", "Room post displays raw roomId instead of fetching room details (title) via RoomRepository.getRoom.", relativeFile(root, file), "Subscribe to RoomRepository.getRoom(roomId, callback) to get the Room object, then use room.title as the display name. The Room has title, status (idle/live/ended), and thumbnailFileId. Add // vise: room-display intentional only if showing the raw ID is intentional."));
2869
+ break;
2870
+ }
2871
+ return findings;
2872
+ }
2280
2873
  function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
2281
2874
  const findings = [];
2282
2875
  const ruleId = `${platform}.feed.target-type-explicit`;
@@ -2292,7 +2885,22 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
2292
2885
  if (!hasCreatePost)
2293
2886
  continue;
2294
2887
  for (const pattern of TARGET_TYPE_PATTERNS) {
2295
- if (pattern.test(text)) {
2888
+ const g = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
2889
+ let ttm;
2890
+ let flagged = false;
2891
+ while ((ttm = g.exec(text)) !== null) {
2892
+ // A literal targetType in a READ query (getPosts/getCommunityFeed params) is correct —
2893
+ // you specify targetType:'community' + targetId to read that feed. Read-query objects
2894
+ // carry feedType/sortBy keys; a createPost payload carries dataType/data. Only the
2895
+ // latter is the reusability concern this rule targets.
2896
+ const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
2897
+ const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
2898
+ if (!isReadQuery) {
2899
+ flagged = true;
2900
+ break;
2901
+ }
2902
+ }
2903
+ if (flagged) {
2296
2904
  findings.push(finding(ruleId, "warning", `A createPost call appears to hardcode targetType to a literal community or user.`, rel, "Feed targets should be passed dynamically (e.g. from props or intent extras) so the composer component is reusable. If this is intentional, add comment // vise: target-type rationale — <reason>."));
2297
2905
  break;
2298
2906
  }