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