@amityco/social-plus-vise 0.14.9 → 0.14.11

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.14.11 — 2026-06-05
8
+
9
+ ### Added
10
+ - **Cross-platform product validators:** rich post composer scope, chat unread visibility, chat message ordering, and profile follower/following counts now have deterministic sensors beyond Android where the bundled SDK surface supports the capability.
11
+ - **Shared expectation bindings:** TypeScript, React Native, Flutter, and iOS product gaps now report shared public expectation IDs such as `feed.rich-post-composer-scope`, `comments.thread-read-write`, `chat.unread-visible`, and `profile.social-counts` while retaining platform sensor evidence.
12
+
13
+ ### Changed
14
+ - **Availability-aware chat gating:** TypeScript and React Native unread support is now recognized from `ChannelUnread` SDK facts; message-order validation remains direct `validate_setup` only there until the SDK surface proves a message-specific sort API.
15
+ - **Plan validation IDs:** add-feed and add-chat plans use shared product expectation IDs across platforms instead of Android-only public validation IDs.
16
+
17
+ ## 0.14.10 — 2026-06-05
18
+
19
+ ### Changed
20
+ - **Shared product expectation coverage gate:** capability tests now require every shared product expectation surfaced by feed-forward availability to have either a deterministic product binding for that platform or an explicit attestation fallback.
21
+ - **Rich post rendering binding:** `feed.rich-post-rendering` now maps to the existing rich-post datatype validators across TypeScript, React Native, Android, Flutter, and iOS, so add-feed validation reports the shared product expectation while retaining platform sensor evidence.
22
+ - **Comment tray fallback honesty:** non-Android comment read/write expectations are now surfaced as attestation-needed until product-level deterministic validators are added for those platforms.
23
+
7
24
  ## 0.14.9 — 2026-06-05
8
25
 
9
26
  ### Changed
@@ -426,7 +426,7 @@ export const OPTIONAL_CAPABILITIES = [
426
426
  hint: "If the user opts into author management, show edit only for post.postedUserId === currentUserId and call PostRepository.editPost with updated text data.",
427
427
  },
428
428
  ];
429
- const SHARED_PRODUCT_EXPECTATIONS = [
429
+ export const SHARED_PRODUCT_EXPECTATIONS = [
430
430
  {
431
431
  id: "feed.rich-post-rendering",
432
432
  label: "Rich post rendering",
@@ -468,7 +468,7 @@ const SHARED_PRODUCT_EXPECTATIONS = [
468
468
  ],
469
469
  },
470
470
  ],
471
- deterministicPlatforms: ["android"],
471
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
472
472
  hint: "implement the selected rich composer paths or record an explicit text-only/rich-post scope decision",
473
473
  },
474
474
  {
@@ -493,6 +493,8 @@ const SHARED_PRODUCT_EXPECTATIONS = [
493
493
  label: "SDK unread state",
494
494
  symbols: [
495
495
  /\bunreadCount\b/i,
496
+ /\bChannelUnread\b/i,
497
+ /\bChannelUnreadInfo\b/i,
496
498
  /\bgetUnread\w*\b/i,
497
499
  /\bgetSubChannelsUnreadCount\b/i,
498
500
  /\bobserveUserUnread\b/i,
@@ -501,7 +503,7 @@ const SHARED_PRODUCT_EXPECTATIONS = [
501
503
  ],
502
504
  },
503
505
  ],
504
- deterministicPlatforms: ["android"],
506
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
505
507
  hint: "render channel-row or global unread state from the SDK, or explicitly choose an unread-free chat surface",
506
508
  },
507
509
  {
@@ -515,7 +517,7 @@ const SHARED_PRODUCT_EXPECTATIONS = [
515
517
  symbols: [/\bsortBy\b/i, /\bSortOption\b/i, /\bMessageQuerySort\b/i, /\bAmityMessageQuerySortOption\b/i],
516
518
  },
517
519
  ],
518
- deterministicPlatforms: ["android"],
520
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
519
521
  hint: "declare first-created/newest-created order in the SDK query or a clearly named UI sort so the thread cannot be reversed by defaults",
520
522
  },
521
523
  {
@@ -536,7 +538,7 @@ const SHARED_PRODUCT_EXPECTATIONS = [
536
538
  ],
537
539
  },
538
540
  ],
539
- deterministicPlatforms: ["android"],
541
+ deterministicPlatforms: ["android", "flutter", "ios", "typescript"],
540
542
  hint: "if follower/following labels are rendered, source the counts or lists from the SDK instead of placeholders",
541
543
  },
542
544
  ];
package/dist/outcomes.js CHANGED
@@ -654,14 +654,10 @@ const addFeed = {
654
654
  "feed target identified",
655
655
  "no invented communityId/targetId/feedId",
656
656
  `${platform}.feed.target.literal`,
657
- `${platform}.feed.post-datatype-handled`,
658
- ...(platform === "android"
659
- ? [
660
- "feed.rich-post-composer-scope",
661
- "comments.thread-read-write",
662
- "profile.social-counts",
663
- ]
664
- : []),
657
+ "feed.rich-post-rendering",
658
+ "feed.rich-post-composer-scope",
659
+ "comments.thread-read-write",
660
+ "profile.social-counts",
665
661
  ],
