@amityco/social-plus-vise 0.8.1 → 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);
@@ -74,11 +74,16 @@ async function inspectRoot(root) {
74
74
  }
75
75
  // When react-native is detected alongside generic typescript signals, prefer react-native
76
76
  // so that platform-specific rules (react-native.*) are used for init/check/run-sensors.
77
+ // Same for android: an agent may create package.json (e.g. to enable npm run sp-check) which
78
+ // would normally trigger typescript detection — suppress it so only android rules apply.
77
79
  const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
78
80
  const hasRN = rawPlatforms.includes("react-native");
81
+ const hasAndroid = rawPlatforms.includes("android");
79
82
  const platforms = hasRN
80
83
  ? ["react-native", ...rawPlatforms.filter((p) => p !== "react-native" && p !== "typescript")]
81
- : rawPlatforms;
84
+ : hasAndroid
85
+ ? rawPlatforms.filter((p) => p !== "typescript")
86
+ : rawPlatforms;
82
87
  return { platforms, signals, designSignals: await detectDesignSignals(root) };
83
88
  }
84
89
  export const inspectProjectTool = {
@@ -166,7 +171,14 @@ async function validateAndroid(root) {
166
171
  const findings = [];
167
172
  const manifestPath = "app/src/main/AndroidManifest.xml";
168
173
  const manifest = await readIfExists(path.join(root, manifestPath));
169
- 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
+ ])];
170
182
  const buildContent = await readMany(buildFiles);
171
183
  const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
172
184
  const sourceContent = await readMany(sourceFiles);
@@ -196,7 +208,17 @@ async function validateAndroid(root) {
196
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."));
197
209
  }
198
210
  else {
199
- 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
+ });
200
222
  if (activitySetup) {
201
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."));
202
224
  }
@@ -208,14 +230,16 @@ async function validateAndroid(root) {
208
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."));
209
231
  }
210
232
  else if (!containsAny(sourceContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
211
- 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."));
212
234
  }
213
235
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
214
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."));
215
237
  }
216
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.
217
241
  const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
218
- 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/])) {
219
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."));
220
244
  }
221
245
  if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
@@ -232,6 +256,13 @@ async function validateAndroid(root) {
232
256
  findings.push(...validateComments(root, "android", sourceContent));
233
257
  findings.push(...validateModeration(root, "android", sourceContent));
234
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));
235
266
  findings.push(...validatePostsStatusFilter(root, "android", sourceContent));
236
267
  findings.push(...validatePaginationCursorOpaque(root, "android", sourceContent));
237
268
  findings.push(...validatePostsParentChild(root, "android", sourceContent));
@@ -250,6 +281,7 @@ async function validateAndroid(root) {
250
281
  findings.push(...validateSessionHandlerRetention(root, "android", sourceContent));
251
282
  findings.push(...validateChat(root, "android", sourceContent));
252
283
  findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
284
+ findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
253
285
  return findings;
254
286
  }
255
287
  async function validateFlutter(root) {
@@ -278,7 +310,14 @@ async function validateFlutter(root) {
278
310
  if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
279
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()."));
280
312
  }
281
- 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/])) {
282
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."));
283
322
  }
284
323
  }
@@ -286,14 +325,16 @@ async function validateFlutter(root) {
286
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."));
287
326
  }
288
327
  else if (!containsAny(dartContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /SessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
289
- 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."));
290
329
  }
291
330
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
292
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."));
293
332
  }
294
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.
295
336
  const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
296
- 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/])) {
297
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."));
298
339
  }
299
340
  if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
@@ -310,6 +351,13 @@ async function validateFlutter(root) {
310
351
  findings.push(...validateComments(root, "flutter", dartContent));
311
352
  findings.push(...validateModeration(root, "flutter", dartContent));
312
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));
313
361
  findings.push(...validatePostsStatusFilter(root, "flutter", dartContent));
314
362
  findings.push(...validatePaginationCursorOpaque(root, "flutter", dartContent));
315
363
  findings.push(...validatePostsParentChild(root, "flutter", dartContent));
@@ -328,6 +376,7 @@ async function validateFlutter(root) {
328
376
  findings.push(...validateSessionHandlerRetention(root, "flutter", dartContent));
329
377
  findings.push(...validateChat(root, "flutter", dartContent));
330
378
  findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
379
+ findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
331
380
  return findings;
332
381
  }
333
382
  async function validateTypeScript(root, platform) {
@@ -335,11 +384,18 @@ async function validateTypeScript(root, platform) {
335
384
  const packageJson = await readIfExists(path.join(root, "package.json"));
336
385
  const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
337
386
  const sourceContent = await readMany(sourceFiles);
338
- const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /createClient\s*\(/]);
387
+ const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/]);
339
388
  const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
