@amityco/social-plus-vise 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +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
5
  async function exists(filePath) {
5
6
  try {
6
7
  await access(filePath);
@@ -71,7 +72,13 @@ async function inspectRoot(root) {
71
72
  reason: "react-native dependency or script signal",
72
73
  });
73
74
  }
74
- const platforms = Array.from(new Set(signals.map((signal) => signal.platform)));
75
+ // When react-native is detected alongside generic typescript signals, prefer react-native
76
+ // so that platform-specific rules (react-native.*) are used for init/check/run-sensors.
77
+ const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
78
+ const hasRN = rawPlatforms.includes("react-native");
79
+ const platforms = hasRN
80
+ ? ["react-native", ...rawPlatforms.filter((p) => p !== "react-native" && p !== "typescript")]
81
+ : rawPlatforms;
75
82
  return { platforms, signals, designSignals: await detectDesignSignals(root) };
76
83
  }
77
84
  export const inspectProjectTool = {
@@ -206,12 +213,42 @@ async function validateAndroid(root) {
206
213
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
207
214
  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."));
208
215
  }
216
+ // Android push payload: if onMessageReceived exists, require push-specific SDK forwarding
217
+ const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
218
+ if (androidPayloadFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
219
+ 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
+ }
209
221
  if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/])) {
210
222
  findings.push(finding("android.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Dispose or unsubscribe observers when the Activity, Fragment, ViewModel, or composable lifecycle ends."));
211
223
  }
212
224
  findings.push(...validateLiteralGuardrails(root, "android", sourceContent));
213
225
  findings.push(...validateLoggingHygiene(root, "android", sourceContent));
214
226
  findings.push(...validateFeedUiStates(root, "android", sourceContent));
227
+ findings.push(...validateFeedPagination(root, "android", sourceContent));
228
+ findings.push(...validateFeedModerationAffordance(root, "android", sourceContent));
229
+ findings.push(...validateLogoutOnUserSwitch(root, "android", sourceContent));
230
+ findings.push(...validateNoAnonymousWrite(root, "android", sourceContent));
231
+ findings.push(...validateErrorHandling(root, "android", sourceContent));
232
+ findings.push(...validateComments(root, "android", sourceContent));
233
+ findings.push(...validateModeration(root, "android", sourceContent));
234
+ findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
235
+ findings.push(...validatePostsStatusFilter(root, "android", sourceContent));
236
+ findings.push(...validatePaginationCursorOpaque(root, "android", sourceContent));
237
+ findings.push(...validatePostsParentChild(root, "android", sourceContent));
238
+ findings.push(...validateFeedTargetTypeExplicit(root, "android", sourceContent));
239
+ findings.push(...validateChannelTypeMatchesShape(root, "android", sourceContent));
240
+ findings.push(...validateReactionConfiguredNameUsed(root, "android", sourceContent));
241
+ findings.push(...validateCustomPostTypeDataTypeDeclared(root, "android", sourceContent));
242
+ findings.push(...validateModerationRoleGatedAction(root, "android", sourceContent));
243
+ findings.push(...validateFlagCountNotLeaked(root, "android", sourceContent));
244
+ findings.push(...validateUserBanStateRespected(root, "android", sourceContent));
245
+ findings.push(...validateNotificationsPreferencesConfigured(root, "android", sourceContent));
246
+ findings.push(...validateUnreadSubscribedNotCounted(root, "android", sourceContent));
247
+ findings.push(...validateFileUploadViaAmityFileClient(root, "android", sourceContent));
248
+ findings.push(...validateImagePostChildResolutionAwaited(root, "android", sourceContent));
249
+ findings.push(...validateCommentReferenceTypeEnum(root, "android", sourceContent));
250
+ findings.push(...validateSessionHandlerRetention(root, "android", sourceContent));
251
+ findings.push(...validateChat(root, "android", sourceContent));
215
252
  findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
216
253
  return findings;
217
254
  }
@@ -254,12 +291,42 @@ async function validateFlutter(root) {
254
291
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
255
292
  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."));
256
293
  }
294
+ // Flutter push payload: if FirebaseMessaging.onMessage/onBackgroundMessage exists, require push-specific SDK forwarding
295
+ const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
296
+ if (flutterPayloadFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
297
+ 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
+ }
257
299
  if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
258
300
  findings.push(finding("flutter.live.cleanup", "warning", "Live Object/Collection stream observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Store the subscription and cancel it from dispose or the owning lifecycle cleanup."));
259
301
  }
260
302
  findings.push(...validateLiteralGuardrails(root, "flutter", dartContent));
261
303
  findings.push(...validateLoggingHygiene(root, "flutter", dartContent));
262
304
  findings.push(...validateFeedUiStates(root, "flutter", dartContent));
305
+ findings.push(...validateFeedPagination(root, "flutter", dartContent));
306
+ findings.push(...validateFeedModerationAffordance(root, "flutter", dartContent));
307
+ findings.push(...validateLogoutOnUserSwitch(root, "flutter", dartContent));
308
+ findings.push(...validateNoAnonymousWrite(root, "flutter", dartContent));
309
+ findings.push(...validateErrorHandling(root, "flutter", dartContent));
310
+ findings.push(...validateComments(root, "flutter", dartContent));
311
+ findings.push(...validateModeration(root, "flutter", dartContent));
312
+ findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
313
+ findings.push(...validatePostsStatusFilter(root, "flutter", dartContent));
314
+ findings.push(...validatePaginationCursorOpaque(root, "flutter", dartContent));
315
+ findings.push(...validatePostsParentChild(root, "flutter", dartContent));
316
+ findings.push(...validateFeedTargetTypeExplicit(root, "flutter", dartContent));
317
+ findings.push(...validateChannelTypeMatchesShape(root, "flutter", dartContent));
318
+ findings.push(...validateReactionConfiguredNameUsed(root, "flutter", dartContent));
319
+ findings.push(...validateCustomPostTypeDataTypeDeclared(root, "flutter", dartContent));
320
+ findings.push(...validateModerationRoleGatedAction(root, "flutter", dartContent));
321
+ findings.push(...validateFlagCountNotLeaked(root, "flutter", dartContent));
322
+ findings.push(...validateUserBanStateRespected(root, "flutter", dartContent));
323
+ findings.push(...validateNotificationsPreferencesConfigured(root, "flutter", dartContent));
324
+ findings.push(...validateUnreadSubscribedNotCounted(root, "flutter", dartContent));
325
+ findings.push(...validateFileUploadViaAmityFileClient(root, "flutter", dartContent));
326
+ findings.push(...validateImagePostChildResolutionAwaited(root, "flutter", dartContent));
327
+ findings.push(...validateCommentReferenceTypeEnum(root, "flutter", dartContent));
328
+ findings.push(...validateSessionHandlerRetention(root, "flutter", dartContent));
329
+ findings.push(...validateChat(root, "flutter", dartContent));
263
330
  findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
264
331
  return findings;
265
332
  }
@@ -286,7 +353,17 @@ async function validateTypeScript(root, platform) {
286
353
  findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
287
354
  }
288
355
  else {
289
- if (!containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/])) {
356
+ // Region detection accepts three idioms:
357
+ // (a) keyword-arg style: createClient({ region: 'sg' }) / apiRegion / apiEndpoint
358
+ // (b) positional with a named variable: const region = ...; createClient(apiKey, region)
359
+ // (c) env-sourced region declared anywhere in the codebase:
360
+ // process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
361
+ // VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
362
+ // 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/]);
365
+ 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
+ if (!setupHasKeywordRegion && !setupHasNamedRegionDecl && !projectHasEnvRegion) {
290
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."));
291
368
  }
292
369
  const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
@@ -309,6 +386,31 @@ async function validateTypeScript(root, platform) {
309
386
  findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
310
387
  findings.push(...validateLoggingHygiene(root, platform, sourceContent));
311
388
  findings.push(...validateFeedUiStates(root, platform, sourceContent));
389
+ findings.push(...validateFeedPagination(root, platform, sourceContent));
390
+ findings.push(...validateFeedModerationAffordance(root, platform, sourceContent));
391
+ findings.push(...validateLogoutOnUserSwitch(root, platform, sourceContent));
392
+ findings.push(...validateNoAnonymousWrite(root, platform, sourceContent));
393
+ findings.push(...validateErrorHandling(root, platform, sourceContent));
394
+ findings.push(...validateComments(root, platform, sourceContent));
395
+ findings.push(...validateModeration(root, platform, sourceContent));
396
+ findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
397
+ findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
398
+ findings.push(...validatePaginationCursorOpaque(root, platform, sourceContent));
399
+ findings.push(...validatePostsParentChild(root, platform, sourceContent));
400
+ findings.push(...validateFeedTargetTypeExplicit(root, platform, sourceContent));
401
+ findings.push(...validateChannelTypeMatchesShape(root, platform, sourceContent));
402
+ findings.push(...validateReactionConfiguredNameUsed(root, platform, sourceContent));
403
+ findings.push(...validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent));
404
+ findings.push(...validateModerationRoleGatedAction(root, platform, sourceContent));
405
+ findings.push(...validateFlagCountNotLeaked(root, platform, sourceContent));
406
+ findings.push(...validateUserBanStateRespected(root, platform, sourceContent));
407
+ findings.push(...validateNotificationsPreferencesConfigured(root, platform, sourceContent));
408
+ findings.push(...validateUnreadSubscribedNotCounted(root, platform, sourceContent));
409
+ findings.push(...validateFileUploadViaAmityFileClient(root, platform, sourceContent));
410
+ findings.push(...validateImagePostChildResolutionAwaited(root, platform, sourceContent));
411
+ findings.push(...validateCommentReferenceTypeEnum(root, platform, sourceContent));
412
+ findings.push(...validateSessionHandlerRetention(root, platform, sourceContent));
413
+ findings.push(...validateChat(root, platform, sourceContent));
312
414
  findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
313
415
  if (platform === "typescript") {
314
416
  findings.push(...(await validateTypeScriptEnvSecretHygiene(root, sourceContent)));
@@ -343,26 +445,31 @@ function validateLoggingHygiene(root, platform, sourceContent) {
343
445
  return [];
344
446
  }
345
447
  const secretShape = /\b(api[_-]?key|api[_-]?secret|access[_-]?token|refresh[_-]?token|bearer[_-]?token|client[_-]?secret|secret[_-]?key)\b/i;
448
+ const piiShape = /\b(user[_-]?name|display[_-]?name|full[_-]?name|email|phone|phone[_-]?number|address|date[_-]?of[_-]?birth|dob|ssn|national[_-]?id)\b/i;
449
+ const findings = [];
346
450
  for (const [file, content] of sourceContent) {
347
451
  for (const line of content.split(/\r?\n/)) {
348
452
  if (!logCallPattern.test(line)) {
349
453
  continue;
350
454
  }
351
- if (!secretShape.test(line)) {
352
- continue;
353
- }
354
455
  // Allow commented-out lines (//, #, /*); rough but kills the obvious
355
456
  // false positives like documentation snippets.
356
457
  const trimmed = line.trimStart();
357
458
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
358
459
  continue;
359
460
  }
360
- return [
361
- finding(`${platform}.logging.no-secret-in-log`, "warning", "A logging call appears to reference a secret-shaped identifier (apiKey, accessToken, etc.).", relativeFile(root, file), "Do not log secret-shaped values. Drop the log line, redact the value, or attest that the value being logged is a placeholder."),
362
- ];
461
+ if (secretShape.test(line) && !findings.some((f) => f.ruleId === `${platform}.logging.no-secret-in-log`)) {
462
+ findings.push(finding(`${platform}.logging.no-secret-in-log`, "warning", "A logging call appears to reference a secret-shaped identifier (apiKey, accessToken, etc.).", relativeFile(root, file), "Do not log secret-shaped values. Drop the log line, redact the value, or attest that the value being logged is a placeholder."));
463
+ }
464
+ if (piiShape.test(line) && !findings.some((f) => f.ruleId === `${platform}.logging.no-pii-in-log`)) {
465
+ findings.push(finding(`${platform}.logging.no-pii-in-log`, "warning", "A logging call appears to reference a PII-shaped identifier (userName, email, phone, displayName, etc.).", relativeFile(root, file), "Do not log user PII. Redact the value, use an anonymized ID, or attest that the data is not real user PII."));
466
+ }
467
+ if (findings.length >= 2) {
468
+ return findings;
469
+ }
363
470
  }
364
471
  }
365
- return [];
472
+ return findings;
366
473
  }
