@amityco/social-plus-vise 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/README.md +19 -9
  3. package/dist/capabilities.js +29 -1
  4. package/dist/entryState.js +71 -0
  5. package/dist/experience.js +70 -0
  6. package/dist/explore.js +1 -1
  7. package/dist/flow.js +382 -0
  8. package/dist/humanFormat.js +25 -0
  9. package/dist/intake.js +117 -0
  10. package/dist/intelligence/placement.js +2 -1
  11. package/dist/outcomes.js +126 -28
  12. package/dist/productExpectations.js +15 -0
  13. package/dist/requestReadiness.js +99 -0
  14. package/dist/server.js +566 -19
  15. package/dist/sidecar.js +5 -0
  16. package/dist/solutionPath.js +1 -1
  17. package/dist/tools/compliance.js +752 -125
  18. package/dist/tools/creative.js +14 -12
  19. package/dist/tools/design.js +321 -11
  20. package/dist/tools/experienceCompiler.js +1 -1
  21. package/dist/tools/experienceSensors.js +1 -1
  22. package/dist/tools/harness.js +13 -0
  23. package/dist/tools/integration.js +263 -90
  24. package/dist/tools/learning.js +1 -3
  25. package/dist/tools/project.js +963 -66
  26. package/dist/tools/smoke.js +134 -0
  27. package/dist/tools/uxHarness.js +54 -6
  28. package/dist/uikitCustomization.js +3 -1
  29. package/dist/version.js +7 -3
  30. package/package.json +1 -1
  31. package/packages/intelligence/catalog/catalog.schema.json +1 -1
  32. package/packages/intelligence/catalog/experience-objects.json +2 -2
  33. package/packages/intelligence/catalog/variants.json +24 -0
  34. package/rules/event.yaml +3 -0
  35. package/rules/feed.yaml +251 -0
  36. package/rules/invitation.yaml +4 -0
  37. package/rules/live-data.yaml +110 -0
  38. package/rules/notification-tray.yaml +4 -0
  39. package/rules/poll.yaml +5 -0
  40. package/rules/sdk-lifecycle.yaml +559 -0
  41. package/rules/search.yaml +5 -0
  42. package/rules/security.yaml +12 -0
  43. package/rules/story.yaml +5 -0
  44. package/rules/user-blocking.yaml +5 -0
  45. package/skills/social-plus-vise/SKILL.md +163 -15
@@ -1,6 +1,7 @@
1
1
  import { access, readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { productFindingIdentity } from "../productExpectations.js";
4
+ import { sidecarDir } from "../sidecar.js";
4
5
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
5
6
  import { findCallExpressions, findSwiftFunctionLocalDeclarations, parse, tryParse, pickLabeledArgument, pickObjectProperty, resolveLiteralValue, stripComments } from "./ast.js";
6
7
  async function exists(filePath) {
@@ -95,6 +96,13 @@ async function inspectRoot(root) {
95
96
  reason: "react-native dependency or script signal",
96
97
  });
97
98
  }
99
+ const hasJsSignal = signals.some((signal) => signal.platform === "typescript" || signal.platform === "react-native");
100
+ if (!hasJsSignal) {
101
+ const sourceSignal = await detectSdkSignalFromSource(root);
102
+ if (sourceSignal) {
103
+ signals.push(sourceSignal);
104
+ }
105
+ }
98
106
  const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
99
107
  const hasRN = rawPlatforms.includes("react-native");
100
108
  const hasAndroid = rawPlatforms.includes("android");
@@ -105,6 +113,198 @@ async function inspectRoot(root) {
105
113
  : rawPlatforms;
106
114
  return { platforms, signals, designSignals: await detectDesignSignals(root) };
107
115
  }