666
662
  stopConditions: (ctx) => filterStops(ctx.answers, [
667
663
  { id: "feed_target", text: "The concrete feed target is unknown; do not invent communityId, targetId, feedId, or global feed assumptions." },
@@ -1027,7 +1023,8 @@ const addChat = {
1027
1023
  `${platform}.chat.message-observer-cleanup`,
1028
1024
  `${platform}.chat.send-error-handling`,
1029
1025
  `${platform}.chat.moderation-affordance-present`,
1030
- ...(platform === "android" ? ["chat.unread-visible", "chat.message-order-explicit"] : []),
1026
+ "chat.unread-visible",
1027
+ ...(platform === "android" || platform === "flutter" || platform === "ios" ? ["chat.message-order-explicit"] : []),
1031
1028
  ],
1032
1029
  stopConditions: (ctx) => filterStops(ctx.answers, [
1033
1030
  { id: "chat_shape", text: "The chat shape is unknown; cannot implement without knowing 1:1, group, or community channel." },
@@ -1,29 +1,144 @@
1
1
  export const PRODUCT_EXPECTATION_BINDINGS = [
2
+ {
3
+ expectationId: "feed.rich-post-rendering",
4
+ sensorId: "typescript.feed.post-datatype-handled",
5
+ platform: "typescript",
6
+ },
7
+ {
8
+ expectationId: "feed.rich-post-rendering",
9
+ sensorId: "react-native.feed.post-datatype-handled",
10
+ platform: "react-native",
11
+ },
12
+ {
13
+ expectationId: "feed.rich-post-rendering",
14
+ sensorId: "android.feed.post-datatype-handled",
15
+ platform: "android",
16
+ },
17
+ {
18
+ expectationId: "feed.rich-post-rendering",
19
+ sensorId: "flutter.feed.post-datatype-handled",
20
+ platform: "flutter",
21
+ },
22
+ {
23
+ expectationId: "feed.rich-post-rendering",
24
+ sensorId: "ios.feed.post-datatype-handled",
25
+ platform: "ios",
26
+ },
27
+ {
28
+ expectationId: "feed.rich-post-composer-scope",
29
+ sensorId: "typescript.feed.rich-post-composer-surfaced",
30
+ platform: "typescript",
31
+ },
32
+ {
33
+ expectationId: "feed.rich-post-composer-scope",
34
+ sensorId: "react-native.feed.rich-post-composer-surfaced",
35
+ platform: "react-native",
36
+ },
2
37
  {
3
38
  expectationId: "feed.rich-post-composer-scope",
4
39
  sensorId: "android.feed.rich-post-composer-surfaced",
5
40
  platform: "android",
6
41
  },
42
+ {
43
+ expectationId: "feed.rich-post-composer-scope",
44
+ sensorId: "flutter.feed.rich-post-composer-surfaced",
45
+ platform: "flutter",
46
+ },
47
+ {
48
+ expectationId: "feed.rich-post-composer-scope",
49
+ sensorId: "ios.feed.rich-post-composer-surfaced",
50
+ platform: "ios",
51
+ },
52
+ {
53
+ expectationId: "comments.thread-read-write",
54
+ sensorId: "typescript.comments.creation-affordance-present",
55
+ platform: "typescript",
56
+ },
57
+ {
58
+ expectationId: "comments.thread-read-write",
59
+ sensorId: "react-native.comments.creation-affordance-present",
60
+ platform: "react-native",
61
+ },
7
62
  {
8
63
  expectationId: "comments.thread-read-write",
9
64
  sensorId: "android.comments.thread-ui-states-present",
10
65
  platform: "android",
11
66
  },
67
+ {
68
+ expectationId: "comments.thread-read-write",
69
+ sensorId: "flutter.comments.creation-affordance-present",
70
+ platform: "flutter",
71
+ },
72
+ {
73
+ expectationId: "comments.thread-read-write",
74
+ sensorId: "ios.comments.creation-affordance-present",
75
+ platform: "ios",
76
+ },
77
+ {
78
+ expectationId: "chat.unread-visible",
79
+ sensorId: "typescript.chat.unread-visible",
80
+ platform: "typescript",
81
+ },
82
+ {
83
+ expectationId: "chat.unread-visible",
84
+ sensorId: "react-native.chat.unread-visible",
85
+ platform: "react-native",
86
+ },
12
87
  {
13
88
  expectationId: "chat.unread-visible",
14
89
  sensorId: "android.chat.unread-visible",
15
90
  platform: "android",
16
91
  },
92
+ {
93
+ expectationId: "chat.unread-visible",
94
+ sensorId: "flutter.chat.unread-visible",
95
+ platform: "flutter",
96
+ },
97
+ {
98
+ expectationId: "chat.unread-visible",
99
+ sensorId: "ios.chat.unread-visible",
100
+ platform: "ios",
101
+ },
17
102
  {
18
103
  expectationId: "chat.message-order-explicit",
19
104
  sensorId: "android.chat.sort-explicit",
20
105
  platform: "android",
21
106
  },
107
+ {
108
+ expectationId: "chat.message-order-explicit",
109
+ sensorId: "flutter.chat.sort-explicit",
110
+ platform: "flutter",
111
+ },
112
+ {
113
+ expectationId: "chat.message-order-explicit",
114
+ sensorId: "ios.chat.sort-explicit",
115
+ platform: "ios",
116
+ },
117
+ {
118
+ expectationId: "profile.social-counts",
119
+ sensorId: "typescript.profile.social-counts-from-sdk",
120
+ platform: "typescript",
121
+ },
122
+ {
123
+ expectationId: "profile.social-counts",
124
+ sensorId: "react-native.profile.social-counts-from-sdk",
125
+ platform: "react-native",
126
+ },
22
127
  {
23
128
  expectationId: "profile.social-counts",
24
129
  sensorId: "android.profile.social-counts-from-sdk",
25
130
  platform: "android",
26
131
  },
132
+ {
133
+ expectationId: "profile.social-counts",
134
+ sensorId: "flutter.profile.social-counts-from-sdk",
135
+ platform: "flutter",
136
+ },
137
+ {
138
+ expectationId: "profile.social-counts",
139
+ sensorId: "ios.profile.social-counts-from-sdk",
140
+ platform: "ios",
141
+ },
27
142
  ];
28
143
  const bindingsBySensorId = new Map(PRODUCT_EXPECTATION_BINDINGS.map((binding) => [binding.sensorId, binding]));
29
144
  export function productExpectationBindingForSensor(sensorId) {
@@ -277,7 +277,7 @@ async function validateAndroid(root) {
277
277
  findings.push(...validatePostsStatusFilter(root, "android", sourceContent));
278
278
  findings.push(...validatePaginationCursorOpaque(root, "android", sourceContent));
279
279
  findings.push(...validatePostsParentChild(root, "android", sourceContent));
280
- findings.push(...validateAndroidRichPostComposerSurfaced(root, sourceContent));
280
+ findings.push(...validateRichPostComposerScope(root, "android", sourceContent));
281
281
  findings.push(...validateFeedTargetTypeExplicit(root, "android", sourceContent));
282
282
  findings.push(...validateChannelTypeMatchesShape(root, "android", sourceContent));
283
283
  findings.push(...validateReactionConfiguredNameUsed(root, "android", sourceContent));
@@ -291,9 +291,9 @@ async function validateAndroid(root) {
291
291
  findings.push(...validateImagePostChildResolutionAwaited(root, "android", sourceContent));
292
292
  findings.push(...validateCommentReferenceTypeEnum(root, "android", sourceContent));
293
293
  findings.push(...validateSessionHandlerRetention(root, "android", sourceContent));
294
- findings.push(...validateAndroidChatUnreadVisible(root, sourceContent));
295
- findings.push(...validateAndroidChatSortExplicit(root, sourceContent));
296
- findings.push(...validateAndroidProfileSocialCounts(root, sourceContent));
294
+ findings.push(...validateChatUnreadVisible(root, "android", sourceContent));
295
+ findings.push(...validateChatMessageOrderExplicit(root, "android", sourceContent));
296
+ findings.push(...validateProfileSocialCounts(root, "android", sourceContent));
297
297
  findings.push(...validateChat(root, "android", sourceContent));
298
298
  findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
299
299
  findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
@@ -371,6 +371,7 @@ async function validateFlutter(root) {
371
371
  findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
372
372
  findings.push(...validateCommentsQueryHasLimit(root, "flutter", dartContent));
373
373
  findings.push(...validateCommentCreationAffordance(root, "flutter", dartContent));
374
+ findings.push(...validateRichPostComposerScope(root, "flutter", dartContent));
374
375
  findings.push(...validateCommunityDisplayNameFromSdk(root, "flutter", dartContent));
375
376
  findings.push(...validateRoomPostFetched(root, "flutter", dartContent));
376
377
  findings.push(...validatePostsStatusFilter(root, "flutter", dartContent));
@@ -389,6 +390,9 @@ async function validateFlutter(root) {
389
390
  findings.push(...validateImagePostChildResolutionAwaited(root, "flutter", dartContent));
390
391
  findings.push(...validateCommentReferenceTypeEnum(root, "flutter", dartContent));
391
392
  findings.push(...validateSessionHandlerRetention(root, "flutter", dartContent));
393
+ findings.push(...validateChatUnreadVisible(root, "flutter", dartContent));
394
+ findings.push(...validateChatMessageOrderExplicit(root, "flutter", dartContent));
395
+ findings.push(...validateProfileSocialCounts(root, "flutter", dartContent));
392
396
  findings.push(...validateChat(root, "flutter", dartContent));
393
397
  findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
394
398
  findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
@@ -482,6 +486,7 @@ async function validateTypeScript(root, platform) {
482
486
  findings.push(...validatePollAnswerDataShape(root, platform, sourceContent));
483
487
  findings.push(...validateCommentsQueryHasLimit(root, platform, sourceContent));
484
488
  findings.push(...validateCommentCreationAffordance(root, platform, sourceContent));
489
+ findings.push(...validateRichPostComposerScope(root, platform, sourceContent));
485
490
  findings.push(...validateCommunityDisplayNameFromSdk(root, platform, sourceContent));
486
491
  findings.push(...validateRoomPostFetched(root, platform, sourceContent));
487
492
  findings.push(...validateReactionStalePostRef(root, platform, sourceContent));
@@ -502,6 +507,9 @@ async function validateTypeScript(root, platform) {
502
507
  findings.push(...validateImagePostChildResolutionAwaited(root, platform, sourceContent));
503
508
  findings.push(...validateCommentReferenceTypeEnum(root, platform, sourceContent));
504
509
  findings.push(...validateSessionHandlerRetention(root, platform, sourceContent));
510
+ findings.push(...validateChatUnreadVisible(root, platform, sourceContent));
511
+ findings.push(...validateChatMessageOrderExplicit(root, platform, sourceContent));
512
+ findings.push(...validateProfileSocialCounts(root, platform, sourceContent));
505
513
  findings.push(...validateChat(root, platform, sourceContent));
506
514
  findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
507
515
  if (platform === "typescript") {
@@ -1881,6 +1889,7 @@ async function validateIos(root) {
1881
1889
  findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
1882
1890
  findings.push(...validateCommentsQueryHasLimit(root, "ios", swiftContent));
1883
1891
  findings.push(...validateCommentCreationAffordance(root, "ios", swiftContent));
1892
+ findings.push(...validateRichPostComposerScope(root, "ios", swiftContent));
1884
1893
  findings.push(...validateCommunityDisplayNameFromSdk(root, "ios", swiftContent));
1885
1894
  findings.push(...validateRoomPostFetched(root, "ios", swiftContent));
1886
1895
  findings.push(...validatePostsStatusFilter(root, "ios", swiftContent));
@@ -1899,6 +1908,9 @@ async function validateIos(root) {
1899
1908
  findings.push(...validateImagePostChildResolutionAwaited(root, "ios", swiftContent));
1900
1909
  findings.push(...validateCommentReferenceTypeEnum(root, "ios", swiftContent));
1901
1910
  findings.push(...validateSessionHandlerRetention(root, "ios", swiftContent));
1911
+ findings.push(...validateChatUnreadVisible(root, "ios", swiftContent));
1912
+ findings.push(...validateChatMessageOrderExplicit(root, "ios", swiftContent));
1913
+ findings.push(...validateProfileSocialCounts(root, "ios", swiftContent));
1902
1914
  findings.push(...validateChat(root, "ios", swiftContent));
1903
1915
  findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
1904
1916
  findings.push(...(await validateEnvSecretHygiene(root, "ios", swiftContent)));
@@ -2700,30 +2712,51 @@ function validatePostDataTypeHandled(root, platform, sourceContent) {
2700
2712
  }
2701
2713
  return findings;
2702
2714
  }
2703
- function validateAndroidRichPostComposerSurfaced(root, sourceContent) {
2715
+ function validateRichPostComposerScope(root, platform, sourceContent) {
2704
2716
  const findings = [];
2705
- const ruleId = "android.feed.rich-post-composer-surfaced";
2717
+ const ruleId = `${platform}.feed.rich-post-composer-surfaced`;
2706
2718
  let textComposerFile;
2707
2719
  let hasRichPostCreation = false;
2708
- const textCreatePat = /\bcreateTextPost\s*\(/;
2709
- const composerUiPat = /\b(PostComposer|Composer|OutlinedTextField|TextField|onPost|createPost\s*\()/;
2710
- const richCreatePat = /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\bAmity(?:Image|Video|Clip|Poll|Room|File)\b|\bActivityResultContracts\.(?:GetContent|PickVisualMedia)\b|\brememberLauncherForActivityResult\b|\/\/\s*vise:\s*rich-post-composer/;
2720
+ const textCreatePat = {
2721
+ typescript: /\b(?:PostRepository\s*\.\s*)?createPost\s*\([\s\S]{0,320}(?:dataType\s*:\s*["']text["']|data\s*:\s*\{[\s\S]{0,120}\btext\b)/i,
2722
+ "react-native": /\b(?:PostRepository\s*\.\s*)?createPost\s*\([\s\S]{0,320}(?:dataType\s*:\s*["']text["']|data\s*:\s*\{[\s\S]{0,120}\btext\b)/i,
2723
+ flutter: /\bcreatePost\s*\([\s\S]{0,320}(?:AmityDataType\.TEXT|dataType\s*:\s*["']?text|data\s*:\s*\{[\s\S]{0,120}text|text\s*:)/i,
2724
+ ios: /\bcreatePost\s*\([\s\S]{0,320}(?:text\s*:|dataType\s*:\s*\.text|["']text["']\s*:)/i,
2725
+ android: /\bcreateTextPost\s*\(/,
2726
+ };
2727
+ const composerUiPat = {
2728
+ typescript: /\b(PostComposer|Composer|textarea|TextArea|TextField|onSubmit|onPost|handlePost|placeholder\s*=\s*["'][^"']*post)/i,
2729
+ "react-native": /\b(PostComposer|Composer|TextInput|Pressable|TouchableOpacity|onPress|onPost|handlePost|placeholder\s*=\s*["'][^"']*post)/i,
2730
+ flutter: /\b(PostComposer|Composer|TextField|TextEditingController|onPressed|FloatingActionButton|ElevatedButton)/i,
2731
+ ios: /\b(PostComposer|Composer|TextEditor|TextField|Button\s*\(|onPost|sendPost)/i,
2732
+ android: /\b(PostComposer|Composer|OutlinedTextField|TextField|onPost|createPost\s*\()/,
2733
+ };
2734
+ const richCreatePat = {
2735
+ typescript: /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\b(?:File|Image|Video|Poll|Room|Clip)Repository\b|\battachments\s*:|\b(?:fileId|pollId|clipId|roomId)\b|\bdataType\s*:\s*["'](?:image|video|file|poll|clip|room|liveStream|livestream|audio|custom)["']|\/\/\s*vise:\s*rich-post-composer/i,
2736
+ "react-native": /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\b(?:File|Image|Video|Poll|Room|Clip)Repository\b|\battachments\s*:|\b(?:fileId|pollId|clipId|roomId)\b|\bdataType\s*:\s*["'](?:image|video|file|poll|clip|room|liveStream|livestream|audio|custom)["']|\/\/\s*vise:\s*rich-post-composer/i,
2737
+ flutter: /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\bAmity(?:Image|Video|Clip|Poll|Room|File)\b|\battachments\s*:|\b(?:fileId|pollId|clipId|roomId)\b|\bAmityDataType\.(?:IMAGE|VIDEO|FILE|POLL|CLIP|ROOM|LIVE_STREAM|AUDIO|CUSTOM)|\/\/\s*vise:\s*rich-post-composer/i,
2738
+ ios: /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\bAmity(?:Image|Video|Clip|Poll|Room|File)\b|\battachments\s*:|\b(?:fileId|pollId|clipId|roomId)\b|\bdataType\s*:\s*\.(?:image|video|file|poll|clip|room|liveStream|livestream|audio|custom)|\/\/\s*vise:\s*rich-post-composer/i,
2739
+ android: /\bcreate(?:Image|Video|File|Audio|Poll|Clip|Room|LiveStream|MixedAttachment)Post\s*\(|\bcreatePoll\s*\(|\bupload(?:Image|Video|File|Clip)\s*\(|\bAmity(?:Image|Video|Clip|Poll|Room|File)\b|\bActivityResultContracts\.(?:GetContent|PickVisualMedia)\b|\brememberLauncherForActivityResult\b|\/\/\s*vise:\s*rich-post-composer/,
2740
+ };
2741
+ const textPat = textCreatePat[platform] ?? textCreatePat.typescript;
2742
+ const uiPat = composerUiPat[platform] ?? composerUiPat.typescript;
2743
+ const richPat = richCreatePat[platform] ?? richCreatePat.typescript;
2711
2744
  for (const [file, content] of sourceContent) {
2712
- if (!file.endsWith(".kt") && !file.endsWith(".java"))
2745
+ if (path.basename(file).endsWith(".d.ts"))
2713
2746
  continue;
2714
2747
  if (/\/\/\s*vise:\s*rich-post-composer\s+intentional/i.test(content))
2715
2748
  return findings;
2716
- const astLang = astLanguageForFile(file, "android");
2749
+ const astLang = astLanguageForFile(file, platform);
2717
2750
  const checkContent = astLang ? stripComments(astLang, content) : content;
2718
- if (richCreatePat.test(checkContent)) {
2751
+ if (richPat.test(checkContent)) {
2719
2752
  hasRichPostCreation = true;
2720
2753
  }
2721
- if (!textComposerFile && textCreatePat.test(checkContent) && composerUiPat.test(checkContent)) {
2754
+ if (!textComposerFile && textPat.test(checkContent) && uiPat.test(checkContent)) {
2722
2755
  textComposerFile = file;
2723
2756
  }
2724
2757
  }
2725
2758
  if (textComposerFile && !hasRichPostCreation) {
2726
- findings.push(finding(ruleId, "warning", "Android feed composer only creates text posts. The user can read a mixed social.plus feed but cannot create image, video, file, poll, clip, room, or mixed-attachment posts.", relativeFile(root, textComposerFile), "Surface the rich-post scope explicitly. Either implement the needed createImagePost/createVideoPost/createFilePost/createPollPost/createClipPost/createRoomPost paths with the matching upload flow, or record a deliberate scope decision with // vise: rich-post-composer intentional — <reason>."));
2759
+ findings.push(finding(ruleId, "warning", "Feed composer only creates text posts. The user can read a mixed social.plus feed but cannot create image, video, file, poll, clip, room, or mixed-attachment posts.", relativeFile(root, textComposerFile), "Surface the rich-post scope explicitly. Either implement the needed createImagePost/createVideoPost/createFilePost/createPollPost/createClipPost/createRoomPost paths with the matching upload flow, or record a deliberate scope decision with // vise: rich-post-composer intentional — <reason>."));
2727
2760
  }
2728
2761
  return findings;
2729
2762
  }
@@ -2981,66 +3014,114 @@ function validateCommentThreadUiStates(root, platform, sourceContent) {
2981
3014
  }
2982
3015
  return findings;
2983
3016
  }
2984
- function validateAndroidChatUnreadVisible(root, sourceContent) {
3017
+ function validateChatUnreadVisible(root, platform, sourceContent) {
2985
3018
  const findings = [];
2986
- const ruleId = "android.chat.unread-visible";
3019
+ const ruleId = `${platform}.chat.unread-visible`;
2987
3020
  let chatListFile;
2988
3021
  let hasUnreadSignal = false;
3022
+ const channelListPat = {
3023
+ typescript: /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
3024
+ "react-native": /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
3025
+ flutter: /\bnewChannelRepository\s*\(\s*\)\s*\.\s*getChannels\b|\bgetChannels\s*\(|\bAmityChannel\b/,
3026
+ ios: /\bAmityChannelRepository\b|\bAmityChannelQuery\b|\bgetChannels\s*\(/,
3027
+ android: /\bAmityChannel\b[\s\S]{0,240}\b(?:getChannels|ChannelRow|ChatScreen|Messages)\b|\bgetChannels\s*\(/,
3028
+ };
3029
+ const chatListUiPat = {
3030
+ typescript: /\b(ChannelList|ChannelRow|ChatList|channels\s*\.\s*map|FlatList|channel\s*\??\.\s*(?:displayName|name|channelId))/i,
3031
+ "react-native": /\b(ChannelList|ChannelRow|ChatList|FlatList|channels\s*\.\s*map|channel\s*\??\.\s*(?:displayName|name|channelId))/i,
3032
+ flutter: /\b(ChannelList|ChannelRow|ChatScreen|ListView|AmityChannel|channel\.(?:displayName|name|channelId))/i,
3033
+ ios: /\b(ChannelList|ChannelRow|ChatView|List\s*\(|ForEach|AmityChannel|channel\.(?:displayName|displayName|channelId))/i,
3034
+ android: /\b(?:getChannels|ChannelRow|ChatScreen|Messages|LazyColumn|RecyclerView)\b/,
3035
+ };
3036
+ const unreadPat = {
3037
+ typescript: /\bunreadCount\b|\bgetUnread\w*\b|\bgetTotalChannelUnread\b|\bgetTotalChannelsUnreadInfo\b|\bobserveUserUnread\b|\bUserUnread\b/i,
3038
+ "react-native": /\bunreadCount\b|\bgetUnread\w*\b|\bgetTotalChannelUnread\b|\bgetTotalChannelsUnreadInfo\b|\bobserveUserUnread\b|\bUserUnread\b/i,
3039
+ flutter: /\bunreadCount\b|\bgetUnread\w*\b|\bgetSubChannelsUnreadCount\b|\bgetTotalChannelsUnreadInfo\b|\bobserveUserUnread\b|\bUserUnread\b/i,
3040
+ ios: /\bunreadCount\b|\bgetUnread\w*\b|\bsubChannelsUnreadCount\b|\btotalChannelUnread\b|\bobserveUserUnread\b|\bUserUnread\b/i,
3041
+ android: /\bgetUnreadCount\s*\(|\bgetSubChannelsUnreadCount\s*\(|\bgetTotalChannelsUnreadInfo\s*\(|\bgetTotalChannelUnread\s*\(|\bobserveUserUnread\s*\(|\bUserUnread\b|\bunreadCount\b/i,
3042
+ };
3043
+ const queryPat = channelListPat[platform] ?? channelListPat.typescript;
3044
+ const uiPat = chatListUiPat[platform] ?? chatListUiPat.typescript;
3045
+ const unreadSignalPat = unreadPat[platform] ?? unreadPat.typescript;
2989
3046
  for (const [file, content] of sourceContent) {
2990
- if (!file.endsWith(".kt") && !file.endsWith(".java"))
2991
- continue;
2992
3047
  if (/\/\/\s*vise:\s*chat-unread intentional/i.test(content))
2993
3048
  return findings;
2994
- const astLang = astLanguageForFile(file, "android");
3049
+ const astLang = astLanguageForFile(file, platform);
2995
3050
  const checkContent = astLang ? stripComments(astLang, content) : content;
2996
- if (/\bgetUnreadCount\s*\(|\bgetSubChannelsUnreadCount\s*\(|\bgetTotalChannelsUnreadInfo\s*\(|\bgetTotalChannelUnread\s*\(|\bobserveUserUnread\s*\(|\bUserUnread\b|\bunreadCount\b/i.test(checkContent)) {
3051
+ if (unreadSignalPat.test(checkContent)) {
2997
3052
  hasUnreadSignal = true;
2998
3053
  }
2999
- if (!chatListFile && /\bAmityChannel\b/.test(checkContent) && /\b(?:getChannels|ChannelRow|ChatScreen|Messages)\b/.test(checkContent)) {
3054
+ if (!chatListFile && queryPat.test(checkContent) && uiPat.test(checkContent)) {
3000
3055
  chatListFile = file;
3001
3056
  }
3002
3057
  }
3003
3058
  if (chatListFile && !hasUnreadSignal) {
3004
- findings.push(finding(ruleId, "warning", "Android chat channel list is present but no SDK unread count is rendered or subscribed. The UI will drop unread badges/counts.", relativeFile(root, chatListFile), "Render unread from the SDK, for example channel.getUnreadCount()/getSubChannelsUnreadCount() for channel rows or AmityCoreClient.observeUserUnread()/getTotalChannelUnread() for a global badge. Add // vise: chat-unread intentional — <reason> only for a deliberately unread-free chat surface."));
3059
+ findings.push(finding(ruleId, "warning", "Chat channel list is present but no SDK unread count is rendered or subscribed. The UI will drop unread badges/counts.", relativeFile(root, chatListFile), "Render unread from the SDK, for example channel.getUnreadCount()/getSubChannelsUnreadCount() for channel rows or AmityCoreClient.observeUserUnread()/getTotalChannelUnread() for a global badge. Add // vise: chat-unread intentional — <reason> only for a deliberately unread-free chat surface."));
3005
3060
  }
3006
3061
  return findings;
3007
3062
  }
3008
- function validateAndroidChatSortExplicit(root, sourceContent) {
3063
+ function validateChatMessageOrderExplicit(root, platform, sourceContent) {
3009
3064
  const findings = [];
3010
- const ruleId = "android.chat.sort-explicit";
3065
+ const ruleId = `${platform}.chat.sort-explicit`;
3066
+ const messageQueryPat = {
3067
+ typescript: /\b(?:MessageRepository|ChatRepository|AmityMessageRepository)\s*\.\s*(?:getMessages|queryMessages)\s*\(/,
3068
+ "react-native": /\b(?:MessageRepository|ChatRepository|AmityMessageRepository)\s*\.\s*(?:getMessages|queryMessages)\s*\(/,
3069
+ flutter: /\bnewMessageRepository\s*\(\s*\)\s*\.\s*getMessages\b|\bgetMessages\s*\(/,
3070
+ ios: /\bAmityMessageRepository\b|\bAmityMessageQuery\b|\bgetMessages\s*\(/,
3071
+ android: /AmityChatClient\s*\.\s*newMessageRepository\s*\(\s*\)\s*\.getMessages\s*\([^)]*\)[\s\S]{0,180}\.build\s*\(\s*\)[\s\S]{0,80}\.query\s*\(\s*\)/,
3072
+ };
3073
+ const explicitSortPat = {
3074
+ typescript: /\bsortBy\s*:|\borderBy\s*:|\bsort\s*:|\bMessageQuerySort\b|\bFIRST_CREATED\b|\bLAST_CREATED\b|\bsorted(?:By|With)?\b|\breverse(?:d)?\s*\(/i,
3075
+ "react-native": /\bsortBy\s*:|\borderBy\s*:|\bsort\s*:|\bMessageQuerySort\b|\bFIRST_CREATED\b|\bLAST_CREATED\b|\bsorted(?:By|With)?\b|\breverse(?:d)?\s*\(/i,
3076
+ flutter: /\.sortBy\s*\(|\bsortBy\s*:|\bAmityMessageQuerySortOption\b|\bFIRST_CREATED\b|\bLAST_CREATED\b|\bsortedBy(?:Descending)?\s*\(|\breversed\s*\(/i,
3077
+ ios: /\bsortBy\s*:|\.sortBy\s*=|\bAmityMessageQuerySortOption\b|\bfirstCreated\b|\blastCreated\b|\bsorted\s*\(|\.reversed\s*\(/i,
3078
+ android: /\.sortBy\s*\(|\bAmityMessageQuerySortOption\.(?:FIRST_CREATED|LAST_CREATED)\b|\bsortedBy(?:Descending)?\s*\(|\breversed\s*\(\s*\)/,
3079
+ };
3080
+ const queryPat = messageQueryPat[platform] ?? messageQueryPat.typescript;
3081
+ const sortPat = explicitSortPat[platform] ?? explicitSortPat.typescript;
3011
3082
  for (const [file, content] of sourceContent) {
3012
- if (!file.endsWith(".kt") && !file.endsWith(".java"))
3013
- continue;
3014
3083
  if (/\/\/\s*vise:\s*chat-sort intentional/i.test(content))
3015
3084
  continue;
3016
- const astLang = astLanguageForFile(file, "android");
3085
+ const astLang = astLanguageForFile(file, platform);
3017
3086
  const checkContent = astLang ? stripComments(astLang, content) : content;
3018
- const hasMessageQuery = /AmityChatClient\s*\.\s*newMessageRepository\s*\(\s*\)\s*\.getMessages\s*\([^)]*\)[\s\S]{0,180}\.build\s*\(\s*\)[\s\S]{0,80}\.query\s*\(\s*\)/.test(checkContent);
3019
- if (!hasMessageQuery)
3087
+ if (!queryPat.test(checkContent))
3020
3088
  continue;
3021
- const hasExplicitSort = /\.sortBy\s*\(|\bAmityMessageQuerySortOption\.(?:FIRST_CREATED|LAST_CREATED)\b|\bsortedBy(?:Descending)?\s*\(|\breversed\s*\(\s*\)/.test(checkContent);
3022
- if (hasExplicitSort)
3089
+ if (sortPat.test(checkContent))
3023
3090
  continue;
3024
- findings.push(finding(ruleId, "warning", "Android message query relies on implicit SDK ordering. This commonly reverses the visible chat order when the UI assumes newest-first or oldest-first.", relativeFile(root, file), "Declare the intended order explicitly with .sortBy(AmityMessageQuerySortOption.FIRST_CREATED or LAST_CREATED), or apply a clearly named UI sort/reverse before rendering. Add // vise: chat-sort intentional — <reason> if the SDK default is intentionally used."));
3091
+ findings.push(finding(ruleId, "warning", "Message query relies on implicit SDK ordering. This commonly reverses the visible chat order when the UI assumes newest-first or oldest-first.", relativeFile(root, file), "Declare the intended order explicitly with .sortBy(AmityMessageQuerySortOption.FIRST_CREATED or LAST_CREATED), or apply a clearly named UI sort/reverse before rendering. Add // vise: chat-sort intentional — <reason> if the SDK default is intentionally used."));
3025
3092
  break;
3026
3093
  }
3027
3094
  return findings;
3028
3095
  }
3029
- function validateAndroidProfileSocialCounts(root, sourceContent) {
3096
+ function validateProfileSocialCounts(root, platform, sourceContent) {
3030
3097
  const findings = [];
3031
- const ruleId = "android.profile.social-counts-from-sdk";
3098
+ const ruleId = `${platform}.profile.social-counts-from-sdk`;
3099
+ const sdkCountsPat = {
3100
+ typescript: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bUserRepository\b/i,
3101
+ "react-native": /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bUserRepository\b/i,
3102
+ flutter: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bnewUserRepository\s*\(/i,
3103
+ ios: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bAmityUserRepository\b/i,
3104
+ android: /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bAmityCoreClient\s*\.\s*newUserRepository\s*\(\s*\)/,
3105
+ };
3106
+ const placeholderCountsPat = {
3107
+ typescript: /value\s*=\s*["'][—-]+["']|value\s*=\s*["']0["']|>\s*[—-]+\s*<|>\s*0\s*</,
3108
+ "react-native": /value\s*=\s*["'][—-]+["']|value\s*=\s*["']0["']|>\s*[—-]+\s*<|>\s*0\s*</,
3109
+ flutter: /Text\s*\(\s*["'][—-]+["']|Text\s*\(\s*["']0["']|value\s*:\s*["'][—-]+["']|value\s*:\s*["']0["']/,
3110
+ ios: /Text\s*\(\s*["'][—-]+["']\s*\)|Text\s*\(\s*["']0["']\s*\)|value\s*:\s*["'][—-]+["']|value\s*:\s*["']0["']/,
3111
+ android: /value\s*=\s*["'][—-]+["']|Text\s*\(\s*["'][—-]+["']|value\s*=\s*["']0["']|Text\s*\(\s*["']0["']/,
3112
+ };
3113
+ const sdkPat = sdkCountsPat[platform] ?? sdkCountsPat.typescript;
3114
+ const placeholderPat = placeholderCountsPat[platform] ?? placeholderCountsPat.typescript;
3032
3115
  for (const [file, content] of sourceContent) {
3033
- if (!file.endsWith(".kt") && !file.endsWith(".java"))
3034
- continue;
3035
3116
  if (/\/\/\s*vise:\s*profile-counts intentional/i.test(content))
3036
3117
  continue;
3037
- const astLang = astLanguageForFile(file, "android");
3118
+ const astLang = astLanguageForFile(file, platform);
3038
3119
  const checkContent = astLang ? stripComments(astLang, content) : content;
3039
3120
  const rendersFollowerLabels = /Followers?/.test(checkContent) && /Following/.test(checkContent);
3040
3121
  if (!rendersFollowerLabels)
3041
3122
  continue;
3042
- const hasSdkCounts = /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(|\bAmityCoreClient\s*\.\s*newUserRepository\s*\(\s*\)/.test(checkContent);
3043
- const hasPlaceholderCounts = /value\s*=\s*["'][—-]+["']|Text\s*\(\s*["'][—-]+["']|value\s*=\s*["']0["']|Text\s*\(\s*["']0["']/.test(checkContent);
3123
+ const hasSdkCounts = sdkPat.test(checkContent);
3124
+ const hasPlaceholderCounts = placeholderPat.test(checkContent);
3044
3125
  if (hasPlaceholderCounts && !hasSdkCounts) {
3045
3126
  findings.push(finding(ruleId, "warning", "Profile screen labels follower/following counts but fills them with placeholders instead of SDK values.", relativeFile(root, file), "Use AmityUser.getFollowerCount()/getFollowingCount() from the current user/live profile object, or query getFollowers()/getFollowings() if the count must update from a collection. Add // vise: profile-counts intentional — <reason> only when the placeholders are deliberate."));
3046
3127
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amityco/social-plus-vise",
3
- "version": "0.14.9",
3
+ "version": "0.14.11",
4
4
  "description": "Skill-guided deterministic CLI for social.plus SDK integration assistance.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
package/rules/chat.yaml CHANGED
@@ -872,6 +872,154 @@
872
872
  }
873
873
  }
874
874
  },
875
+ {
876
+ "id": "typescript.chat.unread-visible",
877
+ "version": 1,
878
+ "title": "TypeScript chat UI must render SDK unread counts",
879
+ "severity": "warning",
880
+ "rationale": "A chat channel list without unread counts or badges silently drops an expected messaging affordance. The SDK exposes unread state on channels or user unread streams for global badges.",
881
+ "applies_when": {
882
+ "platforms": [
883
+ "typescript"
884
+ ],
885
+ "outcomes": [
886
+ "add-feed",
887
+ "add-chat",
888
+ "validate-setup"
889
+ ]
890
+ },
891
+ "enforcement": {
892
+ "deterministic": [
893
+ {
894
+ "check": "validator-finding-absent",
895
+ "finding_rule_id": "typescript.chat.unread-visible"
896
+ }
897
+ ],
898
+ "attestation": {
899
+ "allowed": true,
900
+ "host_agent_min_confidence": "high",
901
+ "human_allowed": true,
902
+ "evidence_required": [
903
+ {
904
+ "field": "chat_unread_source",
905
+ "description": "Where unread counts are sourced from the SDK and rendered, or why the chat surface intentionally omits unread state.",
906
+ "upload_policy": "upload-with-consent"
907
+ }
908
+ ]
909
+ }
910
+ }
911
+ },
912
+ {
913
+ "id": "react-native.chat.unread-visible",
914
+ "version": 1,
915
+ "title": "React Native chat UI must render SDK unread counts",
916
+ "severity": "warning",
917
+ "rationale": "A chat channel list without unread counts or badges silently drops an expected messaging affordance. The SDK exposes unread state on channels or user unread streams for global badges.",
918
+ "applies_when": {
919
+ "platforms": [
920
+ "react-native"
921
+ ],
922
+ "outcomes": [
923
+ "add-feed",
924
+ "add-chat",
925
+ "validate-setup"
926
+ ]
927
+ },
928
+ "enforcement": {
929
+ "deterministic": [
930
+ {
931
+ "check": "validator-finding-absent",
932
+ "finding_rule_id": "react-native.chat.unread-visible"
933
+ }
934
+ ],
935
+ "attestation": {
936
+ "allowed": true,
937
+ "host_agent_min_confidence": "high",
938
+ "human_allowed": true,
939
+ "evidence_required": [
940
+ {
941
+ "field": "chat_unread_source",
942
+ "description": "Where unread counts are sourced from the SDK and rendered, or why the chat surface intentionally omits unread state.",
943
+ "upload_policy": "upload-with-consent"
944
+ }
945
+ ]
946
+ }
947
+ }
948
+ },
949
+ {
950
+ "id": "flutter.chat.unread-visible",
951
+ "version": 1,
952
+ "title": "Flutter chat UI must render SDK unread counts",
953
+ "severity": "warning",
954
+ "rationale": "A chat channel list without unread counts or badges silently drops an expected messaging affordance. The SDK exposes unread state on channels or user unread streams for global badges.",
955
+ "applies_when": {
956
+ "platforms": [
957
+ "flutter"
958
+ ],
959
+ "outcomes": [
960
+ "add-feed",
961
+ "add-chat",
962
+ "validate-setup"
963
+ ]
964
+ },
965
+ "enforcement": {
966
+ "deterministic": [
967
+ {
968
+ "check": "validator-finding-absent",
969
+ "finding_rule_id": "flutter.chat.unread-visible"
970
+ }
971
+ ],
972
+ "attestation": {
973
+ "allowed": true,
974
+ "host_agent_min_confidence": "high",
975
+ "human_allowed": true,
976
+ "evidence_required": [
977
+ {
978
+ "field": "chat_unread_source",
979
+ "description": "Where unread counts are sourced from the SDK and rendered, or why the chat surface intentionally omits unread state.",
980
+ "upload_policy": "upload-with-consent"
981
+ }
982
+ ]
983
+ }
984
+ }
985
+ },
986
+ {
987
+ "id": "ios.chat.unread-visible",
988
+ "version": 1,
989
+ "title": "iOS chat UI must render SDK unread counts",
990
+ "severity": "warning",
991
+ "rationale": "A chat channel list without unread counts or badges silently drops an expected messaging affordance. The SDK exposes unread state on channels or user unread streams for global badges.",
992
+ "applies_when": {
993
+ "platforms": [
994
+ "ios"
995
+ ],
996
+ "outcomes": [
997
+ "add-feed",
998
+ "add-chat",
999
+ "validate-setup"
1000
+ ]
1001
+ },
1002
+ "enforcement": {
1003
+ "deterministic": [
1004
+ {
1005
+ "check": "validator-finding-absent",
1006
+ "finding_rule_id": "ios.chat.unread-visible"
1007
+ }
1008
+ ],
1009
+ "attestation": {
1010
+ "allowed": true,
1011
+ "host_agent_min_confidence": "high",
1012
+ "human_allowed": true,
1013
+ "evidence_required": [
1014
+ {
1015
+ "field": "chat_unread_source",
1016
+ "description": "Where unread counts are sourced from the SDK and rendered, or why the chat surface intentionally omits unread state.",
1017
+ "upload_policy": "upload-with-consent"
1018
+ }
1019
+ ]
1020
+ }
1021
+ }
1022
+ },
875
1023
  {
876
1024
  "id": "android.chat.unread-visible",
877
1025
  "version": 1,
@@ -909,6 +1057,150 @@
909
1057
  }
910
1058
  }
911
1059
  },
1060
+ {
1061
+ "id": "typescript.chat.sort-explicit",
1062
+ "version": 1,
1063
+ "title": "TypeScript chat message order must be explicit",
1064
+ "severity": "warning",
1065
+ "rationale": "Relying on default SDK message ordering is easy to invert in a message list. The query or UI should declare whether messages render first-created or last-created order.",
1066
+ "applies_when": {
1067
+ "platforms": [
1068
+ "typescript"
1069
+ ],
1070
+ "outcomes": [
1071
+ "validate-setup"
1072
+ ]
1073
+ },
1074
+ "enforcement": {
1075
+ "deterministic": [
1076
+ {
1077
+ "check": "validator-finding-absent",
1078
+ "finding_rule_id": "typescript.chat.sort-explicit"
1079
+ }
1080
+ ],
1081
+ "attestation": {
1082
+ "allowed": true,
1083
+ "host_agent_min_confidence": "high",
1084
+ "human_allowed": true,
1085
+ "evidence_required": [
1086
+ {
1087
+ "field": "chat_message_order",
1088
+ "description": "The explicit message order used in SDK query or UI rendering, and why it matches the product UI.",
1089
+ "upload_policy": "upload-with-consent"
1090
+ }
1091
+ ]
1092
+ }
1093
+ }
1094
+ },
1095
+ {
1096
+ "id": "react-native.chat.sort-explicit",
1097
+ "version": 1,
1098
+ "title": "React Native chat message order must be explicit",
1099
+ "severity": "warning",
1100
+ "rationale": "Relying on default SDK message ordering is easy to invert in a message list. The query or UI should declare whether messages render first-created or last-created order.",
1101
+ "applies_when": {
1102
+ "platforms": [
1103
+ "react-native"
1104
+ ],
1105
+ "outcomes": [
1106
+ "validate-setup"
1107
+ ]
1108
+ },
1109
+ "enforcement": {
1110
+ "deterministic": [
1111
+ {
1112
+ "check": "validator-finding-absent",
1113
+ "finding_rule_id": "react-native.chat.sort-explicit"
1114
+ }
1115
+ ],
1116
+ "attestation": {
1117
+ "allowed": true,
1118
+ "host_agent_min_confidence": "high",
1119
+ "human_allowed": true,
1120
+ "evidence_required": [
1121
+ {
1122
+ "field": "chat_message_order",
1123
+ "description": "The explicit message order used in SDK query or UI rendering, and why it matches the product UI.",
1124
+ "upload_policy": "upload-with-consent"
1125
+ }
1126
+ ]
1127
+ }
1128
+ }
1129
+ },
1130
+ {
1131
+ "id": "flutter.chat.sort-explicit",
1132
+ "version": 1,
1133
+ "title": "Flutter chat message order must be explicit",
1134
+ "severity": "warning",
1135
+ "rationale": "Relying on default SDK message ordering is easy to invert in a message list. The query or UI should declare whether messages render first-created or last-created order.",
1136
+ "applies_when": {
1137
+ "platforms": [
1138
+ "flutter"
1139
+ ],
1140
+ "outcomes": [
1141
+ "add-feed",
1142
+ "add-chat",
1143
+ "validate-setup"
1144
+ ]
1145
+ },
1146
+ "enforcement": {
1147
+ "deterministic": [
1148
+ {
1149
+ "check": "validator-finding-absent",
1150
+ "finding_rule_id": "flutter.chat.sort-explicit"
1151
+ }
1152
+ ],
1153
+ "attestation": {
1154
+ "allowed": true,
1155
+ "host_agent_min_confidence": "high",
1156
+ "human_allowed": true,
1157
+ "evidence_required": [
1158
+ {
1159
+ "field": "chat_message_order",
1160
+ "description": "The explicit message order used in SDK query or UI rendering, and why it matches the product UI.",
1161
+ "upload_policy": "upload-with-consent"
1162
+ }
1163
+ ]
1164
+ }
1165
+ }
1166
+ },
1167
+ {
1168
+ "id": "ios.chat.sort-explicit",
1169
+ "version": 1,
1170
+ "title": "iOS chat message order must be explicit",
1171
+ "severity": "warning",
1172
+ "rationale": "Relying on default SDK message ordering is easy to invert in a message list. The query or UI should declare whether messages render first-created or last-created order.",
1173
+ "applies_when": {
1174
+ "platforms": [
1175
+ "ios"
1176
+ ],
1177
+ "outcomes": [
1178
+ "add-feed",
1179
+ "add-chat",
1180
+ "validate-setup"
1181
+ ]
1182
+ },
1183
+ "enforcement": {
1184
+ "deterministic": [
1185
+ {
1186
+ "check": "validator-finding-absent",
1187
+ "finding_rule_id": "ios.chat.sort-explicit"
1188
+ }
1189
+ ],
1190
+ "attestation": {
1191
+ "allowed": true,
1192
+ "host_agent_min_confidence": "high",
1193
+ "human_allowed": true,
1194
+ "evidence_required": [
1195
+ {
1196
+ "field": "chat_message_order",
1197
+ "description": "The explicit message order used in SDK query or UI rendering, and why it matches the product UI.",
1198
+ "upload_policy": "upload-with-consent"
1199
+ }
1200
+ ]
1201
+ }
1202
+ }
1203
+ },
912
1204
  {
913
1205
  "id": "android.chat.sort-explicit",
914
1206
  "version": 1,
package/rules/feed.yaml CHANGED
@@ -3578,6 +3578,150 @@
3578
3578
  }
3579
3579
  }
3580
3580
  },
3581
+ {
3582
+ "id": "typescript.feed.rich-post-composer-surfaced",
3583
+ "version": 1,
3584
+ "title": "TypeScript feed composer must surface rich post scope",
3585
+ "severity": "warning",
3586
+ "rationale": "A social.plus feed can contain rich post types. If the composer only creates text posts, users cannot create image, video, file, poll, clip, room, livestream, or mixed-attachment posts unless the product explicitly scopes those out.",
3587
+ "applies_when": {
3588
+ "platforms": [
3589
+ "typescript"
3590
+ ],
3591
+ "outcomes": [
3592
+ "add-feed",
3593
+ "validate-setup"
3594
+ ]
3595
+ },
3596
+ "enforcement": {
3597
+ "deterministic": [
3598
+ {
3599
+ "check": "validator-finding-absent",
3600
+ "finding_rule_id": "typescript.feed.rich-post-composer-surfaced"
3601
+ }
3602
+ ],
3603
+ "attestation": {
3604
+ "allowed": true,
3605
+ "host_agent_min_confidence": "high",
3606
+ "human_allowed": true,
3607
+ "evidence_required": [
3608
+ {
3609
+ "field": "rich_post_composer_scope",
3610
+ "description": "Which rich post creation flows are implemented, or the explicit product reason for a text-only composer.",
3611
+ "upload_policy": "upload-with-consent"
3612
+ }
3613
+ ]
3614
+ }
3615
+ }
3616
+ },
3617
+ {
3618
+ "id": "react-native.feed.rich-post-composer-surfaced",
3619
+ "version": 1,
3620
+ "title": "React Native feed composer must surface rich post scope",
3621
+ "severity": "warning",
3622
+ "rationale": "A social.plus feed can contain rich post types. If the composer only creates text posts, users cannot create image, video, file, poll, clip, room, livestream, or mixed-attachment posts unless the product explicitly scopes those out.",
3623
+ "applies_when": {
3624
+ "platforms": [
3625
+ "react-native"
3626
+ ],
3627
+ "outcomes": [
3628
+ "add-feed",
3629
+ "validate-setup"
3630
+ ]
3631
+ },
3632
+ "enforcement": {
3633
+ "deterministic": [
3634
+ {
3635
+ "check": "validator-finding-absent",
3636
+ "finding_rule_id": "react-native.feed.rich-post-composer-surfaced"
3637
+ }
3638
+ ],
3639
+ "attestation": {
3640
+ "allowed": true,
3641
+ "host_agent_min_confidence": "high",
3642
+ "human_allowed": true,
3643
+ "evidence_required": [
3644
+ {
3645
+ "field": "rich_post_composer_scope",
3646
+ "description": "Which rich post creation flows are implemented, or the explicit product reason for a text-only composer.",
3647
+ "upload_policy": "upload-with-consent"
3648
+ }
3649
+ ]
3650
+ }
3651
+ }
3652
+ },
3653
+ {
3654
+ "id": "flutter.feed.rich-post-composer-surfaced",
3655
+ "version": 1,
3656
+ "title": "Flutter feed composer must surface rich post scope",
3657
+ "severity": "warning",
3658
+ "rationale": "A social.plus feed can contain rich post types. If the composer only creates text posts, users cannot create image, video, file, poll, clip, room, livestream, or mixed-attachment posts unless the product explicitly scopes those out.",
3659
+ "applies_when": {
3660
+ "platforms": [
3661
+ "flutter"
3662
+ ],
3663
+ "outcomes": [
3664
+ "add-feed",
3665
+ "validate-setup"
3666
+ ]
3667
+ },
3668
+ "enforcement": {
3669
+ "deterministic": [
3670
+ {
3671
+ "check": "validator-finding-absent",
3672
+ "finding_rule_id": "flutter.feed.rich-post-composer-surfaced"
3673
+ }
3674
+ ],
3675
+ "attestation": {
3676
+ "allowed": true,
3677
+ "host_agent_min_confidence": "high",
3678
+ "human_allowed": true,
3679
+ "evidence_required": [
3680
+ {
3681
+ "field": "rich_post_composer_scope",
3682
+ "description": "Which rich post creation flows are implemented, or the explicit product reason for a text-only composer.",
3683
+ "upload_policy": "upload-with-consent"
3684
+ }
3685
+ ]
3686
+ }
3687
+ }
3688
+ },
3689
+ {
3690
+ "id": "ios.feed.rich-post-composer-surfaced",
3691
+ "version": 1,
3692
+ "title": "iOS feed composer must surface rich post scope",
3693
+ "severity": "warning",
3694
+ "rationale": "A social.plus feed can contain rich post types. If the composer only creates text posts, users cannot create image, video, file, poll, clip, room, livestream, or mixed-attachment posts unless the product explicitly scopes those out.",
3695
+ "applies_when": {
3696
+ "platforms": [
3697
+ "ios"
3698
+ ],
3699
+ "outcomes": [
3700
+ "add-feed",
3701
+ "validate-setup"
3702
+ ]
3703
+ },
3704
+ "enforcement": {
3705
+ "deterministic": [
3706
+ {
3707
+ "check": "validator-finding-absent",
3708
+ "finding_rule_id": "ios.feed.rich-post-composer-surfaced"
3709
+ }
3710
+ ],
3711
+ "attestation": {
3712
+ "allowed": true,
3713
+ "host_agent_min_confidence": "high",
3714
+ "human_allowed": true,
3715
+ "evidence_required": [
3716
+ {
3717
+ "field": "rich_post_composer_scope",
3718
+ "description": "Which rich post creation flows are implemented, or the explicit product reason for a text-only composer.",
3719
+ "upload_policy": "upload-with-consent"
3720
+ }
3721
+ ]
3722
+ }
3723
+ }
3724
+ },
3581
3725
  {
3582
3726
  "id": "android.feed.rich-post-composer-surfaced",
3583
3727
  "version": 1,
@@ -3614,6 +3758,154 @@
3614
3758
  }
3615
3759
  }
3616
3760
  },
3761
+ {
3762
+ "id": "typescript.profile.social-counts-from-sdk",
3763
+ "version": 1,
3764
+ "title": "TypeScript profile social counts must come from the SDK",
3765
+ "severity": "warning",
3766
+ "rationale": "Showing Followers/Following labels with placeholder values makes the profile look complete while silently dropping the social graph. social.plus user objects and relationship queries expose follower/following counts or lists.",
3767
+ "applies_when": {
3768
+ "platforms": [
3769
+ "typescript"
3770
+ ],
3771
+ "outcomes": [
3772
+ "add-feed",
3773
+ "add-follow",
3774
+ "validate-setup"
3775
+ ]
3776
+ },
3777
+ "enforcement": {
3778
+ "deterministic": [
3779
+ {
3780
+ "check": "validator-finding-absent",
3781
+ "finding_rule_id": "typescript.profile.social-counts-from-sdk"
3782
+ }
3783
+ ],
3784
+ "attestation": {
3785
+ "allowed": true,
3786
+ "host_agent_min_confidence": "high",
3787
+ "human_allowed": true,
3788
+ "evidence_required": [
3789
+ {
3790
+ "field": "profile_social_counts",
3791
+ "description": "Where follower/following counts are read from AmityUser or relationship live collections, or why the profile intentionally omits them.",
3792
+ "upload_policy": "upload-with-consent"
3793
+ }
3794
+ ]
3795
+ }
3796
+ }
3797
+ },
3798
+ {
3799
+ "id": "react-native.profile.social-counts-from-sdk",
3800
+ "version": 1,
3801
+ "title": "React Native profile social counts must come from the SDK",
3802
+ "severity": "warning",
3803
+ "rationale": "Showing Followers/Following labels with placeholder values makes the profile look complete while silently dropping the social graph. social.plus user objects and relationship queries expose follower/following counts or lists.",
3804
+ "applies_when": {
3805
+ "platforms": [
3806
+ "react-native"
3807
+ ],
3808
+ "outcomes": [
3809
+ "add-feed",
3810
+ "add-follow",
3811
+ "validate-setup"
3812
+ ]
3813
+ },
3814
+ "enforcement": {
3815
+ "deterministic": [
3816
+ {
3817
+ "check": "validator-finding-absent",
3818
+ "finding_rule_id": "react-native.profile.social-counts-from-sdk"
3819
+ }
3820
+ ],
3821
+ "attestation": {
3822
+ "allowed": true,
3823
+ "host_agent_min_confidence": "high",
3824
+ "human_allowed": true,
3825
+ "evidence_required": [
3826
+ {
3827
+ "field": "profile_social_counts",
3828
+ "description": "Where follower/following counts are read from AmityUser or relationship live collections, or why the profile intentionally omits them.",
3829
+ "upload_policy": "upload-with-consent"
3830
+ }
3831
+ ]
3832
+ }
3833
+ }
3834
+ },
3835
+ {
3836
+ "id": "flutter.profile.social-counts-from-sdk",
3837
+ "version": 1,
3838
+ "title": "Flutter profile social counts must come from the SDK",
3839
+ "severity": "warning",
3840
+ "rationale": "Showing Followers/Following labels with placeholder values makes the profile look complete while silently dropping the social graph. social.plus user objects and relationship queries expose follower/following counts or lists.",
3841
+ "applies_when": {
3842
+ "platforms": [
3843
+ "flutter"
3844
+ ],
3845
+ "outcomes": [
3846
+ "add-feed",
3847
+ "add-follow",
3848
+ "validate-setup"
3849
+ ]
3850
+ },
3851
+ "enforcement": {
3852
+ "deterministic": [
3853
+ {
3854
+ "check": "validator-finding-absent",
3855
+ "finding_rule_id": "flutter.profile.social-counts-from-sdk"
3856
+ }
3857
+ ],
3858
+ "attestation": {
3859
+ "allowed": true,
3860
+ "host_agent_min_confidence": "high",
3861
+ "human_allowed": true,
3862
+ "evidence_required": [
3863
+ {
3864
+ "field": "profile_social_counts",
3865
+ "description": "Where follower/following counts are read from AmityUser or relationship live collections, or why the profile intentionally omits them.",
3866
+ "upload_policy": "upload-with-consent"
3867
+ }
3868
+ ]
3869
+ }
3870
+ }
3871
+ },
3872
+ {
3873
+ "id": "ios.profile.social-counts-from-sdk",
3874
+ "version": 1,
3875
+ "title": "iOS profile social counts must come from the SDK",
3876
+ "severity": "warning",
3877
+ "rationale": "Showing Followers/Following labels with placeholder values makes the profile look complete while silently dropping the social graph. social.plus user objects and relationship queries expose follower/following counts or lists.",
3878
+ "applies_when": {
3879
+ "platforms": [
3880
+ "ios"
3881
+ ],
3882
+ "outcomes": [
3883
+ "add-feed",
3884
+ "add-follow",
3885
+ "validate-setup"
3886
+ ]
3887
+ },
3888
+ "enforcement": {
3889
+ "deterministic": [
3890
+ {
3891
+ "check": "validator-finding-absent",
3892
+ "finding_rule_id": "ios.profile.social-counts-from-sdk"
3893
+ }
3894
+ ],
3895
+ "attestation": {
3896
+ "allowed": true,
3897
+ "host_agent_min_confidence": "high",
3898
+ "human_allowed": true,
3899
+ "evidence_required": [
3900
+ {
3901
+ "field": "profile_social_counts",
3902
+ "description": "Where follower/following counts are read from AmityUser or relationship live collections, or why the profile intentionally omits them.",
3903
+ "upload_policy": "upload-with-consent"
3904
+ }
3905
+ ]
3906
+ }
3907
+ }
3908
+ },
3617
3909
  {
3618
3910
  "id": "android.profile.social-counts-from-sdk",
3619
3911
  "version": 1,