340
389
  const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
341
- const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/]);
342
- const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/, /onSnapshot/]);
390
+ // Skip .d.ts declarations when checking for unregister — type stubs declare both register and
391
+ // unregister methods but should not suppress the "unregister missing" finding.
392
+ const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
393
+ const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
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/]);
343
399
  if (!packageJson) {
344
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."));
345
401
  }
@@ -360,11 +416,17 @@ async function validateTypeScript(root, platform) {
360
416
  // process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
361
417
  // VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
362
418
  // Any one of these is sufficient evidence the integration is region-aware.
363
- const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/]);
364
- 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]);
365
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_]*/]);
366
428
  if (!setupHasKeywordRegion && !setupHasNamedRegionDecl && !projectHasEnvRegion) {
367
- 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."));
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."));
368
430
  }
369
431
  const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
370
432
  if (renderCycleSetup && platform === "react-native") {
@@ -375,12 +437,16 @@ async function validateTypeScript(root, platform) {
375
437
  findings.push(finding("typescript.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and await it before subscribing to live collections."));
376
438
  }
377
439
  if (loginFiles.length > 0 && !containsAny(sourceContent, [/sessionWillRenewAccessToken/, /sessionHandler/, /renewal\.renew/])) {
378
- findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) so sessions refresh in production."));
440
+ findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) in the existing login path so sessions refresh in production. Preserve the current session owner and retain the handler for the full session lifetime."));
379
441
  }
380
442
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
381
- 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 on logout or user switch so notifications are not sent to the wrong user."));
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."));
382
444
  }
383
- 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/])) {
384
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."));
385
451
  }
386
452
  findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
@@ -395,6 +461,17 @@ async function validateTypeScript(root, platform) {
395
461
  findings.push(...validateModeration(root, platform, sourceContent));
396
462
  findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
397
463
  findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
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));
472
+ findings.push(...validateReactionStalePostRef(root, platform, sourceContent));
473
+ findings.push(...validateFollowStatusSubscription(root, platform, sourceContent));
474
+ findings.push(...validateMembershipUsesLiveCollection(root, platform, sourceContent));
398
475
  findings.push(...validatePaginationCursorOpaque(root, platform, sourceContent));
399
476
  findings.push(...validatePostsParentChild(root, platform, sourceContent));
400
477
  findings.push(...validateFeedTargetTypeExplicit(root, platform, sourceContent));
@@ -413,24 +490,82 @@ async function validateTypeScript(root, platform) {
413
490
  findings.push(...validateChat(root, platform, sourceContent));
414
491
  findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
415
492
  if (platform === "typescript") {
416
- findings.push(...(await validateTypeScriptEnvSecretHygiene(root, sourceContent)));
493
+ findings.push(...validateClientNoSsrInit(root, sourceContent));
417
494
  }
495
+ findings.push(...(await validateEnvSecretHygiene(root, platform, sourceContent)));
418
496
  return findings;
419
497
  }
