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