367
474
  // Design system reuse: when the project has detected theme / token / design-
368
475
  // system files (via detectDesignSignals), at least one other source file
@@ -390,20 +497,71 @@ async function validateDesignReuse(root, platform, sourceContent) {
390
497
  }
391
498
  // Resolve design file absolute paths so we can skip them when scanning.
392
499
  const designAbsolutePaths = new Set(designSignals.map((signal) => path.resolve(root, signal.file)));
500
+ // Implicit theme-provider reuse patterns. Idiomatic UI on most platforms
501
+ // pulls tokens from an ambient theme context (Theme.of in Flutter, useTheme
502
+ // in MUI/Chakra, MaterialTheme.colorScheme in Compose, UIColor.systemBlue
503
+ // dynamic colors in iOS). These reuse the design system without importing
504
+ // the file that declared the theme — the cross-file marker heuristic misses
505
+ // them. Per-platform list of additional positive signals:
506
+ const IMPLICIT_THEME_MARKERS_BY_PLATFORM = {
507
+ flutter: [
508
+ /Theme\.of\s*\(\s*context\s*\)/,
509
+ /CupertinoTheme\.of\s*\(\s*context\s*\)/,
510
+ /MediaQuery\.of\s*\(\s*context\s*\)/,
511
+ /\bcolorScheme\s*\./,
512
+ /\btextTheme\s*\./,
513
+ // Material 3 widget references — strong signal of design-system reuse.
514
+ /\b(Card|ElevatedButton|FilledButton|OutlinedButton|TextButton|IconButton|Scaffold|AppBar|FloatingActionButton|ListTile|Chip|NavigationBar|NavigationRail)\s*\(/,
515
+ ],
516
+ android: [
517
+ /MaterialTheme\.colorScheme/,
518
+ /MaterialTheme\.typography/,
519
+ /MaterialTheme\.shapes/,
520
+ /androidx\.compose\.material3/,
521
+ ],
522
+ ios: [
523
+ /UIColor\.system/,
524
+ /\.foregroundStyle\(\.system/,
525
+ /Color\.accentColor/,
526
+ /\.preferredColorScheme/,
527
+ ],
528
+ typescript: [
529
+ /useTheme\s*\(/,
530
+ /ThemeProvider/,
531
+ /styled\s*\(/,
532
+ /\bcss`/,
533
+ ],
534
+ "react-native": [
535
+ /useTheme\s*\(/,
536
+ /useColorScheme\s*\(/,
537
+ /StyleSheet\.create/,
538
+ /ThemeProvider/,
539
+ ],
540
+ };
541
+ const implicitMarkers = IMPLICIT_THEME_MARKERS_BY_PLATFORM[platform] ?? [];
393
542
  for (const [file, content] of sourceContent) {
394
543
  if (designAbsolutePaths.has(file)) {
395
544
  continue;
396
545
  }
546
+ // Phase 4: For AST-supported languages, check against comment-stripped
547
+ // content to avoid false passes from references only in comments.
548
+ const astLang = astLanguageForFile(file, platform);
549
+ const checkContent = astLang ? stripComments(astLang, content) : content;
550
+ // (a) Explicit reuse — cross-file basename / PascalCase reference.
397
551
  for (const { markers } of markersByFile) {
398
552
  for (const marker of markers) {
399
553
  if (!marker || marker.length < 3) {
400
554
  continue;
401
555
  }
402
- if (content.includes(marker)) {
556
+ if (checkContent.includes(marker)) {
403
557
  return [];
404
558
  }
405
559
  }
406
560
  }
561
+ // (b) Implicit reuse — ambient theme-provider API or design-system widget.
562
+ if (implicitMarkers.some((re) => re.test(checkContent))) {
563
+ return [];
564
+ }
407
565
  }
408
566
  return [
409
567
  finding(`${platform}.design.reuse-detected-tokens`, "warning", `Detected design source(s) (${designSignals.map((s) => s.file).slice(0, 3).join(", ")}) are not referenced by any other source file.`, designSignals[0].file, "Import or reference the detected design tokens / theme module from your new UI code instead of inlining colors, spacing, or typography. Attest with evidence (the import path) if you use an implicit theme provider context."),
@@ -432,7 +590,11 @@ function validateFeedUiStates(root, platform, sourceContent) {
432
590
  if (!observes) {
433
591
  continue;
434
592
  }
435
- const hasStates = statePatterns.some((pattern) => pattern.test(content));
593
+ // Phase 4: For AST-supported languages, check state patterns against
594
+ // comment-stripped content to avoid false passes from comment text.
595
+ const astLang = astLanguageForFile(file, platform);
596
+ const checkContent = astLang ? stripComments(astLang, content) : content;
597
+ const hasStates = statePatterns.some((pattern) => pattern.test(checkContent));
436
598
  if (hasStates) {
437
599
  continue;
438
600
  }
@@ -459,6 +621,542 @@ const UI_STATE_PATTERNS_BY_PLATFORM = {
459
621
  android: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /CircularProgressIndicator/, /LoadingState/, /\.collectAsState\b/],
460
622
  ios: [/\bisLoading\b/i, /\bloading\b/, /\berror\b/, /\bempty\b/i, /ProgressView/, /LoadingState/, /AsyncImage/],
461
623
  };
624
+ const FEED_LIST_OBSERVATION_PATTERNS_BY_PLATFORM = {
625
+ typescript: [/PostRepository\.getPosts/, /queryPosts/, /observePosts/, /FeedRepository/],
626
+ "react-native": [/PostRepository\.getPosts/, /queryPosts/, /observePosts/, /FeedRepository/],
627
+ flutter: [/AmityPostRepository/, /queryPosts/, /getPosts/, /FeedRepository/],
628
+ android: [/AmitySocialClient\.newPostRepository/, /PostRepository/, /queryPosts/, /getPosts/],
629
+ ios: [/AmityPostRepository/, /queryPosts/, /getPosts/, /FeedRepository/],
630
+ };
631
+ const PAGINATION_MARKERS_BY_PLATFORM = {
632
+ typescript: [/\bhasMore\b/, /\bloadMore\b/, /\bnextPage\b/, /\bcursor\b/, /\bpageToken\b/, /\buseInfiniteQuery\b/, /\bfetchNextPage\b/, /onEndReached/],
633
+ "react-native": [/\bhasMore\b/, /\bloadMore\b/, /\bnextPage\b/, /\bcursor\b/, /\bpageToken\b/, /\buseInfiniteQuery\b/, /\bfetchNextPage\b/, /onEndReached/, /FlatList/],
634
+ flutter: [/ScrollController/, /ListView\.builder/, /PagingController/, /\bloadMore\b/, /\bnextPage\b/, /\bhasMore\b/],
635
+ android: [/PagingData/, /\bPager\b/, /PagingSource/, /LazyPagingItems/, /\bloadMore\b/, /\bnextPage\b/, /LazyColumn/],
636
+ ios: [/\bloadMore\b/, /\bnextPage\b/, /\bcursor\b/, /onAppear/, /willDisplay/, /UITableViewDataSourcePrefetching/, /\bpagination\b/i],
637
+ };
638
+ function validateFeedPagination(root, platform, sourceContent) {
639
+ const feedPatterns = FEED_LIST_OBSERVATION_PATTERNS_BY_PLATFORM[platform];
640
+ const paginationPatterns = PAGINATION_MARKERS_BY_PLATFORM[platform];
641
+ if (!feedPatterns || !paginationPatterns) {
642
+ return [];
643
+ }
644
+ for (const [file, content] of sourceContent) {
645
+ const hasFeed = feedPatterns.some((pattern) => pattern.test(content));
646
+ if (!hasFeed) {
647
+ continue;
648
+ }
649
+ // Check all source files for pagination markers (may be in a separate hook/util)
650
+ const astLang = astLanguageForFile(file, platform);
651
+ const checkContent = astLang ? stripComments(astLang, content) : content;
652
+ const hasPagination = paginationPatterns.some((pattern) => pattern.test(checkContent));
653
+ if (hasPagination) {
654
+ return [];
655
+ }
656
+ // Also check sibling files for pagination markers
657
+ for (const [otherFile, otherContent] of sourceContent) {
658
+ if (otherFile === file)
659
+ continue;
660
+ const otherLang = astLanguageForFile(otherFile, platform);
661
+ const otherCheck = otherLang ? stripComments(otherLang, otherContent) : otherContent;
662
+ if (paginationPatterns.some((pattern) => pattern.test(otherCheck))) {
663
+ return [];
664
+ }
665
+ }
666
+ 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."),
668
+ ];
669
+ }
670
+ return [];
671
+ }
672
+ const MODERATION_MARKERS = [
673
+ /\breport\b/i, /\breportPost\b/, /\bflag\b/i, /\bflagPost\b/,
674
+ /\bblock\b/i, /\bblockUser\b/, /\bmute\b/i, /\bmuteUser\b/,
675
+ /\bhide\b/i, /\bhidePost\b/, /\bmoderation\b/i, /\babuse\b/i,
676
+ ];
677
+ function validateFeedModerationAffordance(root, platform, sourceContent) {
678
+ const feedPatterns = FEED_LIST_OBSERVATION_PATTERNS_BY_PLATFORM[platform];
679
+ if (!feedPatterns) {
680
+ return [];
681
+ }
682
+ let hasFeedFile = false;
683
+ for (const [file, content] of sourceContent) {
684
+ const hasFeed = feedPatterns.some((pattern) => pattern.test(content));
685
+ if (hasFeed) {
686
+ hasFeedFile = true;
687
+ break;
688
+ }
689
+ }
690
+ if (!hasFeedFile) {
691
+ return [];
692
+ }
693
+ // Check all source files for any moderation marker (may be in a separate module)
694
+ for (const [file, content] of sourceContent) {
695
+ const astLang = astLanguageForFile(file, platform);
696
+ const checkContent = astLang ? stripComments(astLang, content) : content;
697
+ if (MODERATION_MARKERS.some((pattern) => pattern.test(checkContent))) {
698
+ return [];
699
+ }
700
+ }
701
+ return [
702
+ finding(`${platform}.feed.moderation-affordance-present`, "warning", "A feed/posts UI is present but no moderation affordance (report, block, mute, hide) was detected.", undefined, "Add at least one user-visible moderation action (report, block, mute, or hide) to the feed UI or a sibling module. Attest if moderation is centralized elsewhere."),
703
+ ];
704
+ }
705
+ // ─── Logout on user-switch ───────────────────────────────────────────────────
706
+ const USER_SWITCH_SYMBOLS = [
707
+ /switchUser/i, /setCurrentUser/i, /authStateChanged/i, /onAuthStateChanged/i,
708
+ /logoutAndLogin/i, /signInAs/i, /changeUser/i, /setUser/i,
709
+ ];
710
+ const LOGIN_PATTERNS_BY_PLATFORM = {
711
+ android: [/AmityCoreClient\.login/, /\.login\s*\(/],
712
+ flutter: [/AmityCoreClient\.login/, /\.login\s*\(/],
713
+ typescript: [/AmityClient.*\.login/, /connectClient/, /\.login\s*\(/],
714
+ "react-native": [/AmityClient.*\.login/, /connectClient/, /\.login\s*\(/],
715
+ ios: [/\.login\s*\(/, /AmityClient.*\.login/],
716
+ };
717
+ const LOGOUT_MARKERS_BY_PLATFORM = {
718
+ android: [/\.logout\s*\(/, /unregisterSession/, /disconnect\s*\(/],
719
+ flutter: [/\.logout\s*\(/, /unregisterSession/, /disconnect\s*\(/],
720
+ typescript: [/\.logout\s*\(/, /\.disconnect\s*\(/, /client\.close/],
721
+ "react-native": [/\.logout\s*\(/, /\.disconnect\s*\(/, /client\.close/],
722
+ ios: [/\.logout\s*\(/, /unregisterSession/, /\.disconnect\s*\(/],
723
+ };
724
+ function validateLogoutOnUserSwitch(root, platform, sourceContent) {
725
+ const loginPatterns = LOGIN_PATTERNS_BY_PLATFORM[platform] ?? LOGIN_PATTERNS_BY_PLATFORM.typescript;
726
+ const logoutMarkers = LOGOUT_MARKERS_BY_PLATFORM[platform] ?? LOGOUT_MARKERS_BY_PLATFORM.typescript;
727
+ // Check: does the project have login AND user-switch symbols?
728
+ const loginFiles = filesMatching(sourceContent, loginPatterns);
729
+ if (loginFiles.length === 0)
730
+ return [];
731
+ const switchFiles = filesMatching(sourceContent, USER_SWITCH_SYMBOLS);
732
+ if (switchFiles.length === 0)
733
+ return [];
734
+ // Require a logout marker somewhere in the source
735
+ if (containsAny(sourceContent, logoutMarkers))
736
+ return [];
737
+ 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."),
739
+ ];
740
+ }
741
+ // ─── No anonymous write ──────────────────────────────────────────────────────
742
+ const WRITE_CALL_PATTERNS = [
743
+ /createPost\s*\(/, /createComment\s*\(/, /sendMessage\s*\(/,
744
+ /createStory\s*\(/, /addReaction\s*\(/, /\.react\s*\(/,
745
+ /PostRepository\.create/, /CommentRepository\.create/, /MessageRepository\.create/,
746
+ /\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
747
+ ];
748
+ const AUTH_GATE_MARKERS = [
749
+ /\bcurrentUser\b/, /\bisAuthenticated\b/, /\brequireAuth\b/,
750
+ /\bsession\b/, /\bisLoggedIn\b/, /\bauthState\b/,
751
+ /\bgetCurrentUser\b/, /\bgetAccessToken\b/, /\baccessToken\b/,
752
+ /\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
753
+ ];
754
+ function validateNoAnonymousWrite(root, platform, sourceContent) {
755
+ const writeFiles = filesMatching(sourceContent, WRITE_CALL_PATTERNS);
756
+ if (writeFiles.length === 0)
757
+ return [];
758
+ // For each file with write calls, check if it has auth gate markers
759
+ for (const filePath of writeFiles) {
760
+ const content = sourceContent.get(filePath) ?? "";
761
+ const astLang = astLanguageForFile(filePath, platform);
762
+ const checkContent = astLang ? stripComments(astLang, content) : content;
763
+ if (AUTH_GATE_MARKERS.some((p) => p.test(checkContent)))
764
+ return [];
765
+ }
766
+ return [
767
+ finding(`${platform}.auth.no-anonymous-write`, "warning", "Write operations (createPost, sendMessage, etc.) found but no auth gate or user-session check was detected in the same file.", relativeFile(root, writeFiles[0]), "Ensure write operations are only reachable after authentication. Check currentUser, session, or isAuthenticated before calling create/send methods."),
768
+ ];
769
+ }
770
+ // ─── Network error handling ──────────────────────────────────────────────────
771
+ const SDK_CALL_PATTERNS = [
772
+ /\.login\s*\(/, /\.setup\s*\(/, /createPost\s*\(/,
773
+ /createComment\s*\(/, /sendMessage\s*\(/, /createStory\s*\(/,
774
+ /registerPushNotification/, /unregisterPushNotification/,
775
+ /\.query\s*\(/, /\.getPosts\s*\(/, /\.getComments\s*\(/,
776
+ ];
777
+ const ERROR_HANDLING_MARKERS_BY_PLATFORM = {
778
+ android: [/try\s*\{/, /runCatching/, /\.onFailure/, /CoroutineExceptionHandler/, /\.catch\s*\{/],
779
+ flutter: [/try\s*\{/, /\.catchError\s*\(/, /\.onError\s*\(/, /\.handleError/, /catch\s*\(/],
780
+ // TypeScript / React: in addition to imperative try/catch and Promise.catch,
781
+ // recognize React's idiomatic error-state pattern:
782
+ // - setError(...) state setter
783
+ // - useState<Error...> or useState<...Error> (error-typed state)
784
+ // - ErrorBoundary component reference (React error boundary)
785
+ // - error.tsx co-located file (Next.js App Router convention)
786
+ // - destructured error from a callback's response object: ({ error }) or (error: foo)
787
+ // These are the canonical patterns the social.plus TS SDK's Live Collection
788
+ // callbacks use; flagging them would force attestation on idiomatic code.
789
+ typescript: [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /error\.tsx\b/, /\berror\s*:\s*\w+\s*[,})]/],
790
+ "react-native": [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /\berror\s*:\s*\w+\s*[,})]/],
791
+ ios: [/do\s*\{/, /try\s+/, /try\?/, /catch\s*\{/, /\.failure\s*\(/, /Result\s*</, /completion.*Error/],
792
+ };
793
+ function validateErrorHandling(root, platform, sourceContent) {
794
+ const sdkCallFiles = filesMatching(sourceContent, SDK_CALL_PATTERNS);
795
+ if (sdkCallFiles.length === 0)
796
+ return [];
797
+ const errorMarkers = ERROR_HANDLING_MARKERS_BY_PLATFORM[platform] ?? ERROR_HANDLING_MARKERS_BY_PLATFORM.typescript;
798
+ // Check if ANY source file has error handling markers
799
+ if (containsAny(sourceContent, errorMarkers))
800
+ return [];
801
+ return [
802
+ finding(`${platform}.network.error-handling-present`, "warning", "SDK calls found but no error handling (try/catch, .catch, Result) was detected. Unhandled network errors produce broken UX or crashes.", relativeFile(root, sdkCallFiles[0]), "Wrap SDK calls in try/catch or attach .catch() handlers. Attest if error handling is centralized in a middleware layer."),
803
+ ];
804
+ }
805
+ // ─── Comments validation ─────────────────────────────────────────────────────
806
+ const COMMENT_PRESENCE_PATTERNS = [
807
+ /createComment\s*\(/, /getComments\s*\(/, /commentRepository/i,
808
+ /CommentRepository/, /AmityCommentRepository/, /commentCollection/i,
809
+ /queryComments/, /addComment/, /postComment/, /replyToComment/,
810
+ ];
811
+ const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
812
+ android: [/dispose\s*\(/, /Disposable/, /CompositeDisposable/, /clear\s*\(/, /removeObserver/, /unsubscribe/],
813
+ flutter: [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/],
814
+ typescript: [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/],
815
+ "react-native": [/unsubscribe/, /\.off\s*\(/, /removeListener/, /dispose/, /useEffect.*return/],
816
+ ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
817
+ };
818
+ const COMMENT_UI_STATE_MARKERS = [
819
+ /loading/i, /empty/i, /error/i, /isLoading/, /isEmpty/, /hasError/,
820
+ /\.loading\b/, /\.empty\b/, /\.error\b/, /LoadingState/, /emptyState/i,
821
+ ];
822
+ function validateComments(root, platform, sourceContent) {
823
+ const findings = [];
824
+ const commentFiles = filesMatching(sourceContent, COMMENT_PRESENCE_PATTERNS);
825
+ if (commentFiles.length === 0)
826
+ return findings;
827
+ // target-resolved: check for hardcoded postId/commentId in comment files
828
+ for (const filePath of commentFiles) {
829
+ const content = sourceContent.get(filePath) ?? "";
830
+ const hardcodedTarget = /(?:postId|commentId|referenceId)\s*[:=]\s*["'`][a-z0-9-]+["'`]/i.exec(content);
831
+ if (hardcodedTarget) {
832
+ findings.push(finding(`${platform}.comments.target-resolved`, "warning", "Comment code references a hardcoded postId/commentId. Comment targets should come from the parent entity, not be invented.", relativeFile(root, filePath), "Pass the parent post/entity ID from navigation or parent component props rather than hardcoding it."));
833
+ break;
834
+ }
835
+ }
836
+ // observer-cleanup: if comment live collection exists, require cleanup
837
+ const cleanupMarkers = COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
838
+ if (!containsAny(sourceContent, cleanupMarkers)) {
839
+ 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
+ }
841
+ // ui-states-present: require loading/empty/error states
842
+ if (!containsAny(sourceContent, COMMENT_UI_STATE_MARKERS)) {
843
+ findings.push(finding(`${platform}.comments.ui-states-present`, "warning", "Comment UI is present but no loading, empty, or error state handling was detected.", relativeFile(root, commentFiles[0]), "Handle loading, empty, and error states in the comment UI for a complete user experience."));
844
+ }
845
+ // moderation-affordance-present: require moderation on comments (scoped to comment files)
846
+ const commentOnlyContent = new Map();
847
+ for (const f of commentFiles)
848
+ commentOnlyContent.set(f, sourceContent.get(f) ?? "");
849
+ if (!containsAny(commentOnlyContent, MODERATION_MARKERS)) {
850
+ findings.push(finding(`${platform}.comments.moderation-affordance-present`, "warning", "Comment UI is present but no moderation affordance (report, block, hide) was detected for comments.", relativeFile(root, commentFiles[0]), "Add at least one moderation action (report, block, hide) to comments. Comments are UGC and need moderation."));
851
+ }
852
+ return findings;
853
+ }
854
+ // --- Moderation validators ---
855
+ const MODERATION_PRESENCE_PATTERNS = [
856
+ /\bAmityModerationRepository\b/,
857
+ /\bModerationRepository\b/,
858
+ /\bblockUser\b/,
859
+ /\bmuteUser\b/,
860
+ /\bhideContent\b/,
861
+ /\bflagContent\b/,
862
+ /\breportContent\b/,
863
+ ];
864
+ const MODERATION_CONFIRMATION_MARKERS = [
865
+ /\bconfirm\b/i,
866
+ /\bAlert\b/,
867
+ /\bAlertDialog\b/,
868
+ /\bshowDialog\b/,
869
+ /\bUIAlertController\b/,
870
+ /\bconfirmationDialog\b/,
871
+ /\bwindow\.confirm\b/,
872
+ /\bmodal\b/i,
873
+ ];
874
+ const MODERATION_BLOCK_STATE_APPLIED_MARKERS = [
875
+ /\bisBlocked\b/,
876
+ /\bisMuted\b/,
877
+ /\bsetBlocked\b/,
878
+ /\bsetMuted\b/,
879
+ /\bblocked\s*state\b/i,
880
+ /\bmuted\s*state\b/i,
881
+ /\bBlockedContent\b/,
882
+ /\bMutedContent\b/,
883
+ ];
884
+ const MODERATION_HIDDEN_RENDERING_MARKERS = [
885
+ /\bhidden\b/i,
886
+ /\bplaceholder\b/i,
887
+ /\bblurred\b/i,
888
+ /\bcontent\s*removed\b/i,
889
+ /\bvisibility\b/i,
890
+ /\bisHidden\b/,
891
+ /\bisRemoved\b/,
892
+ /\bopacity\b/,
893
+ ];
894
+ function validateModeration(root, platform, sourceContent) {
895
+ const findings = [];
896
+ const moderationFiles = filesMatching(sourceContent, MODERATION_PRESENCE_PATTERNS);
897
+ if (moderationFiles.length === 0)
898
+ return findings;
899
+ // report-flow-present: require report action
900
+ const hasReport = containsAny(sourceContent, [/\breport\b/i, /\bflag\b/i, /\bflagContent\b/, /\breportContent\b/]);
901
+ if (!hasReport) {
902
+ findings.push(finding(`${platform}.moderation.report-flow-present`, "warning", "Moderation code is present but no report/flag flow was detected.", relativeFile(root, moderationFiles[0]), "Add a report action that calls the moderation API with a confirmation dialog before submission."));
903
+ }
904
+ // block-or-mute-state-applied: require state rendering markers (not just the action call)
905
+ if (!containsAny(sourceContent, MODERATION_BLOCK_STATE_APPLIED_MARKERS)) {
906
+ findings.push(finding(`${platform}.moderation.block-or-mute-state-applied`, "warning", "Moderation code is present but no block/mute state application was detected.", relativeFile(root, moderationFiles[0]), "After block/mute SDK call, update the UI to reflect the new state (hide content or show placeholder)."));
907
+ }
908
+ // hidden-content-rendering-present: require rendering logic for hidden content
909
+ if (!containsAny(sourceContent, MODERATION_HIDDEN_RENDERING_MARKERS)) {
910
+ findings.push(finding(`${platform}.moderation.hidden-content-rendering-present`, "warning", "Moderation code is present but no hidden/blocked content rendering logic was detected.", relativeFile(root, moderationFiles[0]), "Add rendering logic for hidden/blocked content (placeholder, removed indicator, or blur)."));
911
+ }
912
+ // confirmation-ux-present: require confirmation for destructive moderation actions
913
+ if (!containsAny(sourceContent, MODERATION_CONFIRMATION_MARKERS)) {
914
+ findings.push(finding(`${platform}.moderation.confirmation-ux-present`, "warning", "Destructive moderation actions found without confirmation UX.", relativeFile(root, moderationFiles[0]), "Add a confirmation step (dialog/alert/modal) before submitting destructive moderation actions."));
915
+ }
916
+ return findings;
917
+ }
918
+ // --- Chat validators ---
919
+ const CHAT_PRESENCE_PATTERNS = [
920
+ /\bsendMessage\b/,
921
+ /\bcreateMessage\b/,
922
+ /\bAmityMessageRepository\b/,
923
+ /\bAmityChannelRepository\b/,
924
+ /\bMessageRepository\b/,
925
+ /\bChannelRepository\b/,
926
+ /\bchatClient\b/i,
927
+ /\bmessage\s*live\s*collection\b/i,
928
+ ];
929
+ const CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
930
+ typescript: [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
931
+ "react-native": [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
932
+ android: [/\bonCleared\b/, /\bonDestroy\b/, /\bdispose\b/, /\bremoveObserver\b/],
933
+ flutter: [/\bdispose\b/, /\bcancel\b/, /\bclose\b/],
934
+ ios: [/\bdeinit\b/, /\bonDisappear\b/, /\binvalidate\b/, /\bremoveObserver\b/],
935
+ };
936
+ function validateChat(root, platform, sourceContent) {
937
+ const findings = [];
938
+ const chatFiles = filesMatching(sourceContent, CHAT_PRESENCE_PATTERNS);
939
+ if (chatFiles.length === 0)
940
+ return findings;
941
+ // channel-target-resolved: check for hardcoded channelId/conversationId
942
+ for (const filePath of chatFiles) {
943
+ const content = sourceContent.get(filePath) ?? "";
944
+ const hardcodedChannel = /(?:channelId|conversationId|channel_id)\b[^=\n]*=\s*["'`][a-z0-9-]+["'`]/i.exec(content);
945
+ if (hardcodedChannel) {
946
+ findings.push(finding(`${platform}.chat.channel-target-resolved`, "error", "Chat code references a hardcoded channelId or conversationId.", relativeFile(root, filePath), "Resolve the channel from user selection, SDK query, or app routing — never hardcode."));
947
+ break;
948
+ }
949
+ }
950
+ // message-observer-cleanup: require cleanup
951
+ const chatCleanupMarkers = CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
952
+ if (!containsAny(sourceContent, chatCleanupMarkers)) {
953
+ findings.push(finding(`${platform}.chat.message-observer-cleanup`, "warning", "Chat message observers were found but no obvious cleanup was detected.", relativeFile(root, chatFiles[0]), "Unsubscribe from message live collections on component unmount or view disposal."));
954
+ }
955
+ // send-error-handling: require error handling around sendMessage (scoped to chat files)
956
+ const chatFileContent = new Map();
957
+ for (const f of chatFiles)
958
+ chatFileContent.set(f, sourceContent.get(f) ?? "");
959
+ const hasErrorHandling = containsAny(chatFileContent, [
960
+ /\bcatch\b/,
961
+ /\b\.catch\b/,
962
+ /\bcatchError\b/,
963
+ /\bonError\b/,
964
+ /\bfailure\b/i,
965
+ /\berror\s*:/,
966
+ /\btry\b/,
967
+ ]);
968
+ if (!hasErrorHandling) {
969
+ findings.push(finding(`${platform}.chat.send-error-handling`, "warning", "Message send calls found without error handling.", relativeFile(root, chatFiles[0]), "Wrap sendMessage in try/catch or handle the error callback to show send failure state."));
970
+ }
971
+ // moderation-affordance-present: require moderation on chat messages (scoped to chat files)
972
+ if (!containsAny(chatFileContent, MODERATION_MARKERS)) {
973
+ findings.push(finding(`${platform}.chat.moderation-affordance-present`, "warning", "Chat messages are user-generated content but no moderation affordance (report/block/hide) was detected.", relativeFile(root, chatFiles[0]), "Add a report/block/hide action on chat messages."));
974
+ }
975
+ return findings;
976
+ }
977
+ // ─── Session Handler Retention ────────────────────────────────────────────────
978
+ const SESSION_HANDLER_PRESENCE = {
979
+ android: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
980
+ flutter: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
981
+ ios: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
982
+ typescript: [/\bsessionWillRenewAccessToken\b/],
983
+ "react-native": [/\bsessionWillRenewAccessToken\b/],
984
+ };
985
+ const SESSION_HANDLER_BAD_PATTERNS = {
986
+ android: /fun\s+\w+\s*\([^)]*\)\s*\{[\s\S]{0,200}?\b(?:val|var)\s+\w+\s*=\s*(?:object\s*:\s*)?(?:Amity)?SessionHandler\b/,
987
+ flutter: /(?:void|Future(?:<[^>]+>)?)\s+\w+\s*\([^)]*\)\s*(?:async\s*)?\{[\s\S]{0,200}?\b(?:final|var|(?:Amity)?SessionHandler)\s+\w+\s*=\s*\w*SessionHandler\b/,
988
+ ios: /func\s+\w+\s*\([^)]*\)\s*\{[\s\S]{0,200}?\b(?:let|var)\s+\w+\s*=\s*\w*SessionHandler\b/,
989
+ typescript: /(?:function\s+\w+\s*\([^)]*\)|const\s+\w+\s*=\s*\([^)]*\)\s*=>)\s*\{[\s\S]{0,200}?\b(?:const|let|var)\s+\w+\s*=\s*\{[\s\S]{0,200}?\bsessionWillRenewAccessToken\b/,
990
+ "react-native": /(?:function\s+\w+\s*\([^)]*\)|const\s+\w+\s*=\s*\([^)]*\)\s*=>)\s*\{[\s\S]{0,200}?\b(?:const|let|var)\s+\w+\s*=\s*\{[\s\S]{0,200}?\bsessionWillRenewAccessToken\b/,
991
+ };
992
+ const SESSION_HANDLER_GOOD_PATTERNS = {
993
+ android: [/private\s+(?:val|var)\s+\w+\s*=\s*(?:object\s*:\s*)?(?:Amity)?SessionHandler/, /\/\/\s*vise:\s*handler retained/],
994
+ flutter: [/(?:final|late)\s+(?:Amity)?SessionHandler\s+\w+/, /\/\/\s*vise:\s*handler retained/],
995
+ ios: [/(?:private|public|internal|fileprivate|static|class)\s+(?:let|var)\s+\w+\s*(?::\s*(?:Amity)?SessionHandler)?\s*=\s*\w*SessionHandler/, /\/\/\s*vise:\s*handler retained/],
996
+ typescript: [/\buseRef\b/, /export\s+(?:const|let|var)\s+\w+\s*=\s*\{/, /\/\/\s*vise:\s*handler retained/],
997
+ "react-native": [/\buseRef\b/, /export\s+(?:const|let|var)\s+\w+\s*=\s*\{/, /\/\/\s*vise:\s*handler retained/],
998
+ };
999
+ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
1000
+ const findings = [];
1001
+ const ruleId = `${platform}.live-collection.api-mismatch`;
1002
+ const LIST_QUERY_PATTERNS = [
1003
+ /\bgetPosts\b/,
1004
+ /\bqueryPosts\b/,
1005
+ /\bgetCommunityFeed\b/,
1006
+ /\bgetGlobalFeed\b/,
1007
+ /\bgetComments\b/,
1008
+ /\bgetChannels\b/
1009
+ ];
1010
+ const LIST_UI_PATTERNS = [
1011
+ /\bmap\s*\(/,
1012
+ /\bFlatList\b/,
1013
+ /\bListView\b/,
1014
+ /\bRecyclerView\b/,
1015
+ /\bLazyColumn\b/,
1016
+ /\bForEach\b/,
1017
+ /\bList\s*\(/,
1018
+ /\bsubmitList\b/,
1019
+ /\btableView\b/,
1020
+ /\bcollectionView\b/
1021
+ ];
1022
+ const REACTIVE_MARKERS = [
1023
+ /\bgetLiveCollection\b/,
1024
+ /\bobserve\b/,
1025
+ /\bobserveOnce\b/,
1026
+ /\blisten\b/,
1027
+ /\bonData\b/,
1028
+ /\bStreamBuilder\b/,
1029
+ /\bLiveData\b/,
1030
+ /Amity\w*Flow\b/,
1031
+ /\bPagingData\b/,
1032
+ /\/\/\s*vise:\s*one-shot query/i
1033
+ ];
1034
+ for (const [file, content] of sourceContent) {
1035
+ const rel = relativeFile(root, file);
1036
+ const hasListQuery = LIST_QUERY_PATTERNS.some((p) => p.test(content));
1037
+ if (hasListQuery) {
1038
+ const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
1039
+ if (hasListUI) {
1040
+ const hasReactivity = REACTIVE_MARKERS.some((p) => p.test(content));
1041
+ 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."));
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ return findings;
1048
+ }
1049
+ function validatePostsStatusFilter(root, platform, sourceContent) {
1050
+ const findings = [];
1051
+ const ruleId = `${platform}.posts.status-filter-applied`;
1052
+ const POST_QUERY_PATTERNS = [
1053
+ /\bgetPosts\b/,
1054
+ /\bqueryPosts\b/,
1055
+ /\bgetCommunityFeed\b/
1056
+ ];
1057
+ const FILTER_MARKERS = [
1058
+ /\bfeedTypes?\b/,
1059
+ /\bincludeDeleted\s*\(\s*false\s*\)/,
1060
+ /\bisDeleted\s*:\s*false\b/,
1061
+ /\bisFlagged\s*:\s*false\b/,
1062
+ /\bstatuses\b/,
1063
+ /\bif\s*\([^)]*isDeleted\)/,
1064
+ /\bfilter\s*\([^)]*isDeleted\)/,
1065
+ /\bif\s*\([^)]*isFlagged\)/,
1066
+ /\bfilter\s*\([^)]*isFlagged\)/,
1067
+ /\/\/\s*vise:\s*moderation review feed/i
1068
+ ];
1069
+ for (const [file, content] of sourceContent) {
1070
+ const rel = relativeFile(root, file);
1071
+ const hasPostQuery = POST_QUERY_PATTERNS.some((p) => p.test(content));
1072
+ if (hasPostQuery) {
1073
+ const hasFilter = FILTER_MARKERS.some((p) => p.test(content));
1074
+ if (!hasFilter) {
1075
+ findings.push(finding(ruleId, "warning", `A post query (like getPosts) is present but no status filters (includeDeleted, statuses, feedType) were found.`, rel, "Unfiltered queries leak deleted or flagged posts to the feed. Apply a status filter."));
1076
+ }
1077
+ }
1078
+ }
1079
+ return findings;
1080
+ }
1081
+ function validatePaginationCursorOpaque(root, platform, sourceContent) {
1082
+ const findings = [];
1083
+ const ruleId = `${platform}.pagination.cursor-opaque`;
1084
+ const ARITHMETIC_PAGINATION_PATTERNS = [
1085
+ /nextPage\s*[:=]\s*\d+/,
1086
+ /pageToken\s*[:=]\s*\d+/,
1087
+ /cursor\s*[:=]\s*\d+/,
1088
+ /\bnextPage\s*\(\s*\d+\s*\)/,
1089
+ /\bpageToken\s*\(\s*\d+\s*\)/,
1090
+ /\bcursor\s*\(\s*\d+\s*\)/,
1091
+ /nextPage\s*[:=]\s*\w+\s*[\*\+]\s*\w+/,
1092
+ /pageToken\s*[:=]\s*\w+\s*[\*\+]\s*\w+/,
1093
+ /cursor\s*[:=]\s*\w+\s*[\*\+]\s*\w+/,
1094
+ /\bnextPage\s*\(\s*\w+\s*[\*\+]\s*\w+\s*\)/,
1095
+ /\bpageToken\s*\(\s*\w+\s*[\*\+]\s*\w+\s*\)/,
1096
+ /\bcursor\s*\(\s*\w+\s*[\*\+]\s*\w+\s*\)/,
1097
+ /nextPage\s*[:=]\s*.*\.toString\(\)/i,
1098
+ /pageToken\s*[:=]\s*.*\.toString\(\)/i,
1099
+ /cursor\s*[:=]\s*.*\.toString\(\)/i,
1100
+ /nextPage\s*[:=]\s*String\(/i,
1101
+ /pageToken\s*[:=]\s*String\(/i,
1102
+ /cursor\s*[:=]\s*String\(/i
1103
+ ];
1104
+ for (const [filename, text] of sourceContent) {
1105
+ const rel = relativeFile(root, filename);
1106
+ for (const pattern of ARITHMETIC_PAGINATION_PATTERNS) {
1107
+ if (pattern.test(text)) {
1108
+ findings.push(finding(ruleId, "warning", `Pagination cursor appears to be constructed using arithmetic or numeric stringification.`, rel, "Amity uses opaque cursor tokens. Pass the 'nextPage' or 'nextPageToken' from the previous result directly, or use the collection's loadMore() method."));
1109
+ break;
1110
+ }
1111
+ }
1112
+ }
1113
+ return findings;
1114
+ }
1115
+ function validatePostsParentChild(root, platform, sourceContent) {
1116
+ const findings = [];
1117
+ const ruleId = `${platform}.posts.parent-child-rendered`;
1118
+ const POST_RENDER_PATTERNS = [
1119
+ /\.data\.text\b/i,
1120
+ /\bpost\.data\b/i,
1121
+ /\bpost\.text\b/i
1122
+ ];
1123
+ const CHILD_REFERENCE_PATTERNS = [
1124
+ /\.\s*children\b/,
1125
+ /\.\s*childrenPosts\b/,
1126
+ /\bgetChildren\b/
1127
+ ];
1128
+ for (const [filename, text] of sourceContent) {
1129
+ 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()."));
1135
+ }
1136
+ }
1137
+ }
1138
+ return findings;
1139
+ }
1140
+ function validateSessionHandlerRetention(root, platform, sourceContent) {
1141
+ const presenceMarkers = SESSION_HANDLER_PRESENCE[platform] ?? SESSION_HANDLER_PRESENCE.typescript;
1142
+ const handlerFiles = filesMatching(sourceContent, presenceMarkers);
1143
+ if (handlerFiles.length === 0)
1144
+ return [];
1145
+ const badPattern = SESSION_HANDLER_BAD_PATTERNS[platform];
1146
+ const goodPatterns = SESSION_HANDLER_GOOD_PATTERNS[platform] ?? SESSION_HANDLER_GOOD_PATTERNS.typescript;
1147
+ for (const file of handlerFiles) {
1148
+ const content = sourceContent.get(file) ?? "";
1149
+ if (badPattern && badPattern.test(content)) {
1150
+ if (containsAny(new Map([[file, content]]), goodPatterns)) {
1151
+ continue;
1152
+ }
1153
+ return [
1154
+ finding(`${platform}.session-handler.retained`, "warning", "Session handler appears to be declared as a function-local variable, making it eligible for garbage collection. The SDK's access token renewal callback will fail to fire, causing sessions to silently expire.", relativeFile(root, file), "Declare the session handler as a class-level property, a module-scoped variable, or retain it via a state container so it outlives the setup function. Add `// vise: handler retained` if it is explicitly assigned to a long-lived store.")
1155
+ ];
1156
+ }
1157
+ }
1158
+ return [];
1159
+ }
462
1160
  const LOGGING_CALL_PATTERNS_BY_PLATFORM = {
463
1161
  android: /\b(?:Log\.[a-z]\s*\(|println\s*\(|print\s*\()/,
464
1162
  flutter: /\b(?:print\s*\(|debugPrint\s*\(|log\s*\()/,
@@ -478,12 +1176,31 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
478
1176
  findings.push(finding(`${platform}.feed.target.literal`, "warning", `A hardcoded feed target literal was found: ${feedTarget.name}.`, relativeFile(root, feedTarget.file), "Do not invent or hardcode communityId, targetId, feedId, or channelId. Ask the user for the target or use an existing app-owned selection/create flow."));
479
1177
  }
480
1178
  const inlineApiKey = firstLiteralAssignment(sourceContent, [
1179
+ // Direct literal assignment — apiKey: "literal" or apiKey = "literal".
481
1180
  /\bapiKey\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
482
1181
  /\bapi_key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
483
1182
  /\bapi-key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
1183
+ // Env-fallback secret leak patterns — the literal is on the RHS of an
1184
+ // env-lookup fallback, not directly assigned to apiKey. The v0.7 §14
1185
+ // Antigravity benchmark surfaced this gap: Dart's
1186
+ // `apiKey = String.fromEnvironment(KEY, defaultValue: 'literal')` and
1187
+ // JS/TS's `apiKey: process.env.X ?? 'literal'` both shipped a hardcoded
1188
+ // key while the original regex (which requires the literal immediately
1189
+ // after `apiKey [:=]`) saw `String.fromEnvironment(` or `process.env.`
1190
+ // first and missed the literal. The patterns below explicitly walk
1191
+ // through the env-lookup wrapper to the literal default.
1192
+ //
1193
+ // Dart: `defaultValue:` form inside String.fromEnvironment. Uses
1194
+ // non-greedy any-char so the regex can bridge a multi-line argument
1195
+ // list (real Dart code commonly puts each named arg on its own line).
1196
+ /\bapi[-_]?key\b\s*[:=][\s\S]{0,200}?defaultValue\s*:\s*["'`]([^"'`]+)["'`]/i,
1197
+ // JS/TS: process.env.X ?? 'literal' or process.env.X || 'literal'.
1198
+ /\bapi[-_]?key\b\s*[:=][\s\S]{0,200}?(?:process\.env\.[A-Z0-9_]+|import\.meta\.env\.[A-Z0-9_]+)\s*(?:\?\?|\|\|)\s*["'`]([^"'`]+)["'`]/i,
1199
+ // Ternary fallback: `apiKey = X ? 'literal' : ...` captures the truthy branch.
1200
+ /\bapi[-_]?key\b\s*[:=][\s\S]{0,200}?\?\s*["'`]([^"'`]+)["'`]\s*:/i,
484
1201
  ]);
485
1202
  if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
486
- findings.push(finding(`${platform}.secret.inline-api-key`, "warning", "A social.plus API key appears to be hardcoded in source.", relativeFile(root, inlineApiKey.file), "Use the host app's environment/config pattern instead of committing API keys directly into source files."));
1203
+ findings.push(finding(`${platform}.secret.inline-api-key`, "warning", "A social.plus API key appears to be hardcoded in source.", relativeFile(root, inlineApiKey.file), "Use the host app's environment/config pattern instead of committing API keys directly into source files. The literal is still committed even when wrapped in an env-fallback (e.g. `defaultValue:`, `??`, `||`, ternary)."));
487
1204
  }
488
1205
  // Hardcoded user identity — every benchmark Pure MCP failure mode included
489
1206
  // `userId: "current-user"` or similar. The user identity should come from
@@ -497,6 +1214,135 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
497
1214
  if (literalUserId && !isAllowedPlaceholder(literalUserId.value)) {
498
1215
  findings.push(finding(`${platform}.auth.no-literal-user-id`, "warning", `A hardcoded user identity literal was found: ${literalUserId.name}.`, relativeFile(root, literalUserId.file), "Do not hardcode a userId in source. Read the authenticated user from the host app's auth state (current session, route param, user-store hook, etc.)."));
499
1216
  }
1217
+ // AST pass — catches indirect literals via identifier resolution.
1218
+ // Runs for TypeScript/TSX files. Covers: auth.no-literal-user-id,
1219
+ // secret.inline-api-key, and feed.target.literal.
1220
+ if (platform === "typescript" || platform === "react-native") {
1221
+ for (const [file, content] of sourceContent) {
1222
+ if (!file.endsWith(".ts") && !file.endsWith(".tsx"))
1223
+ continue;
1224
+ const rel = relativeFile(root, file);
1225
+ const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
1226
+ const tree = parse(lang, content);
1227
+ // ── auth.no-literal-user-id via .login({ userId: CONST }) ──
1228
+ const userIdRule = `${platform}.auth.no-literal-user-id`;
1229
+ if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
1230
+ const loginCalls = findCallExpressions(tree, /\.login\b/);
1231
+ for (const call of loginCalls) {
1232
+ const objectArg = call.args[0];
1233
+ if (!objectArg)
1234
+ continue;
1235
+ const userIdNode = pickObjectProperty(objectArg, "userId");
1236
+ if (!userIdNode)
1237
+ continue;
1238
+ const value = resolveLiteralValue(userIdNode, tree);
1239
+ if (value && !isAllowedPlaceholder(value)) {
1240
+ findings.push(finding(userIdRule, "warning", `A hardcoded user identity was resolved from identifier "${userIdNode.text}" → "${value}".`, rel, "Do not hardcode a userId in source, including via local constants. Read the authenticated user from the host app's auth state."));
1241
+ break;
1242
+ }
1243
+ }
1244
+ }
1245
+ // ── secret.inline-api-key via createClient({ apiKey: CONST }) or createClient(CONST, ...) ──
1246
+ const secretRule = `${platform}.secret.inline-api-key`;
1247
+ if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
1248
+ const createCalls = findCallExpressions(tree, /\bcreateClient\b/);
1249
+ for (const call of createCalls) {
1250
+ // Object form: createClient({ apiKey: CONST })
1251
+ const firstArg = call.args[0];
1252
+ if (!firstArg)
1253
+ continue;
1254
+ let apiKeyNode = pickObjectProperty(firstArg, "apiKey");
1255
+ // Positional form: createClient(KEY, region)
1256
+ if (!apiKeyNode && firstArg.type !== "object") {
1257
+ apiKeyNode = firstArg;
1258
+ }
1259
+ if (!apiKeyNode)
1260
+ continue;
1261
+ const value = resolveLiteralValue(apiKeyNode, tree);
1262
+ if (value && !isAllowedPlaceholder(value)) {
1263
+ findings.push(finding(secretRule, "warning", `A hardcoded API key was resolved from identifier "${apiKeyNode.text}" → "${value}".`, rel, "Use the host app's environment/config pattern instead of committing API keys via local constants."));
1264
+ break;
1265
+ }
1266
+ }
1267
+ }
1268
+ // ── feed.target.literal via getPosts({ targetId: CONST }) or similar ──
1269
+ const feedRule = `${platform}.feed.target.literal`;
1270
+ if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
1271
+ const feedCalls = findCallExpressions(tree, /\bgetPosts\b|\bgetComments\b|\bcreatePost\b/);
1272
+ for (const call of feedCalls) {
1273
+ const objectArg = call.args[0];
1274
+ if (!objectArg)
1275
+ continue;
1276
+ for (const prop of ["targetId", "communityId", "feedId", "channelId"]) {
1277
+ const node = pickObjectProperty(objectArg, prop);
1278
+ if (!node)
1279
+ continue;
1280
+ const value = resolveLiteralValue(node, tree);
1281
+ if (value && !isAllowedPlaceholder(value)) {
1282
+ findings.push(finding(feedRule, "warning", `A hardcoded feed target was resolved from identifier "${node.text}" → "${value}".`, rel, "Do not hardcode communityId, targetId, feedId, or channelId via local constants. Ask the user for the target."));
1283
+ break;
1284
+ }
1285
+ }
1286
+ if (findings.some((f) => f.ruleId === feedRule && f.file === rel))
1287
+ break;
1288
+ }
1289
+ }
1290
+ }
1291
+ }
1292
+ // AST pass for Kotlin/Android — catches indirect literals via identifier resolution.
1293
+ if (platform === "android") {
1294
+ for (const [file, content] of sourceContent) {
1295
+ if (!file.endsWith(".kt"))
1296
+ continue;
1297
+ const rel = relativeFile(root, file);
1298
+ const tree = parse("kotlin", content);
1299
+ // ── auth.no-literal-user-id via .login(CONST) ──
1300
+ const userIdRule = `${platform}.auth.no-literal-user-id`;
1301
+ if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
1302
+ const loginCalls = findCallExpressions(tree, /\.login$/);
1303
+ for (const call of loginCalls) {
1304
+ const firstArg = call.args[0];
1305
+ if (!firstArg)
1306
+ continue;
1307
+ const value = resolveLiteralValue(firstArg, tree);
1308
+ if (value && !isAllowedPlaceholder(value)) {
1309
+ findings.push(finding(userIdRule, "warning", `A hardcoded user identity was resolved from identifier "${firstArg.text}" → "${value}".`, rel, "Do not hardcode a userId in source, including via local constants. Read the authenticated user from the host app's auth state."));
1310
+ break;
1311
+ }
1312
+ }
1313
+ }
1314
+ // ── secret.inline-api-key via .setup(CONST) ──
1315
+ const secretRule = `${platform}.secret.inline-api-key`;
1316
+ if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
1317
+ const setupCalls = findCallExpressions(tree, /\.setup$/);
1318
+ for (const call of setupCalls) {
1319
+ const firstArg = call.args[0];
1320
+ if (!firstArg)
1321
+ continue;
1322
+ const value = resolveLiteralValue(firstArg, tree);
1323
+ if (value && !isAllowedPlaceholder(value)) {
1324
+ findings.push(finding(secretRule, "warning", `A hardcoded API key was resolved from identifier "${firstArg.text}" → "${value}".`, rel, "Use the host app's environment/config pattern (BuildConfig, secrets.properties) instead of committing API keys via local constants."));
1325
+ break;
1326
+ }
1327
+ }
1328
+ }
1329
+ // ── feed.target.literal via .targetCommunity(CONST) / .targetUser(CONST) ──
1330
+ const feedRule = `${platform}.feed.target.literal`;
1331
+ if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
1332
+ const feedCalls = findCallExpressions(tree, /targetCommunity$|targetUser$|targetFeed$/);
1333
+ for (const call of feedCalls) {
1334
+ const firstArg = call.args[0];
1335
+ if (!firstArg)
1336
+ continue;
1337
+ const value = resolveLiteralValue(firstArg, tree);
1338
+ if (value && !isAllowedPlaceholder(value)) {
1339
+ findings.push(finding(feedRule, "warning", `A hardcoded feed target was resolved from identifier "${firstArg.text}" → "${value}".`, rel, "Do not hardcode feed targets via local constants. Ask the user for the target or derive it from app state."));
1340
+ break;
1341
+ }
1342
+ }
1343
+ }
1344
+ }
1345
+ }
500
1346
  return findings;
501
1347
  }
502
1348
  function firstLiteralAssignment(contents, patterns) {
@@ -519,6 +1365,11 @@ function isAllowedPlaceholder(value) {
519
1365
  "api-key",
520
1366
  "your-api-key",
521
1367
  "your_api_key",
1368
+ "your-amity-api-key",
1369
+ "your_amity_api_key",
1370
+ "placeholder",
1371
+ "placeholder-api-key",
1372
+ "placeholder_api_key",
522
1373
  "provided-by-customer",
523
1374
  "customer-provided",
524
1375
  "replace-me",
@@ -639,12 +1490,47 @@ async function validateIos(root) {
639
1490
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
640
1491
  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."));
641
1492
  }
1493
+ // iOS push permission: if push registration exists, require UNUserNotificationCenter.requestAuthorization
1494
+ if (pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/requestAuthorization/, /UNUserNotificationCenter/])) {
1495
+ findings.push(finding("ios.push.permission-prompt-before-register", "warning", "Push registration was found but no UNUserNotificationCenter.requestAuthorization call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Request push permission from the user via UNUserNotificationCenter before registering for remote notifications."));
1496
+ }
1497
+ // iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
1498
+ // require push-specific SDK forwarding marker.
1499
+ 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/])) {
1501
+ 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
+ }
642
1503
  if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
643
1504
  findings.push(finding("ios.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Keep the notification token and invalidate or release it when the view/controller lifecycle ends."));
644
1505
  }
645
1506
  findings.push(...validateLiteralGuardrails(root, "ios", swiftContent));
646
1507
  findings.push(...validateLoggingHygiene(root, "ios", swiftContent));
647
1508
  findings.push(...validateFeedUiStates(root, "ios", swiftContent));
1509
+ findings.push(...validateFeedPagination(root, "ios", swiftContent));
1510
+ findings.push(...validateFeedModerationAffordance(root, "ios", swiftContent));
1511
+ findings.push(...validateLogoutOnUserSwitch(root, "ios", swiftContent));
1512
+ findings.push(...validateNoAnonymousWrite(root, "ios", swiftContent));
1513
+ findings.push(...validateErrorHandling(root, "ios", swiftContent));
1514
+ findings.push(...validateComments(root, "ios", swiftContent));
1515
+ findings.push(...validateModeration(root, "ios", swiftContent));
1516
+ findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
1517
+ findings.push(...validatePostsStatusFilter(root, "ios", swiftContent));
1518
+ findings.push(...validatePaginationCursorOpaque(root, "ios", swiftContent));
1519
+ findings.push(...validatePostsParentChild(root, "ios", swiftContent));
1520
+ findings.push(...validateFeedTargetTypeExplicit(root, "ios", swiftContent));
1521
+ findings.push(...validateChannelTypeMatchesShape(root, "ios", swiftContent));
1522
+ findings.push(...validateReactionConfiguredNameUsed(root, "ios", swiftContent));
1523
+ findings.push(...validateCustomPostTypeDataTypeDeclared(root, "ios", swiftContent));
1524
+ findings.push(...validateModerationRoleGatedAction(root, "ios", swiftContent));
1525
+ findings.push(...validateFlagCountNotLeaked(root, "ios", swiftContent));
1526
+ findings.push(...validateUserBanStateRespected(root, "ios", swiftContent));
1527
+ findings.push(...validateNotificationsPreferencesConfigured(root, "ios", swiftContent));
1528
+ findings.push(...validateUnreadSubscribedNotCounted(root, "ios", swiftContent));
1529
+ findings.push(...validateFileUploadViaAmityFileClient(root, "ios", swiftContent));
1530
+ findings.push(...validateImagePostChildResolutionAwaited(root, "ios", swiftContent));
1531
+ findings.push(...validateCommentReferenceTypeEnum(root, "ios", swiftContent));
1532
+ findings.push(...validateSessionHandlerRetention(root, "ios", swiftContent));
1533
+ findings.push(...validateChat(root, "ios", swiftContent));
648
1534
  findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
649
1535
  return findings;
650
1536
  }
@@ -906,3 +1792,314 @@ function containsAnyForFiles(contents, files, patterns) {
906
1792
  function relativeFile(root, file) {
907
1793
  return file ? path.relative(root, file) : undefined;
908
1794
  }
1795
+ /**
1796
+ * Map a source file path + platform to the AST Language type (if supported).
1797
+ * Returns undefined for languages without tree-sitter support (Dart, Swift).
1798
+ */
1799
+ function astLanguageForFile(filePath, platform) {
1800
+ const ext = path.extname(filePath).toLowerCase();
1801
+ if (ext === ".ts")
1802
+ return "typescript";
1803
+ if (ext === ".tsx")
1804
+ return "tsx";
1805
+ if (ext === ".kt" || ext === ".kts")
1806
+ return "kotlin";
1807
+ // Platforms that use TS/TSX but file doesn't have extension — infer from platform
1808
+ if ((platform === "typescript" || platform === "react-native") && (ext === ".js" || ext === ".jsx")) {
1809
+ return "typescript";
1810
+ }
1811
+ return undefined;
1812
+ }
1813
+ function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
1814
+ const findings = [];
1815
+ const ruleId = `${platform}.comment.reference-type-enum`;
1816
+ const REFERENCE_TYPE_PATTERNS = [
1817
+ /referenceType\s*[:=\(]\s*(['"][^'"]+['"])/i
1818
+ ];
1819
+ for (const [filename, text] of sourceContent) {
1820
+ const rel = relativeFile(root, filename);
1821
+ if (/\/\/\s*vise:\s*reference-type rationale/i.test(text)) {
1822
+ continue;
1823
+ }
1824
+ const hasCreateComment = /\bcreateComment\b/.test(text);
1825
+ if (!hasCreateComment)
1826
+ continue;
1827
+ for (const pattern of REFERENCE_TYPE_PATTERNS) {
1828
+ if (pattern.test(text)) {
1829
+ findings.push(finding(ruleId, "warning", `A createComment call appears to hardcode referenceType to a raw string instead of an enum.`, rel, `Comment reference types should be strongly typed using the SDK enums (e.g. AmityCommentReferenceType.POST) to prevent casing errors. If this is intentional, add comment // vise: reference-type rationale — <reason>.`));
1830
+ break; // one per file is enough for this rule
1831
+ }
1832
+ }
1833
+ }
1834
+ return findings;
1835
+ }
1836
+ function validateChannelTypeMatchesShape(root, platform, sourceContent) {
1837
+ const findings = [];
1838
+ const ruleId = `${platform}.channel.type-matches-shape`;
1839
+ for (const [filename, text] of sourceContent) {
1840
+ const rel = path.relative(root, filename);
1841
+ if (/\/\/\s*vise:\s*channel type rationale/i.test(text)) {
1842
+ continue;
1843
+ }
1844
+ const hasCreateChannel = /\bcreateChannel\b/.test(text);
1845
+ if (!hasCreateChannel)
1846
+ continue;
1847
+ const lowerName = (rel || filename).toLowerCase();
1848
+ const isDirectIntent = lowerName.includes('direct') || lowerName.includes('dm') || lowerName.includes('1on1') || lowerName.includes('conversation') || lowerName.includes('private-chat') || lowerName.includes('privatechat');
1849
+ const isBroadcastIntent = lowerName.includes('broadcast') || lowerName.includes('announcement');
1850
+ if (isDirectIntent) {
1851
+ if (/(?:channel)?type\s*[:=\(]\s*(?:AmityChannelType\.)?(?:\.)?(?:COMMUNITY|LIVE|community|live|"community"|"live"|'community'|'live')/i.test(text)) {
1852
+ findings.push(finding(ruleId, "warning", `File ${rel} implies a 1:1 conversation but creates a community/live channel.`, rel, `Using 'community' for 1:1 chat breaks conversation semantics. Use 'conversation' instead. If this is intentional, add comment // vise: channel type rationale — <reason>.`));
1853
+ }
1854
+ }
1855
+ else if (isBroadcastIntent) {
1856
+ if (/(?:channel)?type\s*[:=\(]\s*(?:AmityChannelType\.)?(?:CONVERSATION|COMMUNITY|conversation|community|"conversation"|"community"|'conversation'|'community')/i.test(text)) {
1857
+ findings.push(finding(ruleId, "warning", `File ${rel} implies a broadcast channel but creates a conversation/community channel.`, rel, `Using 'conversation'/'community' for broadcast chat breaks broadcast semantics. Use 'broadcast' instead. If this is intentional, add comment // vise: channel type rationale — <reason>.`));
1858
+ }
1859
+ }
1860
+ }
1861
+ return findings;
1862
+ }
1863
+ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
1864
+ const findings = [];
1865
+ const ruleId = `${platform}.reactions.configured-name-used`;
1866
+ for (const [filename, text] of sourceContent) {
1867
+ const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1868
+ if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
1869
+ continue;
1870
+ }
1871
+ if (/\b(addReaction|removeReaction|flagReaction|react)\s*\(\s*['"`][^'"`]+['"`]/i.test(text)) {
1872
+ 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
+ }
1874
+ }
1875
+ return findings;
1876
+ }
1877
+ function validateImagePostChildResolutionAwaited(root, platform, sourceContent) {
1878
+ const findings = [];
1879
+ const ruleId = platform + '.image-post.child-resolution-awaited';
1880
+ for (const [filename, text] of sourceContent) {
1881
+ const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1882
+ // Look for createPost and immediate render/setState/display
1883
+ if (/createPost\s*\(/.test(text) &&
1884
+ /(?:render|setState|display)\s*\(/.test(text)) {
1885
+ const hasResolution = /whenComplete/.test(text) ||
1886
+ /whenReady/.test(text) ||
1887
+ /childrenPosts.*await/.test(text) ||
1888
+ /getPost/.test(text) ||
1889
+ /\.observe/.test(text) ||
1890
+ /\/\/\s*vise:\s*child resolution handled by/.test(text);
1891
+ if (!hasResolution) {
1892
+ findings.push(finding(ruleId, "warning", "File " + rel + " renders an image post immediately after creation without awaiting child resolution.", rel, "After createPost with an image attachment, the parent post returns immediately but the image child post is still being processed server-side. Rendering the result immediately yields an empty image / broken thumbnail. You must await the child post's ready state."));
1893
+ }
1894
+ }
1895
+ }
1896
+ return findings;
1897
+ }
1898
+ function validateFileUploadViaAmityFileClient(root, platform, sourceContent) {
1899
+ const findings = [];
1900
+ const ruleId = platform + '.file-upload.via-amity-file-client';
1901
+ for (const [filename, text] of sourceContent) {
1902
+ const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1903
+ let hasExternalUrlUpload = false;
1904
+ if (platform === 'typescript' || platform === 'react-native') {
1905
+ hasExternalUrlUpload = /createPost\s*\(\s*\{[^}]*(?:attachments|data)\s*:\s*\[?\s*\{[^}]*url\s*:\s*['"`]https?:/.test(text);
1906
+ }
1907
+ else if (platform === 'android') {
1908
+ hasExternalUrlUpload = /createPost\s*\(.*attachments\s*=.*Uri\.parse/.test(text);
1909
+ }
1910
+ else if (platform === 'flutter') {
1911
+ hasExternalUrlUpload = /createPost\s*\(.*attachments\s*:.*url:\s*['"`]https?:/.test(text);
1912
+ }
1913
+ else if (platform === 'ios') {
1914
+ hasExternalUrlUpload = /createPost\s*\(.*attachments:\s*\[\.url\(URL\(string:/.test(text);
1915
+ }
1916
+ if (hasExternalUrlUpload) {
1917
+ const hasAmityFileFlow = /AmityFileClient\.(?:uploadFile|uploadImage)/.test(text) ||
1918
+ /AmityFileRepository\.uploadFile/.test(text) ||
1919
+ /newFileRepository\(\)\.uploadFile/.test(text) ||
1920
+ /AmityImageData/.test(text) ||
1921
+ /AmityVideoData/.test(text) ||
1922
+ /getFile/.test(text) ||
1923
+ /AmityFileId/.test(text) ||
1924
+ /\/\/\s*vise:\s*media upload flow/.test(text);
1925
+ if (!hasAmityFileFlow) {
1926
+ findings.push(finding(ruleId, "warning", "File " + rel + " creates a post with an external media URL directly.", rel, "Uploading media to external buckets directly and trying to attach the resulting URL to an Amity post will fail. The SDK won't recognize external URLs as attachments — media must flow through AmityFileClient/Repository to get an AmityFileId."));
1927
+ }
1928
+ }
1929
+ }
1930
+ return findings;
1931
+ }
1932
+ function validateUnreadSubscribedNotCounted(root, platform, sourceContent) {
1933
+ const findings = [];
1934
+ const ruleId = platform + '.unread.subscribed-not-counted';
1935
+ let hasManualCount = false;
1936
+ let hasSubscribedStream = false;
1937
+ let firstManualCountFile = '';
1938
+ for (const [filename, text] of sourceContent) {
1939
+ if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(text) ||
1940
+ /\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(text) ||
1941
+ /\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(text) ||
1942
+ /unreadCount\s*=\s*[^;]*\bfilter\b/.test(text)) {
1943
+ hasManualCount = true;
1944
+ if (!firstManualCountFile) {
1945
+ firstManualCountFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1946
+ }
1947
+ }
1948
+ if (/AmityCoreClient\.unreadCount/.test(text) ||
1949
+ /\.unreadCount\(\)/.test(text) ||
1950
+ /unreadCountObservable/.test(text) ||
1951
+ /getUnreadCount/.test(text) ||
1952
+ /subscribeUnread/.test(text) ||
1953
+ /unreadCount\.observe/.test(text) ||
1954
+ /unread\.stream/.test(text) ||
1955
+ /\/\/\s*vise:\s*unread sourced from/.test(text)) {
1956
+ hasSubscribedStream = true;
1957
+ }
1958
+ }
1959
+ if (hasManualCount && !hasSubscribedStream) {
1960
+ findings.push(finding(ruleId, "warning", "Project manually counts unread items instead of using Amity's unreadCount stream.", firstManualCountFile, "Amity exposes a global unreadCount stream that syncs across devices. Hand-rolling unread counting on the client causes counts to go out of sync because the server's read state isn't pushed to the manual counter."));
1961
+ }
1962
+ return findings;
1963
+ }
1964
+ function validateNotificationsPreferencesConfigured(root, platform, sourceContent) {
1965
+ const findings = [];
1966
+ const ruleId = platform + '.notifications.amity-preferences-configured';
1967
+ let hasPushRegistration = false;
1968
+ let hasPreferences = false;
1969
+ let firstPushRegistrationFile = '';
1970
+ for (const [filename, text] of sourceContent) {
1971
+ if (/\b(registerPushNotification|enablePushNotification)\b/.test(text)) {
1972
+ hasPushRegistration = true;
1973
+ if (!firstPushRegistrationFile) {
1974
+ firstPushRegistrationFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
1975
+ }
1976
+ }
1977
+ if (/\.notifications\s*\(\s*\)\.(?:getSettings|setSettings)/.test(text) ||
1978
+ /notificationRepository\.(?:getSettings|setSettings)/.test(text) ||
1979
+ /\bNotificationSettings\b/.test(text) ||
1980
+ /\/\/\s*vise:\s*notification preferences synced via/.test(text)) {
1981
+ hasPreferences = true;
1982
+ }
1983
+ }
1984
+ if (hasPushRegistration && !hasPreferences) {
1985
+ findings.push(finding(ruleId, "warning", "Project registers push token but does not handle Amity's server-side notification preferences.", firstPushRegistrationFile, "Amity has its own server-side notification preferences (mute community, mute user, granular per-event toggles) separate from FCM/APNS. Customers often register the push token and stop there, meaning notifications fire but ignore the user's preferences, leading to bug reports."));
1986
+ }
1987
+ return findings;
1988
+ }
1989
+ function validateUserBanStateRespected(root, platform, sourceContent) {
1990
+ const findings = [];
1991
+ const ruleId = platform + '.user.ban-state-respected';
1992
+ for (const [filename, text] of sourceContent) {
1993
+ 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)) {
1996
+ continue;
1997
+ }
1998
+ // Ban-state positive markers
1999
+ const hasBanCheck = /currentUser\.isGlobalBan/.test(text) ||
2000
+ /isCurrentUserBannedFromCommunity/.test(text) ||
2001
+ /community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
2002
+ /channel\.isMuted/.test(text) ||
2003
+ /member\.isMuted/.test(text) ||
2004
+ /user\.banState/.test(text) ||
2005
+ /\.isBanned/.test(text) ||
2006
+ /\/\/\s*vise:\s*ban state checked at/i.test(text);
2007
+ if (!hasBanCheck) {
2008
+ 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."));
2009
+ }
2010
+ }
2011
+ return findings;
2012
+ }
2013
+ function validateFlagCountNotLeaked(root, platform, sourceContent) {
2014
+ const findings = [];
2015
+ const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
2016
+ for (const [filename, text] of sourceContent) {
2017
+ const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2018
+ // Check if flagCount is used in the file
2019
+ if (!/\bflagCount\b/.test(text)) {
2020
+ continue;
2021
+ }
2022
+ // Role-check positive markers
2023
+ const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2024
+ /\.hasRole\s*\(.*moderator/i.test(text) ||
2025
+ /community\.hasModeratorRole/.test(text) ||
2026
+ /isCommunityModerator/.test(text) ||
2027
+ /isModerator\s*[(=]/.test(text) ||
2028
+ /member\.isModerator/.test(text) ||
2029
+ /roles\.includes\s*\(.*moderator/i.test(text) ||
2030
+ /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2031
+ /myReactions\.(?:contains|includes).*flag/i.test(text) ||
2032
+ /\/\/\s*vise:\s*flagCount visible only to moderators/i.test(text);
2033
+ if (!hasRoleCheck) {
2034
+ findings.push(finding(ruleId, "warning", "File " + rel + " references flagCount without a role check.", rel, "flagCount is moderator-only data. Rendering it to everyone leaks moderation signal. Ensure flagCount rendering is gated by a role check."));
2035
+ }
2036
+ }
2037
+ return findings;
2038
+ }
2039
+ function validateModerationRoleGatedAction(root, platform, sourceContent) {
2040
+ const findings = [];
2041
+ const ruleId = platform + '.moderation.role-gated-action';
2042
+ for (const [filename, text] of sourceContent) {
2043
+ 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)) {
2046
+ continue;
2047
+ }
2048
+ // Role-check positive markers
2049
+ const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(text) ||
2050
+ /\.hasRole\s*\(.*moderator/i.test(text) ||
2051
+ /community\.hasModeratorRole/.test(text) ||
2052
+ /isCommunityModerator/.test(text) ||
2053
+ /isModerator\s*[(=]/.test(text) ||
2054
+ /member\.isModerator/.test(text) ||
2055
+ /roles\.includes\s*\(.*moderator/i.test(text) ||
2056
+ /permissions\.(?:contains|includes).*moderation/i.test(text) ||
2057
+ /\/\/\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'))."));
2060
+ }
2061
+ }
2062
+ return findings;
2063
+ }
2064
+ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
2065
+ const findings = [];
2066
+ const ruleId = `${platform}.custom-post-type.dataType-declared`;
2067
+ for (const [filename, text] of sourceContent) {
2068
+ const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
2069
+ if (/\/\/\s*vise:\s*custom post type/i.test(text)) {
2070
+ continue;
2071
+ }
2072
+ const matches = text.match(/createPost[\s\S]{0,100}data\s*[:=(]\s*(?:\{|\w+\s*\(|\[)[^)]*\)?/gi);
2073
+ if (!matches)
2074
+ continue;
2075
+ for (const match of matches) {
2076
+ if (!/dataType\s*[:=\(]/i.test(match)) {
2077
+ 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
+ }
2079
+ }
2080
+ }
2081
+ return findings;
2082
+ }
2083
+ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
2084
+ const findings = [];
2085
+ const ruleId = `${platform}.feed.target-type-explicit`;
2086
+ const TARGET_TYPE_PATTERNS = [
2087
+ /targetType\s*[:=\(]\s*(?:co\.amity\.android\.sdk\.)?(AmityPostTargetType\.|TargetType\.|\.community|\.user|\.my_feed|\.COMMUNITY|\.USER|\.MY_FEED|"COMMUNITY"|"USER"|"MY_FEED"|'COMMUNITY'|'USER'|'MY_FEED')/i
2088
+ ];
2089
+ for (const [filename, text] of sourceContent) {
2090
+ const rel = relativeFile(root, filename);
2091
+ if (/\/\/\s*vise:\s*target-type rationale/i.test(text)) {
2092
+ continue;
2093
+ }
2094
+ const hasCreatePost = /\bcreatePost\b/.test(text);
2095
+ if (!hasCreatePost)
2096
+ continue;
2097
+ for (const pattern of TARGET_TYPE_PATTERNS) {
2098
+ if (pattern.test(text)) {
2099
+ 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
+ break;
2101
+ }
2102
+ }
2103
+ }
2104
+ return findings;
2105
+ }