420
- async function validateTypeScriptEnvSecretHygiene(root, sourceContent) {
498
+ async function validateEnvSecretHygiene(root, platform, sourceContent) {
421
499
  const findings = [];
422
- const envSecretFiles = await committedEnvSecretFiles(root);
423
- for (const file of envSecretFiles) {
424
- findings.push(finding("typescript.secret.committed-env", "warning", "A local env file appears to contain a social.plus secret.", file, "Do not commit .env files with real API keys. Commit a placeholder .env.example and keep local env files ignored."));
500
+ if (platform === "typescript") {
501
+ const envSecretFiles = await committedEnvSecretFiles(root);
502
+ for (const file of envSecretFiles) {
503
+ findings.push(finding("typescript.secret.committed-env", "warning", "A local env file appears to contain a social.plus secret.", file, "Do not commit .env files with real API keys. Commit a placeholder .env.example and keep local env files ignored."));
504
+ }
505
+ }
506
+ if (platform === "typescript" || platform === "react-native") {
507
+ if (usesEnvSecretConfig(sourceContent)) {
508
+ const gitignore = await readIfExists(path.join(root, ".gitignore"));
509
+ if (!gitignoreIgnoresEnvFiles(gitignore)) {
510
+ findings.push(finding(`${platform}.secret.env-gitignore`, "warning", "Source uses env-based social.plus secret config but .gitignore does not ignore local env files.", ".gitignore", "Add .env, .env.local, and environment-specific local env files to .gitignore."));
511
+ }
512
+ const hasExample = await exists(path.join(root, ".env.example")) || await exists(path.join(root, ".env.sample"));
513
+ if (!hasExample) {
514
+ findings.push(finding(`${platform}.secret.env-example`, "warning", "Source uses env-based social.plus secret config but no env example file was found.", ".env.example", "Commit .env.example with placeholder keys so agents and developers know which values to provide locally."));
515
+ }
516
+ }
517
+ }
518
+ if (platform === "flutter") {
519
+ const gitignore = await readIfExists(path.join(root, ".gitignore"));
520
+ const envContent = await readIfExists(path.join(root, ".env"));
521
+ const secretsDartContent = await readIfExists(path.join(root, "lib/secrets.dart")) || await readIfExists(path.join(root, "secrets.dart"));
522
+ const hasSecretValue = (content) => /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(content) && !content.includes("your_api_key");
523
+ const hasTargetEnv = envContent && hasSecretValue(envContent);
524
+ const hasTargetDart = secretsDartContent && hasSecretValue(secretsDartContent);
525
+ if ((hasTargetEnv && !gitignore.includes(".env")) || (hasTargetDart && !gitignore.includes("secrets.dart"))) {
526
+ findings.push(finding("flutter.secret.env-gitignore", "warning", ".env or secrets.dart contains a social.plus key but is not in .gitignore.", ".gitignore", "Add .env or secrets.dart to .gitignore."));
527
+ }
425
528
  }
426
- if (usesEnvSecretConfig(sourceContent)) {
529
+ if (platform === "android") {
427
530
  const gitignore = await readIfExists(path.join(root, ".gitignore"));
428
- if (!gitignoreIgnoresEnvFiles(gitignore)) {
429
- findings.push(finding("typescript.secret.env-gitignore", "warning", "Source uses env-based social.plus secret config but .gitignore does not ignore local env files.", ".gitignore", "Add .env, .env.local, and environment-specific local env files to .gitignore."));
531
+ const localProps = await readIfExists(path.join(root, "local.properties"));
532
+ if (localProps && /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(localProps) && !gitignore.includes("local.properties")) {
533
+ findings.push(finding("android.secret.env-gitignore", "warning", "local.properties contains a social.plus key but is not in .gitignore.", ".gitignore", "Add local.properties to .gitignore."));
534
+ }
535
+ }
536
+ if (platform === "ios") {
537
+ const gitignore = await readIfExists(path.join(root, ".gitignore"));
538
+ const ignoresSecrets = gitignore.includes("Secrets.plist") || gitignore.includes("xcconfig");
539
+ let hasIosSecretFile = false;
540
+ for (const [file, content] of sourceContent) {
541
+ if ((file.endsWith("Secrets.plist") || file.endsWith(".xcconfig")) && /(?:AMITY|SOCIAL_PLUS|SOCIALPLUS).*?(?:API[_-]?KEY|KEY)/i.test(content)) {
542
+ hasIosSecretFile = true;
543
+ break;
544
+ }
545
+ }
546
+ if (hasIosSecretFile && !ignoresSecrets) {
547
+ findings.push(finding("ios.secret.env-gitignore", "warning", "Secrets.plist or .xcconfig contains a social.plus key but is not in .gitignore.", ".gitignore", "Add Secrets.plist and/or *.xcconfig to .gitignore."));
548
+ }
549
+ }
550
+ return findings;
551
+ }
552
+ function validateClientNoSsrInit(root, sourceContent) {
553
+ const findings = [];
554
+ const clientCreatePatterns = [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/];
555
+ const serverContextPathPatterns = [/layout\.tsx?$/, /page\.tsx?$/];
556
+ const serverContextContentPatterns = [/getServerSideProps/, /getStaticProps/];
557
+ for (const [file, content] of sourceContent) {
558
+ if (!clientCreatePatterns.some((p) => p.test(content))) {
559
+ continue;
430
560
  }
431
- const hasExample = await exists(path.join(root, ".env.example")) || await exists(path.join(root, ".env.sample"));
432
- if (!hasExample) {
433
- findings.push(finding("typescript.secret.env-example", "warning", "Source uses env-based social.plus secret config but no env example file was found.", ".env.example", "Commit .env.example with placeholder keys so agents and developers know which values to provide locally."));
561
+ if (/["']use client["']/.test(content)) {
562
+ continue;
563
+ }
564
+ const basename = path.basename(file);
565
+ const isServerContext = serverContextPathPatterns.some((p) => p.test(basename)) || serverContextContentPatterns.some((p) => p.test(content));
566
+ if (isServerContext) {
567
+ findings.push(finding("typescript.client.no-ssr-init", "error", "TypeScript SDK client must not be initialized in a server-side context.", relativeFile(root, file), "If this is a Next.js project, SDK initialization must happen client-side only. Wrap the client initialization call (Client.create() or createClient()) in a 'use client' component, a useEffect, or use dynamic() with { ssr: false }. Do not call it in a Server Component, layout file, or getServerSideProps."));
568
+ break;
434
569
  }
435
570
  }
436
571
  return findings;
@@ -664,7 +799,7 @@ function validateFeedPagination(root, platform, sourceContent) {
664
799
  }
665
800
  }
666
801
  return [
667
- finding(`${platform}.feed.pagination-wired`, "warning", "A feed/posts observer is present but no pagination mechanism was detected.", relativeFile(root, file), "Wire pagination (hasMore/loadMore/nextPage, FlatList.onEndReached, LazyColumn scroll trigger, ScrollController, etc.) to avoid loading all content on mount."),
802
+ finding(`${platform}.feed.pagination-wired`, "warning", "A feed/posts observer is present but no pagination mechanism was detected.", relativeFile(root, file), "Wire pagination (hasMore/loadMore/nextPage, pageToken, FlatList.onEndReached, LazyColumn scroll trigger, ScrollController, etc.) to avoid loading all content on mount. When repairing a live feed, preserve any existing pagination inputs or state instead of dropping them."),
668
803
  ];
669
804
  }
670
805
  return [];
@@ -704,8 +839,11 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
704
839
  }
705
840
  // ─── Logout on user-switch ───────────────────────────────────────────────────
706
841
  const USER_SWITCH_SYMBOLS = [
707
- /switchUser/i, /setCurrentUser/i, /authStateChanged/i, /onAuthStateChanged/i,
708
- /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,
709
847
  ];
710
848
  const LOGIN_PATTERNS_BY_PLATFORM = {
711
849
  android: [/AmityCoreClient\.login/, /\.login\s*\(/],
@@ -735,7 +873,7 @@ function validateLogoutOnUserSwitch(root, platform, sourceContent) {
735
873
  if (containsAny(sourceContent, logoutMarkers))
736
874
  return [];
737
875
  return [
738
- 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. Attest if the switch is handled by a centralized auth coordinator."),
876
+ 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."),
739
877
  ];
740
878
  }
741
879
  // ─── No anonymous write ──────────────────────────────────────────────────────
@@ -746,9 +884,14 @@ const WRITE_CALL_PATTERNS = [
746
884
  /\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
747
885
  ];
748
886
  const AUTH_GATE_MARKERS = [
749
- /\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/,
750
891
  /\bsession\b/, /\bisLoggedIn\b/, /\bauthState\b/,
751
- /\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/,
752
895
  /\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
753
896
  ];
754
897
  function validateNoAnonymousWrite(root, platform, sourceContent) {
@@ -811,8 +954,8 @@ const COMMENT_PRESENCE_PATTERNS = [
811
954
  const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
812
955
  android: [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/],
813
956
  flutter: [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/],
814
- typescript: [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/],
815
- "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*=>/],
816
959
  ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
817
960
  };
818
961
  const COMMENT_UI_STATE_MARKERS = [
@@ -833,9 +976,19 @@ function validateComments(root, platform, sourceContent) {
833
976
  break;
834
977
  }
835
978
  }
836
- // 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
+ });
837
990
  const cleanupMarkers = COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
838
- if (!containsAny(sourceContent, cleanupMarkers)) {
991
+ if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
839
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."));
840
993
  }
841
994
  // ui-states-present: require loading/empty/error states
@@ -938,6 +1091,25 @@ function validateChat(root, platform, sourceContent) {
938
1091
  const chatFiles = filesMatching(sourceContent, CHAT_PRESENCE_PATTERNS);
939
1092
  if (chatFiles.length === 0)
940
1093
  return findings;
1094
+ // channel-type-dm: createChannel with type 'community' AND userIds indicates DM intent with wrong type.
1095
+ // Disambiguating condition: community channels do not pass userIds in createChannel (membership is
1096
+ // managed separately). If userIds appears alongside type:'community', the intent is clearly a DM
1097
+ // conversation but the wrong channel type was used. Do NOT fire on community channels without userIds.
1098
+ for (const filePath of chatFiles) {
1099
+ const content = sourceContent.get(filePath) ?? "";
1100
+ if (/\bcreateChannel\b/.test(content)) {
1101
+ const hasCommunityType = /createChannel[\s\S]{0,400}type\s*:\s*['"]community['"]/.test(content);
1102
+ const hasUserIds = /createChannel[\s\S]{0,400}\buserIds\b/.test(content);
1103
+ const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
1104
+ // Only fire on react-native/typescript where this rule is registered.
1105
+ // Only fire when both type:'community' AND userIds are present — that combination signals
1106
+ // DM intent. Community channel creation does not pass userIds to createChannel.
1107
+ if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
1108
+ 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."));
1109
+ break;
1110
+ }
1111
+ }
1112
+ }
941
1113
  // channel-target-resolved: check for hardcoded channelId/conversationId
942
1114
  for (const filePath of chatFiles) {
943
1115
  const content = sourceContent.get(filePath) ?? "";
@@ -1013,6 +1185,9 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1013
1185
  /\bListView\b/,
1014
1186
  /\bRecyclerView\b/,
1015
1187
  /\bLazyColumn\b/,
1188
+ /\bLazyRow\b/, // Jetpack Compose lists
1189
+ /\bLazyVerticalGrid\b/,
1190
+ /\bLazyVerticalStaggeredGrid\b/,
1016
1191
  /\bForEach\b/,
1017
1192
  /\bList\s*\(/,
1018
1193
  /\bsubmitList\b/,
@@ -1027,19 +1202,39 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1027
1202
  /\bonData\b/,
1028
1203
  /\bStreamBuilder\b/,
1029
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
1030
1209
  /Amity\w*Flow\b/,
1031
1210
  /\bPagingData\b/,
1211
+ /LazyPagingItems/, // Paging3 Compose: getX().collectAsLazyPagingItems() in a LazyColumn — reactive
1212
+ /collectAsLazyPagingItems/,
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)/,
1032
1225
  /\/\/\s*vise:\s*one-shot query/i
1033
1226
  ];
1034
1227
  for (const [file, content] of sourceContent) {
1035
1228
  const rel = relativeFile(root, file);
1229
+ if (path.basename(file).endsWith('.d.ts'))
1230
+ continue;
1036
1231
  const hasListQuery = LIST_QUERY_PATTERNS.some((p) => p.test(content));
1037
1232
  if (hasListQuery) {
1038
1233
  const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1039
1234
  if (hasListUI) {
1040
1235
  const hasReactivity = REACTIVE_MARKERS.some((p) => p.test(content));
1041
1236
  if (!hasReactivity) {
1042
- 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. If a one-shot query is truly intended, add comment // vise: one-shot query."));
1237
+ 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."));
1043
1238
  }
1044
1239
  }
1045
1240
  }
@@ -1052,7 +1247,10 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1052
1247
  const POST_QUERY_PATTERNS = [
1053
1248
  /\bgetPosts\b/,
1054
1249
  /\bqueryPosts\b/,
1055
- /\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.
1056
1254
  ];
1057
1255
  const FILTER_MARKERS = [
1058
1256
  /\bfeedTypes?\b/,
@@ -1078,6 +1276,99 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
1078
1276
  }
1079
1277
  return findings;
1080
1278
  }
1279
+ function validateActivityPostsTagFilter(root, platform, sourceContent) {
1280
+ const findings = [];
1281
+ const ruleId = `${platform}.posts.activity-tag-filter`;
1282
+ for (const [file, content] of sourceContent) {
1283
+ const rel = relativeFile(root, file);
1284
+ const filename = path.basename(file).toLowerCase();
1285
+ if (filename.endsWith('.d.ts'))
1286
+ continue;
1287
+ // Only fire for files that are clearly activity / toast / notification hooks
1288
+ if (!/activity|toast|notification|event/.test(filename))
1289
+ continue;
1290
+ if (!/\bgetPosts\b/.test(content))
1291
+ continue;
1292
+ if (/\btags\s*:/.test(content))
1293
+ continue;
1294
+ if (/\/\/\s*vise:\s*untagged posts intentional/i.test(content))
1295
+ continue;
1296
+ findings.push(finding(ruleId, "warning", "A getPosts query in an activity or toast hook is missing a tags filter.", rel, "Add a tags: ['activity'] parameter to the existing query object — e.g. change PostRepository.getPosts({ communityId }) to PostRepository.getPosts({ communityId, tags: ['activity'] }). Without this filter every community post enters the toast pipeline, not just activity events. Do not rewrite the query style; just add the tags field."));
1297
+ }
1298
+ return findings;
1299
+ }
1300
+ function validateReactionStalePostRef(root, platform, sourceContent) {
1301
+ const findings = [];
1302
+ const ruleId = `${platform}.posts.reaction-stale-post-ref`;
1303
+ for (const [file, content] of sourceContent) {
1304
+ const rel = relativeFile(root, file);
1305
+ if (path.basename(file).endsWith('.d.ts'))
1306
+ continue;
1307
+ // Only check files that have reaction calls
1308
+ if (!/\baddReaction\b|\bremoveReaction\b/.test(content))
1309
+ continue;
1310
+ // Only check files that use a ref (React hook — TypeScript/React Native only)
1311
+ if (!/\buseRef\b/.test(content))
1312
+ continue;
1313
+ // Look for .current passed into the reaction call — the stale-ref pattern
1314
+ if (!/.current\b/.test(content))
1315
+ continue;
1316
+ // If the file already reads post.postId from live state it is correct
1317
+ if (/\bpost\s*\??\.\s*postId\b/.test(content))
1318
+ continue;
1319
+ if (/\/\/\s*vise:\s*captured ref intentional/i.test(content))
1320
+ continue;
1321
+ findings.push(finding(ruleId, "warning", "A reaction call (addReaction/removeReaction) uses a captured ref instead of reading postId from live state.", rel, "Delete the captured ref variable (capturedPostId / similar) completely — remove the useRef declaration, the assignment inside the subscription callback, and every reference to .current. Then pass post?.postId directly to addReaction and removeReaction, and add the live post variable to the useCallback dependency array: useCallback(async (name) => { await addReaction('post', post?.postId, name); }, [post]). Keeping the ref — even without the null-guard — is still the stale-ref pattern."));
1322
+ }
1323
+ return findings;
1324
+ }
1325
+ function validateFollowStatusSubscription(root, platform, sourceContent) {
1326
+ const findings = [];
1327
+ const ruleId = `${platform}.follow.status-subscription`;
1328
+ for (const [file, content] of sourceContent) {
1329
+ const rel = relativeFile(root, file);
1330
+ if (path.basename(file).endsWith('.d.ts'))
1331
+ continue;
1332
+ if (!/\bgetFollowStatus\b/.test(content))
1333
+ continue;
1334
+ // Positive markers: live object subscription or explicit escape hatch.
1335
+ // The canonical "live" pattern is `getFollowStatus(userId, ({ data }) => ...)` —
1336
+ // a callback that receives the live snapshot. That's also what the
1337
+ // recommendation below suggests. If the call passes a function-style argument
1338
+ // (arrow or function), treat it as a live subscription.
1339
+ if (/\.on\s*\(\s*['"]dataUpdated['"]/.test(content) ||
1340
+ /\bLiveObject\b/.test(content) ||
1341
+ /\bobserve\s*\(/.test(content) ||
1342
+ // `getFollowStatus(userId, callback)` — two-arg form (comma inside the
1343
+ // first level of parens). Matches the recommended pattern whether the
1344
+ // callback is inline `({ data }) => …` or a named reference.
1345
+ // [^()]* keeps us inside the first level — disqualifies promise chains
1346
+ // like `getFollowStatus(userId).then(...)` which have no comma inside.
1347
+ /getFollowStatus\s*\([^()]*,/.test(content) ||
1348
+ /\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
1349
+ continue;
1350
+ 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(); };"));
1351
+ break;
1352
+ }
1353
+ return findings;
1354
+ }
1355
+ function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
1356
+ const findings = [];
1357
+ const ruleId = `${platform}.membership.use-live-collection`;
1358
+ for (const [file, content] of sourceContent) {
1359
+ const rel = relativeFile(root, file);
1360
+ if (path.basename(file).endsWith('.d.ts'))
1361
+ continue;
1362
+ if (!/\bsearchMembers\b/.test(content))
1363
+ continue;
1364
+ if (/\bgetMembers\b/.test(content))
1365
+ continue; // already uses live collection
1366
+ if (/\/\/\s*vise:\s*one-shot membership intentional/i.test(content))
1367
+ continue;
1368
+ 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();"));
1369
+ }
1370
+ return findings;
1371
+ }
1081
1372
  function validatePaginationCursorOpaque(root, platform, sourceContent) {
1082
1373
  const findings = [];
1083
1374
  const ruleId = `${platform}.pagination.cursor-opaque`;
@@ -1115,23 +1406,57 @@ function validatePaginationCursorOpaque(root, platform, sourceContent) {
1115
1406
  function validatePostsParentChild(root, platform, sourceContent) {
1116
1407
  const findings = [];
1117
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).
1118
1420
  const POST_RENDER_PATTERNS = [
1119
- /\.data\.text\b/i,
1120
- /\bpost\.data\b/i,
1121
- /\bpost\.text\b/i
1421
+ /\.data\.text\b/,
1422
+ /\bpost\.text\b/
1122
1423
  ];
1123
1424
  const CHILD_REFERENCE_PATTERNS = [
1124
1425
  /\.\s*children\b/,
1125
1426
  /\.\s*childrenPosts\b/,
1126
1427
  /\bgetChildren\b/
1127
1428
  ];
1429
+ // Child-typed media the parent-child model carries (TS/RN string dataTypes).
1430
+ const CHILD_MEDIA_TYPES = ["poll", "video", "room", "file", "audio"];
1128
1431
  for (const [filename, text] of sourceContent) {
1129
1432
  const rel = relativeFile(root, filename);
1130
- const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(text));
1131
- if (hasPostRender) {
1132
- const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(text));
1133
- if (!hasChildRef) {
1134
- 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."));
1135
1460
  }
1136
1461
  }
1137
1462
  }
@@ -1223,7 +1548,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1223
1548
  continue;
1224
1549
  const rel = relativeFile(root, file);
1225
1550
  const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
1226
- const tree = parse(lang, content);
1551
+ const tree = tryParse(lang, content);
1552
+ if (!tree)
1553
+ continue; // oversized/unparseable file — regex passes above still apply
1227
1554
  // ── auth.no-literal-user-id via .login({ userId: CONST }) ──
1228
1555
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1229
1556
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
@@ -1295,7 +1622,9 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
1295
1622
  if (!file.endsWith(".kt"))
1296
1623
  continue;
1297
1624
  const rel = relativeFile(root, file);
1298
- const tree = parse("kotlin", content);
1625
+ const tree = tryParse("kotlin", content);
1626
+ if (!tree)
1627
+ continue; // oversized/unparseable file — regex passes above still apply
1299
1628
  // ── auth.no-literal-user-id via .login(CONST) ──
1300
1629
  const userIdRule = `${platform}.auth.no-literal-user-id`;
1301
1630
  if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
@@ -1450,11 +1779,15 @@ async function validateIos(root) {
1450
1779
  const findings = [];
1451
1780
  const manifestFiles = await existingFiles(root, ["Podfile", "Package.swift"]);
1452
1781
  const manifestContent = await readMany(manifestFiles);
1453
- const swiftFiles = await findFiles(root, [".swift"], 400);
1782
+ const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
1454
1783
  const swiftContent = await readMany(swiftFiles);
1455
1784
  const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
1456
1785
  const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
1457
- 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/]);
1458
1791
  const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
1459
1792
  const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
1460
1793
  if (manifestFiles.length === 0) {
@@ -1485,7 +1818,7 @@ async function validateIos(root) {
1485
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."));
1486
1819
  }
1487
1820
  else if (!containsAny(swiftContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\.renew\s*\(/])) {
1488
- 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."));
1489
1822
  }
1490
1823
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
1491
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."));
@@ -1496,8 +1829,11 @@ async function validateIos(root) {
1496
1829
  }
1497
1830
  // iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
1498
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).
1499
1835
  const iosPayloadHandlerFiles = filesMatching(swiftContent, [/didReceiveRemoteNotification/, /userNotificationCenter\s*\(\s*_\s*,\s*didReceive/]);
1500
- 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/])) {
1501
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."));
1502
1838
  }
1503
1839
  if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
@@ -1514,6 +1850,13 @@ async function validateIos(root) {
1514
1850
  findings.push(...validateComments(root, "ios", swiftContent));
1515
1851
  findings.push(...validateModeration(root, "ios", swiftContent));
1516
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));
1517
1860
  findings.push(...validatePostsStatusFilter(root, "ios", swiftContent));
1518
1861
  findings.push(...validatePaginationCursorOpaque(root, "ios", swiftContent));
1519
1862
  findings.push(...validatePostsParentChild(root, "ios", swiftContent));
@@ -1532,6 +1875,7 @@ async function validateIos(root) {
1532
1875
  findings.push(...validateSessionHandlerRetention(root, "ios", swiftContent));
1533
1876
  findings.push(...validateChat(root, "ios", swiftContent));
1534
1877
  findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
1878
+ findings.push(...(await validateEnvSecretHygiene(root, "ios", swiftContent)));
1535
1879
  return findings;
1536
1880
  }
1537
1881
  function statusForFindings(findings) {
@@ -1812,6 +2156,16 @@ function astLanguageForFile(filePath, platform) {
1812
2156
  }
1813
2157
  function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
1814
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;
1815
2169
  const ruleId = `${platform}.comment.reference-type-enum`;
1816
2170
  const REFERENCE_TYPE_PATTERNS = [
1817
2171
  /referenceType\s*[:=\(]\s*(['"][^'"]+['"])/i
@@ -1868,7 +2222,23 @@ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
1868
2222
  if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
1869
2223
  continue;
1870
2224
  }
1871
- 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) {
1872
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.`));
1873
2243
  }
1874
2244
  }
@@ -1986,23 +2356,67 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
1986
2356
  }
1987
2357
  return findings;
1988
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
+ }
1989
2383
  function validateUserBanStateRespected(root, platform, sourceContent) {
1990
2384
  const findings = [];
1991
2385
  const ruleId = platform + '.user.ban-state-respected';
1992
2386
  for (const [filename, text] of sourceContent) {
1993
2387
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1994
- // Check if interaction methods are used in the file
1995
- if (!/\b(createPost|createComment|sendMessage|addReaction)\b/.test(text)) {
2388
+ // Skip type declaration files they declare the method signatures but are not
2389
+ // the implementation that needs the ban-state guard.
2390
+ if (path.basename(filename).endsWith('.d.ts'))
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;
2396
+ // Check if interaction methods are used in the file (includes flag actions — banned users
2397
+ // should also not be able to flag content, preventing spurious moderation queue spam)
2398
+ if (!/\b(createPost|createComment|sendMessage|addReaction|flagComment|flagPost)\b/.test(text)) {
1996
2399
  continue;
1997
2400
  }
1998
- // Ban-state positive markers
1999
- const hasBanCheck = /currentUser\.isGlobalBan/.test(text) ||
2401
+ // Ban-state positive markers. Accepts both member-access form
2402
+ // (`user.isBanned`, `currentUser.isGlobalBan`) and bare-variable form
2403
+ // (`disabled={isBanned}`, `!isBanned && ...`) — the latter is common
2404
+ // when a hook destructures the flag and the screen consumes it directly.
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) ||
2000
2415
  /isCurrentUserBannedFromCommunity/.test(text) ||
2001
2416
  /community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
2002
2417
  /channel\.isMuted/.test(text) ||
2003
2418
  /member\.isMuted/.test(text) ||
2004
2419
  /user\.banState/.test(text) ||
2005
- /\.isBanned/.test(text) ||
2006
2420
  /\/\/\s*vise:\s*ban state checked at/i.test(text);
2007
2421
  if (!hasBanCheck) {
2008
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."));
@@ -2015,6 +2429,10 @@ function validateFlagCountNotLeaked(root, platform, sourceContent) {
2015
2429
  const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
2016
2430
  for (const [filename, text] of sourceContent) {
2017
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;
2018
2436
  // Check if flagCount is used in the file
2019
2437
  if (!/\bflagCount\b/.test(text)) {
2020
2438
  continue;
@@ -2041,11 +2459,25 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2041
2459
  const ruleId = platform + '.moderation.role-gated-action';
2042
2460
  for (const [filename, text] of sourceContent) {
2043
2461
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2044
- // Check if any of the target actions exist in the file
2045
- 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) {
2046
2478
  continue;
2047
2479
  }
2048
- // Role-check positive markers
2480
+ // Moderator role markers
2049
2481
  const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2050
2482
  /\.hasRole\s*\(.*moderator/i.test(text) ||
2051
2483
  /community\.hasModeratorRole/.test(text) ||
@@ -2054,9 +2486,26 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2054
2486
  /member\.isModerator/.test(text) ||
2055
2487
  /roles\.includes\s*\(.*moderator/i.test(text) ||
2056
2488
  /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2489
+ /\bhasPermission\s*\(/.test(text) ||
2057
2490
  /\/\/\s*vise:\s*role check applied/i.test(text);
2058
- if (!hasRoleCheck) {
2059
- 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."));
2060
2509
  }
2061
2510
  }
2062
2511
  return findings;
@@ -2073,6 +2522,11 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2073
2522
  if (!matches)
2074
2523
  continue;
2075
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;
2076
2530
  if (!/dataType\s*[:=\(]/i.test(match)) {
2077
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.`));
2078
2532
  }
@@ -2080,6 +2534,342 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2080
2534
  }
2081
2535
  return findings;
2082
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
+ }
2083
2873
  function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
2084
2874
  const findings = [];
2085
2875
  const ruleId = `${platform}.feed.target-type-explicit`;
@@ -2095,7 +2885,22 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
2095
2885
  if (!hasCreatePost)
2096
2886
  continue;
2097
2887
  for (const pattern of TARGET_TYPE_PATTERNS) {
2098
- 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) {
2099
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>."));
2100
2905
  break;
2101
2906
  }