116
+ async function detectSdkSignalFromSource(root) {
117
+ const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".htm"], 500);
118
+ if (sourceFiles.length === 0) {
119
+ return null;
120
+ }
121
+ const sourceContent = await readMany(sourceFiles);
122
+ const rnFiles = filesMatching(sourceContent, [/@amityco\/ts-sdk-react-native/]);
123
+ if (rnFiles.length > 0) {
124
+ return { platform: "react-native", file: path.relative(root, rnFiles[0]), reason: "social.plus React Native SDK usage detected in source (no manifest)." };
125
+ }
126
+ const tsSpecifier = filesMatching(sourceContent, [/@amityco\/ts-sdk/]);
127
+ if (tsSpecifier.length > 0) {
128
+ return { platform: "typescript", file: path.relative(root, tsSpecifier[0]), reason: "social.plus TypeScript/JS SDK usage detected in source (no manifest — e.g. CDN/esm dynamic import)." };
129
+ }
130
+ const createClientFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /\bcreateClient\s*\(/]);
131
+ const corroborated = containsAny(sourceContent, [/\bPostRepository\b/, /\bCommentRepository\b/, /\bChannelRepository\b/, /\bMessageRepository\b/, /\bsessionWillRenewAccessToken\b/]);
132
+ if (createClientFiles.length > 0 && corroborated) {
133
+ return { platform: "typescript", file: path.relative(root, createClientFiles[0]), reason: "social.plus SDK API usage detected in source (createClient + repositories/session handler), no manifest." };
134
+ }
135
+ return null;
136
+ }
137
+ function hasSdkSourceUsage(sourceContent, platform) {
138
+ const implContent = new Map([...sourceContent].filter(([file]) => !/\.(?:d\.)?ts$/i.test(path.basename(file)) || !path.basename(file).endsWith(".d.ts")));
139
+ const content = implContent.size > 0 ? implContent : sourceContent;
140
+ const patterns = platform === "flutter"
141
+ ? [
142
+ /import\s+['"]package:amity_sdk\/amity_sdk\.dart['"]/,
143
+ /\bAmityCoreClient\b/,
144
+ /\bAmitySocialClient\b/,
145
+ /\bAmityUIKit\d*Manager\b/,
146
+ /\bAmity(?:Post|Comment|Community|Channel|Message|User)\b/,
147
+ ]
148
+ : platform === "ios"
149
+ ? [
150
+ /\bAmityUIKit\d*Manager\s*\.\s*(?:setup|registerDevice)\b/,
151
+ /\bAmityCollection\s*</,
152
+ /\.observe\s*\(/,
153
+ /\b(?:Amity)?[A-Za-z]*(?:Post|Comment|Community|Channel|Message|User|Reaction|Notification|Invitation|File|Room)[A-Za-z]*Repository(?:\s*\([^)]*\))?\s*\.\s*(?:get|query|create|update|delete|flag|join|leave|follow|unfollow|register|unregister)\w*\s*\(/,
154
+ /\b(?:get|query|create|update|delete|flag|join|leave|follow|unfollow)(?:Posts?|Comments?|Communit(?:y|ies)|Channels?|Messages?|Users?|Followers?|Followings?|Members?|Invitations?|Settings|Room|Rooms)?\s*\(/,
155
+ /\b(?:registerPushNotification|unregisterPushNotification)\s*\(/,
156
+ /\bnotificationManager\s*\.\s*getSettings\s*\(/,
157
+ ]
158
+ : [
159
+ /@amityco\/ts-sdk(?:-react-native)?/,
160
+ /\bClient\s*\.\s*(?:createClient|login)\b/,
161
+ /\bAmity(?:UIKit|UiKit)Provider\b/,
162
+ /\b(?:Post|Comment|Community|Channel|Message|User|Reaction|Notification)Repository\b/,
163
+ /\bcreate(?:Post|Comment|Channel|Message)\b/,
164
+ /\bget(?:Posts|Comments|Communities|Channels|Messages|Users|Followers|Followings)\b/,
165
+ ];
166
+ return containsAny(content, patterns);
167
+ }
168
+ function hasFeedSourceUsage(sourceContent, platform) {
169
+ const implContent = new Map([...sourceContent].filter(([file]) => {
170
+ const basename = path.basename(file);
171
+ return !(basename.endsWith(".d.ts") || basename.endsWith(".md"));
172
+ }));
173
+ const content = implContent.size > 0 ? implContent : sourceContent;
174
+ const patterns = platform === "flutter"
175
+ ? [
176
+ /\bAmitySocialClient\s*\.\s*newPostRepository\s*\(/,
177
+ /\bAmityPostRepository\b/,
178
+ /\bAmityPost\b/,
179
+ /\bcreate(?:Text|Image|Video|File|Poll|Clip|Room)?Post\b/,
180
+ /\b(?:get|query)Posts?\b/,
181
+ ]
182
+ : platform === "ios"
183
+ ? [
184
+ /\b(?:Amity)?[A-Za-z]*Post[A-Za-z]*Repository(?:\s*\([^)]*\))?\s*\.\s*(?:get|query|create|update|delete|flag)\w*\s*\(/,
185
+ /\b(?:query|get|create|edit|delete|flag)Posts?\s*\(/,
186
+ /\bcreate(?:Text|Image|Video|File|Poll|Clip|Room)?Post\s*\(/,
187
+ /\bAmityPost\b/,
188
+ /\bpost\s*\.\s*data\b/,
189
+ /\bcommentsCount\b/,
190
+ /\bpostId\b/,
191
+ ]
192
+ : [
193
+ /\bPostRepository\b/,
194
+ /\b(?:query|get|create|edit|delete|flag)Posts?\b/,
195
+ /\bcreate(?:Text|Image|Video|File|Poll|Clip|Room)?Post\b/,
196
+ /\bAmityPost\b/,
197
+ /\bAmity\s*\.\s*Post\b/,
198
+ /\bAmityPostContent\b/,
199
+ /\bpost\s*\.\s*data\b/,
200
+ /\bcommentsCount\b/,
201
+ /\bpostId\b/,
202
+ ];
203
+ return containsAny(content, patterns);
204
+ }
205
+ async function shouldRequireFeedSourceUsage(root) {
206
+ return await sidecarOutcomeInScope(root, "add-feed");
207
+ }
208
+ async function hasInstalledFeedBlockSource(root) {
209
+ const payload = await readJsonObjectIfExists(path.join(sidecarDir(root), "blocks.json"));
210
+ const installed = Array.isArray(payload?.installed) ? payload.installed : [];
211
+ for (const item of installed) {
212
+ if (!isJsonObject(item)) {
213
+ continue;
214
+ }
215
+ const blockId = typeof item.blockId === "string" ? item.blockId : "";
216
+ const capabilities = Array.isArray(item.providesCapabilities)
217
+ ? item.providesCapabilities.filter((value) => typeof value === "string")
218
+ : [];
219
+ const isFeedBlock = blockId === "feed" && capabilities.some((id) => id === "pagination" || id === "comment-preview");
220
+ if (isFeedBlock && await installedBlockEntryStillValid(root, item)) {
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ async function hasInstalledSdkBlockSource(root) {
227
+ const payload = await readJsonObjectIfExists(path.join(sidecarDir(root), "blocks.json"));
228
+ const installed = Array.isArray(payload?.installed) ? payload.installed : [];
229
+ for (const item of installed) {
230
+ if (!isJsonObject(item)) {
231
+ continue;
232
+ }
233
+ const dependencyName = typeof item.dependencyName === "string" ? item.dependencyName : "";
234
+ if (!dependencyName.startsWith("@socialplus/blocks-")) {
235
+ continue;
236
+ }
237
+ if (await installedBlockEntryStillValid(root, item)) {
238
+ return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+ async function installedBlockEntryStillValid(root, entry) {
244
+ const dependencyName = typeof entry.dependencyName === "string" ? entry.dependencyName : "";
245
+ if (!dependencyName) {
246
+ return false;
247
+ }
248
+ const platform = typeof entry.platform === "string" ? entry.platform : "";
249
+ if (platform === "flutter") {
250
+ const pubspec = await readIfExists(path.join(root, "pubspec.yaml"));
251
+ if (!new RegExp(`\\b${escapeRegExp(dependencyName)}\\s*:`).test(pubspec)) {
252
+ return false;
253
+ }
254
+ }
255
+ else {
256
+ const packageJson = await readIfExists(path.join(root, "package.json"));
257
+ if (!hasPackageDependency(packageJson, dependencyName)) {
258
+ return false;
259
+ }
260
+ }
261
+ const filesTouched = Array.isArray(entry.filesTouched) ? entry.filesTouched : [];
262
+ for (const touched of filesTouched) {
263
+ if (typeof touched !== "string" || touched.trim() === "") {
264
+ return false;
265
+ }
266
+ if (!(await exists(path.join(root, touched)))) {
267
+ return false;
268
+ }
269
+ }
270
+ return true;
271
+ }
272
+ async function sidecarOutcomeInScope(root, outcome) {
273
+ const dir = sidecarDir(root);
274
+ for (const file of ["compliance.json", "intake.json", "engagement.json"]) {
275
+ const payload = await readJsonObjectIfExists(path.join(dir, file));
276
+ if (!payload) {
277
+ continue;
278
+ }
279
+ if (payload.outcome === outcome || jsonArrayIncludes(payload.outcomes, outcome)) {
280
+ return true;
281
+ }
282
+ const scope = isJsonObject(payload.scope) ? payload.scope : undefined;
283
+ if (scope && jsonArrayIncludes(scope.outcomes, outcome)) {
284
+ return true;
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+ async function readJsonObjectIfExists(filePath) {
290
+ const raw = await readIfExists(filePath);
291
+ if (!raw) {
292
+ return null;
293
+ }
294
+ try {
295
+ const parsed = JSON.parse(raw);
296
+ return isJsonObject(parsed) ? parsed : null;
297
+ }
298
+ catch {
299
+ return null;
300
+ }
301
+ }
302
+ function isJsonObject(value) {
303
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
304
+ }
305
+ function jsonArrayIncludes(value, expected) {
306
+ return Array.isArray(value) && value.includes(expected);
307
+ }
108
308
  export const inspectProjectTool = {
109
309
  name: "inspect_project",
110
310
  description: "Inspect a customer repository and detect likely platform/framework signals.",
@@ -200,8 +400,10 @@ async function validateAndroid(root) {
200
400
  const versionCatalogContent = await readMany(versionCatalogFiles);
201
401
  const sourceFiles = await findFiles(root, [".kt", ".java"], 300);
202
402
  const sourceContent = await readMany(sourceFiles);
203
- const setupFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*setup/, /AmityClient\s*\.\s*setup/]);
204
- const loginFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*login/, /AmityClient\s*\.\s*login/]);
403
+ const setupFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*setup/, /AmityClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
404
+ const coreLoginFiles = filesMatching(sourceContent, [/AmityCoreClient\s*\.\s*login/, /AmityClient\s*\.\s*login/]);
405
+ const uikitLoginFiles = filesMatching(sourceContent, [/AmityUIKit\d*Manager\s*\.\s*registerDevice/]);
406
+ const loginFiles = [...new Set([...coreLoginFiles, ...uikitLoginFiles])];
205
407
  const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
206
408
  const pushUnregisterFiles = filesMatching(sourceContent, [/unregisterPushNotification/, /disablePushNotification/, /unregister.*DeviceToken/i]);
207
409
  const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.observe\s*\(/, /AmitySocialClient\s*\.\s*newPostRepository/, /\bgetPosts\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
@@ -242,8 +444,8 @@ async function validateAndroid(root) {
242
444
  if (loginFiles.length === 0) {
243
445
  findings.push(finding("android.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and before subscribing to social.plus collections."));
244
446
  }
245
- else if (!containsAny(sourceContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
246
- findings.push(finding("android.session.renewal", "warning", "Login was found but no SessionHandler with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a SessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
447
+ else if (coreLoginFiles.length > 0 && !hasSessionRenewalCall(sourceContent)) {
448
+ findings.push(finding("android.session.renewal", "warning", "Login was found but no SessionHandler with session renewal was detected.", relativeFile(root, coreLoginFiles[0]), "Pass a SessionHandler to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
247
449
  }
248
450
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
249
451
  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."));
@@ -276,6 +478,7 @@ async function validateAndroid(root) {
276
478
  findings.push(...validatePollVoteStatusGuard(root, "android", sourceContent));
277
479
  findings.push(...validateFollowStatusSubscription(root, "android", sourceContent));
278
480
  findings.push(...validatePostDataTypeHandled(root, "android", sourceContent));
481
+ findings.push(...validatePostBodyRendered(root, "android", sourceContent));
279
482
  findings.push(...validateAvatarFromSdk(root, "android", sourceContent));
280
483
  findings.push(...validatePollAnswerDataShape(root, "android", sourceContent));
281
484
  findings.push(...validateCommentsQueryHasLimit(root, "android", sourceContent));
@@ -313,22 +516,42 @@ async function validateAndroid(root) {
313
516
  async function validateFlutter(root) {
314
517
  const findings = [];
315
518
  const pubspec = await readIfExists(path.join(root, "pubspec.yaml"));
519
+ const pubspecLock = await readIfExists(path.join(root, "pubspec.lock"));
316
520
  const dartFiles = await findFiles(path.join(root, "lib"), [".dart"], 300);
317
521
  const dartContent = await readMany(dartFiles);
318
- const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/]);
319
- const loginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
522
+ const setupFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
523
+ const coreLoginFiles = filesMatching(dartContent, [/AmityCoreClient\s*\.\s*login/]);
524
+ const uikitLoginFiles = filesMatching(dartContent, [/AmityUIKit\d*Manager\s*\.\s*registerDevice/]);
525
+ const loginFiles = [...new Set([...coreLoginFiles, ...uikitLoginFiles])];
320
526
  const pushRegistrationFiles = filesMatching(dartContent, [/registerPushNotification/, /registerDeviceNotification/, /enablePushNotification/, /PushNotification/]);
321
527
  const pushUnregisterFiles = filesMatching(dartContent, [/unregisterPushNotification/, /unregisterDeviceNotification/, /disablePushNotification/]);
322
528
  const liveDataFiles = filesMatching(dartContent, [/LiveCollection/, /LiveObject/, /\.listen\s*\(/, /\.observe\s*\(/, /queryPosts\s*\(/, /getPost\s*\(/]);
529
+ const hasAmitySdkDependency = Boolean(pubspec && /\bamity_sdk\s*:/.test(pubspec));
323
530
  if (!pubspec) {
324
531
  findings.push(finding("flutter.pubspec.present", "warning", "No pubspec.yaml file was found.", "pubspec.yaml", "Point repoPath at the Flutter project root."));
325
532
  }
326
- else if (!/\bamity_sdk\s*:/.test(pubspec) && !pubspec.toLowerCase().includes("amity")) {
533
+ else if (!hasAmitySdkDependency && !pubspec.toLowerCase().includes("amity")) {
327
534
  findings.push(finding("flutter.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in pubspec.yaml.", "pubspec.yaml", "Add the Flutter SDK dependency from the Flutter quick-start docs."));
328
535
  }
329
- else if (/\bamity_sdk\s*:\s*(?:any|\*|latest)\b/i.test(pubspec)) {
330
- findings.push(finding("flutter.sdk.version.pinned", "warning", "The Flutter SDK dependency uses an uncontrolled version (e.g. any / a moving git ref), not a pinned version or reviewed semver range.", "pubspec.yaml", "Pin amity_sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
536
+ else if (flutterAmitySdkIsFloating(pubspec)) {
537
+ findings.push(finding("flutter.sdk.version.pinned", "warning", "The Flutter SDK dependency uses an uncontrolled version (any / * / latest / a bare or open-ended range / a moving git ref), not a pinned version or reviewed semver range.", "pubspec.yaml", "Pin amity_sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
331
538
  }
539
+ if (pubspec && hasAmitySdkDependency) {
540
+ const sdkSourceSatisfiedByBlock = await hasInstalledSdkBlockSource(root);
541
+ const feedSourceRequired = await shouldRequireFeedSourceUsage(root);
542
+ const feedSourceSatisfiedByBlock = feedSourceRequired ? await hasInstalledFeedBlockSource(root) : false;
543
+ const dioProblem = flutterDioCompatibilityProblem(pubspec, pubspecLock);
544
+ if (dioProblem) {
545
+ findings.push(finding("flutter.dependency.dio-compatible", "warning", dioProblem.message, dioProblem.file, "Add a direct `dio: 5.9.2` dependency or a bounded `<5.10.0` constraint, then run `flutter pub get` and verify pubspec.lock resolves dio below 5.10.0 before building Android."));
546
+ }
547
+ if (!sdkSourceSatisfiedByBlock && !hasSdkSourceUsage(dartContent, "flutter")) {
548
+ findings.push(finding("flutter.sdk.source-used", "warning", "The Flutter SDK dependency is installed, but no social.plus SDK import or API usage was found in lib/*.dart.", "pubspec.yaml", "Route the product surface to real SDK-backed code: import package:amity_sdk/amity_sdk.dart, initialize/login, and query or create the target community/feed/chat/profile data. A dependency-only or static social shell is not a completed integration."));
549
+ }
550
+ if (feedSourceRequired && !feedSourceSatisfiedByBlock && !hasFeedSourceUsage(dartContent, "flutter")) {
551
+ findings.push(finding("flutter.feed.source-used", "warning", "The active Flutter source uses social.plus, but no feed/post SDK API usage was found.", "pubspec.yaml", "For an add-feed surface, route product UI to post/feed source: query or create posts through AmitySocialClient.newPostRepository()/AmityPostRepository, render SDK post data, and wire composer/comment/reaction affordances. Community-only SDK usage is not a feed integration."));
552
+ }
553
+ }
554
+ findings.push(...validateFlutterUnrenderedSdkData(root, dartContent));
332
555
  if (setupFiles.length === 0) {
333
556
  findings.push(finding("flutter.setup.present", "warning", "No obvious AmityCoreClient.setup call was found in lib/*.dart.", undefined, "Call AmityCoreClient.setup before any social.plus API usage."));
334
557
  }
@@ -339,12 +562,19 @@ async function validateFlutter(root) {
339
562
  if (!containsAnyForFiles(dartContent, setupFiles, [/AmityEndpoint\./, /\bendpoint\s*:/, /\bregion\s*:/, /\bhttpEndpoint\s*:/i, /\bmqttEndpoint\s*:/i, /\buploadEndpoint\s*:/i, /\bAmityRegional(?:Http|Mqtt)Endpoint\b/, /\bAmityRegion\b/])) {
340
563
  findings.push(finding("flutter.setup.region", "warning", "SDK setup was found but no explicit endpoint/region marker was detected.", relativeFile(root, setupFiles[0]), "Set the endpoint/region explicitly to match the customer's social.plus console project."));
341
564
  }
565
+ if ((coreLoginFiles.length > 0 || liveDataFiles.length > 0) && !containsAnyForFiles(dartContent, setupFiles, [/\bsycInitialization\s*:\s*true\b/])) {
566
+ findings.push(finding("flutter.setup.sync-initialization", "warning", "Flutter SDK setup is followed by login or live queries but does not request synchronous SDK initialization.", relativeFile(root, setupFiles[0]), "Pass `sycInitialization: true` to `AmityCoreClient.setup(...)`, or otherwise gate every login/query on a proven SDK-ready signal. Without this, runtime can fail with `MessageDbAdapter not ready` while build/analyze still pass."));
567
+ }
342
568
  }
343
569
  if (loginFiles.length === 0) {
344
570
  findings.push(finding("flutter.login.present", "warning", "No obvious AmityCoreClient.login call was found.", undefined, "Call login after the app has a known user identity and before social.plus queries/subscriptions."));
345
571
  }
346
- else if (!containsAny(dartContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /SessionHandler/, /renewal\s*\.\s*renew\s*\(/])) {
347
- findings.push(finding("flutter.session.renewal", "warning", "Login was found but no session handler callback with session renewal was detected.", relativeFile(root, loginFiles[0]), "Pass a session handler callback (AccessTokenRenewal renewal) to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
572
+ else if (coreLoginFiles.length > 0 && !hasSessionRenewalCall(dartContent)) {
573
+ findings.push(finding("flutter.session.renewal", "warning", "Login was found but no session handler callback with session renewal was detected.", relativeFile(root, coreLoginFiles[0]), "Pass a session handler callback (AccessTokenRenewal renewal) to login and call renewal.renew() in sessionWillRenewAccessToken so sessions refresh in production."));
574
+ }
575
+ const sessionObserverFiles = filesMatching(dartContent, [/AmityCoreClient\.observeSessionState\s*\(\s*\)\s*\.listen/]);
576
+ if (coreLoginFiles.length > 0 && sessionObserverFiles.length > 0 && !hasFlutterInitialLoginKickoff(dartContent)) {
577
+ findings.push(finding("flutter.session.initial-login-kickoff", "warning", "Flutter login appears to depend only on observeSessionState listener emissions, with no immediate initial login/session kickoff.", relativeFile(root, sessionObserverFiles[0]), "After subscribing to observeSessionState(), also seed the current session path immediately: call the idempotent login method from initState/Future.microtask/addPostFrameCallback, or otherwise synchronously drive the NotLoggedIn/Established branch. Do not rely on the listener to emit the initial state before the first screenshot."));
348
578
  }
349
579
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
350
580
  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."));
@@ -353,8 +583,8 @@ async function validateFlutter(root) {
353
583
  if (flutterPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
354
584
  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."));
355
585
  }
356
- if (liveDataFiles.length > 0 && !containsAny(dartContent, [/StreamSubscription/, /\.cancel\s*\(/, /dispose\s*\(/])) {
357
- 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."));
586
+ if (liveDataFiles.length > 0 && !containsAnyStripped(dartContent, "flutter", [/\.cancel\s*\(/, /\bdispose\s*\(/])) {
587
+ 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 (.cancel()) from dispose() or the owning lifecycle cleanup. Declaring a StreamSubscription field alone does not cancel the stream."));
358
588
  }
359
589
  findings.push(...validateLiteralGuardrails(root, "flutter", dartContent));
360
590
  findings.push(...validateLoggingHygiene(root, "flutter", dartContent));
@@ -373,6 +603,7 @@ async function validateFlutter(root) {
373
603
  findings.push(...validateBlockedUsers(root, "flutter", dartContent));
374
604
  findings.push(...validatePollVoteStatusGuard(root, "flutter", dartContent));
375
605
  findings.push(...validatePostDataTypeHandled(root, "flutter", dartContent));
606
+ findings.push(...validatePostBodyRendered(root, "flutter", dartContent));
376
607
  findings.push(...validateAvatarFromSdk(root, "flutter", dartContent));
377
608
  findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
378
609
  findings.push(...validateCommentsQueryHasLimit(root, "flutter", dartContent));
@@ -410,8 +641,12 @@ async function validateTypeScript(root, platform) {
410
641
  const packageJson = await readIfExists(path.join(root, "package.json"));
411
642
  const sourceFiles = await findFiles(root, [".ts", ".tsx", ".js", ".jsx"], 500);
412
643
  const sourceContent = await readMany(sourceFiles);
413
- const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/]);
414
- const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
644
+ const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/, /Amity(?:UIKit|UiKit)Provider/]);
645
+ const rawLoginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
646
+ const uikitLoginFiles = [...sourceContent]
647
+ .filter(([, content]) => /Amity(?:UIKit|UiKit)Provider/.test(content) && /\buserId\b/.test(content))
648
+ .map(([file]) => file);
649
+ const loginFiles = [...new Set([...rawLoginFiles, ...uikitLoginFiles])];
415
650
  const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
416
651
  const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
417
652
  const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
@@ -423,9 +658,34 @@ async function validateTypeScript(root, platform) {
423
658
  else if (!packageJson.includes("@amityco/ts-sdk") && !packageJson.toLowerCase().includes("amity")) {
424
659
  findings.push(finding("typescript.dependency.sdk", "warning", "No obvious social.plus/Amity package was found in package.json.", "package.json", "Add the TypeScript SDK dependency from the web quick-start docs."));
425
660
  }
426
- else if (isFloatingPackageDependency(packageJson, "@amityco/ts-sdk")) {
427
- findings.push(finding(`${platform}.sdk.version.pinned`, "warning", "The TypeScript SDK dependency uses an uncontrolled version (latest / * / x), not a pinned version or reviewed semver range (^/~ is accepted).", "package.json", "Pin @amityco/ts-sdk to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
661
+ else if (isFloatingPackageDependency(packageJson, "@amityco/ts-sdk") || isFloatingPackageDependency(packageJson, "@amityco/ts-sdk-react-native")) {
662
+ findings.push(finding(`${platform}.sdk.version.pinned`, "warning", "The social.plus SDK dependency uses an uncontrolled version (latest / * / x), not a pinned version or reviewed semver range (^/~ is accepted).", "package.json", "Pin the social.plus SDK (@amityco/ts-sdk, or @amityco/ts-sdk-react-native for React Native) to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
663
+ }
664
+ const hasTsSdkDependency = hasPackageDependency(packageJson, "@amityco/ts-sdk") || hasPackageDependency(packageJson, "@amityco/ts-sdk-react-native");
665
+ const sdkSourceSatisfiedByBlock = hasTsSdkDependency ? await hasInstalledSdkBlockSource(root) : false;
666
+ const feedSourceRequired = await shouldRequireFeedSourceUsage(root);
667
+ const feedSourceSatisfiedByBlock = feedSourceRequired ? await hasInstalledFeedBlockSource(root) : false;
668
+ if (hasTsSdkDependency && !sdkSourceSatisfiedByBlock && !hasSdkSourceUsage(sourceContent, platform)) {
669
+ findings.push(finding(`${platform}.sdk.source-used`, "warning", "The social.plus SDK dependency is installed, but no SDK import, provider, client setup, or repository usage was found in source files.", "package.json", "Route the product surface to real SDK-backed code: import the social.plus SDK or UIKit provider, initialize/login, and query or create the target community/feed/chat/profile data. A dependency-only or static social shell is not a completed integration."));
428
670
  }
671
+ if (hasTsSdkDependency && feedSourceRequired && !feedSourceSatisfiedByBlock && !hasFeedSourceUsage(sourceContent, platform)) {
672
+ findings.push(finding(`${platform}.feed.source-used`, "warning", "The active source uses social.plus, but no feed/post SDK API usage was found.", "package.json", "For an add-feed surface, route product UI to post/feed source: query or create posts through PostRepository, render SDK post data, and wire composer/comment/reaction affordances. Community-only SDK usage is not a feed integration."));
673
+ }
674
+ findings.push(...validateRuntimeProbeUi(root, platform, sourceContent));
675
+ if (platform === "react-native" && hasPackageDependency(packageJson, "@amityco/ts-sdk-react-native") && !hasPackageDependency(packageJson, "@react-native-async-storage/async-storage")) {
676
+ findings.push(finding("react-native.dependency.async-storage", "warning", "The React Native social.plus SDK is present, but @react-native-async-storage/async-storage is not listed in package.json.", "package.json", "Add @react-native-async-storage/async-storage as an app dependency and rebuild the native app so React Native autolinks the storage module required by the SDK. A TypeScript build can pass while the APK crashes with `NativeModule: AsyncStorage is null`."));
677
+ }
678
+ if (platform === "react-native" && hasPackageDependency(packageJson, "@amityco/ts-sdk-react-native") && !hasPackageDependency(packageJson, "@react-native-community/netinfo")) {
679
+ findings.push(finding("react-native.dependency.netinfo", "warning", "The React Native social.plus SDK is present, but @react-native-community/netinfo is not listed in package.json.", "package.json", "Add @react-native-community/netinfo as an app dependency and rebuild the native app so React Native autolinks RNCNetInfo. A TypeScript or Gradle build can pass while the APK crashes with `@react-native-community/netinfo: NativeModule.RNCNetInfo is null`."));
680
+ }
681
+ const asyncStorageVersion = packageDependencyVersion(packageJson, "@react-native-async-storage/async-storage");
682
+ if (platform === "react-native" &&
683
+ hasPackageDependency(packageJson, "@amityco/ts-sdk-react-native") &&
684
+ asyncStorageVersion &&
685
+ !isReactNativeAsyncStorageVersionCompatible(asyncStorageVersion)) {
686
+ findings.push(finding("react-native.dependency.async-storage-compatible", "warning", `@react-native-async-storage/async-storage is declared as ${asyncStorageVersion}, which is likely incompatible with the current social.plus React Native SDK dependency graph.`, "package.json", "Use an AsyncStorage v1.x range compatible with @amityco/ts-sdk-react-native 7.x, such as ^1.24.0, then reinstall so npm dedupes to a single AsyncStorage copy. Installing the latest 2.x/3.x package can leave the SDK using a nested JS copy while the native module is registered from another package, causing `NativeModule: AsyncStorage is null` at runtime."));
687
+ }
688
+ findings.push(...validateNoInternalSdkImports(root, platform, sourceContent));
429
689
  if (setupFiles.length === 0) {
430
690
  findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
431
691
  }
@@ -436,7 +696,8 @@ async function validateTypeScript(root, platform) {
436
696
  if (!setupHasKeywordRegion && !setupHasNamedRegionDecl && !projectHasEnvRegion) {
437
697
  findings.push(finding("typescript.client.region", "warning", "Client initialization was found but no explicit region or endpoint marker was detected.", relativeFile(root, setupFiles[0]), "Pass the region or endpoint that matches the customer's social.plus console project. Reuse the app's existing config or environment source of truth if one exists, and do not hardcode a guessed default region just to make setup appear green."));
438
698
  }
439
- const renderCycleSetup = setupFiles.find((file) => /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*\s*=/.test(sourceContent.get(file) ?? ""));
699
+ const componentColocatedWithSetup = /useEffect|function\s+[A-Z]\w*\s*\(|const\s+[A-Z]\w*(?:\s*:\s*[^=]+)?\s*=\s*(?:React\.)?(?:memo\(|forwardRef\(|\([^)]*\)\s*(?::[^=>]+)?=>|[A-Za-z_$][\w$]*\s*=>|function\b|class\b)/;
700
+ const renderCycleSetup = setupFiles.find((file) => componentColocatedWithSetup.test(sourceContent.get(file) ?? ""));
440
701
  if (renderCycleSetup && platform === "react-native") {
441
702
  findings.push(finding("react-native.setup.lifecycle", "warning", "Client setup appears near component lifecycle code.", relativeFile(root, renderCycleSetup), "Keep client setup in a stable app initialization module to avoid duplicate initialization on remount."));
442
703
  }
@@ -444,9 +705,10 @@ async function validateTypeScript(root, platform) {
444
705
  if (loginFiles.length === 0) {
445
706
  findings.push(finding("typescript.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity, and await it before subscribing to live collections."));
446
707
  }
447
- if (loginFiles.length > 0 && !containsAny(sourceContent, [/sessionWillRenewAccessToken/, /sessionHandler/, /renewal\.renew/])) {
448
- findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, loginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) in the existing login path so sessions refresh in production. Preserve the current session owner and retain the handler for the full session lifetime."));
708
+ if (rawLoginFiles.length > 0 && !hasSessionRenewalCall(sourceContent)) {
709
+ findings.push(finding(`${platform}.session.renewal`, "warning", "Login was found but no access-token renewal handler marker was detected.", relativeFile(root, rawLoginFiles[0]), "Implement a session handler that renews the access token (sessionWillRenewAccessToken + renewal.renew()) in the existing login path so sessions refresh in production. Preserve the current session owner and retain the handler for the full session lifetime."));
449
710
  }
711
+ findings.push(...validateClientSessionStateAfterCreate(root, platform, sourceContent));
450
712
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
451
713
  findings.push(finding(`${platform}.push.unregister.present`, "warning", "Push registration was found but no obvious unregister call was detected.", relativeFile(root, pushRegistrationFiles[0]), "Unregister device tokens when the user logs out or the component unmounts. In React/React Native, return the unregister call from useEffect: return () => { NotificationRepository.unregisterPushNotification(deviceToken).catch(() => {}); }. Also update any local registration-state you maintain. Missing unregistration means notifications continue arriving after logout, which is a privacy and UX bug."));
452
714
  }
@@ -455,7 +717,9 @@ async function validateTypeScript(root, platform) {
455
717
  }
456
718
  findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
457
719
  findings.push(...validateLoggingHygiene(root, platform, sourceContent));
720
+ findings.push(...validateUnsafeUserContentHtml(root, platform, sourceContent));
458
721
  findings.push(...validateFeedUiStates(root, platform, sourceContent));
722
+ findings.push(...validateLoadingStatePresent(root, platform, sourceContent));
459
723
  findings.push(...validateFeedPagination(root, platform, sourceContent));
460
724
  findings.push(...validateFeedModerationAffordance(root, platform, sourceContent));
461
725
  findings.push(...validateLogoutOnUserSwitch(root, platform, sourceContent));
@@ -475,7 +739,9 @@ async function validateTypeScript(root, platform) {
475
739
  findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
476
740
  findings.push(...validateActivityPostsTagFilter(root, platform, sourceContent));
477
741
  findings.push(...validatePostDataTypeHandled(root, platform, sourceContent));
742
+ findings.push(...validatePostBodyRendered(root, platform, sourceContent));
478
743
  findings.push(...validateAvatarFromSdk(root, platform, sourceContent));
744
+ findings.push(...validateSdkFieldShape(root, platform, sourceContent));
479
745
  findings.push(...validatePollAnswerDataShape(root, platform, sourceContent));
480
746
  findings.push(...validateCommentsQueryHasLimit(root, platform, sourceContent));
481
747
  findings.push(...validateCommentCreationAffordance(root, platform, sourceContent));
@@ -513,6 +779,111 @@ async function validateTypeScript(root, platform) {
513
779
  findings.push(...(await validateEnvSecretHygiene(root, platform, sourceContent)));
514
780
  return findings;
515
781
  }
782
+ function validateNoInternalSdkImports(root, platform, sourceContent) {
783
+ if (platform !== "typescript" && platform !== "react-native") {
784
+ return [];
785
+ }
786
+ const findings = [];
787
+ const internalImportPattern = /(?:from\s*["']|import\s*\(\s*["']|require\s*\(\s*["'])(@amityco\/ts-sdk(?:-react-native)?\/[^"']+)/g;
788
+ for (const [file, raw] of sourceContent) {
789
+ if (path.basename(file).endsWith(".d.ts")) {
790
+ continue;
791
+ }
792
+ const content = commentStripped(file, platform, raw);
793
+ let match;
794
+ internalImportPattern.lastIndex = 0;
795
+ while ((match = internalImportPattern.exec(content)) !== null) {
796
+ findings.push(finding(`${platform}.sdk.no-internal-imports`, "warning", `Source imports an internal social.plus SDK subpath: ${match[1]}.`, relativeFile(root, file), "Import social.plus APIs only from the package root (`@amityco/ts-sdk` or `@amityco/ts-sdk-react-native`). Do not deep-import from `dist/`, `src/`, `client/`, or event internals; Metro/release bundling can fail even when TypeScript or Jest passes. Use documented top-level exports such as Client and repositories."));
797
+ break;
798
+ }
799
+ }
800
+ return findings;
801
+ }
802
+ function validateRuntimeProbeUi(root, platform, sourceContent) {
803
+ if (platform !== "react-native") {
804
+ return [];
805
+ }
806
+ const findings = [];
807
+ for (const [file, raw] of sourceContent) {
808
+ if (path.basename(file).endsWith(".d.ts")) {
809
+ continue;
810
+ }
811
+ const content = commentStripped(file, platform, raw);
812
+ const rel = relativeFile(root, file);
813
+ if (/<Text[^>]*>\s*Runtime\s*<\/Text>|["'`]Runtime["'`]|Waiting for (?:crew discovery|feed|profile context)|(?:crew discovery|feed sync|profile graph)[^"'`\n]*needs another pass|services are available for this member|\bProbeRow\b/i.test(content)) {
814
+ findings.push(finding("react-native.ui.runtime-probe-copy", "warning", "React Native product UI appears to expose runtime/probe status copy instead of normal product state.", rel, "Keep runtime proof in BENCHMARK_AGENT_NOTES.md, screenshots, or sp-vise evidence. The visible app should render SDK-returned communities, posts, comments, reactions, and profile/follow state as product UI, not a Runtime/Probe/status panel."));
815
+ }
816
+ if (/\bvoid\s+(?:ReactionRepository|PostRepository|CommentRepository|CommunityRepository|Client)\b|hiddenTouchTarget|Keep reaction services warm|opacity\s*:\s*0[\s\S]{0,220}(?:ReactionRepository|services warm)/i.test(content)) {
817
+ findings.push(finding("react-native.ui.hidden-sdk-noop-control", "warning", "React Native source appears to add a hidden/no-op SDK control instead of a real visible affordance.", rel, "Do not satisfy SDK-usage scanners with hidden controls or `void Repository` markers. Wire visible product actions to real SDK calls, such as add/remove reaction, create post/comment, report content, or follow/unfollow."));
818
+ }
819
+ }
820
+ return findings;
821
+ }
822
+ function validateFlutterUnrenderedSdkData(root, sourceContent) {
823
+ const findings = [];
824
+ const sdkDataQueryPattern = /\bAmity(?:Post|Comment|Community|Channel|Message|User|Follow|Reaction|Notification)Repository\b|\bAmitySocialClient\b|\bnew(?:Post|Comment|Community|Channel|Message|User)Repository\s*\(|\bget(?:Posts|Post|Comments|Communities|Community|Channels|Messages|Users|Followers|Followings|Members|RecommendedCommunities|TrendingCommunities)\b|\bcreate(?:Post|Comment|Community|Channel|Message)\b|\baddReaction\b|\bremoveReaction\b|\bflag(?:Post|Comment|Message)\b/i;
825
+ const sdkListStatePattern = /\b(?:final\s+)?List\s*<\s*Amity(?:Post|Comment|Community|Channel|Message|User|CommunityMember)[^>]*>\s+(_?[A-Za-z][A-Za-z0-9_]*)\s*=/g;
826
+ for (const [file, raw] of sourceContent) {
827
+ const content = commentStripped(file, "flutter", raw);
828
+ if (!sdkDataQueryPattern.test(content)) {
829
+ continue;
830
+ }
831
+ const buildStart = content.indexOf("Widget build(");
832
+ const buildContent = buildStart >= 0 ? content.slice(buildStart) : "";
833
+ let match;
834
+ sdkListStatePattern.lastIndex = 0;
835
+ while ((match = sdkListStatePattern.exec(content)) !== null) {
836
+ const variableName = match[1];
837
+ const usagePattern = new RegExp(`\\b${escapeRegExp(variableName)}\\b`, "g");
838
+ const usageCount = content.match(usagePattern)?.length ?? 0;
839
+ const usedInBuild = new RegExp(`\\b${escapeRegExp(variableName)}\\b`).test(buildContent);
840
+ if (usageCount <= 3 || !usedInBuild) {
841
+ findings.push(finding("flutter.ui.sdk-data-rendered", "warning", `Flutter source stores SDK data in "${variableName}" but does not render or otherwise consume it from the widget tree.`, relativeFile(root, file), "Render SDK-returned communities/posts/comments/users in the visible product UI, pass them into a rendered child widget, or remove the unused query. Hidden SDK queries and unused state are not runtime-visible product proof."));
842
+ break;
843
+ }
844
+ }
845
+ }
846
+ return findings;
847
+ }
848
+ function validateClientSessionStateAfterCreate(root, platform, sourceContent) {
849
+ if (platform !== "typescript" && platform !== "react-native") {
850
+ return [];
851
+ }
852
+ const findings = [];
853
+ const sessionStatePattern = /\bClient\s*\.\s*isConnected\s*\(/g;
854
+ for (const [file, raw] of sourceContent) {
855
+ if (path.basename(file).endsWith(".d.ts")) {
856
+ continue;
857
+ }
858
+ const content = commentStripped(file, platform, raw);
859
+ let match;
860
+ sessionStatePattern.lastIndex = 0;
861
+ while ((match = sessionStatePattern.exec(content)) !== null) {
862
+ if (clientStateReadHasLocalGuard(content, match.index)) {
863
+ continue;
864
+ }
865
+ if (clientStateReadHasSameFunctionCreate(content, match.index)) {
866
+ continue;
867
+ }
868
+ findings.push(finding(`${platform}.client.session-state-after-create`, "warning", "Client session state is read before this file proves the client has been created.", relativeFile(root, file), "Do not call Client.isConnected() from module scope, React state initializers, or other code that can run before Client.createClient(...). Track a local `clientCreated`/`clientReady` flag, return a safe not-ready state until setup runs, and only call Client.isConnected() after the createClient path has completed. Otherwise React Native can crash at launch with `Amity SDK (800000): There is no active client`."));
869
+ break;
870
+ }
871
+ }
872
+ return findings;
873
+ }
874
+ function clientStateReadHasLocalGuard(content, callIndex) {
875
+ const prefix = content.slice(Math.max(0, callIndex - 220), callIndex);
876
+ const guardName = /\b(?:clientCreated|hasClient|clientReady|isClientReady|clientInitialized|isClientInitialized|clientReadyRef\.current|clientCreatedRef\.current)\b/;
877
+ if (!guardName.test(prefix)) {
878
+ return false;
879
+ }
880
+ return /(?:&&|\?|if\s*\([^)]*)\s*$/.test(prefix) || /\bif\s*\([^)]*(?:clientCreated|hasClient|clientReady|isClientReady|clientInitialized|isClientInitialized|clientReadyRef\.current|clientCreatedRef\.current)[^)]*\)\s*\{?[^{};]{0,160}$/.test(prefix);
881
+ }
882
+ function clientStateReadHasSameFunctionCreate(content, callIndex) {
883
+ const functionStart = Math.max(content.lastIndexOf("function ", callIndex), content.lastIndexOf("=>", callIndex));
884
+ const localPrefix = content.slice(functionStart >= 0 ? functionStart : Math.max(0, callIndex - 300), callIndex);
885
+ return /(?:\bClient\s*\.\s*createClient\b|\bClient\s*\.\s*create\s*\(|\bcreateClient\s*\()/.test(localPrefix);
886
+ }
516
887
  async function validateEnvSecretHygiene(root, platform, sourceContent) {
517
888
  const findings = [];
518
889
  if (platform === "typescript") {
@@ -588,7 +959,7 @@ function validateClientNoSsrInit(root, sourceContent) {
588
959
  const isServerContext = serverContextPathPatterns.some((p) => p.test(basename)) || serverContextContentPatterns.some((p) => p.test(content));
589
960
  if (isServerContext) {
590
961
  findings.push(finding("typescript.client.no-ssr-init", "error", "TypeScript SDK client must not be initialized in a server-side context.", relativeFile(root, file), "If this is a Next.js project, SDK initialization must happen client-side only. Wrap the client initialization call (Client.create() or createClient()) in a 'use client' component, a useEffect, or use dynamic() with { ssr: false }. Do not call it in a Server Component, layout file, or getServerSideProps."));
591
- break;
962
+ continue;
592
963
  }
593
964
  }
594
965
  return findings;
@@ -602,7 +973,13 @@ function validateLoggingHygiene(root, platform, sourceContent) {
602
973
  const piiShape = /\b(user[_-]?name|display[_-]?name|full[_-]?name|email|phone|phone[_-]?number|address|date[_-]?of[_-]?birth|dob|ssn|national[_-]?id)\b/i;
603
974
  const findings = [];
604
975
  for (const [file, content] of sourceContent) {
976
+ const rel = relativeFile(root, file);
977
+ let firedSecret = false;
978
+ let firedPii = false;
605
979
  for (const line of content.split(/\r?\n/)) {
980
+ if (firedSecret && firedPii) {
981
+ break;
982
+ }
606
983
  if (!logCallPattern.test(line)) {
607
984
  continue;
608
985
  }
@@ -610,19 +987,85 @@ function validateLoggingHygiene(root, platform, sourceContent) {
610
987
  if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
611
988
  continue;
612
989
  }
613
- if (secretShape.test(line) && !findings.some((f) => f.ruleId === `${platform}.logging.no-secret-in-log`)) {
614
- 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."));
615
- }
616
- if (piiShape.test(line) && !findings.some((f) => f.ruleId === `${platform}.logging.no-pii-in-log`)) {
617
- 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."));
990
+ if (!firedSecret && secretShape.test(line)) {
991
+ findings.push(finding(`${platform}.logging.no-secret-in-log`, "warning", "A logging call appears to reference a secret-shaped identifier (apiKey, accessToken, etc.).", rel, "Do not log secret-shaped values. Drop the log line, redact the value, or attest that the value being logged is a placeholder."));
992
+ firedSecret = true;
618
993
  }
619
- if (findings.length >= 2) {
620
- return findings;
994
+ if (!firedPii && piiShape.test(line)) {
995
+ 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.).", rel, "Do not log user PII. Redact the value, use an anonymized ID, or attest that the data is not real user PII."));
996
+ firedPii = true;
621
997
  }
622
998
  }
623
999
  }
624
1000
  return findings;
625
1001
  }
1002
+ const SOCIAL_PLUS_USER_CONTENT_MARKER = /@amityco\/ts-sdk|\b(?:PostRepository|CommentRepository|CommunityRepository|UserRepository|MessageRepository|FeedRepository|ChannelRepository)\b|\b(?:post|comment|message|community|user|member|profile|channel|author|creator)s?\s*(?:\?\.|\.)\s*(?:data|metadata|displayName|avatar|avatarUrl|avatarFileId|postedUserId|userId|text|description|name)\b|\b(?:postedUserId|displayName|avatarUrl|avatarFileId)\b/i;
1003
+ const HTML_SANITIZER_MARKER = /\b(?:DOMPurify\s*\.\s*sanitize|sanitizeHtml|escapeHtml|escapeHTML|encodeHTML|htmlEscape)\s*\(/i;
1004
+ function validateUnsafeUserContentHtml(root, platform, sourceContent) {
1005
+ if (platform !== "typescript") {
1006
+ return [];
1007
+ }
1008
+ const findings = [];
1009
+ for (const [file, raw] of sourceContent) {
1010
+ if (path.basename(file).endsWith(".d.ts")) {
1011
+ continue;
1012
+ }
1013
+ const content = commentStripped(file, platform, raw);
1014
+ if (!SOCIAL_PLUS_USER_CONTENT_MARKER.test(content)) {
1015
+ continue;
1016
+ }
1017
+ const riskyBindings = collectUserContentBindings(content);
1018
+ const riskyExpression = riskyHtmlExpressionPattern(riskyBindings);
1019
+ const sinks = htmlSinkExpressions(content);
1020
+ if (!sinks.some((expression) => isUnsafeUserContentHtmlExpression(expression, riskyExpression))) {
1021
+ continue;
1022
+ }
1023
+ findings.push(finding("typescript.security.no-unsafe-inner-html", "warning", "SDK/user-shaped content is rendered through innerHTML or another raw HTML sink.", relativeFile(root, file), "Render SDK text with textContent/createTextNode/React text children, or sanitize with DOMPurify before using innerHTML, insertAdjacentHTML, or dangerouslySetInnerHTML."));
1024
+ }
1025
+ return findings;
1026
+ }
1027
+ function collectUserContentBindings(content) {
1028
+ const bindings = new Set();
1029
+ const declaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
1030
+ let match;
1031
+ while ((match = declaration.exec(content)) !== null) {
1032
+ if (SOCIAL_PLUS_USER_CONTENT_MARKER.test(match[2])) {
1033
+ bindings.add(match[1]);
1034
+ }
1035
+ }
1036
+ return bindings;
1037
+ }
1038
+ function riskyHtmlExpressionPattern(bindings) {
1039
+ const bindingPattern = [...bindings].map(escapeRegExp).join("|");
1040
+ const basePattern = [
1041
+ "\\b(?:post|posts|comment|comments|message|messages|community|communities|user|users|member|members|profile|channel|author|creator)\\b",
1042
+ "\\b(?:postedUserId|displayName|avatarUrl|avatarFileId|creatorId|userId)\\b",
1043
+ "\\b(?:err|error)\\s*\\.\\s*message\\b",
1044
+ ];
1045
+ if (bindingPattern) {
1046
+ basePattern.push(`\\b(?:${bindingPattern})\\b`);
1047
+ }
1048
+ return new RegExp(basePattern.join("|"), "i");
1049
+ }
1050
+ function htmlSinkExpressions(content) {
1051
+ const expressions = [];
1052
+ collectMatches(content, /\.\s*innerHTML\s*=\s*([\s\S]{0,1200}?)(?:;|\n\s*\n|$)/g, expressions);
1053
+ collectMatches(content, /\.\s*insertAdjacentHTML\s*\(\s*["'][^"']+["']\s*,\s*([\s\S]{0,1200}?)(?:\)\s*;?|\n\s*\n|$)/g, expressions);
1054
+ collectMatches(content, /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([\s\S]{0,1200}?)(?:\}\s*\}|$)/g, expressions);
1055
+ return expressions;
1056
+ }
1057
+ function collectMatches(content, pattern, expressions) {
1058
+ let match;
1059
+ while ((match = pattern.exec(content)) !== null) {
1060
+ expressions.push(match[1] ?? "");
1061
+ }
1062
+ }
1063
+ function isUnsafeUserContentHtmlExpression(expression, riskyExpression) {
1064
+ if (HTML_SANITIZER_MARKER.test(expression)) {
1065
+ return false;
1066
+ }
1067
+ return riskyExpression.test(expression);
1068
+ }
626
1069
  async function validateDesignReuse(root, platform, sourceContent) {
627
1070
  const designSignals = await detectDesignSignals(root);
628
1071
  if (designSignals.length === 0) {
@@ -711,6 +1154,7 @@ function validateFeedUiStates(root, platform, sourceContent) {
711
1154
  if (!observationPatterns || !statePatterns) {
712
1155
  return [];
713
1156
  }
1157
+ const findings = [];
714
1158
  for (const [file, content] of sourceContent) {
715
1159
  if (isNonUiSourceFile(file)) {
716
1160
  continue;
@@ -727,11 +1171,43 @@ function validateFeedUiStates(root, platform, sourceContent) {
727
1171
  if (hasStates) {
728
1172
  continue;
729
1173
  }
730
- return [
731
- finding(`${platform}.feed.ui-states-present`, "warning", "A source file observes a live data stream/collection but does not appear to render loading, empty, or error states.", relativeFile(root, file), "Render loading / empty / error states next to the live-data binding. Common markers: isLoading, error, empty, CircularProgressIndicator (Flutter), ProgressView (SwiftUI), CircularProgressIndicator (Compose)."),
732
- ];
1174
+ findings.push(finding(`${platform}.feed.ui-states-present`, "warning", "A source file observes a live data stream/collection but does not appear to render loading, empty, or error states.", relativeFile(root, file), "Render loading / empty / error states next to the live-data binding. Common markers: isLoading, error, empty, CircularProgressIndicator (Flutter), ProgressView (SwiftUI), CircularProgressIndicator (Compose)."));
733
1175
  }
734
- return [];
1176
+ return findings;
1177
+ }
1178
+ const LOADING_MARKER_TSJS = /\bisLoading\b|\bisValidating\b|\bisPending\b|\bisFetching\b|\bfetching\b|\bbusy\b|\bloadingStatus\b|\bLoadingStatus\b|\bloading\b|\.loading\b|\bloader\b|\bSkeleton\b|\bShimmer\b|\bSpinner\b|\bActivityIndicator\b|\bCircularProgress\w*|\bLinearProgress\w*|\bProgressView\b|\bindeterminate\b|<\s*[\w.]*(?:Loading|Loader|Spinner|Spin|Skeleton|Shimmer|Placeholder|Progress)\w*|status\s*===?\s*['"`]pending['"`]/i;
1179
+ const LIVE_BINDING_TSJS = /\b(?:getPosts|getComments|getMembers|getChannels|getChannel|getMessages|getFollowInfo|getFollowers|getFollowings|getCommunities|getRecommendedCommunities|getTrendingCommunities|getUsers?)\s*\(/g;
1180
+ const SDK_DATA_CTX_TSJS = /(?:Post|Comment|Community|Channel|Message|User|Reaction)Repository\b|\bLiveCollection\b|\bLiveObject\b|\bAmityCollection\b/;
1181
+ function validateLoadingStatePresent(root, platform, sourceContent) {
1182
+ const findings = [];
1183
+ if (platform !== "typescript" && platform !== "react-native")
1184
+ return findings;
1185
+ for (const [file, content] of sourceContent) {
1186
+ if (isNonUiSourceFile(file))
1187
+ continue;
1188
+ if (path.basename(file).endsWith(".d.ts"))
1189
+ continue;
1190
+ if (/\/\/\s*vise:\s*loading-state intentional/i.test(content))
1191
+ continue;
1192
+ const stripped = commentStripped(file, platform, content);
1193
+ if (!SDK_DATA_CTX_TSJS.test(stripped))
1194
+ continue;
1195
+ const checkContent = blankDeadDeclarations(stripped, "(?:isLoading|loading|isPending|isFetching|loadingStatus)");
1196
+ LIVE_BINDING_TSJS.lastIndex = 0;
1197
+ let match;
1198
+ let unguarded = false;
1199
+ while ((match = LIVE_BINDING_TSJS.exec(checkContent)) !== null) {
1200
+ const window = checkContent.slice(Math.max(0, match.index - 1000), match.index + 1500);
1201
+ if (!LOADING_MARKER_TSJS.test(window)) {
1202
+ unguarded = true;
1203
+ break;
1204
+ }
1205
+ }
1206
+ if (unguarded) {
1207
+ findings.push(finding(`${platform}.feed.loading-state-present`, "warning", "A live-data binding renders with no loading state nearby — during the initial fetch the surface shows a blank/empty flash (or the empty-state copy) instead of a loading indicator.", relativeFile(root, file), "Render a loading state next to the live-data binding before the data resolves — a skeleton/spinner keyed off the SDK live object's loading status (`collection.loading` / `loadingStatus`), or a `useState(true)` flag cleared in the observer callback. Add `// vise: loading-state intentional` if this surface deliberately has none."));
1208
+ }
1209
+ }
1210
+ return findings;
735
1211
  }
736
1212
  const LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM = {
737
1213
  typescript: [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
@@ -937,7 +1413,7 @@ function validateComments(root, platform, sourceContent) {
937
1413
  const hardcodedTarget = /(?:postId|commentId|referenceId)\s*[:=]\s*["'`][a-z0-9-]+["'`]/i.exec(content);
938
1414
  if (hardcodedTarget) {
939
1415
  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."));
940
- break;
1416
+ continue;
941
1417
  }
942
1418
  }
943
1419
  const isObserverContent = (c) => {
@@ -1067,7 +1543,7 @@ function validateChat(root, platform, sourceContent) {
1067
1543
  const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
1068
1544
  if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
1069
1545
  findings.push(finding(`${platform}.chat.channel-type-dm`, "warning", "createChannel uses type 'community' with userIds, which indicates DM intent — but 'community' creates a discoverable group channel, not a private conversation.", relativeFile(root, filePath), "Use type: 'conversation' for 1-to-1 direct messages between known users. 'community' channels are discoverable group channels; passing userIds to createChannel with type:'community' does not create a private DM."));
1070
- break;
1546
+ continue;
1071
1547
  }
1072
1548
  }
1073
1549
  }
@@ -1126,7 +1602,7 @@ function validateChatDeprecations(root, platform, sourceContent) {
1126
1602
  if (DEPRECATED_MARKER_SYNC_ESCAPE.test(raw))
1127
1603
  continue;
1128
1604
  findings.push(finding(`${platform}.chat.deprecated-marker-sync`, "warning", "Chat code uses the deprecated MarkerSyncEngine receipt sync API (startMessageReceiptSync/stopMessageReceiptSync), which is being sun-set.", relativeFile(root, filePath), "MarkerSyncEngine receipt sync is deprecated. Track read state with the channel unread count (channel.unreadCount / getTotalChannelUnread) and mark read via markRead() — the unread-count APIs stay fully supported. Add `// vise: receipt-sync intentional — <reason>` only if you are deliberately staying on the legacy API during migration."));
1129
- break;
1605
+ continue;
1130
1606
  }
1131
1607
  }
1132
1608
  }
@@ -1136,7 +1612,7 @@ function validateChatDeprecations(root, platform, sourceContent) {
1136
1612
  if (DEPRECATED_SUBCHANNEL_ESCAPE.test(raw))
1137
1613
  continue;
1138
1614
  findings.push(finding(`${platform}.chat.deprecated-subchannel`, "warning", "Chat code manually manages sub-channels (createSubChannel/updateSubChannel/deleteSubChannel), a deprecated pattern that is being sun-set.", relativeFile(root, filePath), "Manual sub-channel management is deprecated. Use the channel's default sub-channel via channel.defaultSubChannelId (channel.getDefaultSubChannelId() on Android) — you do not need to create or query sub-channels separately. Add `// vise: subchannel-management intentional — <reason>` only if a multi-sub-channel layout is deliberately required."));
1139
- break;
1615
+ continue;
1140
1616
  }
1141
1617
  }
1142
1618
  return findings;
@@ -1160,7 +1636,7 @@ function validateLivestreamDeprecation(root, platform, sourceContent) {
1160
1636
  if (DEPRECATED_LEGACY_STREAM_ESCAPE.test(raw))
1161
1637
  continue;
1162
1638
  findings.push(finding(`${platform}.livestream.deprecated-stream`, "warning", "Livestream code uses the deprecated legacy Stream API (AmityStreamRepository/StreamRepository/getStreamById/createLiveStreamPost), which is being sun-set in favour of Room-based livestreaming.", relativeFile(root, filePath), "The legacy Stream feature is deprecated. Migrate to the Room API: AmityRoomRepository/RoomRepository.getRoom(roomId) (observe the returned room live object and handle all four statuses — idle/live/ended/recorded) and createRoomPost in place of createLiveStreamPost. Add `// vise: legacy-stream intentional — <reason>` only if you are deliberately staying on legacy Stream during migration."));
1163
- break;
1639
+ continue;
1164
1640
  }
1165
1641
  }
1166
1642
  return findings;
@@ -1542,7 +2018,7 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
1542
2018
  /\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
1543
2019
  continue;
1544
2020
  findings.push(finding(ruleId, "warning", "getFollowInfo is called without a live subscription. Follow state will not update when the relationship changes.", rel, "Subscribe to the live object returned by getFollowInfo and dispose on cleanup: const unsubFollow = UserRepository.getFollowInfo(userId, ({ data }) => { if (data) setFollowStatus(data.status); }); return () => { unsubFollow(); };"));
1545
- break;
2021
+ continue;
1546
2022
  }
1547
2023
  return findings;
1548
2024
  }
@@ -1945,6 +2421,7 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1945
2421
  const handlerFiles = filesMatching(sourceContent, presenceMarkers);
1946
2422
  if (handlerFiles.length === 0)
1947
2423
  return [];
2424
+ const findings = [];
1948
2425
  const badPattern = SESSION_HANDLER_BAD_PATTERNS[platform];
1949
2426
  const goodPatterns = SESSION_HANDLER_GOOD_PATTERNS[platform] ?? SESSION_HANDLER_GOOD_PATTERNS.typescript;
1950
2427
  for (const file of handlerFiles) {
@@ -1954,7 +2431,7 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1954
2431
  if (tree) {
1955
2432
  const locals = findSwiftFunctionLocalDeclarations(tree, /^\w*SessionHandler\b/);
1956
2433
  if (locals.length > 0 && !containsAny(new Map([[file, content]]), goodPatterns)) {
1957
- return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
2434
+ findings.push(sessionHandlerRetainedFinding(platform, relativeFile(root, file)));
1958
2435
  }
1959
2436
  continue;
1960
2437
  }
@@ -1966,10 +2443,10 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
1966
2443
  if (hasGood) {
1967
2444
  continue;
1968
2445
  }
1969
- return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
2446
+ findings.push(sessionHandlerRetainedFinding(platform, relativeFile(root, file)));
1970
2447
  }
1971
2448
  }
1972
- return [];
2449
+ return findings;
1973
2450
  }
1974
2451
  function androidSessionHandlerRetained(file, content) {
1975
2452
  if (/\/\/\s*vise:\s*handler retained/.test(content))
@@ -2017,9 +2494,16 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
2017
2494
  if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
2018
2495
  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)."));
2019
2496
  }
2497
+ if (!findings.some((finding) => finding.ruleId === `${platform}.secret.inline-api-key`)) {
2498
+ const keyShaped = firstLiteralAssignment(sourceContent, [/["'`]([0-9a-f]{48})["'`]/], platform);
2499
+ if (keyShaped && !isAllowedPlaceholder(keyShaped.value)) {
2500
+ findings.push(finding(`${platform}.secret.inline-api-key`, "warning", "A hardcoded social.plus API key (a 48-character hex literal) was found in source. This is flagged by VALUE, not property name — renaming the property (e.g. `apiKey` → `clientKey`) or moving the key into a separate config file the page still loads does NOT make it safe; the secret is still committed.", relativeFile(root, keyShaped.file), "Use the host app's environment/config pattern (a build-time env var or a server-injected value), not a literal in any source/config file shipped to the browser."));
2501
+ }
2502
+ }
2020
2503
  const literalUserId = firstLiteralAssignment(sourceContent, [
2021
2504
  /\buserId\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
2022
2505
  /\buser_id\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
2506
+ /\b(?:currentUser|current_user|userIdentifier)\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
2023
2507
  /\.login\s*\(\s*["'`]([^"'`]+)["'`]/i,
2024
2508
  /\.login\s*\(\s*userId\s*:\s*["'`]([^"'`]+)["'`]/i,
2025
2509
  ], platform);
@@ -2265,6 +2749,206 @@ function isFloatingPackageDependency(packageJson, dependencyName) {
2265
2749
  }
2266
2750
  return /^(?:latest|\*|x|X)$/i.test(version.trim());
2267
2751
  }
2752
+ function hasPackageDependency(packageJson, dependencyName) {
2753
+ return packageDependencyVersion(packageJson, dependencyName) !== undefined;
2754
+ }
2755
+ function packageDependencyVersion(packageJson, dependencyName) {
2756
+ let parsed;
2757
+ try {
2758
+ parsed = JSON.parse(packageJson);
2759
+ }
2760
+ catch {
2761
+ return undefined;
2762
+ }
2763
+ return (parsed.dependencies?.[dependencyName] ??
2764
+ parsed.devDependencies?.[dependencyName] ??
2765
+ parsed.peerDependencies?.[dependencyName] ??
2766
+ parsed.optionalDependencies?.[dependencyName]);
2767
+ }
2768
+ function isReactNativeAsyncStorageVersionCompatible(versionSpec) {
2769
+ const normalized = versionSpec.trim();
2770
+ if (!normalized) {
2771
+ return false;
2772
+ }
2773
+ if (/^(?:latest|\*|x|X)$/i.test(normalized)) {
2774
+ return false;
2775
+ }
2776
+ const exactMajor = normalized.match(/^(?:[~^<>=\s]*)?(\d+)(?:\.|\b)/);
2777
+ if (!exactMajor) {
2778
+ return true;
2779
+ }
2780
+ return Number(exactMajor[1]) <= 1;
2781
+ }
2782
+ function hasSessionRenewalCall(contents) {
2783
+ return containsAny(contents, [
2784
+ /sessionWillRenewAccessToken[\s\S]{0,400}renewal\s*\.\s*(?:renew|renewWithAuthToken)\s*\(/,
2785
+ /renewal\s*\.\s*(?:renew|renewWithAuthToken)\s*\(/,
2786
+ ]);
2787
+ }
2788
+ function hasFlutterInitialLoginKickoff(contents) {
2789
+ const kickoff = String.raw `(?:unawaited\s*\(\s*)?(?:await\s+)?(?:_login\s*\([^;{}]*\)|AmityCoreClient\.login\s*\([^;{}]*\)|login\s*\([^;{}]*\))\s*\)?\s*;`;
2790
+ const asyncKickoff = String.raw `(?:Future\.microtask|Future\.delayed|WidgetsBinding\.instance\.addPostFrameCallback)\s*\([\s\S]{0,180}(?:_login\s*\(|AmityCoreClient\.login\s*\()`;
2791
+ for (const [file, content] of contents) {
2792
+ const stripped = commentStripped(file, "flutter", content);
2793
+ if (!/AmityCoreClient\.observeSessionState\s*\(\s*\)\s*\.listen/.test(stripped)) {
2794
+ continue;
2795
+ }
2796
+ const initStateAfterObserverCall = new RegExp(String.raw `initState\s*\(\s*\)\s*\{[\s\S]{0,1400}_observeSessionState\s*\(\s*\)\s*;[\s\S]{0,500}(?:${kickoff}|${asyncKickoff})`);
2797
+ if (initStateAfterObserverCall.test(stripped)) {
2798
+ return true;
2799
+ }
2800
+ const observerListen = stripped.match(/AmityCoreClient\.observeSessionState\s*\(\s*\)\s*\.listen[\s\S]{0,2600}^\s*\}\s*\)\s*;/m);
2801
+ if (observerListen) {
2802
+ const afterListen = stripped.slice((observerListen.index ?? 0) + observerListen[0].length, (observerListen.index ?? 0) + observerListen[0].length + 700);
2803
+ if (new RegExp(String.raw `(?:${kickoff}|${asyncKickoff})`).test(afterListen)) {
2804
+ return true;
2805
+ }
2806
+ }
2807
+ const inlineObserverThenKickoff = new RegExp(String.raw `AmityCoreClient\.observeSessionState\s*\(\s*\)\s*\.listen[\s\S]{0,2600}\)\s*;[\s\S]{0,700}(?:${kickoff}|${asyncKickoff})`);
2808
+ if (inlineObserverThenKickoff.test(stripped)) {
2809
+ return true;
2810
+ }
2811
+ }
2812
+ return false;
2813
+ }
2814
+ function flutterAmitySdkIsFloating(pubspec) {
2815
+ const line = pubspec.match(/^[ \t]*amity_sdk[ \t]*:[ \t]*(.*)$/m);
2816
+ if (!line)
2817
+ return false;
2818
+ const inline = (line[1] ?? "").replace(/#.*$/, "").trim();
2819
+ if (inline === "" || inline === "|" || inline === ">") {
2820
+ const after = pubspec.slice((line.index ?? 0) + line[0].length).split("\n");
2821
+ const block = [];
2822
+ for (const l of after) {
2823
+ if (l.trim() === "" || /^[ \t]/.test(l))
2824
+ block.push(l);
2825
+ else
2826
+ break;
2827
+ }
2828
+ const body = block.join("\n");
2829
+ if (/\bgit\s*:/.test(body)) {
2830
+ const ref = body.match(/\bref\s*:\s*["']?([^"'\n]+)["']?/);
2831
+ if (!ref)
2832
+ return true;
2833
+ const r = ref[1].trim();
2834
+ return !(/^[0-9a-f]{40}$/i.test(r) || /^v?\d+\.\d+\.\d+/.test(r));
2835
+ }
2836
+ if (/\b(?:path|hosted|sdk)\s*:/.test(body))
2837
+ return false;
2838
+ return body.trim() === "";
2839
+ }
2840
+ const v = inline.replace(/^["']|["']$/g, "").trim();
2841
+ if (v === "" || v === "*" || /^(?:any|latest)$/i.test(v))
2842
+ return true;
2843
+ if (/^>=?/.test(v) && !v.includes("<"))
2844
+ return true;
2845
+ return false;
2846
+ }
2847
+ function flutterDioCompatibilityProblem(pubspec, pubspecLock) {
2848
+ if (!flutterUsesAmitySdkSeven(pubspec, pubspecLock)) {
2849
+ return undefined;
2850
+ }
2851
+ const lockedDioVersion = pubspecLock ? pubspecLockPackageVersion(pubspecLock, "dio") : undefined;
2852
+ if (lockedDioVersion) {
2853
+ if (compareLooseSemver(lockedDioVersion, "5.10.0") >= 0) {
2854
+ return {
2855
+ file: "pubspec.lock",
2856
+ message: `pubspec.lock resolves dio ${lockedDioVersion}, but amity_sdk 7.x is not compatible with dio 5.10.0+ during Android builds because DioExceptionType.transformTimeout makes the SDK switch non-exhaustive.`,
2857
+ };
2858
+ }
2859
+ return undefined;
2860
+ }
2861
+ const dioSpec = flutterPubspecDependencySpec(pubspec, "dio");
2862
+ if (!dioSpec || !flutterDioConstraintStaysBelowFiveTen(dioSpec)) {
2863
+ const description = dioSpec ? `declares dio ${dioSpec}` : "does not declare a direct dio dependency";
2864
+ return {
2865
+ file: "pubspec.yaml",
2866
+ message: `pubspec.yaml ${description}, but amity_sdk 7.x needs dio pinned below 5.10.0 to avoid Android build failures from DioExceptionType.transformTimeout.`,
2867
+ };
2868
+ }
2869
+ return undefined;
2870
+ }
2871
+ function flutterUsesAmitySdkSeven(pubspec, pubspecLock) {
2872
+ const lockedAmityVersion = pubspecLock ? pubspecLockPackageVersion(pubspecLock, "amity_sdk") : undefined;
2873
+ if (lockedAmityVersion) {
2874
+ return looseSemverMajor(lockedAmityVersion) === 7;
2875
+ }
2876
+ const amitySpec = flutterPubspecDependencySpec(pubspec, "amity_sdk");
2877
+ return amitySpec ? looseSemverMajor(amitySpec) === 7 : false;
2878
+ }
2879
+ function flutterPubspecDependencySpec(pubspec, dependencyName) {
2880
+ const escaped = escapeRegExp(dependencyName);
2881
+ const matches = [...pubspec.matchAll(new RegExp(`^[ \\t]*${escaped}[ \\t]*:[ \\t]*(.*)$`, "gm"))];
2882
+ const line = matches.at(-1);
2883
+ if (!line)
2884
+ return undefined;
2885
+ const inline = stripYamlLineComment(line[1] ?? "").trim();
2886
+ if (inline && inline !== "|" && inline !== ">") {
2887
+ return inline.replace(/^["']|["']$/g, "").trim();
2888
+ }
2889
+ const after = pubspec.slice((line.index ?? 0) + line[0].length).split("\n");
2890
+ const block = [];
2891
+ for (const l of after) {
2892
+ if (l.trim() === "" || /^[ \t]/.test(l))
2893
+ block.push(l);
2894
+ else
2895
+ break;
2896
+ }
2897
+ const body = block.join("\n");
2898
+ const version = body.match(/^[ \t]*version[ \t]*:[ \t]*["']?([^"'\n#]+)["']?/m);
2899
+ return version?.[1]?.trim();
2900
+ }
2901
+ function pubspecLockPackageVersion(pubspecLock, packageName) {
2902
+ const escaped = escapeRegExp(packageName);
2903
+ const block = pubspecLock.match(new RegExp(`(?:^|\\n)[ \\t]{2}${escaped}:[\\r\\n]([\\s\\S]*?)(?=\\n[ \\t]{2}\\S|$)`));
2904
+ if (!block)
2905
+ return undefined;
2906
+ const version = block[1].match(/(?:^|\n)[ \t]{4}version[ \t]*:[ \t]*["']?([^"'\n]+)["']?/);
2907
+ return version?.[1]?.trim();
2908
+ }
2909
+ function stripYamlLineComment(value) {
2910
+ return value.replace(/[ \t]+#.*$/, "");
2911
+ }
2912
+ function flutterDioConstraintStaysBelowFiveTen(spec) {
2913
+ const normalized = spec.trim().replace(/^["']|["']$/g, "");
2914
+ if (!normalized || /^(?:any|latest|\*|x)$/i.test(normalized)) {
2915
+ return false;
2916
+ }
2917
+ if (/^5\.(?:[0-8]|9)(?:\.\d+)?(?:[+\-\w.]*)?$/.test(normalized)) {
2918
+ return true;
2919
+ }
2920
+ if (/(?:^|\s)<\s*5\.10\.0(?:\s|$)/.test(normalized)) {
2921
+ return true;
2922
+ }
2923
+ if (/(?:^|\s)<=\s*5\.9(?:\.\d+)?(?:\s|$)/.test(normalized)) {
2924
+ return true;
2925
+ }
2926
+ if (/^~\s*5\.9(?:\.\d+)?/.test(normalized)) {
2927
+ return true;
2928
+ }
2929
+ return false;
2930
+ }
2931
+ function looseSemverMajor(value) {
2932
+ const match = value.match(/(?:^|[\s^~<>=])v?(\d+)(?:\.|\b)/);
2933
+ return match ? Number.parseInt(match[1], 10) : undefined;
2934
+ }
2935
+ function compareLooseSemver(a, b) {
2936
+ const pa = looseSemverParts(a);
2937
+ const pb = looseSemverParts(b);
2938
+ for (let i = 0; i < 3; i += 1) {
2939
+ if (pa[i] !== pb[i])
2940
+ return pa[i] - pb[i];
2941
+ }
2942
+ return 0;
2943
+ }
2944
+ function looseSemverParts(value) {
2945
+ const match = value.match(/v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
2946
+ return [
2947
+ Number.parseInt(match?.[1] ?? "0", 10),
2948
+ Number.parseInt(match?.[2] ?? "0", 10),
2949
+ Number.parseInt(match?.[3] ?? "0", 10),
2950
+ ];
2951
+ }
2268
2952
  function usesEnvSecretConfig(sourceContent) {
2269
2953
  return containsAny(sourceContent, [
2270
2954
  /import\.meta\.env\.[A-Z0-9_]*(?:AMITY|SOCIAL_PLUS|SOCIALPLUS)[A-Z0-9_]*(?:API[_-]?KEY|KEY)/i,
@@ -2302,29 +2986,122 @@ function gitignoreIgnoresEnvFiles(gitignore) {
2302
2986
  .map((line) => line.trim())
2303
2987
  .some((line) => line === ".env" || line === ".env*" || line === ".env.local" || line === "*.env" || line === ".env.*");
2304
2988
  }
2989
+ function projectYmlAmityPackageIsFloating(yml) {
2990
+ if (!yml.trim())
2991
+ return false;
2992
+ const lines = yml.split(/\r?\n/);
2993
+ const pkgHeader = lines.findIndex((l) => /^\s*packages\s*:/.test(l));
2994
+ if (pkgHeader < 0)
2995
+ return false;
2996
+ const pkgIndent = (lines[pkgHeader].match(/^(\s*)/)?.[1] ?? "").length;
2997
+ const entries = [];
2998
+ let entryIndent = -1;
2999
+ let cur = null;
3000
+ for (let j = pkgHeader + 1; j < lines.length; j++) {
3001
+ const line = lines[j];
3002
+ if (line.trim() === "") {
3003
+ cur?.push(line);
3004
+ continue;
3005
+ }
3006
+ const ind = (line.match(/^(\s*)/)?.[1] ?? "").length;
3007
+ if (ind <= pkgIndent)
3008
+ break;
3009
+ if (entryIndent === -1)
3010
+ entryIndent = ind;
3011
+ if (ind === entryIndent && /^\s*[\w".\-/]+\s*:/.test(line)) {
3012
+ cur = [line];
3013
+ entries.push(cur);
3014
+ }
3015
+ else {
3016
+ cur?.push(line);
3017
+ }
3018
+ }
3019
+ for (const entry of entries) {
3020
+ const text = entry.join("\n");
3021
+ const isAmity = /amity/i.test(entry[0] ?? "") || /url\s*:\s*\S*amity/i.test(text);
3022
+ if (isAmity)
3023
+ return /^\s*branch\s*:/im.test(text);
3024
+ }
3025
+ return false;
3026
+ }
3027
+ function pbxprojAmityPackageIsFloating(pbxproj) {
3028
+ if (!pbxproj.trim())
3029
+ return false;
3030
+ const chunks = pbxproj.split(/XCRemoteSwiftPackageReference/).slice(1);
3031
+ for (const chunk of chunks) {
3032
+ if (/repositoryURL\s*=\s*"[^"]*amity[^"]*"/i.test(chunk)) {
3033
+ return /kind\s*=\s*branch/i.test(chunk);
3034
+ }
3035
+ }
3036
+ return false;
3037
+ }
3038
+ function iosAmitySdkIsFloating(s) {
3039
+ if (/\.package\s*\([^)]*amity[^)]*\bbranch\s*:/is.test(s.pkg))
3040
+ return true;
3041
+ if (/^\s*pod\s+['"][^'"]*amity[^'"]*['"]\s*$/im.test(s.pod))
3042
+ return true;
3043
+ if (projectYmlAmityPackageIsFloating(s.yml))
3044
+ return true;
3045
+ if (pbxprojAmityPackageIsFloating(s.pbx))
3046
+ return true;
3047
+ return false;
3048
+ }
2305
3049
  async function validateIos(root) {
2306
3050
  const findings = [];
2307
- const manifestFiles = await existingFiles(root, ["Podfile", "Package.swift"]);
2308
- const manifestContent = await readMany(manifestFiles);
3051
+ const joinExisting = async (names) => [...(await readMany(await existingFiles(root, names))).values()].join("\n");
3052
+ const podText = await joinExisting(["Podfile"]);
3053
+ const pkgSwiftText = await joinExisting(["Package.swift"]);
3054
+ const projectYmlText = await joinExisting(["project.yml"]);
3055
+ const rootEntries = await readdir(root, { withFileTypes: true }).catch(() => []);
3056
+ const pbxprojPaths = [];
3057
+ let pbxprojText = "";
3058
+ for (const entry of rootEntries) {
3059
+ if (entry.isDirectory() && /\.xcodeproj$/i.test(String(entry.name))) {
3060
+ const p = path.join(root, String(entry.name), "project.pbxproj");
3061
+ const content = await readIfExists(p);
3062
+ if (content) {
3063
+ pbxprojPaths.push(p);
3064
+ pbxprojText += "\n" + content;
3065
+ }
3066
+ }
3067
+ }
3068
+ const manifestFiles = [
3069
+ ...(await existingFiles(root, ["Podfile", "Package.swift", "project.yml"])),
3070
+ ...pbxprojPaths,
3071
+ ];
3072
+ const manifestCombined = [podText, pkgSwiftText, projectYmlText, pbxprojText].join("\n");
3073
+ const hasManifest = manifestCombined.trim().length > 0;
2309
3074
  const swiftFiles = await findFiles(root, [".swift", ".xcconfig", ".plist"], 400);
2310
3075
  const swiftContent = await readMany(swiftFiles);
2311
- const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
2312
- const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
3076
+ const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/, /AmityUIKit\d*Manager\s*\.\s*setup/]);
3077
+ const coreLoginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
3078
+ const uikitLoginFiles = filesMatching(swiftContent, [/AmityUIKit\d*Manager\s*\.\s*registerDevice/]);
3079
+ const loginFiles = [...new Set([...coreLoginFiles, ...uikitLoginFiles])];
2313
3080
  const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
2314
3081
  const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
2315
3082
  const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
2316
- if (manifestFiles.length === 0) {
2317
- findings.push(finding("ios.dependency.manifest", "warning", "No Podfile or Package.swift was found.", undefined, "Point repoPath at the iOS project root or verify the dependency manager manually."));
3083
+ if (!hasManifest) {
3084
+ findings.push(finding("ios.dependency.manifest", "warning", "No iOS dependency manifest (Podfile, Package.swift, xcodegen project.yml, or .xcodeproj SwiftPM package references) was found.", undefined, "Point repoPath at the iOS project root or verify the dependency manager manually."));
2318
3085
  }
2319
- else if (!containsAny(manifestContent, [/Amity/i, /AmitySDK/i])) {
2320
- findings.push(finding("ios.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in Podfile or Package.swift.", relativeFile(root, manifestFiles[0]), "Add the iOS SDK dependency from the iOS quick-start docs."));
3086
+ else if (!/Amity/i.test(manifestCombined)) {
3087
+ findings.push(finding("ios.dependency.sdk", "warning", "No obvious social.plus/Amity dependency was found in the iOS dependency manifest.", relativeFile(root, manifestFiles[0]), "Add the iOS SDK dependency from the iOS quick-start docs."));
2321
3088
  }
2322
- else if (containsAny(manifestContent, [/Amity-Social-Cloud-SDK-iOS-IPA/i])) {
3089
+ else if (/Amity-Social-Cloud-SDK-iOS-IPA/i.test(manifestCombined)) {
2323
3090
  findings.push(finding("ios.dependency.swiftpm-repo", "warning", "The iOS SwiftPM dependency points at the obsolete Amity-Social-Cloud-SDK-iOS-IPA package repository.", relativeFile(root, manifestFiles[0]), "Use https://github.com/AmityCo/Amity-Social-Cloud-SDK-iOS-SwiftPM.git with product AmitySDK so SwiftPM can resolve the social.plus iOS SDK."));
2324
3091
  }
2325
- else if (containsAny(manifestContent, [/\.package\s*\([^)]*\bbranch\s*:/s, /pod\s+['"][^'"]*Amity[^'"]*['"]\s*$/m])) {
3092
+ else if (iosAmitySdkIsFloating({ pod: podText, pkg: pkgSwiftText, yml: projectYmlText, pbx: pbxprojText })) {
2326
3093
  findings.push(finding("ios.sdk.version.pinned", "warning", "The iOS SDK dependency uses an uncontrolled version (a moving branch or unspecified CocoaPods version), not a pinned version or reviewed semver range.", relativeFile(root, manifestFiles[0]), "Pin the social.plus iOS SDK to an explicit version or reviewed semver range so CI does not pick up unreviewed SDK APIs."));
2327
3094
  }
3095
+ const hasIosSdkDependency = /Amity/i.test(manifestCombined);
3096
+ const sdkSourceSatisfiedByBlock = hasIosSdkDependency ? await hasInstalledSdkBlockSource(root) : false;
3097
+ const feedSourceRequired = await shouldRequireFeedSourceUsage(root);
3098
+ const feedSourceSatisfiedByBlock = feedSourceRequired ? await hasInstalledFeedBlockSource(root) : false;
3099
+ if (hasIosSdkDependency && !sdkSourceSatisfiedByBlock && !hasSdkSourceUsage(swiftContent, "ios")) {
3100
+ findings.push(finding("ios.sdk.source-used", "warning", "The iOS SDK dependency is installed, but product Swift source only initializes or references the SDK without using a real social.plus data API.", relativeFile(root, manifestFiles[0]), "Route the product surface to real SDK-backed code: retain the client/session handler, login/register the runtime user, then query or create the target community/feed/chat/profile data through an Amity repository or UIKit manager. AmityClient initialization plus static social cards is not a completed integration."));
3101
+ }
3102
+ if (hasIosSdkDependency && feedSourceRequired && !feedSourceSatisfiedByBlock && !hasFeedSourceUsage(swiftContent, "ios")) {
3103
+ findings.push(finding("ios.feed.source-used", "warning", "The active iOS source uses social.plus, but no feed/post SDK API usage was found.", relativeFile(root, manifestFiles[0]), "For an add-feed surface, route product UI to post/feed source: query or create posts through AmityPostRepository, render SDK post data, and wire composer/comment/reaction affordances. Community-only SDK usage is not a feed integration."));
3104
+ }
2328
3105
  if (setupFiles.length === 0) {
2329
3106
  findings.push(finding("ios.setup.present", "warning", "No obvious AmityClient initialization was found in Swift files.", undefined, "Initialize AmityClient before login and before social.plus API usage."));
2330
3107
  }
@@ -2340,8 +3117,8 @@ async function validateIos(root) {
2340
3117
  if (loginFiles.length === 0) {
2341
3118
  findings.push(finding("ios.login.present", "warning", "No obvious social.plus login call was found.", undefined, "Call login after the app has a known user identity and handle session renewal for production auth."));
2342
3119
  }
2343
- else if (!containsAny(swiftContent, [/sessionWillRenewAccessToken/, /AmitySessionHandler/, /renewal\.renew\s*\(/])) {
2344
- findings.push(finding("ios.session.renewal", "warning", "Login was found but no SessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, loginFiles[0]), "Implement a SessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
3120
+ else if (coreLoginFiles.length > 0 && !hasSessionRenewalCall(swiftContent)) {
3121
+ findings.push(finding("ios.session.renewal", "warning", "Login was found but no SessionHandler with sessionWillRenewAccessToken / renewal.renew() was detected.", relativeFile(root, coreLoginFiles[0]), "Implement a SessionHandler and call renewal.renew() in sessionWillRenewAccessToken so the session can refresh in production."));
2345
3122
  }
2346
3123
  if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
2347
3124
  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."));
@@ -2353,8 +3130,20 @@ async function validateIos(root) {
2353
3130
  if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
2354
3131
  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."));
2355
3132
  }
2356
- if (liveDataFiles.length > 0 && !containsAny(swiftContent, [/AmityNotificationToken/, /\.invalidate\s*\(/, /deinit/, /viewWillDisappear/])) {
2357
- 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."));
3133
+ const iosTokenNames = new Set();
3134
+ for (const [file, content] of swiftContent) {
3135
+ const stripped = commentStripped(file, "ios", content);
3136
+ for (const m of stripped.matchAll(/\b(?:var|let)\s+([A-Za-z_]\w*)\s*:\s*AmityNotificationToken\b/g)) {
3137
+ iosTokenNames.add(m[1]);
3138
+ }
3139
+ }
3140
+ const iosTokenReleasedByNil = iosTokenNames.size > 0 &&
3141
+ Array.from(swiftContent).some(([file, content]) => {
3142
+ const stripped = commentStripped(file, "ios", content);
3143
+ return Array.from(iosTokenNames).some((name) => new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*nil\\b`).test(stripped));
3144
+ });
3145
+ if (liveDataFiles.length > 0 && !iosTokenReleasedByNil && !containsAnyStripped(swiftContent, "ios", [/\.invalidate\s*\(/, /\bdeinit\b/, /viewWillDisappear/, /\.onDisappear\b/])) {
3146
+ findings.push(finding("ios.live.cleanup", "warning", "Live Object/Collection observation was found but no obvious cleanup was detected.", relativeFile(root, liveDataFiles[0]), "Invalidate or release the AmityNotificationToken when the view/controller lifecycle ends (call .invalidate() in deinit / viewWillDisappear, release it in SwiftUI .onDisappear, or drop the last reference with `token = nil`). Storing the token alone does not stop observation."));
2358
3147
  }
2359
3148
  findings.push(...validateLiteralGuardrails(root, "ios", swiftContent));
2360
3149
  findings.push(...validateLoggingHygiene(root, "ios", swiftContent));
@@ -2377,6 +3166,7 @@ async function validateIos(root) {
2377
3166
  findings.push(...validatePollVoteStatusGuard(root, "ios", swiftContent));
2378
3167
  findings.push(...validateFollowStatusSubscription(root, "ios", swiftContent));
2379
3168
  findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
3169
+ findings.push(...validatePostBodyRendered(root, "ios", swiftContent));
2380
3170
  findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
2381
3171
  findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
2382
3172
  findings.push(...validateCommentsQueryHasLimit(root, "ios", swiftContent));
@@ -2881,8 +3671,14 @@ function validateImagePostChildResolutionAwaited(root, platform, sourceContent)
2881
3671
  const ruleId = platform + '.image-post.child-resolution-awaited';
2882
3672
  for (const [filename, text] of sourceContent) {
2883
3673
  const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
3674
+ const hasImageAttachment = /createPost\s*\(\s*[^)]*\b(?:image|video|file)\b/i.test(text) ||
3675
+ /\.(?:image|video|file)\s*\(/.test(text) ||
3676
+ /\bAmity(?:Image|Video|File)\b/.test(text) ||
3677
+ /\b(?:uploadImage|uploadVideo|uploadFile|fileId|attachments?)\b/.test(text) ||
3678
+ /AmityDataType\.(?:IMAGE|VIDEO|FILE)/i.test(text);
2884
3679
  if (/createPost\s*\(/.test(text) &&
2885
- /(?:render|setState|display)\s*\(/.test(text)) {
3680
+ /(?:render|setState|display)\s*\(/.test(text) &&
3681
+ hasImageAttachment) {
2886
3682
  const hasResolution = /whenComplete/.test(text) ||
2887
3683
  /whenReady/.test(text) ||
2888
3684
  /childrenPosts.*await/.test(text) ||
@@ -3165,7 +3961,51 @@ function validatePostDataTypeHandled(root, platform, sourceContent) {
3165
3961
  if (guardPat.test(checkContent))
3166
3962
  continue;
3167
3963
  findings.push(finding(ruleId, "warning", "Post renderer reads text data without checking post.dataType. Non-text posts (image, video, file, poll) will render as blank.", relativeFile(root, file), "Branch on the post data type before accessing content. For TypeScript/React Native: switch on post.dataType (e.g. 'text', 'image', 'video'). For Android: when (post.getData()) { is AmityPost.Data.TEXT -> … is AmityPost.Data.IMAGE -> … }. For Flutter: check post.type == AmityDataType.TEXT. For iOS: check post.dataType. Add // vise: text-only post intentional if a single datatype is intentional."));
3168
- break;
3964
+ continue;
3965
+ }
3966
+ return findings;
3967
+ }
3968
+ function validatePostBodyRendered(root, platform, sourceContent) {
3969
+ const findings = [];
3970
+ const ruleId = `${platform}.feed.post-body-rendered`;
3971
+ const uiTextPat = {
3972
+ typescript: /<\s*(?:article|section|div|p|span|Text)\b|\.textContent\s*=|\.innerText\s*=/,
3973
+ "react-native": /<\s*(?:View|Text|Pressable|TouchableOpacity)\b|\bFlatList\b/,
3974
+ flutter: /\b(?:Text|ListTile|Card|Column|Row)\s*\(/,
3975
+ ios: /\b(?:UILabel|UITextView|Text\s*\(|cell\.textLabel|label\.text|addSubview)\b/,
3976
+ android: /\b(?:Text\s*\(|TextView|\.text\s*=|setText\s*\()/,
3977
+ };
3978
+ const postSummaryPat = {
3979
+ typescript: /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b|\bpostId\b/,
3980
+ "react-native": /\bpost\s*\??\.\s*(?:postId|commentsCount|reactionsCount)\b|\bpostId\b/,
3981
+ flutter: /\b(?:post|_posts\s*\[[^\]]+\])\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
3982
+ ios: /\bpost\s*\.\s*(?:postId|commentsCount|reactionsCount)\b/,
3983
+ android: /\bpost\s*\.\s*(?:postId|commentsCount|reactionsCount|getPostId|getCommentCount|getReactionCount)\b/,
3984
+ };
3985
+ const bodyAccessPat = {
3986
+ typescript: /\bpost\s*\??\.\s*(?:text|description|caption)\b|\bpost\s*\??\.\s*data[\s\S]{0,140}\b(?:text|description|caption)\b|\b(?:render|format|get|build)(?:Post)?(?:Body|Text|Description|Caption)\s*\(\s*post\s*\)|<\s*(?:PostBody|PostText|PostDescription|PostContent)\b[^>]*\bpost=/i,
3987
+ "react-native": /\bpost\s*\??\.\s*(?:text|description|caption)\b|\bpost\s*\??\.\s*data[\s\S]{0,140}\b(?:text|description|caption)\b|\b(?:render|format|get|build)(?:Post)?(?:Body|Text|Description|Caption)\s*\(\s*post\s*\)|<\s*(?:PostBody|PostText|PostDescription|PostContent)\b[^>]*\bpost=/i,
3988
+ flutter: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\s*<|\bAmityTextPost\b|\bAmityPostData\b|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3989
+ ios: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\(\)\s*as\?[\s\S]{0,80}\bAmityPost\.Data\.TEXT\b|\bgetText\s*\(\s*\)|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3990
+ android: /\bpost\s*\.\s*(?:text|description|caption)\b|\bpost\s*\.\s*getData\(\)\s*as\??[\s\S]{0,80}\bAmityPost\.Data\.TEXT\b|\bAmityPost\.Data\.TEXT\b|\bgetText\s*\(\s*\)|\b(?:postBody|postText|postDescription|postCaption)\s*\(\s*post\s*\)/i,
3991
+ };
3992
+ const uiPat = uiTextPat[platform] ?? uiTextPat.typescript;
3993
+ const summaryPat = postSummaryPat[platform] ?? postSummaryPat.typescript;
3994
+ const bodyPat = bodyAccessPat[platform] ?? bodyAccessPat.typescript;
3995
+ for (const [file, content] of sourceContent) {
3996
+ if (path.basename(file).endsWith(".d.ts"))
3997
+ continue;
3998
+ if (/\/\/\s*vise:\s*post-body intentional/i.test(content))
3999
+ continue;
4000
+ const astLang = astLanguageForFile(file, platform);
4001
+ const checkContent = astLang ? stripComments(astLang, content) : content;
4002
+ if (!uiPat.test(checkContent))
4003
+ continue;
4004
+ if (!summaryPat.test(checkContent))
4005
+ continue;
4006
+ if (bodyPat.test(checkContent))
4007
+ continue;
4008
+ findings.push(finding(ruleId, "warning", "Post card renders post chrome or identifiers but does not appear to render the post body, description, or caption. Users can see an ID/count card with the actual post content missing.", relativeFile(root, file), "Render the normal post body/description/caption when present through SDK text/data accessors, and keep postId as a key or debug detail only. The post title field is optional and does not need to exist; do not require post.title, metadata.title, or a custom-payload title to satisfy this renderer."));
3169
4009
  }
3170
4010
  return findings;
3171
4011
  }
@@ -3250,7 +4090,64 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
3250
4090
  if (avatarPat.test(checkContent))
3251
4091
  continue;
3252
4092
  findings.push(finding(ruleId, "warning", "Community or user display uses a string initial instead of the SDK-provided avatar URL.", relativeFile(root, file), "Check the avatar field before falling back to initials. For TypeScript/React Native: community.avatar?.fileUrl or user.avatar?.fileUrl. For Flutter: community.avatarImage?.getUrl(AmityImageSize.MEDIUM). For iOS: community.avatar?.fileURL. For Android: community.getAvatar()?.getUrl(). Add // vise: avatar fallback intentional if no avatar field is available in your SDK version."));
3253
- break;
4093
+ continue;
4094
+ }
4095
+ return findings;
4096
+ }
4097
+ function validateSdkFieldShape(root, platform, sourceContent) {
4098
+ const findings = [];
4099
+ if (platform !== "typescript" && platform !== "react-native")
4100
+ return findings;
4101
+ const checks = [
4102
+ {
4103
+ id: "comment.author-field-shape",
4104
+ ctx: /\bgetComments\b|\bCommentRepository\b|\bAmityComment\b|\bcomment\s*\??\.\s*commentId\b/,
4105
+ wrong: /\bcomment\s*\??\.\s*user\b/,
4106
+ marker: /\/\/\s*vise:\s*comment-author-shape intentional/i,
4107
+ message: "Comment author is read from `comment.user`, a field that does not exist on Amity.Comment (the author is `comment.creator`). This reads undefined and silently falls back to a placeholder name/initial.",
4108
+ recommendation: "Read the author from `comment.creator` (Amity.User): comment.creator?.displayName / comment.creator?.avatar. Add `// vise: comment-author-shape intentional` only if `comment` here is your own non-SDK wrapper object.",
4109
+ },
4110
+ {
4111
+ id: "reaction.count-shape",
4112
+ ctx: /\breactionsCount\b/,
4113
+ wrong: /\breactionsCount\s*\??\.\s*(?!toString|toFixed|valueOf|toLocaleString|toPrecision|toExponential)[A-Za-z_]\w*/,
4114
+ marker: /\/\/\s*vise:\s*reaction-count-shape intentional/i,
4115
+ message: "`reactionsCount` is a number (the total reaction count), not a per-reaction map — `reactionsCount.<name>` is always undefined, so per-reaction counts render as 0/empty.",
4116
+ recommendation: "Use the `reactions` Record for per-reaction counts (e.g. `reactions?.[name] ?? 0`) and `reactionsCount` for the total. Add `// vise: reaction-count-shape intentional` to override.",
4117
+ },
4118
+ {
4119
+ id: "community.member-field-shape",
4120
+ ctx: /\bgetMembers\b|\bQueryCommunityMembers\b|\bgetCommunityMembers\b|\bcommunityMembers\b/,
4121
+ wrong: /\bmember\s*\??\.\s*displayName\b/,
4122
+ correct: /\bmember\s*\??\.\s*user\s*\??\.\s*displayName\b/,
4123
+ marker: /\/\/\s*vise:\s*member-field-shape intentional/i,
4124
+ message: "A community member's display name is read from `member.displayName`, a field that does not exist on the membership object — the user lives at `member.user` (Amity.InternalUser). This renders undefined and falls back to the raw userId.",
4125
+ recommendation: "Read `member.user?.displayName` (and member.user?.avatar). Add `// vise: member-field-shape intentional` only if `member` here is your own non-SDK shape.",
4126
+ },
4127
+ {
4128
+ id: "follow.status-enum",
4129
+ ctx: /\bgetFollowInfo\b|\bFollowInfo\b|\bfollowInfo\b/,
4130
+ wrong: /\.\s*status\s*===?\s*['"`]following['"`]|['"`]following['"`]\s*===?\s*[A-Za-z_][\w.?]*\bstatus\b/,
4131
+ marker: /\/\/\s*vise:\s*follow-status-enum intentional/i,
4132
+ message: "FollowInfo.status is compared against 'following', which is NOT a valid value — FollowStatus is one of all|pending|accepted|blocked|none. A successful follow yields 'accepted', so `status === 'following'` is always false and the Follow/Following UI never reflects the followed state.",
4133
+ recommendation: "Compare against the real status: `=== 'accepted'` for an accepted follow (`=== 'pending'` for a follow request). Add `// vise: follow-status-enum intentional` to override.",
4134
+ },
4135
+ ];
4136
+ for (const [file, content] of sourceContent) {
4137
+ if (path.basename(file).endsWith(".d.ts"))
4138
+ continue;
4139
+ const checkContent = commentStripped(file, platform, content);
4140
+ for (const check of checks) {
4141
+ if (check.marker.test(content))
4142
+ continue;
4143
+ if (!check.ctx.test(checkContent))
4144
+ continue;
4145
+ if (!check.wrong.test(checkContent))
4146
+ continue;
4147
+ if (check.correct && check.correct.test(checkContent))
4148
+ continue;
4149
+ findings.push(finding(`${platform}.${check.id}`, "warning", check.message, relativeFile(root, file), check.recommendation));
4150
+ }
3254
4151
  }
3255
4152
  return findings;
3256
4153
  }
@@ -3290,7 +4187,7 @@ function validatePollAnswerDataShape(root, platform, sourceContent) {
3290
4187
  if (correctPat.test(checkContent))
3291
4188
  continue;
3292
4189
  findings.push(finding(ruleId, "warning", "Poll answer text is read as an object property (.data.text or .data?.text), but PollAnswer.data is a plain string.", relativeFile(root, file), "Use answer.data directly as the display text — it is already the string content. For image answers, use answer.image?.fileUrl (TypeScript/React Native) or the linked image object. Add // vise: poll-answer-shape intentional if the data field really is an object in your SDK version."));
3293
- break;
4190
+ continue;
3294
4191
  }
3295
4192
  return findings;
3296
4193
  }
@@ -3338,7 +4235,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3338
4235
  if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
3339
4236
  continue;
3340
4237
  findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
3341
- return findings;
4238
+ break;
3342
4239
  }
3343
4240
  }
3344
4241
  return findings;
@@ -3359,7 +4256,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
3359
4256
  if (limitPat.test(checkContent))
3360
4257
  continue;
3361
4258
  findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
3362
- break;
4259
+ continue;
3363
4260
  }
3364
4261
  return findings;
3365
4262
  }
@@ -3432,7 +4329,7 @@ function validateCommentThreadUiStates(root, platform, sourceContent) {
3432
4329
  const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|EmptyState|emptyState|\bisEmpty\b/.test(checkContent);
3433
4330
  if (!(hasLoading && hasError && hasEmpty)) {
3434
4331
  findings.push(finding(ruleId, "warning", "Comment tray renders a paged comment list but does not handle loading, error, and empty states. It can show an empty tray while comments are still loading or hide failures.", relativeFile(root, file), "For collectAsLazyPagingItems(), branch on comments.loadState.refresh/append for Loading and Error, show retry for errors, and show the empty state only when refresh is NotLoading and itemCount == 0. Add // vise: comment-ui-states intentional — <reason> only when a parent component handles these states."));
3435
- break;
4332
+ continue;
3436
4333
  }
3437
4334
  }
3438
4335
  return findings;
@@ -3511,7 +4408,7 @@ function validateChatMessageOrderExplicit(root, platform, sourceContent) {
3511
4408
  if (sortPat.test(checkContent))
3512
4409
  continue;
3513
4410
  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."));
3514
- break;
4411
+ continue;
3515
4412
  }
3516
4413
  return findings;
3517
4414
  }
@@ -3546,7 +4443,7 @@ function validateProfileSocialCounts(root, platform, sourceContent) {
3546
4443
  const hasPlaceholderCounts = placeholderPat.test(checkContent);
3547
4444
  if (hasPlaceholderCounts && !hasSdkCounts) {
3548
4445
  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."));
3549
- break;
4446
+ continue;
3550
4447
  }
3551
4448
  }
3552
4449
  return findings;
@@ -3578,7 +4475,7 @@ function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
3578
4475
  if (CORRECT.test(checkContent))
3579
4476
  continue;
3580
4477
  findings.push(finding(ruleId, "warning", "A community ID is used as a display name fallback. The raw communityId will be shown to users instead of the community's actual display name.", relativeFile(root, file), "Pass the full Community object (not just the ID) when navigating to a community detail screen, then use community.displayName. If you only have the ID, subscribe to CommunityRepository.getCommunity(communityId, cb) to get the live display name. Add // vise: community-id display intentional only if showing the ID is truly required."));
3581
- break;
4478
+ continue;
3582
4479
  }
3583
4480
  return findings;
3584
4481
  }
@@ -3616,7 +4513,7 @@ function validateRoomPostFetched(root, platform, sourceContent) {
3616
4513
  if (fetchPat.test(checkContent))
3617
4514
  continue;
3618
4515
  findings.push(finding(ruleId, "warning", "Room post displays raw roomId instead of fetching room details (title) via RoomRepository.getRoom.", relativeFile(root, file), "Subscribe to RoomRepository.getRoom(roomId, callback) to get the Room object, then use room.title as the display name. The Room has title, status (idle/live/ended), and thumbnailFileId. Add // vise: room-display intentional only if showing the raw ID is intentional."));
3619
- break;
4516
+ continue;
3620
4517
  }
3621
4518
  return findings;
3622
4519
  }