@amityco/social-plus-vise 1.0.0 → 1.1.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/CHANGELOG.md +71 -24
- package/LICENSE +8 -6
- package/README.md +168 -358
- package/dist/capabilities.js +19 -62
- package/dist/intelligence/grounding.js +0 -23
- package/dist/intelligence/placement.js +0 -9
- package/dist/outcomes.js +44 -22
- package/dist/server.js +75 -38
- package/dist/tools/ast.js +3 -209
- package/dist/tools/blocks.js +6 -20
- package/dist/tools/compliance.js +168 -43
- package/dist/tools/creative.js +15 -41
- package/dist/tools/debug.js +0 -16
- package/dist/tools/design.js +18 -364
- package/dist/tools/docs.js +53 -24
- package/dist/tools/experienceCompiler.js +7 -10
- package/dist/tools/experienceSensors.js +1 -1
- package/dist/tools/harness.js +2 -27
- package/dist/tools/integration.js +6 -38
- package/dist/tools/learning.js +1 -1
- package/dist/tools/project.js +763 -546
- package/dist/tools/sdkFacts.js +2 -15
- package/dist/tools/sdkVersion.js +3 -36
- package/dist/tools/sensors.js +0 -6
- package/dist/tools/uxHarness.js +12 -9
- package/package.json +8 -97
- package/rules/chat.yaml +225 -0
- package/rules/event.yaml +45 -0
- package/rules/feed.yaml +24 -24
- package/rules/invitation.yaml +58 -0
- package/rules/live-data.yaml +104 -2
- package/rules/notification-tray.yaml +106 -0
- package/rules/poll.yaml +71 -0
- package/rules/sdk-lifecycle.yaml +112 -6
- package/rules/search.yaml +131 -0
- package/rules/story.yaml +221 -0
- package/rules/user-blocking.yaml +71 -0
- package/sdk-surface/flutter.json +1 -1
- package/sdk-surface/ios.json +1 -1
- package/sdk-surface/manifest.json +12 -12
- package/sdk-surface/models.flutter.json +96 -96
- package/sdk-surface/models.ios.json +1 -1
- package/sdk-surface/typescript.json +4 -4
- package/skills/social-plus-vise/SKILL.md +25 -5
- package/scripts/catalog-coverage-html.mjs +0 -325
- package/scripts/catalog-relationships-html.mjs +0 -686
- package/scripts/catalog-sheets.mjs +0 -286
- package/scripts/dart-model-extractor/bin/extract_models.dart +0 -169
- package/scripts/dart-model-extractor/pubspec.lock +0 -149
- package/scripts/dart-model-extractor/pubspec.yaml +0 -16
- package/scripts/extract-sdk-models.mjs +0 -749
- package/scripts/import-sdk-surface.mjs +0 -161
- package/scripts/pilot-feedback.mjs +0 -107
- package/scripts/workshop-board-html.mjs +0 -1018
- package/scripts/workshop-kit.mjs +0 -252
- package/skills/vise-harness-engineer/SKILL.md +0 -35
package/dist/tools/project.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, readdir, readFile } from "node:fs/promises";
|
|
1
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { productFindingIdentity } from "../productExpectations.js";
|
|
4
4
|
import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
|
|
@@ -20,8 +20,21 @@ async function readIfExists(filePath) {
|
|
|
20
20
|
return "";
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
async function assertRepoPathIsDirectory(repoRoot) {
|
|
24
|
+
let info;
|
|
25
|
+
try {
|
|
26
|
+
info = await stat(repoRoot);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error(`repoPath does not exist: ${repoRoot}`);
|
|
30
|
+
}
|
|
31
|
+
if (!info.isDirectory()) {
|
|
32
|
+
throw new Error(`repoPath is not a directory: ${repoRoot}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
23
35
|
export async function inspectProject(repoPath, surfacePath) {
|
|
24
36
|
const repoRoot = path.resolve(repoPath);
|
|
37
|
+
await assertRepoPathIsDirectory(repoRoot);
|
|
25
38
|
const surfaces = await detectProjectSurfaces(repoRoot);
|
|
26
39
|
const effectiveRoot = resolveSurfaceRoot(repoRoot, surfacePath);
|
|
27
40
|
const selectedSurface = surfacePath ? await surfaceForPath(repoRoot, effectiveRoot) : surfaceMatchingRoot(surfaces, repoRoot);
|
|
@@ -82,10 +95,6 @@ async function inspectRoot(root) {
|
|
|
82
95
|
reason: "react-native dependency or script signal",
|
|
83
96
|
});
|
|
84
97
|
}
|
|
85
|
-
// When react-native is detected alongside generic typescript signals, prefer react-native
|
|
86
|
-
// so that platform-specific rules (react-native.*) are used for init/check/run-sensors.
|
|
87
|
-
// Same for android: an agent may create package.json (e.g. to enable a local Vise check script) which
|
|
88
|
-
// would normally trigger typescript detection — suppress it so only android rules apply.
|
|
89
98
|
const rawPlatforms = Array.from(new Set(signals.map((signal) => signal.platform)));
|
|
90
99
|
const hasRN = rawPlatforms.includes("react-native");
|
|
91
100
|
const hasAndroid = rawPlatforms.includes("android");
|
|
@@ -181,9 +190,6 @@ async function validateAndroid(root) {
|
|
|
181
190
|
const findings = [];
|
|
182
191
|
const manifestPath = "app/src/main/AndroidManifest.xml";
|
|
183
192
|
const manifest = await readIfExists(path.join(root, manifestPath));
|
|
184
|
-
// Scan ALL Gradle build scripts, not just app/root — a multi-module app idiomatically declares the
|
|
185
|
-
// SDK dependency in the consuming FEATURE module's build.gradle(.kts) (e.g. feature/community/impl).
|
|
186
|
-
// Only checking app/build.gradle false-fired android.dependency.sdk on those (brownfield Now-in-Android).
|
|
187
193
|
const gradleScripts = (await findFiles(root, [".gradle", ".kts"], 200)).filter((f) => /(?:^|[\\/])(?:build|settings)\.gradle(?:\.kts)?$/.test(f));
|
|
188
194
|
const buildFiles = [...new Set([
|
|
189
195
|
...(await existingFiles(root, ["app/build.gradle", "app/build.gradle.kts", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])),
|
|
@@ -222,13 +228,8 @@ async function validateAndroid(root) {
|
|
|
222
228
|
else {
|
|
223
229
|
const activitySetup = setupFiles.find((file) => {
|
|
224
230
|
const c = sourceContent.get(file) ?? "";
|
|
225
|
-
// Application-scoped setup (a Hilt @Module / @HiltAndroidApp Application / SingletonComponent
|
|
226
|
-
// provider) is the CORRECT place — never flag it. The old marker matched the bare word
|
|
227
|
-
// "Activity" anywhere (incl. a comment like "outlives any Activity/ViewModel" in a DI module),
|
|
228
|
-
// false-firing on idiomatic Hilt setup (brownfield Now-in-Android).
|
|
229
231
|
if (/@Module\b|@HiltAndroidApp\b|SingletonComponent\b|@ApplicationContext\b|:\s*Application\b|\bApplication\s*\(\s*\)/.test(c))
|
|
230
232
|
return false;
|
|
231
|
-
// Otherwise flag setup in an actual UI lifecycle: an Activity/Fragment class, a @Composable, or onCreateView.
|
|
232
233
|
return /\bclass\s+\w*(?:Activity|Fragment)\b|:\s*(?:ComponentActivity|AppCompatActivity|Activity|Fragment)\b|@Composable\b|\bonCreateView\b/.test(c);
|
|
233
234
|
});
|
|
234
235
|
if (activitySetup) {
|
|
@@ -247,14 +248,11 @@ async function validateAndroid(root) {
|
|
|
247
248
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
248
249
|
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."));
|
|
249
250
|
}
|
|
250
|
-
// Android push payload: if onMessageReceived exists, require push-specific SDK forwarding
|
|
251
|
-
// Nexus gate (see iOS): a generic FCM handler with no social.plus push registration is the host
|
|
252
|
-
// app's own push, not a social.plus forwarding gap — require social.plus push registration first.
|
|
253
251
|
const androidPayloadFiles = filesMatching(sourceContent, [/onMessageReceived/, /FirebaseMessagingService/]);
|
|
254
252
|
if (androidPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(sourceContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
255
253
|
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."));
|
|
256
254
|
}
|
|
257
|
-
if (liveDataFiles.length > 0 && !
|
|
255
|
+
if (liveDataFiles.length > 0 && !containsAnyStripped(sourceContent, "android", [/\.dispose\s*\(/, /\bdispose\s*\(\s*\)/, /\.clear\s*\(/, /\bremoveObserver\b/, /\bunsubscribe\b/, /\.cancel\s*\(/])) {
|
|
258
256
|
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."));
|
|
259
257
|
}
|
|
260
258
|
findings.push(...validateLiteralGuardrails(root, "android", sourceContent));
|
|
@@ -268,6 +266,15 @@ async function validateAndroid(root) {
|
|
|
268
266
|
findings.push(...validateComments(root, "android", sourceContent));
|
|
269
267
|
findings.push(...validateModeration(root, "android", sourceContent));
|
|
270
268
|
findings.push(...validateLiveCollectionApiMismatch(root, "android", sourceContent));
|
|
269
|
+
findings.push(...validateStories(root, "android", sourceContent));
|
|
270
|
+
findings.push(...validateSearch(root, "android", sourceContent));
|
|
271
|
+
findings.push(...validateNotificationTray(root, "android", sourceContent));
|
|
272
|
+
findings.push(...validateMembershipList(root, "android", sourceContent));
|
|
273
|
+
findings.push(...validateEvents(root, "android", sourceContent));
|
|
274
|
+
findings.push(...validateBlockedUsers(root, "android", sourceContent));
|
|
275
|
+
findings.push(...validateInvitations(root, "android", sourceContent));
|
|
276
|
+
findings.push(...validatePollVoteStatusGuard(root, "android", sourceContent));
|
|
277
|
+
findings.push(...validateFollowStatusSubscription(root, "android", sourceContent));
|
|
271
278
|
findings.push(...validatePostDataTypeHandled(root, "android", sourceContent));
|
|
272
279
|
findings.push(...validateAvatarFromSdk(root, "android", sourceContent));
|
|
273
280
|
findings.push(...validatePollAnswerDataShape(root, "android", sourceContent));
|
|
@@ -297,6 +304,8 @@ async function validateAndroid(root) {
|
|
|
297
304
|
findings.push(...validateChatMessageOrderExplicit(root, "android", sourceContent));
|
|
298
305
|
findings.push(...validateProfileSocialCounts(root, "android", sourceContent));
|
|
299
306
|
findings.push(...validateChat(root, "android", sourceContent));
|
|
307
|
+
findings.push(...validateChatDeprecations(root, "android", sourceContent));
|
|
308
|
+
findings.push(...validateLivestreamDeprecation(root, "android", sourceContent));
|
|
300
309
|
findings.push(...(await validateDesignReuse(root, "android", sourceContent)));
|
|
301
310
|
findings.push(...(await validateEnvSecretHygiene(root, "android", sourceContent)));
|
|
302
311
|
return findings;
|
|
@@ -327,13 +336,6 @@ async function validateFlutter(root) {
|
|
|
327
336
|
if (!containsAny(dartContent, [/WidgetsFlutterBinding\.ensureInitialized\s*\(/])) {
|
|
328
337
|
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()."));
|
|
329
338
|
}
|
|
330
|
-
// Region is configured via AmityRegion.* OR an explicit endpoint (the idiomatic Flutter
|
|
331
|
-
// forms — `httpEndpoint:` is not matched by `\bendpoint:` because of the camelCase boundary
|
|
332
|
-
// + case). The real endpoint symbols are httpEndpoint/mqttEndpoint/uploadEndpoint and the
|
|
333
|
-
// AmityRegional{Http,Mqtt}Endpoint enums (verified against the SDK surface — there is no
|
|
334
|
-
// `socketEndpoint`). NOTE: do NOT key on AmityCoreClientOption — that builder is used by
|
|
335
|
-
// EVERY setup regardless of whether a region is set, so it would silence this rule for any
|
|
336
|
-
// standard setup (a false negative).
|
|
337
339
|
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/])) {
|
|
338
340
|
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."));
|
|
339
341
|
}
|
|
@@ -347,9 +349,6 @@ async function validateFlutter(root) {
|
|
|
347
349
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
348
350
|
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."));
|
|
349
351
|
}
|
|
350
|
-
// Flutter push payload: if FirebaseMessaging.onMessage/onBackgroundMessage exists, require push-specific SDK forwarding
|
|
351
|
-
// Nexus gate (see iOS): require social.plus push registration before demanding forwarding, so a
|
|
352
|
-
// host app's own FirebaseMessaging handler doesn't false-fire when push isn't a social.plus concern.
|
|
353
352
|
const flutterPayloadFiles = filesMatching(dartContent, [/FirebaseMessaging\.onMessage/, /onBackgroundMessage/, /FirebaseMessaging\.instance/]);
|
|
354
353
|
if (flutterPayloadFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(dartContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
355
354
|
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."));
|
|
@@ -368,6 +367,11 @@ async function validateFlutter(root) {
|
|
|
368
367
|
findings.push(...validateComments(root, "flutter", dartContent));
|
|
369
368
|
findings.push(...validateModeration(root, "flutter", dartContent));
|
|
370
369
|
findings.push(...validateLiveCollectionApiMismatch(root, "flutter", dartContent));
|
|
370
|
+
findings.push(...validateStories(root, "flutter", dartContent));
|
|
371
|
+
findings.push(...validateSearch(root, "flutter", dartContent));
|
|
372
|
+
findings.push(...validateMembershipList(root, "flutter", dartContent));
|
|
373
|
+
findings.push(...validateBlockedUsers(root, "flutter", dartContent));
|
|
374
|
+
findings.push(...validatePollVoteStatusGuard(root, "flutter", dartContent));
|
|
371
375
|
findings.push(...validatePostDataTypeHandled(root, "flutter", dartContent));
|
|
372
376
|
findings.push(...validateAvatarFromSdk(root, "flutter", dartContent));
|
|
373
377
|
findings.push(...validatePollAnswerDataShape(root, "flutter", dartContent));
|
|
@@ -396,6 +400,7 @@ async function validateFlutter(root) {
|
|
|
396
400
|
findings.push(...validateChatMessageOrderExplicit(root, "flutter", dartContent));
|
|
397
401
|
findings.push(...validateProfileSocialCounts(root, "flutter", dartContent));
|
|
398
402
|
findings.push(...validateChat(root, "flutter", dartContent));
|
|
403
|
+
findings.push(...validateChatDeprecations(root, "flutter", dartContent));
|
|
399
404
|
findings.push(...(await validateDesignReuse(root, "flutter", dartContent)));
|
|
400
405
|
findings.push(...(await validateEnvSecretHygiene(root, "flutter", dartContent)));
|
|
401
406
|
return findings;
|
|
@@ -408,15 +413,10 @@ async function validateTypeScript(root, platform) {
|
|
|
408
413
|
const setupFiles = filesMatching(sourceContent, [/Client\s*\.\s*createClient/, /Client\s*\.\s*create\s*\(/, /createClient\s*\(/]);
|
|
409
414
|
const loginFiles = filesMatching(sourceContent, [/Client\s*\.\s*login/, /\.login\s*\(/]);
|
|
410
415
|
const pushRegistrationFiles = filesMatching(sourceContent, [/registerPushNotification/, /enablePushNotification/, /PushNotification/]);
|
|
411
|
-
// Skip .d.ts declarations when checking for unregister — type stubs declare both register and
|
|
412
|
-
// unregister methods but should not suppress the "unregister missing" finding.
|
|
413
416
|
const implContent = new Map([...sourceContent].filter(([f]) => !path.basename(f).endsWith('.d.ts')));
|
|
414
417
|
const pushUnregisterFiles = filesMatching(implContent, [/unregisterPushNotification/, /disablePushNotification/]);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// queryPosts(/getPost( are NOT live-data markers (they'd false-fire live.cleanup on
|
|
418
|
-
// one-shot awaits — found on a weak-model build). The reactive forms below remain.
|
|
419
|
-
const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/]);
|
|
418
|
+
const liveDataFiles = filesMatching(sourceContent, [/LiveCollection/, /LiveObject/, /\.subscribe\s*\(/, /\.observe\s*\(/, /\.onData\s*\(/, /onSnapshot/,
|
|
419
|
+
/\bgetNotificationTrayItems\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/]);
|
|
420
420
|
if (!packageJson) {
|
|
421
421
|
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."));
|
|
422
422
|
}
|
|
@@ -430,19 +430,6 @@ async function validateTypeScript(root, platform) {
|
|
|
430
430
|
findings.push(finding("typescript.client.create", "warning", "No obvious TypeScript client initialization pattern was found.", undefined, "Create the social.plus client before login and before API usage."));
|
|
431
431
|
}
|
|
432
432
|
else {
|
|
433
|
-
// Region detection accepts three idioms:
|
|
434
|
-
// (a) keyword-arg style: createClient({ region: 'sg' }) / apiRegion / apiEndpoint
|
|
435
|
-
// (b) positional with a named variable: const region = ...; createClient(apiKey, region)
|
|
436
|
-
// (c) env-sourced region declared anywhere in the codebase:
|
|
437
|
-
// process.env.<*REGION*> | <*ENDPOINT*>, including NEXT_PUBLIC_, EXPO_PUBLIC_,
|
|
438
|
-
// VITE_ prefixes; also import.meta.env.<*REGION*> for Vite/Astro.
|
|
439
|
-
// Any one of these is sufficient evidence the integration is region-aware.
|
|
440
|
-
// `API_REGIONS` is the canonical social.plus TS SDK region enum
|
|
441
|
-
// (API_REGIONS.SG/EU/US) — its presence is definitive region evidence. The
|
|
442
|
-
// named-decl form is case-insensitive so `const REGION = API_REGIONS.SG` counts.
|
|
443
|
-
// (d) positional region argument: createClient(API_KEY, AMITY_REGION) — a 2nd arg whose name
|
|
444
|
-
// contains "region" (incl. an uppercase env const like AMITY_REGION). Idiom (b)'s regex only
|
|
445
|
-
// caught a `const region` decl named exactly "region"; the positional call site was missed (RN build).
|
|
446
433
|
const setupHasKeywordRegion = containsAnyForFiles(sourceContent, setupFiles, [/\bregion\s*:/, /\bapiRegion\b/, /\bapiEndpoint\b/, /\bAPI_REGIONS\b/, /createClient\s*\([^,)]+,\s*[\w.$]*[Rr]egion/i]);
|
|
447
434
|
const setupHasNamedRegionDecl = containsAnyForFiles(sourceContent, setupFiles, [/\b(?:const|let)\s+(?:region|apiRegion|endpoint|apiEndpoint)\b/i]);
|
|
448
435
|
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_]*/]);
|
|
@@ -463,11 +450,7 @@ async function validateTypeScript(root, platform) {
|
|
|
463
450
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
464
451
|
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."));
|
|
465
452
|
}
|
|
466
|
-
|
|
467
|
-
// its caller — the idiomatic repository/store pattern. Anchored on the return-type
|
|
468
|
-
// annotation `): Unsubscriber` (not a bare mention), so a disposer captured and
|
|
469
|
-
// dropped on the floor still fires.
|
|
470
|
-
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/])) {
|
|
453
|
+
if (liveDataFiles.length > 0 && !containsAny(sourceContent, [/unsubscribe\s*\(/, /\.unsubscribe\s*\(/, /return\s*\(\s*\)\s*=>/, /return\s+unsubscribe/, /AbortController/, /\)\s*:\s*(?:Amity\.)?Unsubscriber\b/, /\)\s*:\s*\(\s*\)\s*=>\s*void\b/])) {
|
|
471
454
|
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."));
|
|
472
455
|
}
|
|
473
456
|
findings.push(...validateLiteralGuardrails(root, platform, sourceContent));
|
|
@@ -481,6 +464,14 @@ async function validateTypeScript(root, platform) {
|
|
|
481
464
|
findings.push(...validateComments(root, platform, sourceContent));
|
|
482
465
|
findings.push(...validateModeration(root, platform, sourceContent));
|
|
483
466
|
findings.push(...validateLiveCollectionApiMismatch(root, platform, sourceContent));
|
|
467
|
+
findings.push(...validateStories(root, platform, sourceContent));
|
|
468
|
+
findings.push(...validateSearch(root, platform, sourceContent));
|
|
469
|
+
findings.push(...validateNotificationTray(root, platform, sourceContent));
|
|
470
|
+
findings.push(...validateMembershipList(root, platform, sourceContent));
|
|
471
|
+
findings.push(...validateEvents(root, platform, sourceContent));
|
|
472
|
+
findings.push(...validateBlockedUsers(root, platform, sourceContent));
|
|
473
|
+
findings.push(...validateInvitations(root, platform, sourceContent));
|
|
474
|
+
findings.push(...validatePollVoteStatusGuard(root, platform, sourceContent));
|
|
484
475
|
findings.push(...validatePostsStatusFilter(root, platform, sourceContent));
|
|
485
476
|
findings.push(...validateActivityPostsTagFilter(root, platform, sourceContent));
|
|
486
477
|
findings.push(...validatePostDataTypeHandled(root, platform, sourceContent));
|
|
@@ -513,6 +504,8 @@ async function validateTypeScript(root, platform) {
|
|
|
513
504
|
findings.push(...validateChatMessageOrderExplicit(root, platform, sourceContent));
|
|
514
505
|
findings.push(...validateProfileSocialCounts(root, platform, sourceContent));
|
|
515
506
|
findings.push(...validateChat(root, platform, sourceContent));
|
|
507
|
+
findings.push(...validateChatDeprecations(root, platform, sourceContent));
|
|
508
|
+
findings.push(...validateLivestreamDeprecation(root, platform, sourceContent));
|
|
516
509
|
findings.push(...(await validateDesignReuse(root, platform, sourceContent)));
|
|
517
510
|
if (platform === "typescript") {
|
|
518
511
|
findings.push(...validateClientNoSsrInit(root, sourceContent));
|
|
@@ -525,9 +518,6 @@ async function validateEnvSecretHygiene(root, platform, sourceContent) {
|
|
|
525
518
|
if (platform === "typescript") {
|
|
526
519
|
const envSecretFiles = await committedEnvSecretFiles(root);
|
|
527
520
|
if (envSecretFiles.length > 0) {
|
|
528
|
-
// Only fire when the secret-bearing env file is NOT gitignored. A gitignored local `.env` (the
|
|
529
|
-
// exact practice this rule's own recommendation endorses) won't be committed, so flagging it is
|
|
530
|
-
// a false positive — committed-env is about a `.env` that WILL be committed (no .gitignore cover).
|
|
531
521
|
const gitignore = await readIfExists(path.join(root, ".gitignore"));
|
|
532
522
|
if (!gitignore || !gitignoreIgnoresEnvFiles(gitignore)) {
|
|
533
523
|
for (const file of envSecretFiles) {
|
|
@@ -603,10 +593,6 @@ function validateClientNoSsrInit(root, sourceContent) {
|
|
|
603
593
|
}
|
|
604
594
|
return findings;
|
|
605
595
|
}
|
|
606
|
-
// Logging hygiene: customers shipping debug logs of secret-shaped values is a
|
|
607
|
-
// real failure mode (console.log("client", apiKey) escapes to production).
|
|
608
|
-
// Per platform we match the relevant log function calls; the secret-shaped
|
|
609
|
-
// regex is shared. Returns at most one finding per scan to keep noise low.
|
|
610
596
|
function validateLoggingHygiene(root, platform, sourceContent) {
|
|
611
597
|
const logCallPattern = LOGGING_CALL_PATTERNS_BY_PLATFORM[platform];
|
|
612
598
|
if (!logCallPattern) {
|
|
@@ -620,8 +606,6 @@ function validateLoggingHygiene(root, platform, sourceContent) {
|
|
|
620
606
|
if (!logCallPattern.test(line)) {
|
|
621
607
|
continue;
|
|
622
608
|
}
|
|
623
|
-
// Allow commented-out lines (//, #, /*); rough but kills the obvious
|
|
624
|
-
// false positives like documentation snippets.
|
|
625
609
|
const trimmed = line.trimStart();
|
|
626
610
|
if (trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
|
|
627
611
|
continue;
|
|
@@ -639,20 +623,11 @@ function validateLoggingHygiene(root, platform, sourceContent) {
|
|
|
639
623
|
}
|
|
640
624
|
return findings;
|
|
641
625
|
}
|
|
642
|
-
// Design system reuse: when the project has detected theme / token / design-
|
|
643
|
-
// system files (via detectDesignSignals), at least one other source file
|
|
644
|
-
// should reference one of those files by basename or class identifier.
|
|
645
|
-
// Tolerant heuristic — false negatives are accepted (the agent might use a
|
|
646
|
-
// theme provider context implicitly); the goal is to catch the obvious
|
|
647
|
-
// "agent ignored the detected design system and wrote inline styles."
|
|
648
626
|
async function validateDesignReuse(root, platform, sourceContent) {
|
|
649
627
|
const designSignals = await detectDesignSignals(root);
|
|
650
628
|
if (designSignals.length === 0) {
|
|
651
629
|
return [];
|
|
652
630
|
}
|
|
653
|
-
// Build reference markers per design file. For each design file we look for
|
|
654
|
-
// its basename (without extension) AND its PascalCase identifier guess. A
|
|
655
|
-
// source file that imports / mentions any marker counts as a reference.
|
|
656
631
|
const markersByFile = [];
|
|
657
632
|
for (const signal of designSignals) {
|
|
658
633
|
const base = path.basename(signal.file).replace(/\.(ts|tsx|js|jsx|dart|kt|java|swift|xml|css)$/i, "");
|
|
@@ -663,14 +638,7 @@ async function validateDesignReuse(root, platform, sourceContent) {
|
|
|
663
638
|
}
|
|
664
639
|
markersByFile.push({ file: signal.file, markers: [...markers] });
|
|
665
640
|
}
|
|
666
|
-
// Resolve design file absolute paths so we can skip them when scanning.
|
|
667
641
|
const designAbsolutePaths = new Set(designSignals.map((signal) => path.resolve(root, signal.file)));
|
|
668
|
-
// Implicit theme-provider reuse patterns. Idiomatic UI on most platforms
|
|
669
|
-
// pulls tokens from an ambient theme context (Theme.of in Flutter, useTheme
|
|
670
|
-
// in MUI/Chakra, MaterialTheme.colorScheme in Compose, UIColor.systemBlue
|
|
671
|
-
// dynamic colors in iOS). These reuse the design system without importing
|
|
672
|
-
// the file that declared the theme — the cross-file marker heuristic misses
|
|
673
|
-
// them. Per-platform list of additional positive signals:
|
|
674
642
|
const IMPLICIT_THEME_MARKERS_BY_PLATFORM = {
|
|
675
643
|
flutter: [
|
|
676
644
|
/Theme\.of\s*\(\s*context\s*\)/,
|
|
@@ -678,7 +646,6 @@ async function validateDesignReuse(root, platform, sourceContent) {
|
|
|
678
646
|
/MediaQuery\.of\s*\(\s*context\s*\)/,
|
|
679
647
|
/\bcolorScheme\s*\./,
|
|
680
648
|
/\btextTheme\s*\./,
|
|
681
|
-
// Material 3 widget references — strong signal of design-system reuse.
|
|
682
649
|
/\b(Card|ElevatedButton|FilledButton|OutlinedButton|TextButton|IconButton|Scaffold|AppBar|FloatingActionButton|ListTile|Chip|NavigationBar|NavigationRail)\s*\(/,
|
|
683
650
|
],
|
|
684
651
|
android: [
|
|
@@ -711,11 +678,8 @@ async function validateDesignReuse(root, platform, sourceContent) {
|
|
|
711
678
|
if (designAbsolutePaths.has(file)) {
|
|
712
679
|
continue;
|
|
713
680
|
}
|
|
714
|
-
// Phase 4: For AST-supported languages, check against comment-stripped
|
|
715
|
-
// content to avoid false passes from references only in comments.
|
|
716
681
|
const astLang = astLanguageForFile(file, platform);
|
|
717
682
|
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
718
|
-
// (a) Explicit reuse — cross-file basename / PascalCase reference.
|
|
719
683
|
for (const { markers } of markersByFile) {
|
|
720
684
|
for (const marker of markers) {
|
|
721
685
|
if (!marker || marker.length < 3) {
|
|
@@ -726,7 +690,6 @@ async function validateDesignReuse(root, platform, sourceContent) {
|
|
|
726
690
|
}
|
|
727
691
|
}
|
|
728
692
|
}
|
|
729
|
-
// (b) Implicit reuse — ambient theme-provider API or design-system widget.
|
|
730
693
|
if (implicitMarkers.some((re) => re.test(checkContent))) {
|
|
731
694
|
return [];
|
|
732
695
|
}
|
|
@@ -742,11 +705,6 @@ function toPascalCase(snakeOrKebab) {
|
|
|
742
705
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
743
706
|
.join("");
|
|
744
707
|
}
|
|
745
|
-
// Feed UI states: when a source file observes a live data stream/collection,
|
|
746
|
-
// it should also render loading / empty / error states. The heuristic is
|
|
747
|
-
// per-platform: detect a live-data observation pattern in a file, then check
|
|
748
|
-
// that file for at least one state marker. Returns at most one finding to
|
|
749
|
-
// keep noise low.
|
|
750
708
|
function validateFeedUiStates(root, platform, sourceContent) {
|
|
751
709
|
const observationPatterns = LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM[platform];
|
|
752
710
|
const statePatterns = UI_STATE_PATTERNS_BY_PLATFORM[platform];
|
|
@@ -761,10 +719,7 @@ function validateFeedUiStates(root, platform, sourceContent) {
|
|
|
761
719
|
if (!observes) {
|
|
762
720
|
continue;
|
|
763
721
|
}
|
|
764
|
-
|
|
765
|
-
// comment-stripped content to avoid false passes from comment text.
|
|
766
|
-
const astLang = astLanguageForFile(file, platform);
|
|
767
|
-
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
722
|
+
const checkContent = blankDeadDeclarations(commentStripped(file, platform, content), "(?:isLoading|loading|error|empty|isEmpty)");
|
|
768
723
|
const hasStates = statePatterns.some((pattern) => pattern.test(checkContent));
|
|
769
724
|
if (hasStates) {
|
|
770
725
|
continue;
|
|
@@ -779,10 +734,7 @@ const LIVE_DATA_OBSERVATION_PATTERNS_BY_PLATFORM = {
|
|
|
779
734
|
typescript: [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
|
|
780
735
|
"react-native": [/PostRepository\.getPosts/, /\.subscribe\s*\(/, /\.observe\s*\(/, /onSnapshot/],
|
|
781
736
|
flutter: [/\.listen\s*\(/, /StreamSubscription/, /getStreamController\s*\(/, /AmityCollection/, /AmityObject/],
|
|
782
|
-
// Kotlin uses trailing lambdas — `.observe { ... }` is more common than
|
|
783
|
-
// `.observe(...)`. Match either form.
|
|
784
737
|
android: [/LiveCollection/, /LiveObject/, /\.observe\s*[({]/, /\.subscribe\s*[({]/, /\.collectAsState\b/, /AmitySocialClient\.newPostRepository/, /AmityCollection/],
|
|
785
|
-
// Swift trailing-closure form: `.observe { snapshot in ... }`.
|
|
786
738
|
ios: [/AmityCollection/, /AmityObject/, /\.observe\s*[({]/, /getPosts\s*\(/, /queryPosts\s*\(/],
|
|
787
739
|
};
|
|
788
740
|
const UI_STATE_PATTERNS_BY_PLATFORM = {
|
|
@@ -817,14 +769,12 @@ function validateFeedPagination(root, platform, sourceContent) {
|
|
|
817
769
|
if (!hasFeed) {
|
|
818
770
|
continue;
|
|
819
771
|
}
|
|
820
|
-
// Check all source files for pagination markers (may be in a separate hook/util)
|
|
821
772
|
const astLang = astLanguageForFile(file, platform);
|
|
822
773
|
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
823
774
|
const hasPagination = paginationPatterns.some((pattern) => pattern.test(checkContent));
|
|
824
775
|
if (hasPagination) {
|
|
825
776
|
return [];
|
|
826
777
|
}
|
|
827
|
-
// Also check sibling files for pagination markers
|
|
828
778
|
for (const [otherFile, otherContent] of sourceContent) {
|
|
829
779
|
if (otherFile === file)
|
|
830
780
|
continue;
|
|
@@ -861,7 +811,6 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
|
|
|
861
811
|
if (!hasFeedFile) {
|
|
862
812
|
return [];
|
|
863
813
|
}
|
|
864
|
-
// Check all source files for any moderation marker (may be in a separate module)
|
|
865
814
|
for (const [file, content] of sourceContent) {
|
|
866
815
|
const astLang = astLanguageForFile(file, platform);
|
|
867
816
|
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
@@ -873,11 +822,7 @@ function validateFeedModerationAffordance(root, platform, sourceContent) {
|
|
|
873
822
|
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."),
|
|
874
823
|
];
|
|
875
824
|
}
|
|
876
|
-
// ─── Logout on user-switch ───────────────────────────────────────────────────
|
|
877
825
|
const USER_SWITCH_SYMBOLS = [
|
|
878
|
-
// setActiveUser is the real SDK user-switch. `setCurrentUser`/`setUser` were REMOVED — they collide
|
|
879
|
-
// with ubiquitous React useState setters (`const [u, setCurrentUser] = useState(...)`) and false-fired
|
|
880
|
-
// logout-on-user-switch on an RN feed screen that merely READ the active user into state.
|
|
881
826
|
/switchUser/i, /setActiveUser/i, /authStateChanged/i, /onAuthStateChanged/i,
|
|
882
827
|
/logoutAndLogin/i, /signInAs/i, /changeUser/i,
|
|
883
828
|
];
|
|
@@ -898,21 +843,18 @@ const LOGOUT_MARKERS_BY_PLATFORM = {
|
|
|
898
843
|
function validateLogoutOnUserSwitch(root, platform, sourceContent) {
|
|
899
844
|
const loginPatterns = LOGIN_PATTERNS_BY_PLATFORM[platform] ?? LOGIN_PATTERNS_BY_PLATFORM.typescript;
|
|
900
845
|
const logoutMarkers = LOGOUT_MARKERS_BY_PLATFORM[platform] ?? LOGOUT_MARKERS_BY_PLATFORM.typescript;
|
|
901
|
-
// Check: does the project have login AND user-switch symbols?
|
|
902
846
|
const loginFiles = filesMatching(sourceContent, loginPatterns);
|
|
903
847
|
if (loginFiles.length === 0)
|
|
904
848
|
return [];
|
|
905
849
|
const switchFiles = filesMatching(sourceContent, USER_SWITCH_SYMBOLS);
|
|
906
850
|
if (switchFiles.length === 0)
|
|
907
851
|
return [];
|
|
908
|
-
// Require a logout marker somewhere in the source
|
|
909
852
|
if (containsAny(sourceContent, logoutMarkers))
|
|
910
853
|
return [];
|
|
911
854
|
return [
|
|
912
855
|
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. Preserve the existing sessionHandler or renewal wiring on the subsequent login call, and attest only if a centralized auth coordinator owns the switch flow."),
|
|
913
856
|
];
|
|
914
857
|
}
|
|
915
|
-
// ─── No anonymous write ──────────────────────────────────────────────────────
|
|
916
858
|
const WRITE_CALL_PATTERNS = [
|
|
917
859
|
/createPost\s*\(/, /createComment\s*\(/, /sendMessage\s*\(/,
|
|
918
860
|
/createStory\s*\(/, /addReaction\s*\(/, /\.react\s*\(/,
|
|
@@ -920,13 +862,8 @@ const WRITE_CALL_PATTERNS = [
|
|
|
920
862
|
/\.createPost\s*\(/, /\.createComment\s*\(/, /\.sendMessage\s*\(/,
|
|
921
863
|
];
|
|
922
864
|
const AUTH_GATE_MARKERS = [
|
|
923
|
-
// `currentUser` prefix, NOT \bcurrentUser\b — a write gated on `currentUserId` (the
|
|
924
|
-
// session-derived user id, idiomatically passed into a component) is a valid
|
|
925
|
-
// anonymous-write guard, and \b after "currentUser" would not match "currentUserId".
|
|
926
865
|
/\bcurrentUser\w*/, /\bisAuthenticated\b/, /\brequireAuth\b/,
|
|
927
|
-
/\bsession\b/, /\
|
|
928
|
-
// \w* not \b: `getCurrentUserId()` (the session-derived id used to gate writes) must match —
|
|
929
|
-
// the trailing \b would stop at "getCurrentUser" and miss the camelCase suffix (recurring pattern).
|
|
866
|
+
/\bsession\b/, /\bis(?:User)?LoggedIn\b/, /\bauthState\b/,
|
|
930
867
|
/\bgetCurrentUser\w*/, /\bgetAccessToken\b/, /\baccessToken\b/,
|
|
931
868
|
/\bauthGuard\b/, /\bwithAuth\b/, /\bProtectedRoute\b/, /\bAuthProvider\b/,
|
|
932
869
|
];
|
|
@@ -934,19 +871,17 @@ function validateNoAnonymousWrite(root, platform, sourceContent) {
|
|
|
934
871
|
const writeFiles = filesMatching(sourceContent, WRITE_CALL_PATTERNS);
|
|
935
872
|
if (writeFiles.length === 0)
|
|
936
873
|
return [];
|
|
937
|
-
// For each file with write calls, check if it has auth gate markers
|
|
938
874
|
for (const filePath of writeFiles) {
|
|
939
875
|
const content = sourceContent.get(filePath) ?? "";
|
|
940
876
|
const astLang = astLanguageForFile(filePath, platform);
|
|
941
|
-
const
|
|
942
|
-
if (AUTH_GATE_MARKERS.some((p) => p.test(
|
|
877
|
+
const authSurface = blankDeadDeclarations(astLang ? stripComments(astLang, content) : content, "currentUser\\w*");
|
|
878
|
+
if (AUTH_GATE_MARKERS.some((p) => p.test(authSurface)))
|
|
943
879
|
return [];
|
|
944
880
|
}
|
|
945
881
|
return [
|
|
946
882
|
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."),
|
|
947
883
|
];
|
|
948
884
|
}
|
|
949
|
-
// ─── Network error handling ──────────────────────────────────────────────────
|
|
950
885
|
const SDK_CALL_PATTERNS = [
|
|
951
886
|
/\.login\s*\(/, /\.setup\s*\(/, /createPost\s*\(/,
|
|
952
887
|
/createComment\s*\(/, /sendMessage\s*\(/, /createStory\s*\(/,
|
|
@@ -956,15 +891,6 @@ const SDK_CALL_PATTERNS = [
|
|
|
956
891
|
const ERROR_HANDLING_MARKERS_BY_PLATFORM = {
|
|
957
892
|
android: [/try\s*\{/, /runCatching/, /\.onFailure/, /CoroutineExceptionHandler/, /\.catch\s*\{/],
|
|
958
893
|
flutter: [/try\s*\{/, /\.catchError\s*\(/, /\.onError\s*\(/, /\.handleError/, /catch\s*\(/],
|
|
959
|
-
// TypeScript / React: in addition to imperative try/catch and Promise.catch,
|
|
960
|
-
// recognize React's idiomatic error-state pattern:
|
|
961
|
-
// - setError(...) state setter
|
|
962
|
-
// - useState<Error...> or useState<...Error> (error-typed state)
|
|
963
|
-
// - ErrorBoundary component reference (React error boundary)
|
|
964
|
-
// - error.tsx co-located file (Next.js App Router convention)
|
|
965
|
-
// - destructured error from a callback's response object: ({ error }) or (error: foo)
|
|
966
|
-
// These are the canonical patterns the social.plus TS SDK's Live Collection
|
|
967
|
-
// callbacks use; flagging them would force attestation on idiomatic code.
|
|
968
894
|
typescript: [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /error\.tsx\b/, /\berror\s*:\s*\w+\s*[,})]/],
|
|
969
895
|
"react-native": [/try\s*\{/, /\.catch\s*\(/, /\.then\s*\([^)]*,[^)]*\)/, /catchError/, /onError/, /setError\s*\(/, /useState\s*<[^>]*[Ee]rror/, /ErrorBoundary/, /\berror\s*:\s*\w+\s*[,})]/],
|
|
970
896
|
ios: [/do\s*\{/, /try\s+/, /try\?/, /catch\s*\{/, /\.failure\s*\(/, /Result\s*</, /completion.*Error/],
|
|
@@ -974,14 +900,12 @@ function validateErrorHandling(root, platform, sourceContent) {
|
|
|
974
900
|
if (sdkCallFiles.length === 0)
|
|
975
901
|
return [];
|
|
976
902
|
const errorMarkers = ERROR_HANDLING_MARKERS_BY_PLATFORM[platform] ?? ERROR_HANDLING_MARKERS_BY_PLATFORM.typescript;
|
|
977
|
-
// Check if ANY source file has error handling markers
|
|
978
903
|
if (containsAny(sourceContent, errorMarkers))
|
|
979
904
|
return [];
|
|
980
905
|
return [
|
|
981
906
|
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."),
|
|
982
907
|
];
|
|
983
908
|
}
|
|
984
|
-
// ─── Comments validation ─────────────────────────────────────────────────────
|
|
985
909
|
const COMMENT_PRESENCE_PATTERNS = [
|
|
986
910
|
/createComment\s*\(/, /getComments\s*\(/, /commentRepository/i,
|
|
987
911
|
/CommentRepository/, /AmityCommentRepository/, /commentCollection/i,
|
|
@@ -995,15 +919,16 @@ const COMMENT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
|
|
|
995
919
|
ios: [/\.invalidate\s*\(/, /AmityNotificationToken/, /deinit/, /viewWillDisappear/],
|
|
996
920
|
};
|
|
997
921
|
const COMMENT_UI_STATE_MARKERS = [
|
|
998
|
-
/
|
|
922
|
+
/isLoading/, /isEmpty/, /hasError/, /isError\b/, /isFetching/, /isPending/,
|
|
999
923
|
/\.loading\b/, /\.empty\b/, /\.error\b/, /LoadingState/, /emptyState/i,
|
|
924
|
+
/\bloading\s*[?:&]/i, /\berror\s*[?:&]/i, /\bempty\s*[?:&]/i, /No comments/i,
|
|
925
|
+
/\.status\s*===?\s*["']?(?:loading|error|empty)/i,
|
|
1000
926
|
];
|
|
1001
927
|
function validateComments(root, platform, sourceContent) {
|
|
1002
928
|
const findings = [];
|
|
1003
929
|
const commentFiles = filesMatching(sourceContent, COMMENT_PRESENCE_PATTERNS);
|
|
1004
930
|
if (commentFiles.length === 0)
|
|
1005
931
|
return findings;
|
|
1006
|
-
// target-resolved: check for hardcoded postId/commentId in comment files
|
|
1007
932
|
for (const filePath of commentFiles) {
|
|
1008
933
|
const content = sourceContent.get(filePath) ?? "";
|
|
1009
934
|
const hardcodedTarget = /(?:postId|commentId|referenceId)\s*[:=]\s*["'`][a-z0-9-]+["'`]/i.exec(content);
|
|
@@ -1012,12 +937,6 @@ function validateComments(root, platform, sourceContent) {
|
|
|
1012
937
|
break;
|
|
1013
938
|
}
|
|
1014
939
|
}
|
|
1015
|
-
// observer-cleanup: require cleanup only for a REACTIVE comment observer. The
|
|
1016
|
-
// distinguisher is `await`: a one-shot `await queryComments(...)` returns a Promise
|
|
1017
|
-
// with nothing to clean up (false-fired on a weak-model build), whereas a non-awaited
|
|
1018
|
-
// getComments/queryComments creates a live collection that must be disposed. Strip the
|
|
1019
|
-
// awaited one-shot queries, then any remaining (bare-call, assigned, or chained) comment
|
|
1020
|
-
// query is a live observer.
|
|
1021
940
|
const hasReactiveCommentObserver = [...sourceContent.values()].some((c) => {
|
|
1022
941
|
const stripped = c.replace(/\bawait\b[^\n;]*?(?:get|query)Comments\s*\(/g, "");
|
|
1023
942
|
return /(?:get|query)Comments\s*\(/.test(stripped)
|
|
@@ -1027,12 +946,12 @@ function validateComments(root, platform, sourceContent) {
|
|
|
1027
946
|
if (commentFiles.length > 0 && hasReactiveCommentObserver && !containsAny(sourceContent, cleanupMarkers)) {
|
|
1028
947
|
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."));
|
|
1029
948
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
949
|
+
const strippedCommentFiles = new Map();
|
|
950
|
+
for (const f of commentFiles)
|
|
951
|
+
strippedCommentFiles.set(f, commentStripped(f, platform, sourceContent.get(f) ?? ""));
|
|
952
|
+
if (!containsAnyForFiles(strippedCommentFiles, commentFiles, COMMENT_UI_STATE_MARKERS)) {
|
|
1033
953
|
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."));
|
|
1034
954
|
}
|
|
1035
|
-
// moderation-affordance-present: require moderation on comments (scoped to comment files)
|
|
1036
955
|
const commentOnlyContent = new Map();
|
|
1037
956
|
for (const f of commentFiles)
|
|
1038
957
|
commentOnlyContent.set(f, sourceContent.get(f) ?? "");
|
|
@@ -1041,7 +960,6 @@ function validateComments(root, platform, sourceContent) {
|
|
|
1041
960
|
}
|
|
1042
961
|
return findings;
|
|
1043
962
|
}
|
|
1044
|
-
// --- Moderation validators ---
|
|
1045
963
|
const MODERATION_PRESENCE_PATTERNS = [
|
|
1046
964
|
/\bAmityModerationRepository\b/,
|
|
1047
965
|
/\bModerationRepository\b/,
|
|
@@ -1086,31 +1004,22 @@ function validateModeration(root, platform, sourceContent) {
|
|
|
1086
1004
|
const moderationFiles = filesMatching(sourceContent, MODERATION_PRESENCE_PATTERNS);
|
|
1087
1005
|
if (moderationFiles.length === 0)
|
|
1088
1006
|
return findings;
|
|
1089
|
-
// report-flow-present: require report action
|
|
1090
1007
|
const hasReport = containsAny(sourceContent, [/\breport\b/i, /\bflag\b/i, /\bflagContent\b/, /\breportContent\b/]);
|
|
1091
1008
|
if (!hasReport) {
|
|
1092
1009
|
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."));
|
|
1093
1010
|
}
|
|
1094
|
-
|
|
1095
|
-
if (!containsAny(
|
|
1011
|
+
const blockStateContent = new Map([...sourceContent].map(([f, c]) => [f, blankDeadDeclarations(c, "isBlocked")]));
|
|
1012
|
+
if (!containsAny(blockStateContent, MODERATION_BLOCK_STATE_APPLIED_MARKERS)) {
|
|
1096
1013
|
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)."));
|
|
1097
1014
|
}
|
|
1098
|
-
// hidden-content-rendering-present: require rendering logic for hidden content
|
|
1099
1015
|
if (!containsAny(sourceContent, MODERATION_HIDDEN_RENDERING_MARKERS)) {
|
|
1100
1016
|
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)."));
|
|
1101
1017
|
}
|
|
1102
|
-
// confirmation-ux-present: require confirmation for destructive moderation actions
|
|
1103
1018
|
if (!containsAny(sourceContent, MODERATION_CONFIRMATION_MARKERS)) {
|
|
1104
1019
|
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."));
|
|
1105
1020
|
}
|
|
1106
1021
|
return findings;
|
|
1107
1022
|
}
|
|
1108
|
-
// --- Chat validators ---
|
|
1109
|
-
// social.plus chat presence. NOTE: bare `sendMessage` is intentionally NOT here — it is not a
|
|
1110
|
-
// social.plus symbol (the SDK sends via createMessage; see sdk-surface), and it false-matched
|
|
1111
|
-
// foreign chat SDKs (e.g. Google Gemini's `chat.sendMessage`) — on the Flexify pilot smoke that
|
|
1112
|
-
// mis-classified a feed-only app's AI-assistant store as a chat file and fired chat.* rules.
|
|
1113
|
-
// Detection anchors on social.plus-specific message/channel symbols instead.
|
|
1114
1023
|
const CHAT_PRESENCE_PATTERNS = [
|
|
1115
1024
|
/\bcreateMessage\b/,
|
|
1116
1025
|
/\bAmityMessageRepository\b/,
|
|
@@ -1123,7 +1032,7 @@ const CHAT_PRESENCE_PATTERNS = [
|
|
|
1123
1032
|
const CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM = {
|
|
1124
1033
|
typescript: [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
|
|
1125
1034
|
"react-native": [/\bunsubscribe\b/, /\bdispose\b/, /\bremoveListener\b/, /\buseEffect\b.*\breturn\b/s],
|
|
1126
|
-
android: [/\
|
|
1035
|
+
android: [/\bdispose\s*\(/, /\bremoveObserver\b/, /\bunsubscribe\b/, /\bcancel\s*\(/, /\bclose\s*\(/],
|
|
1127
1036
|
flutter: [/\bdispose\b/, /\bcancel\b/, /\bclose\b/],
|
|
1128
1037
|
ios: [/\bdeinit\b/, /\bonDisappear\b/, /\binvalidate\b/, /\bremoveObserver\b/],
|
|
1129
1038
|
};
|
|
@@ -1132,28 +1041,18 @@ function validateChat(root, platform, sourceContent) {
|
|
|
1132
1041
|
const chatFiles = filesMatching(sourceContent, CHAT_PRESENCE_PATTERNS);
|
|
1133
1042
|
if (chatFiles.length === 0)
|
|
1134
1043
|
return findings;
|
|
1135
|
-
// channel-type-dm: createChannel with type 'community' AND userIds indicates DM intent with wrong type.
|
|
1136
|
-
// Disambiguating condition: community channels do not pass userIds in createChannel (membership is
|
|
1137
|
-
// managed separately). If userIds appears alongside type:'community', the intent is clearly a DM
|
|
1138
|
-
// conversation but the wrong channel type was used. Do NOT fire on community channels without userIds.
|
|
1139
1044
|
for (const filePath of chatFiles) {
|
|
1140
1045
|
const content = sourceContent.get(filePath) ?? "";
|
|
1141
1046
|
if (/\bcreateChannel\b/.test(content)) {
|
|
1142
1047
|
const hasCommunityType = /createChannel[\s\S]{0,400}type\s*:\s*['"]community['"]/.test(content);
|
|
1143
1048
|
const hasUserIds = /createChannel[\s\S]{0,400}\buserIds\b/.test(content);
|
|
1144
1049
|
const hasConversationType = /createChannel[\s\S]{0,400}type\s*:\s*['"]conversation['"]/.test(content);
|
|
1145
|
-
// Only fire on react-native/typescript where this rule is registered.
|
|
1146
|
-
// Only fire when both type:'community' AND userIds are present — that combination signals
|
|
1147
|
-
// DM intent. Community channel creation does not pass userIds to createChannel.
|
|
1148
1050
|
if ((platform === "react-native" || platform === "typescript") && hasCommunityType && hasUserIds && !hasConversationType) {
|
|
1149
1051
|
findings.push(finding(`${platform}.chat.channel-type-dm`, "warning", "createChannel uses type 'community' with userIds, which indicates DM intent — but 'community' creates a discoverable group channel, not a private conversation.", relativeFile(root, filePath), "Use type: 'conversation' for 1-to-1 direct messages between known users. 'community' channels are discoverable group channels; passing userIds to createChannel with type:'community' does not create a private DM."));
|
|
1150
1052
|
break;
|
|
1151
1053
|
}
|
|
1152
1054
|
}
|
|
1153
1055
|
}
|
|
1154
|
-
// channel-target-resolved: check for hardcoded channelId/conversationId.
|
|
1155
|
-
// Comment-stripped so a commented-out or documented channelId can't trip this
|
|
1156
|
-
// no-escape (exit-2) gate.
|
|
1157
1056
|
for (const filePath of chatFiles) {
|
|
1158
1057
|
const content = commentStripped(filePath, platform, sourceContent.get(filePath) ?? "");
|
|
1159
1058
|
const hardcodedChannel = /(?:channelId|conversationId|channel_id)\b[^=\n]*=\s*["'`][a-z0-9-]+["'`]/i.exec(content);
|
|
@@ -1162,12 +1061,10 @@ function validateChat(root, platform, sourceContent) {
|
|
|
1162
1061
|
break;
|
|
1163
1062
|
}
|
|
1164
1063
|
}
|
|
1165
|
-
// message-observer-cleanup: require cleanup
|
|
1166
1064
|
const chatCleanupMarkers = CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM[platform] ?? CHAT_OBSERVER_CLEANUP_MARKERS_BY_PLATFORM.typescript;
|
|
1167
|
-
if (!
|
|
1065
|
+
if (!containsAnyStripped(sourceContent, platform, chatCleanupMarkers)) {
|
|
1168
1066
|
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."));
|
|
1169
1067
|
}
|
|
1170
|
-
// send-error-handling: require error handling around sendMessage (scoped to chat files)
|
|
1171
1068
|
const chatFileContent = new Map();
|
|
1172
1069
|
for (const f of chatFiles)
|
|
1173
1070
|
chatFileContent.set(f, sourceContent.get(f) ?? "");
|
|
@@ -1183,13 +1080,72 @@ function validateChat(root, platform, sourceContent) {
|
|
|
1183
1080
|
if (!hasErrorHandling) {
|
|
1184
1081
|
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."));
|
|
1185
1082
|
}
|
|
1186
|
-
// moderation-affordance-present: require moderation on chat messages (scoped to chat files)
|
|
1187
1083
|
if (!containsAny(chatFileContent, MODERATION_MARKERS)) {
|
|
1188
1084
|
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."));
|
|
1189
1085
|
}
|
|
1190
1086
|
return findings;
|
|
1191
1087
|
}
|
|
1192
|
-
|
|
1088
|
+
const DEPRECATED_MARKER_SYNC_PATTERNS = [
|
|
1089
|
+
/\bstartMessageReceiptSync\b/,
|
|
1090
|
+
/\bstopMessageReceiptSync\b/,
|
|
1091
|
+
];
|
|
1092
|
+
const DEPRECATED_MARKER_SYNC_PLATFORMS = new Set(["typescript", "react-native", "android", "ios"]);
|
|
1093
|
+
const DEPRECATED_SUBCHANNEL_PATTERNS = [
|
|
1094
|
+
/\bcreateSubChannel\b/,
|
|
1095
|
+
/\bupdateSubChannel\b/,
|
|
1096
|
+
/\bdeleteSubChannel\b/,
|
|
1097
|
+
/\bAmitySubChannelQuery\b/,
|
|
1098
|
+
];
|
|
1099
|
+
const DEPRECATED_MARKER_SYNC_ESCAPE = /\/\/\s*vise:\s*receipt-sync intentional/i;
|
|
1100
|
+
const DEPRECATED_SUBCHANNEL_ESCAPE = /\/\/\s*vise:\s*subchannel-management intentional/i;
|
|
1101
|
+
function validateChatDeprecations(root, platform, sourceContent) {
|
|
1102
|
+
const findings = [];
|
|
1103
|
+
if (DEPRECATED_MARKER_SYNC_PLATFORMS.has(platform)) {
|
|
1104
|
+
for (const [filePath, raw] of sourceContent) {
|
|
1105
|
+
const content = commentStripped(filePath, platform, raw);
|
|
1106
|
+
if (DEPRECATED_MARKER_SYNC_PATTERNS.some((pattern) => pattern.test(content))) {
|
|
1107
|
+
if (DEPRECATED_MARKER_SYNC_ESCAPE.test(raw))
|
|
1108
|
+
continue;
|
|
1109
|
+
findings.push(finding(`${platform}.chat.deprecated-marker-sync`, "warning", "Chat code uses the deprecated MarkerSyncEngine receipt sync API (startMessageReceiptSync/stopMessageReceiptSync), which is being sun-set.", relativeFile(root, filePath), "MarkerSyncEngine receipt sync is deprecated. Track read state with the channel unread count (channel.unreadCount / getTotalChannelUnread) and mark read via markRead() — the unread-count APIs stay fully supported. Add `// vise: receipt-sync intentional — <reason>` only if you are deliberately staying on the legacy API during migration."));
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
for (const [filePath, raw] of sourceContent) {
|
|
1115
|
+
const content = commentStripped(filePath, platform, raw);
|
|
1116
|
+
if (DEPRECATED_SUBCHANNEL_PATTERNS.some((pattern) => pattern.test(content))) {
|
|
1117
|
+
if (DEPRECATED_SUBCHANNEL_ESCAPE.test(raw))
|
|
1118
|
+
continue;
|
|
1119
|
+
findings.push(finding(`${platform}.chat.deprecated-subchannel`, "warning", "Chat code manually manages sub-channels (createSubChannel/updateSubChannel/deleteSubChannel), a deprecated pattern that is being sun-set.", relativeFile(root, filePath), "Manual sub-channel management is deprecated. Use the channel's default sub-channel via channel.defaultSubChannelId (channel.getDefaultSubChannelId() on Android) — you do not need to create or query sub-channels separately. Add `// vise: subchannel-management intentional — <reason>` only if a multi-sub-channel layout is deliberately required."));
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return findings;
|
|
1124
|
+
}
|
|
1125
|
+
const DEPRECATED_LEGACY_STREAM_PATTERNS = [
|
|
1126
|
+
/\bAmityStreamRepository\b/,
|
|
1127
|
+
/\bStreamRepository\b/,
|
|
1128
|
+
/\bgetStreamById\b/,
|
|
1129
|
+
/\bcreateLiveStreamPost\b/,
|
|
1130
|
+
/\bAmityLiveStreamPostCreator\b/,
|
|
1131
|
+
];
|
|
1132
|
+
const DEPRECATED_LEGACY_STREAM_PLATFORMS = new Set(["typescript", "react-native", "android", "ios"]);
|
|
1133
|
+
const DEPRECATED_LEGACY_STREAM_ESCAPE = /\/\/\s*vise:\s*legacy-stream intentional/i;
|
|
1134
|
+
function validateLivestreamDeprecation(root, platform, sourceContent) {
|
|
1135
|
+
const findings = [];
|
|
1136
|
+
if (!DEPRECATED_LEGACY_STREAM_PLATFORMS.has(platform))
|
|
1137
|
+
return findings;
|
|
1138
|
+
for (const [filePath, raw] of sourceContent) {
|
|
1139
|
+
const content = commentStripped(filePath, platform, raw);
|
|
1140
|
+
if (DEPRECATED_LEGACY_STREAM_PATTERNS.some((pattern) => pattern.test(content))) {
|
|
1141
|
+
if (DEPRECATED_LEGACY_STREAM_ESCAPE.test(raw))
|
|
1142
|
+
continue;
|
|
1143
|
+
findings.push(finding(`${platform}.livestream.deprecated-stream`, "warning", "Livestream code uses the deprecated legacy Stream API (AmityStreamRepository/StreamRepository/getStreamById/createLiveStreamPost), which is being sun-set in favour of Room-based livestreaming.", relativeFile(root, filePath), "The legacy Stream feature is deprecated. Migrate to the Room API: AmityRoomRepository/RoomRepository.getRoom(roomId) (observe the returned room live object and handle all four statuses — idle/live/ended/recorded) and createRoomPost in place of createLiveStreamPost. Add `// vise: legacy-stream intentional — <reason>` only if you are deliberately staying on legacy Stream during migration."));
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return findings;
|
|
1148
|
+
}
|
|
1193
1149
|
const SESSION_HANDLER_PRESENCE = {
|
|
1194
1150
|
android: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
|
|
1195
1151
|
flutter: [/\bAmitySessionHandler\b/, /\bSessionHandler\b/],
|
|
@@ -1228,7 +1184,7 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1228
1184
|
/\bListView\b/,
|
|
1229
1185
|
/\bRecyclerView\b/,
|
|
1230
1186
|
/\bLazyColumn\b/,
|
|
1231
|
-
/\bLazyRow\b/,
|
|
1187
|
+
/\bLazyRow\b/,
|
|
1232
1188
|
/\bLazyVerticalGrid\b/,
|
|
1233
1189
|
/\bLazyVerticalStaggeredGrid\b/,
|
|
1234
1190
|
/\bForEach\b/,
|
|
@@ -1245,26 +1201,17 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1245
1201
|
/\bonData\b/,
|
|
1246
1202
|
/\bStreamBuilder\b/,
|
|
1247
1203
|
/\bLiveData\b/,
|
|
1248
|
-
/\bMutableLiveData\b/,
|
|
1204
|
+
/\bMutableLiveData\b/,
|
|
1249
1205
|
/\bMediatorLiveData\b/,
|
|
1250
|
-
/collectAsState/,
|
|
1251
|
-
/observeAsState/,
|
|
1206
|
+
/collectAsState/,
|
|
1207
|
+
/observeAsState/,
|
|
1252
1208
|
/Amity\w*Flow\b/,
|
|
1253
1209
|
/\bPagingData\b/,
|
|
1254
|
-
/LazyPagingItems/,
|
|
1210
|
+
/LazyPagingItems/,
|
|
1255
1211
|
/collectAsLazyPagingItems/,
|
|
1256
|
-
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1257
|
-
/\$snapshots\b/,
|
|
1258
|
-
/\
|
|
1259
|
-
/\bLiveCollectionCallback\b/, // TS/RN live-collection callback type (Amity.LiveCollectionCallback)
|
|
1260
|
-
/\bLiveObjectCallback\b/, // TS/RN live-object callback type
|
|
1261
|
-
// TS/RN reactive form: getPosts(params, callback) / getComments(params, cb) — the
|
|
1262
|
-
// 2-arg callback form IS the live collection (callback fires on every update). Earlier
|
|
1263
|
-
// markers only knew getLiveCollection/observe and missed this idiomatic v6 form. The
|
|
1264
|
-
// first arg is a flat params OBJECT or an identifier; the callback is a genuine SECOND
|
|
1265
|
-
// argument — matching `{...},` (not a comma *inside* the object, which would falsely
|
|
1266
|
-
// count a one-shot `queryPosts({ a, b })` as reactive).
|
|
1267
|
-
/\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1212
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1213
|
+
/\$snapshots\b/,
|
|
1214
|
+
/\b(?:get|query)(?:Posts|Comments|Channels|Communities|Users|Members|Reactions|CommunityFeed|GlobalFeed)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1268
1215
|
/\/\/\s*vise:\s*one-shot query/i
|
|
1269
1216
|
];
|
|
1270
1217
|
for (const [file, content] of sourceContent) {
|
|
@@ -1275,7 +1222,8 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1275
1222
|
if (hasListQuery) {
|
|
1276
1223
|
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1277
1224
|
if (hasListUI) {
|
|
1278
|
-
const
|
|
1225
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1226
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1279
1227
|
if (!hasReactivity) {
|
|
1280
1228
|
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. Preserve existing query options and pagination wiring (for example pageToken, hasMore/loadMore, useInfiniteQuery) while converting the list to live updates. If a one-shot query is truly intended, add comment // vise: one-shot query."));
|
|
1281
1229
|
}
|
|
@@ -1284,23 +1232,212 @@ function validateLiveCollectionApiMismatch(root, platform, sourceContent) {
|
|
|
1284
1232
|
}
|
|
1285
1233
|
return findings;
|
|
1286
1234
|
}
|
|
1235
|
+
function validateStories(root, platform, sourceContent) {
|
|
1236
|
+
const findings = [];
|
|
1237
|
+
const ruleId = `${platform}.story.live-collection`;
|
|
1238
|
+
const STORY_LIST_QUERY_PATTERNS = [
|
|
1239
|
+
/\bgetActiveStoriesByTarget\b/,
|
|
1240
|
+
/\bgetActiveStories\b/,
|
|
1241
|
+
/\bgetStoriesByTargets\b/,
|
|
1242
|
+
/\bgetStoriesByTargetIds\b/,
|
|
1243
|
+
/\bgetGlobalStoryTargets\b/,
|
|
1244
|
+
];
|
|
1245
|
+
const LIST_UI_PATTERNS = [
|
|
1246
|
+
/\bmap\s*\(/,
|
|
1247
|
+
/\bFlatList\b/,
|
|
1248
|
+
/\bListView\b/,
|
|
1249
|
+
/\bRecyclerView\b/,
|
|
1250
|
+
/\bLazyColumn\b/,
|
|
1251
|
+
/\bLazyRow\b/,
|
|
1252
|
+
/\bLazyVerticalGrid\b/,
|
|
1253
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1254
|
+
/\bForEach\b/,
|
|
1255
|
+
/\bList\s*\(/,
|
|
1256
|
+
/\bsubmitList\b/,
|
|
1257
|
+
/\btableView\b/,
|
|
1258
|
+
/\bcollectionView\b/
|
|
1259
|
+
];
|
|
1260
|
+
const REACTIVE_MARKERS = [
|
|
1261
|
+
/\bgetLiveCollection\b/,
|
|
1262
|
+
/\bobserve\b/,
|
|
1263
|
+
/\bobserveOnce\b/,
|
|
1264
|
+
/\blisten\b/,
|
|
1265
|
+
/\bonData\b/,
|
|
1266
|
+
/\bStreamBuilder\b/,
|
|
1267
|
+
/\bLiveData\b/,
|
|
1268
|
+
/\bMutableLiveData\b/,
|
|
1269
|
+
/\bMediatorLiveData\b/,
|
|
1270
|
+
/collectAsState/,
|
|
1271
|
+
/observeAsState/,
|
|
1272
|
+
/Amity\w*Flow\b/,
|
|
1273
|
+
/\bPagingData\b/,
|
|
1274
|
+
/LazyPagingItems/,
|
|
1275
|
+
/collectAsLazyPagingItems/,
|
|
1276
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1277
|
+
/\$snapshots\b/,
|
|
1278
|
+
/\bStoryLiveCollection\b/,
|
|
1279
|
+
/\bget(?:ActiveStoriesByTarget|StoriesByTargets|StoriesByTargetIds|GlobalStoryTargets)\s*\(\s*(?:\{[^{}]*\}|\w+)\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1280
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1281
|
+
];
|
|
1282
|
+
for (const [file, content] of sourceContent) {
|
|
1283
|
+
const rel = relativeFile(root, file);
|
|
1284
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1285
|
+
continue;
|
|
1286
|
+
const hasStoryQuery = STORY_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1287
|
+
if (hasStoryQuery) {
|
|
1288
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1289
|
+
if (hasListUI) {
|
|
1290
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1291
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1292
|
+
if (!hasReactivity) {
|
|
1293
|
+
findings.push(finding(ruleId, "warning", `A story query (like getActiveStoriesByTarget) renders a list but no reactive markers (observe, onData, the (params, callback) live form, StreamBuilder, PagingData) were found in the same file.`, rel, "Render story trays from the reactive Story LiveCollection so the UI updates when stories are created, viewed, or expire. Pass the live callback (TS/RN: getActiveStoriesByTarget(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter StreamBuilder). If a one-shot query is truly intended, add comment // vise: one-shot query."));
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return findings;
|
|
1299
|
+
}
|
|
1300
|
+
function validateSearch(root, platform, sourceContent) {
|
|
1301
|
+
const findings = [];
|
|
1302
|
+
const ruleId = `${platform}.search.live-collection`;
|
|
1303
|
+
const SEARCH_LIST_QUERY_PATTERNS = [
|
|
1304
|
+
/\bsearchCommunities\b/,
|
|
1305
|
+
/\bsemanticSearchCommunities\b/,
|
|
1306
|
+
/\bsemanticSearchPosts\b/,
|
|
1307
|
+
/\bsearchPostsByHashtag\b/,
|
|
1308
|
+
/\bsearchUsers\b/,
|
|
1309
|
+
/\bsearchUserByDisplayName\b/,
|
|
1310
|
+
/\bsearchChannels\b/,
|
|
1311
|
+
];
|
|
1312
|
+
const LIST_UI_PATTERNS = [
|
|
1313
|
+
/\bmap\s*\(/,
|
|
1314
|
+
/\bFlatList\b/,
|
|
1315
|
+
/\bListView\b/,
|
|
1316
|
+
/\bRecyclerView\b/,
|
|
1317
|
+
/\bLazyColumn\b/,
|
|
1318
|
+
/\bLazyRow\b/,
|
|
1319
|
+
/\bLazyVerticalGrid\b/,
|
|
1320
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1321
|
+
/\bForEach\b/,
|
|
1322
|
+
/\bList\s*\(/,
|
|
1323
|
+
/\bsubmitList\b/,
|
|
1324
|
+
/\btableView\b/,
|
|
1325
|
+
/\bcollectionView\b/
|
|
1326
|
+
];
|
|
1327
|
+
const REACTIVE_MARKERS = [
|
|
1328
|
+
/\bgetLiveCollection\b/,
|
|
1329
|
+
/\bobserve\b/,
|
|
1330
|
+
/\bobserveOnce\b/,
|
|
1331
|
+
/\blisten\b/,
|
|
1332
|
+
/\bonData\b/,
|
|
1333
|
+
/\bStreamBuilder\b/,
|
|
1334
|
+
/\bLiveData\b/,
|
|
1335
|
+
/\bMutableLiveData\b/,
|
|
1336
|
+
/\bMediatorLiveData\b/,
|
|
1337
|
+
/collectAsState/,
|
|
1338
|
+
/observeAsState/,
|
|
1339
|
+
/Amity\w*Flow\b/,
|
|
1340
|
+
/\bPagingData\b/,
|
|
1341
|
+
/LazyPagingItems/,
|
|
1342
|
+
/collectAsLazyPagingItems/,
|
|
1343
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1344
|
+
/\$snapshots\b/,
|
|
1345
|
+
/\bPagingController\b/,
|
|
1346
|
+
/\bgetPagingData\b/,
|
|
1347
|
+
/\b(?:search(?:Communities|Users|UserByDisplayName|PostsByHashtag|Channels)|semanticSearch(?:Communities|Posts))\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1348
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1349
|
+
];
|
|
1350
|
+
for (const [file, content] of sourceContent) {
|
|
1351
|
+
const rel = relativeFile(root, file);
|
|
1352
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1353
|
+
continue;
|
|
1354
|
+
const hasSearchQuery = SEARCH_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1355
|
+
if (hasSearchQuery) {
|
|
1356
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1357
|
+
if (hasListUI) {
|
|
1358
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1359
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1360
|
+
if (!hasReactivity) {
|
|
1361
|
+
findings.push(finding(ruleId, "warning", `A search query (like searchCommunities/searchUsers) renders a list but no reactive/paginated markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render search results from the reactive, paginated Search LiveCollection so the list updates and pages past the first result set. Use the live callback (TS/RN: searchX(params, callback)), observe the collection (iOS .observe / Android PagingData), or wire a PagingController (Flutter). If a one-shot, single-page search is truly intended, add comment // vise: one-shot query."));
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return findings;
|
|
1367
|
+
}
|
|
1368
|
+
function validateNotificationTray(root, platform, sourceContent) {
|
|
1369
|
+
const findings = [];
|
|
1370
|
+
const ruleId = `${platform}.notification-tray.live-collection`;
|
|
1371
|
+
const TRAY_LIST_QUERY_PATTERNS = [
|
|
1372
|
+
/\bgetNotificationTrayItems\b/,
|
|
1373
|
+
];
|
|
1374
|
+
const LIST_UI_PATTERNS = [
|
|
1375
|
+
/\bmap\s*\(/,
|
|
1376
|
+
/\bFlatList\b/,
|
|
1377
|
+
/\bListView\b/,
|
|
1378
|
+
/\bRecyclerView\b/,
|
|
1379
|
+
/\bLazyColumn\b/,
|
|
1380
|
+
/\bLazyRow\b/,
|
|
1381
|
+
/\bLazyVerticalGrid\b/,
|
|
1382
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1383
|
+
/\bForEach\b/,
|
|
1384
|
+
/\bList\s*\(/,
|
|
1385
|
+
/\bsubmitList\b/,
|
|
1386
|
+
/\btableView\b/,
|
|
1387
|
+
/\bcollectionView\b/
|
|
1388
|
+
];
|
|
1389
|
+
const REACTIVE_MARKERS = [
|
|
1390
|
+
/\bgetLiveCollection\b/,
|
|
1391
|
+
/\bobserve\b/,
|
|
1392
|
+
/\bobserveOnce\b/,
|
|
1393
|
+
/\blisten\b/,
|
|
1394
|
+
/\bonData\b/,
|
|
1395
|
+
/\bStreamBuilder\b/,
|
|
1396
|
+
/\bLiveData\b/,
|
|
1397
|
+
/\bMutableLiveData\b/,
|
|
1398
|
+
/\bMediatorLiveData\b/,
|
|
1399
|
+
/collectAsState/,
|
|
1400
|
+
/observeAsState/,
|
|
1401
|
+
/Amity\w*Flow\b/,
|
|
1402
|
+
/\bPagingData\b/,
|
|
1403
|
+
/LazyPagingItems/,
|
|
1404
|
+
/collectAsLazyPagingItems/,
|
|
1405
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1406
|
+
/\$snapshots\b/,
|
|
1407
|
+
/\bgetNotificationTrayItems\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1408
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1409
|
+
];
|
|
1410
|
+
for (const [file, content] of sourceContent) {
|
|
1411
|
+
const rel = relativeFile(root, file);
|
|
1412
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1413
|
+
continue;
|
|
1414
|
+
const hasTrayQuery = TRAY_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1415
|
+
if (hasTrayQuery) {
|
|
1416
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1417
|
+
if (hasListUI) {
|
|
1418
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1419
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1420
|
+
if (!hasReactivity) {
|
|
1421
|
+
findings.push(finding(ruleId, "warning", `A notification tray query (getNotificationTrayItems) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData) were found in the same file.`, rel, "Render the notification tray from the reactive Tray LiveCollection so it updates as notifications arrive and seen-state changes. Use the live callback (TS/RN: getNotificationTrayItems(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot tray snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return findings;
|
|
1427
|
+
}
|
|
1287
1428
|
function validatePostsStatusFilter(root, platform, sourceContent) {
|
|
1288
1429
|
const findings = [];
|
|
1289
1430
|
const ruleId = `${platform}.posts.status-filter-applied`;
|
|
1290
1431
|
const POST_QUERY_PATTERNS = [
|
|
1291
1432
|
/\bgetPosts\b/,
|
|
1292
1433
|
/\bqueryPosts\b/,
|
|
1293
|
-
// getCommunityFeed intentionally NOT matched: it is also a common name for a
|
|
1294
|
-
// user-defined wrapper, and firing on a wrapper CALL SITE (which has no query
|
|
1295
|
-
// params to filter) is a false positive. The explicit getPosts/queryPosts cover
|
|
1296
|
-
// the real query path where filters are actually applied.
|
|
1297
1434
|
];
|
|
1298
1435
|
const FILTER_MARKERS = [
|
|
1299
|
-
/\bfeedTypes?\
|
|
1436
|
+
/\bfeedTypes?\s*[:(]/,
|
|
1300
1437
|
/\bincludeDeleted\s*\(\s*false\s*\)/,
|
|
1301
1438
|
/\bisDeleted\s*:\s*false\b/,
|
|
1302
1439
|
/\bisFlagged\s*:\s*false\b/,
|
|
1303
|
-
/\bstatuses\
|
|
1440
|
+
/\bstatuses\s*[:(]/,
|
|
1304
1441
|
/\bif\s*\([^)]*isDeleted\)/,
|
|
1305
1442
|
/\bfilter\s*\([^)]*isDeleted\)/,
|
|
1306
1443
|
/\bif\s*\([^)]*isFlagged\)/,
|
|
@@ -1311,7 +1448,8 @@ function validatePostsStatusFilter(root, platform, sourceContent) {
|
|
|
1311
1448
|
const rel = relativeFile(root, file);
|
|
1312
1449
|
const hasPostQuery = POST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1313
1450
|
if (hasPostQuery) {
|
|
1314
|
-
const
|
|
1451
|
+
const filterSurface = commentStripped(file, platform, content);
|
|
1452
|
+
const hasFilter = /\/\/\s*vise:\s*moderation review feed/i.test(content) || FILTER_MARKERS.some((p) => p.test(filterSurface));
|
|
1315
1453
|
if (!hasFilter) {
|
|
1316
1454
|
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."));
|
|
1317
1455
|
}
|
|
@@ -1327,7 +1465,6 @@ function validateActivityPostsTagFilter(root, platform, sourceContent) {
|
|
|
1327
1465
|
const filename = path.basename(file).toLowerCase();
|
|
1328
1466
|
if (filename.endsWith('.d.ts'))
|
|
1329
1467
|
continue;
|
|
1330
|
-
// Only fire for files that are clearly activity / toast / notification hooks
|
|
1331
1468
|
if (!/activity|toast|notification|event/.test(filename))
|
|
1332
1469
|
continue;
|
|
1333
1470
|
if (!/\bgetPosts\b/.test(content))
|
|
@@ -1347,16 +1484,12 @@ function validateReactionStalePostRef(root, platform, sourceContent) {
|
|
|
1347
1484
|
const rel = relativeFile(root, file);
|
|
1348
1485
|
if (path.basename(file).endsWith('.d.ts'))
|
|
1349
1486
|
continue;
|
|
1350
|
-
// Only check files that have reaction calls
|
|
1351
1487
|
if (!/\baddReaction\b|\bremoveReaction\b/.test(content))
|
|
1352
1488
|
continue;
|
|
1353
|
-
// Only check files that use a ref (React hook — TypeScript/React Native only)
|
|
1354
1489
|
if (!/\buseRef\b/.test(content))
|
|
1355
1490
|
continue;
|
|
1356
|
-
// Look for .current passed into the reaction call — the stale-ref pattern
|
|
1357
1491
|
if (!/.current\b/.test(content))
|
|
1358
1492
|
continue;
|
|
1359
|
-
// If the file already reads post.postId from live state it is correct
|
|
1360
1493
|
if (/\bpost\s*\??\.\s*postId\b/.test(content))
|
|
1361
1494
|
continue;
|
|
1362
1495
|
if (/\/\/\s*vise:\s*captured ref intentional/i.test(content))
|
|
@@ -1368,29 +1501,24 @@ function validateReactionStalePostRef(root, platform, sourceContent) {
|
|
|
1368
1501
|
function validateFollowStatusSubscription(root, platform, sourceContent) {
|
|
1369
1502
|
const findings = [];
|
|
1370
1503
|
const ruleId = `${platform}.follow.status-subscription`;
|
|
1504
|
+
if (platform === 'flutter')
|
|
1505
|
+
return findings;
|
|
1371
1506
|
for (const [file, content] of sourceContent) {
|
|
1372
1507
|
const rel = relativeFile(root, file);
|
|
1373
1508
|
if (path.basename(file).endsWith('.d.ts'))
|
|
1374
1509
|
continue;
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
/\bobserve\s*\(/.test(content) ||
|
|
1385
|
-
// `getFollowStatus(userId, callback)` — two-arg form (comma inside the
|
|
1386
|
-
// first level of parens). Matches the recommended pattern whether the
|
|
1387
|
-
// callback is inline `({ data }) => …` or a named reference.
|
|
1388
|
-
// [^()]* keeps us inside the first level — disqualifies promise chains
|
|
1389
|
-
// like `getFollowStatus(userId).then(...)` which have no comma inside.
|
|
1390
|
-
/getFollowStatus\s*\([^()]*,/.test(content) ||
|
|
1510
|
+
const checkContent = commentStripped(file, platform, content);
|
|
1511
|
+
if (!/\bgetFollowInfo\b/.test(checkContent))
|
|
1512
|
+
continue;
|
|
1513
|
+
if (/\.on\s*\(\s*['"]dataUpdated['"]/.test(checkContent) ||
|
|
1514
|
+
/\bobserve\s*\(/.test(checkContent) ||
|
|
1515
|
+
/\bobserve\s*\{/.test(checkContent) ||
|
|
1516
|
+
/\bdoOnNext\b/.test(checkContent) ||
|
|
1517
|
+
/\.subscribe\s*\(/.test(checkContent) ||
|
|
1518
|
+
/getFollowInfo\s*\([^()]*,/.test(checkContent) ||
|
|
1391
1519
|
/\/\/\s*vise:\s*one-shot follow status intentional/i.test(content))
|
|
1392
1520
|
continue;
|
|
1393
|
-
findings.push(finding(ruleId, "warning", "
|
|
1521
|
+
findings.push(finding(ruleId, "warning", "getFollowInfo is called without a live subscription. Follow state will not update when the relationship changes.", rel, "Subscribe to the live object returned by getFollowInfo and dispose on cleanup: const unsubFollow = UserRepository.getFollowInfo(userId, ({ data }) => { if (data) setFollowStatus(data.status); }); return () => { unsubFollow(); };"));
|
|
1394
1522
|
break;
|
|
1395
1523
|
}
|
|
1396
1524
|
return findings;
|
|
@@ -1398,6 +1526,8 @@ function validateFollowStatusSubscription(root, platform, sourceContent) {
|
|
|
1398
1526
|
function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
|
|
1399
1527
|
const findings = [];
|
|
1400
1528
|
const ruleId = `${platform}.membership.use-live-collection`;
|
|
1529
|
+
if (platform !== 'react-native')
|
|
1530
|
+
return findings;
|
|
1401
1531
|
for (const [file, content] of sourceContent) {
|
|
1402
1532
|
const rel = relativeFile(root, file);
|
|
1403
1533
|
if (path.basename(file).endsWith('.d.ts'))
|
|
@@ -1405,10 +1535,313 @@ function validateMembershipUsesLiveCollection(root, platform, sourceContent) {
|
|
|
1405
1535
|
if (!/\bsearchMembers\b/.test(content))
|
|
1406
1536
|
continue;
|
|
1407
1537
|
if (/\bgetMembers\b/.test(content))
|
|
1408
|
-
continue;
|
|
1538
|
+
continue;
|
|
1539
|
+
if (/searchMembers\s*\(\s*\{[\s\S]*?\}\s*,/.test(content))
|
|
1540
|
+
continue;
|
|
1409
1541
|
if (/\/\/\s*vise:\s*one-shot membership intentional/i.test(content))
|
|
1410
1542
|
continue;
|
|
1411
|
-
findings.push(finding(ruleId, "warning", "searchMembers is a one-shot
|
|
1543
|
+
findings.push(finding(ruleId, "warning", "searchMembers is called as a one-shot Promise (no callback) — it fetches membership once and will not update when members join or leave.", rel, "Pass a callback so searchMembers subscribes to its LiveCollection (or use CommunityRepository.Membership.getMembers): const unsub = CommunityRepository.Membership.searchMembers({ communityId }, ({ data }) => setMembers(data)); return () => unsub();"));
|
|
1544
|
+
}
|
|
1545
|
+
return findings;
|
|
1546
|
+
}
|
|
1547
|
+
function validateMembershipList(root, platform, sourceContent) {
|
|
1548
|
+
const findings = [];
|
|
1549
|
+
if (platform === 'react-native')
|
|
1550
|
+
return findings;
|
|
1551
|
+
const ruleId = `${platform}.membership.use-live-collection`;
|
|
1552
|
+
const MEMBER_LIST_QUERY_PATTERNS = [
|
|
1553
|
+
/\bgetMembers\b/,
|
|
1554
|
+
/\bsearchMembers\b/,
|
|
1555
|
+
];
|
|
1556
|
+
const LIST_UI_PATTERNS = [
|
|
1557
|
+
/\bmap\s*\(/,
|
|
1558
|
+
/\bFlatList\b/,
|
|
1559
|
+
/\bListView\b/,
|
|
1560
|
+
/\bRecyclerView\b/,
|
|
1561
|
+
/\bLazyColumn\b/,
|
|
1562
|
+
/\bLazyRow\b/,
|
|
1563
|
+
/\bLazyVerticalGrid\b/,
|
|
1564
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1565
|
+
/\bForEach\b/,
|
|
1566
|
+
/\bList\s*\(/,
|
|
1567
|
+
/\bsubmitList\b/,
|
|
1568
|
+
/\btableView\b/,
|
|
1569
|
+
/\bcollectionView\b/
|
|
1570
|
+
];
|
|
1571
|
+
const REACTIVE_MARKERS = [
|
|
1572
|
+
/\bgetLiveCollection\b/,
|
|
1573
|
+
/\bobserve\b/,
|
|
1574
|
+
/\bobserveOnce\b/,
|
|
1575
|
+
/\blisten\b/,
|
|
1576
|
+
/\bonData\b/,
|
|
1577
|
+
/\bStreamBuilder\b/,
|
|
1578
|
+
/\bLiveData\b/,
|
|
1579
|
+
/\bMutableLiveData\b/,
|
|
1580
|
+
/\bMediatorLiveData\b/,
|
|
1581
|
+
/collectAsState/,
|
|
1582
|
+
/observeAsState/,
|
|
1583
|
+
/Amity\w*Flow\b/,
|
|
1584
|
+
/\bPagingData\b/,
|
|
1585
|
+
/LazyPagingItems/,
|
|
1586
|
+
/collectAsLazyPagingItems/,
|
|
1587
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1588
|
+
/\$snapshots\b/,
|
|
1589
|
+
/\bPagingController\b/,
|
|
1590
|
+
/\bgetPagingData\b/,
|
|
1591
|
+
/\bCommunityMemberLiveCollection\b/,
|
|
1592
|
+
/\b(?:getMembers|searchMembers)\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1593
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1594
|
+
];
|
|
1595
|
+
for (const [file, content] of sourceContent) {
|
|
1596
|
+
const rel = relativeFile(root, file);
|
|
1597
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1598
|
+
continue;
|
|
1599
|
+
const hasMemberQuery = MEMBER_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1600
|
+
if (hasMemberQuery) {
|
|
1601
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1602
|
+
if (hasListUI) {
|
|
1603
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1604
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1605
|
+
if (!hasReactivity) {
|
|
1606
|
+
findings.push(finding(ruleId, "warning", `A community-member query (getMembers/searchMembers) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the member list from the reactive member LiveCollection so the roster updates as people join, leave, or change role. Use the live callback (TS/RN: getMembers(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter PagingController/CommunityMemberLiveCollection). If a one-shot member snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return findings;
|
|
1612
|
+
}
|
|
1613
|
+
function validateEvents(root, platform, sourceContent) {
|
|
1614
|
+
const findings = [];
|
|
1615
|
+
if (platform !== 'typescript' && platform !== 'android' && platform !== 'ios')
|
|
1616
|
+
return findings;
|
|
1617
|
+
const ruleId = `${platform}.event.live-collection`;
|
|
1618
|
+
const EVENT_LIST_QUERY_PATTERNS = [
|
|
1619
|
+
/\bgetEvents\b/,
|
|
1620
|
+
/\bgetRSVPs\b/,
|
|
1621
|
+
];
|
|
1622
|
+
const LIST_UI_PATTERNS = [
|
|
1623
|
+
/\bmap\s*\(/,
|
|
1624
|
+
/\bFlatList\b/,
|
|
1625
|
+
/\bListView\b/,
|
|
1626
|
+
/\bRecyclerView\b/,
|
|
1627
|
+
/\bLazyColumn\b/,
|
|
1628
|
+
/\bLazyRow\b/,
|
|
1629
|
+
/\bLazyVerticalGrid\b/,
|
|
1630
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1631
|
+
/\bForEach\b/,
|
|
1632
|
+
/\bList\s*\(/,
|
|
1633
|
+
/\bsubmitList\b/,
|
|
1634
|
+
/\btableView\b/,
|
|
1635
|
+
/\bcollectionView\b/
|
|
1636
|
+
];
|
|
1637
|
+
const REACTIVE_MARKERS = [
|
|
1638
|
+
/\bgetLiveCollection\b/,
|
|
1639
|
+
/\bobserve\b/,
|
|
1640
|
+
/\bobserveOnce\b/,
|
|
1641
|
+
/\blisten\b/,
|
|
1642
|
+
/\bonData\b/,
|
|
1643
|
+
/\bStreamBuilder\b/,
|
|
1644
|
+
/\bLiveData\b/,
|
|
1645
|
+
/\bMutableLiveData\b/,
|
|
1646
|
+
/\bMediatorLiveData\b/,
|
|
1647
|
+
/collectAsState/,
|
|
1648
|
+
/observeAsState/,
|
|
1649
|
+
/Amity\w*Flow\b/,
|
|
1650
|
+
/\bPagingData\b/,
|
|
1651
|
+
/LazyPagingItems/,
|
|
1652
|
+
/collectAsLazyPagingItems/,
|
|
1653
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1654
|
+
/\$snapshots\b/,
|
|
1655
|
+
/\bget(?:Events|RSVPs)\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1656
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1657
|
+
];
|
|
1658
|
+
for (const [file, content] of sourceContent) {
|
|
1659
|
+
const rel = relativeFile(root, file);
|
|
1660
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1661
|
+
continue;
|
|
1662
|
+
const hasEventQuery = EVENT_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1663
|
+
if (hasEventQuery) {
|
|
1664
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1665
|
+
if (hasListUI) {
|
|
1666
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1667
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1668
|
+
if (!hasReactivity) {
|
|
1669
|
+
findings.push(finding(ruleId, "warning", `An event/RSVP query (getEvents/getRSVPs) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData) were found in the same file.`, rel, "Render event calendars and attendee lists from the reactive Event/RSVP LiveCollection so rsvpCount and the roster update as people RSVP. Use the live callback (TS: getEvents(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
return findings;
|
|
1675
|
+
}
|
|
1676
|
+
function validateBlockedUsers(root, platform, sourceContent) {
|
|
1677
|
+
const findings = [];
|
|
1678
|
+
const ruleId = `${platform}.blocked-users.live-collection`;
|
|
1679
|
+
const BLOCKED_LIST_QUERY_PATTERNS = [
|
|
1680
|
+
/\bgetBlockedUsers\b/,
|
|
1681
|
+
];
|
|
1682
|
+
const LIST_UI_PATTERNS = [
|
|
1683
|
+
/\bmap\s*\(/,
|
|
1684
|
+
/\bFlatList\b/,
|
|
1685
|
+
/\bListView\b/,
|
|
1686
|
+
/\bRecyclerView\b/,
|
|
1687
|
+
/\bLazyColumn\b/,
|
|
1688
|
+
/\bLazyRow\b/,
|
|
1689
|
+
/\bLazyVerticalGrid\b/,
|
|
1690
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1691
|
+
/\bForEach\b/,
|
|
1692
|
+
/\bList\s*\(/,
|
|
1693
|
+
/\bsubmitList\b/,
|
|
1694
|
+
/\btableView\b/,
|
|
1695
|
+
/\bcollectionView\b/
|
|
1696
|
+
];
|
|
1697
|
+
const REACTIVE_MARKERS = [
|
|
1698
|
+
/\bgetLiveCollection\b/,
|
|
1699
|
+
/\bobserve\b/,
|
|
1700
|
+
/\bobserveOnce\b/,
|
|
1701
|
+
/\blisten\b/,
|
|
1702
|
+
/\bonData\b/,
|
|
1703
|
+
/\bStreamBuilder\b/,
|
|
1704
|
+
/\bLiveData\b/,
|
|
1705
|
+
/\bMutableLiveData\b/,
|
|
1706
|
+
/\bMediatorLiveData\b/,
|
|
1707
|
+
/collectAsState/,
|
|
1708
|
+
/observeAsState/,
|
|
1709
|
+
/Amity\w*Flow\b/,
|
|
1710
|
+
/\bPagingData\b/,
|
|
1711
|
+
/LazyPagingItems/,
|
|
1712
|
+
/collectAsLazyPagingItems/,
|
|
1713
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1714
|
+
/\$snapshots\b/,
|
|
1715
|
+
/\bPagingController\b/,
|
|
1716
|
+
/\bgetPagingData\b/,
|
|
1717
|
+
/\bgetBlockedUsers\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1718
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1719
|
+
];
|
|
1720
|
+
for (const [file, content] of sourceContent) {
|
|
1721
|
+
const rel = relativeFile(root, file);
|
|
1722
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1723
|
+
continue;
|
|
1724
|
+
const hasBlockedQuery = BLOCKED_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1725
|
+
if (hasBlockedQuery) {
|
|
1726
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1727
|
+
if (hasListUI) {
|
|
1728
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1729
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1730
|
+
if (!hasReactivity) {
|
|
1731
|
+
findings.push(finding(ruleId, "warning", `A blocked-users query (getBlockedUsers) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the blocked-users list from the reactive getBlockedUsers LiveCollection so it refreshes when the user blocks/unblocks. Use the live callback (TS/RN: getBlockedUsers(params, callback)) or observe the collection (iOS .observe / Android PagingData / Flutter PagingController). For a one-shot feed-filter snapshot use getAllBlockedUsers instead, or add comment // vise: one-shot query."));
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
return findings;
|
|
1737
|
+
}
|
|
1738
|
+
function validateInvitations(root, platform, sourceContent) {
|
|
1739
|
+
const findings = [];
|
|
1740
|
+
const ruleId = `${platform}.invitation.live-collection`;
|
|
1741
|
+
const isNative = platform === 'android' || platform === 'ios';
|
|
1742
|
+
const INVITATION_LIST_QUERY_PATTERNS = isNative
|
|
1743
|
+
? [/\bgetMyCommunityInvitations\b/, /\bgetMemberInvitations\b/]
|
|
1744
|
+
: [/\bgetMyCommunityInvitations\b/];
|
|
1745
|
+
const LIST_UI_PATTERNS = [
|
|
1746
|
+
/\bmap\s*\(/,
|
|
1747
|
+
/\bFlatList\b/,
|
|
1748
|
+
/\bListView\b/,
|
|
1749
|
+
/\bRecyclerView\b/,
|
|
1750
|
+
/\bLazyColumn\b/,
|
|
1751
|
+
/\bLazyRow\b/,
|
|
1752
|
+
/\bLazyVerticalGrid\b/,
|
|
1753
|
+
/\bLazyVerticalStaggeredGrid\b/,
|
|
1754
|
+
/\bForEach\b/,
|
|
1755
|
+
/\bList\s*\(/,
|
|
1756
|
+
/\bsubmitList\b/,
|
|
1757
|
+
/\btableView\b/,
|
|
1758
|
+
/\bcollectionView\b/
|
|
1759
|
+
];
|
|
1760
|
+
const REACTIVE_MARKERS = [
|
|
1761
|
+
/\bgetLiveCollection\b/,
|
|
1762
|
+
/\bobserve\b/,
|
|
1763
|
+
/\bobserveOnce\b/,
|
|
1764
|
+
/\blisten\b/,
|
|
1765
|
+
/\bonData\b/,
|
|
1766
|
+
/\bStreamBuilder\b/,
|
|
1767
|
+
/\bLiveData\b/,
|
|
1768
|
+
/\bMutableLiveData\b/,
|
|
1769
|
+
/\bMediatorLiveData\b/,
|
|
1770
|
+
/collectAsState/,
|
|
1771
|
+
/observeAsState/,
|
|
1772
|
+
/Amity\w*Flow\b/,
|
|
1773
|
+
/\bPagingData\b/,
|
|
1774
|
+
/LazyPagingItems/,
|
|
1775
|
+
/collectAsLazyPagingItems/,
|
|
1776
|
+
/\.on\s*\(\s*['"]data[A-Z]/,
|
|
1777
|
+
/\$snapshots\b/,
|
|
1778
|
+
/\bPagingController\b/,
|
|
1779
|
+
/\bgetPagingData\b/,
|
|
1780
|
+
/\bgetMyCommunityInvitations\s*\(\s*\{[^{}]*\}\s*,\s*(?:\w+|\([^)]*\)\s*=>|function\b|async\b)/,
|
|
1781
|
+
/\/\/\s*vise:\s*one-shot query/i
|
|
1782
|
+
];
|
|
1783
|
+
for (const [file, content] of sourceContent) {
|
|
1784
|
+
const rel = relativeFile(root, file);
|
|
1785
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1786
|
+
continue;
|
|
1787
|
+
const hasInvitationQuery = INVITATION_LIST_QUERY_PATTERNS.some((p) => p.test(content));
|
|
1788
|
+
if (hasInvitationQuery) {
|
|
1789
|
+
const hasListUI = LIST_UI_PATTERNS.some((p) => p.test(content));
|
|
1790
|
+
if (hasListUI) {
|
|
1791
|
+
const reactiveSurface = commentStripped(file, platform, content);
|
|
1792
|
+
const hasReactivity = /\/\/\s*vise:\s*one-shot query/i.test(content) || REACTIVE_MARKERS.some((p) => p.test(reactiveSurface));
|
|
1793
|
+
if (!hasReactivity) {
|
|
1794
|
+
findings.push(finding(ruleId, "warning", `A community-invitations query (getMyCommunityInvitations/getMemberInvitations) renders a list but no reactive markers (observe, the (params, callback) live form, PagingData, PagingController) were found in the same file.`, rel, "Render the invitations list from the reactive invitation LiveCollection so it refreshes as invitations arrive or change status. Use the live callback (TS/RN: getMyCommunityInvitations(params, callback)) or observe the collection (iOS .observe / Android PagingData). If a one-shot snapshot is truly intended, add comment // vise: one-shot query."));
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return findings;
|
|
1800
|
+
}
|
|
1801
|
+
function validatePollVoteStatusGuard(root, platform, sourceContent) {
|
|
1802
|
+
const findings = [];
|
|
1803
|
+
const ruleId = `${platform}.poll.vote-status-guard`;
|
|
1804
|
+
const VOTE_PATTERNS = [/\bvotePoll\b/, /\bvote\s*\(\s*pollId/];
|
|
1805
|
+
const ANSWER_REF_PATTERNS = [/\banswers\b/, /\bgetAnswers\b/];
|
|
1806
|
+
const LIST_UI_PATTERNS = [
|
|
1807
|
+
/\bmap\s*\(/,
|
|
1808
|
+
/\bforEach\b/,
|
|
1809
|
+
/\bForEach\b/,
|
|
1810
|
+
/\bLazyColumn\b/,
|
|
1811
|
+
/\bLazyRow\b/,
|
|
1812
|
+
/\bList\s*\(/,
|
|
1813
|
+
/\bRecyclerView\b/,
|
|
1814
|
+
/\bsubmitList\b/,
|
|
1815
|
+
/\btableView\b/,
|
|
1816
|
+
/\bcollectionView\b/,
|
|
1817
|
+
/\bFlatList\b/
|
|
1818
|
+
];
|
|
1819
|
+
const GUARD_PATTERNS = [
|
|
1820
|
+
/\bisVoted/,
|
|
1821
|
+
/\bvoteCount\b/,
|
|
1822
|
+
/\bgetStatus\b/,
|
|
1823
|
+
/\bPollStatus\b/,
|
|
1824
|
+
/\bisClosed?\b/,
|
|
1825
|
+
/\.status\b/,
|
|
1826
|
+
/\bclosedAt\b/
|
|
1827
|
+
];
|
|
1828
|
+
for (const [file, content] of sourceContent) {
|
|
1829
|
+
const rel = relativeFile(root, file);
|
|
1830
|
+
if (path.basename(file).endsWith('.d.ts'))
|
|
1831
|
+
continue;
|
|
1832
|
+
const surface = commentStripped(file, platform, content);
|
|
1833
|
+
const hasVote = VOTE_PATTERNS.some((p) => p.test(surface));
|
|
1834
|
+
if (!hasVote)
|
|
1835
|
+
continue;
|
|
1836
|
+
const hasAnswerUI = ANSWER_REF_PATTERNS.some((p) => p.test(surface)) && LIST_UI_PATTERNS.some((p) => p.test(surface));
|
|
1837
|
+
if (!hasAnswerUI)
|
|
1838
|
+
continue;
|
|
1839
|
+
if (/\/\/\s*vise:\s*poll status handled/i.test(content))
|
|
1840
|
+
continue;
|
|
1841
|
+
const hasGuard = GUARD_PATTERNS.some((p) => p.test(surface));
|
|
1842
|
+
if (!hasGuard) {
|
|
1843
|
+
findings.push(finding(ruleId, "warning", `A poll vote action (votePoll/vote) renders the answers but never reads poll status, voted-state, or vote counts — users can vote on a closed poll, re-vote without feedback, and see no results.`, rel, "Gate the vote affordance on poll state and render results: check poll.status / isClosed and poll.isVoted before allowing a vote, and show answer.voteCount / answer.isVotedByUser. If poll status/voted-state is handled in another file, add comment // vise: poll status handled."));
|
|
1844
|
+
}
|
|
1412
1845
|
}
|
|
1413
1846
|
return findings;
|
|
1414
1847
|
}
|
|
@@ -1449,17 +1882,6 @@ function validatePaginationCursorOpaque(root, platform, sourceContent) {
|
|
|
1449
1882
|
function validatePostsParentChild(root, platform, sourceContent) {
|
|
1450
1883
|
const findings = [];
|
|
1451
1884
|
const ruleId = `${platform}.posts.parent-child-rendered`;
|
|
1452
|
-
// Direct text-field reads that signal a component is rendering the post body.
|
|
1453
|
-
// Deliberately NOT a bare `post.data` — that also matches passing post.data as
|
|
1454
|
-
// an argument to a content/share helper (e.g. textFromContent(post.dataType,
|
|
1455
|
-
// post.data)) in a non-rendering file like an actions menu, which produced a
|
|
1456
|
-
// false positive on a correctly-factored app where rendering and the actions
|
|
1457
|
-
// menu live in separate files.
|
|
1458
|
-
// Case-SENSITIVE on purpose: lowercase `post.data.text` is post-body rendering,
|
|
1459
|
-
// but the case-insensitive form also matched Kotlin/Swift sealed-class type
|
|
1460
|
-
// discriminators like `AmityComment.Data.TEXT` / `AmityMessage.Data.TEXT` —
|
|
1461
|
-
// which are type checks in comment/message code, not post rendering. That
|
|
1462
|
-
// produced a large false-positive cluster on real Kotlin (Android sample app).
|
|
1463
1885
|
const POST_RENDER_PATTERNS = [
|
|
1464
1886
|
/\.data\.text\b/,
|
|
1465
1887
|
/\bpost\.text\b/
|
|
@@ -1469,29 +1891,19 @@ function validatePostsParentChild(root, platform, sourceContent) {
|
|
|
1469
1891
|
/\.\s*childrenPosts\b/,
|
|
1470
1892
|
/\bgetChildren\b/
|
|
1471
1893
|
];
|
|
1472
|
-
// Child-typed media the parent-child model carries (TS/RN string dataTypes).
|
|
1473
1894
|
const CHILD_MEDIA_TYPES = ["poll", "video", "room", "file", "audio"];
|
|
1474
1895
|
for (const [filename, text] of sourceContent) {
|
|
1475
1896
|
const rel = relativeFile(root, filename);
|
|
1476
|
-
// Escape-hatch checked on RAW content (stripComments would delete the marker).
|
|
1477
1897
|
if (/\/\/\s*vise:\s*child-types intentional/i.test(text))
|
|
1478
1898
|
continue;
|
|
1479
1899
|
const astLang = astLanguageForFile(filename, platform);
|
|
1480
1900
|
const checkContent = astLang ? stripComments(astLang, text) : text;
|
|
1481
1901
|
const hasPostRender = POST_RENDER_PATTERNS.some((p) => p.test(checkContent));
|
|
1482
1902
|
const hasChildRef = CHILD_REFERENCE_PATTERNS.some((p) => p.test(checkContent));
|
|
1483
|
-
// (1) Presence: renders post text but never inspects children at all.
|
|
1484
1903
|
if (hasPostRender && !hasChildRef) {
|
|
1485
1904
|
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()."));
|
|
1486
1905
|
continue;
|
|
1487
1906
|
}
|
|
1488
|
-
// (2) Inconsistency (TS/RN): the renderer DOES inspect children (so the
|
|
1489
|
-
// parent-child model is in play) but gates some media types on the PARENT
|
|
1490
|
-
// post's dataType with no matching child lookup. A feed parent is dataType
|
|
1491
|
-
// 'text', so `post.dataType === 'poll'` never matches and that content
|
|
1492
|
-
// silently never renders. Only fires when child handling is already
|
|
1493
|
-
// present for something — which keeps false positives low (a feed with
|
|
1494
|
-
// genuinely top-level posts won't be inspecting childrenPosts at all).
|
|
1495
1907
|
if (hasChildRef && (platform === "typescript" || platform === "react-native")) {
|
|
1496
1908
|
const missed = CHILD_MEDIA_TYPES.filter((t) => {
|
|
1497
1909
|
const parentGated = new RegExp(`post\\s*\\.\\s*dataType\\s*===\\s*['"\`]${t}['"\`]`).test(checkContent);
|
|
@@ -1514,14 +1926,6 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1514
1926
|
const goodPatterns = SESSION_HANDLER_GOOD_PATTERNS[platform] ?? SESSION_HANDLER_GOOD_PATTERNS.typescript;
|
|
1515
1927
|
for (const file of handlerFiles) {
|
|
1516
1928
|
const content = sourceContent.get(file) ?? "";
|
|
1517
|
-
// iOS AST arm: scope-aware detection. The regex bad-pattern bridges up to 200
|
|
1518
|
-
// chars from ANY `func … {` into the next `let/var … = …SessionHandler`, which
|
|
1519
|
-
// false-fires on a class-scope property that merely FOLLOWS a short function
|
|
1520
|
-
// (it cannot see brace depth). The AST asks the real question — is the binding
|
|
1521
|
-
// function-local? — and also catches type-annotated locals
|
|
1522
|
-
// (`let h: AmitySessionHandler = AppSessionHandler()`) the regex bridge missed.
|
|
1523
|
-
// Same rule id / severity / message; regex stays the floor when the file can't
|
|
1524
|
-
// be parsed (grammar unavailable, oversized).
|
|
1525
1929
|
if (platform === "ios" && file.endsWith(".swift")) {
|
|
1526
1930
|
const tree = tryParse("swift", content);
|
|
1527
1931
|
if (tree) {
|
|
@@ -1529,11 +1933,14 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1529
1933
|
if (locals.length > 0 && !containsAny(new Map([[file, content]]), goodPatterns)) {
|
|
1530
1934
|
return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
|
|
1531
1935
|
}
|
|
1532
|
-
continue;
|
|
1936
|
+
continue;
|
|
1533
1937
|
}
|
|
1534
1938
|
}
|
|
1535
1939
|
if (badPattern && badPattern.test(content)) {
|
|
1536
|
-
|
|
1940
|
+
const hasGood = platform === "android"
|
|
1941
|
+
? androidSessionHandlerRetained(file, content)
|
|
1942
|
+
: containsAny(new Map([[file, content]]), goodPatterns);
|
|
1943
|
+
if (hasGood) {
|
|
1537
1944
|
continue;
|
|
1538
1945
|
}
|
|
1539
1946
|
return [sessionHandlerRetainedFinding(platform, relativeFile(root, file))];
|
|
@@ -1541,6 +1948,20 @@ function validateSessionHandlerRetention(root, platform, sourceContent) {
|
|
|
1541
1948
|
}
|
|
1542
1949
|
return [];
|
|
1543
1950
|
}
|
|
1951
|
+
function androidSessionHandlerRetained(file, content) {
|
|
1952
|
+
if (/\/\/\s*vise:\s*handler retained/.test(content))
|
|
1953
|
+
return true;
|
|
1954
|
+
const stripped = commentStripped(file, "android", content);
|
|
1955
|
+
const declRe = /private\s+(?:val|var)\s+(\w+)\s*=\s*(?:object\s*:\s*)?(?:Amity)?SessionHandler/g;
|
|
1956
|
+
let match;
|
|
1957
|
+
while ((match = declRe.exec(stripped)) !== null) {
|
|
1958
|
+
const name = match[1];
|
|
1959
|
+
const uses = stripped.match(new RegExp(`\\b${escapeRegExp(name)}\\b`, "g")) ?? [];
|
|
1960
|
+
if (uses.length > 1)
|
|
1961
|
+
return true;
|
|
1962
|
+
}
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1544
1965
|
function sessionHandlerRetainedFinding(platform, file) {
|
|
1545
1966
|
return 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.", 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.");
|
|
1546
1967
|
}
|
|
@@ -1563,56 +1984,16 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1563
1984
|
findings.push(finding(`${platform}.feed.target.literal`, "warning", `A hardcoded feed target literal was found: ${feedTarget.name}.`, relativeFile(root, feedTarget.file), "Do not invent or hardcode communityId, targetId, feedId, or channelId. Ask the user for the target or use an existing app-owned selection/create flow."));
|
|
1564
1985
|
}
|
|
1565
1986
|
const inlineApiKey = firstLiteralAssignment(sourceContent, [
|
|
1566
|
-
// Direct literal assignment — apiKey: "literal" or apiKey = "literal".
|
|
1567
1987
|
/\bapiKey\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
1568
1988
|
/\bapi_key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
1569
1989
|
/\bapi-key\b\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
1570
|
-
// Env-fallback secret leak patterns — the literal is on the RHS of an
|
|
1571
|
-
// env-lookup fallback, not directly assigned to apiKey. The v0.7 §14
|
|
1572
|
-
// Antigravity benchmark surfaced this gap: Dart's
|
|
1573
|
-
// `apiKey = String.fromEnvironment(KEY, defaultValue: 'literal')` and
|
|
1574
|
-
// JS/TS's `apiKey: process.env.X ?? 'literal'` both shipped a hardcoded
|
|
1575
|
-
// key while the original regex (which requires the literal immediately
|
|
1576
|
-
// after `apiKey [:=]`) saw `String.fromEnvironment(` or `process.env.`
|
|
1577
|
-
// first and missed the literal. The patterns below explicitly walk
|
|
1578
|
-
// through the env-lookup wrapper to the literal default.
|
|
1579
|
-
//
|
|
1580
|
-
// Dart: `defaultValue:` form inside String.fromEnvironment. Uses
|
|
1581
|
-
// non-greedy any-char so the regex can bridge a multi-line argument
|
|
1582
|
-
// list (real Dart code commonly puts each named arg on its own line).
|
|
1583
1990
|
/\bapi[-_]?key\b\s*[:=][\s\S]{0,200}?defaultValue\s*:\s*["'`]([^"'`]+)["'`]/i,
|
|
1584
|
-
// JS/TS: process.env.X ?? 'literal' or process.env.X || 'literal'.
|
|
1585
|
-
//
|
|
1586
|
-
// The bridge here is `[^;,}\n]` (NOT the `[\s\S]` used by the Dart arm
|
|
1587
|
-
// above) and that difference is load-bearing. A wide `[\s\S]` bridge is
|
|
1588
|
-
// not statement-scoped: anchored on `apiKey`, it walks past the apiKey
|
|
1589
|
-
// assignment into the NEXT statement/property and binds a DIFFERENT
|
|
1590
|
-
// variable's env-fallback literal — e.g. an idiomatic
|
|
1591
|
-
// const apiKey = process.env.API_KEY
|
|
1592
|
-
// const region = process.env.REGION || 'sg'
|
|
1593
|
-
// mis-reports the region default 'sg' as a hardcoded apiKey. Forbidding
|
|
1594
|
-
// `;`, `,`, and `\n` in the bridge keeps it inside the apiKey assignment:
|
|
1595
|
-
// `;` stops a semicolon'd next statement, `\n` stops an ASI/no-semicolon
|
|
1596
|
-
// next statement, and `,` stops the next object property
|
|
1597
|
-
// (`{ apiKey: env, region: env || 'sg' }`). The genuine single-expression
|
|
1598
|
-
// leak `apiKey: process.env.X || 'realkey'` is still caught because
|
|
1599
|
-
// nothing separates the key from its own fallback. Known residual: a real
|
|
1600
|
-
// leak written with the env lookup on a line BELOW the key
|
|
1601
|
-
// (`apiKey:\n process.env.X || 'realkey'`) is missed — rare; the AST pass
|
|
1602
|
-
// (resolveLiteralValue) is the structural place to catch that.
|
|
1603
1991
|
/\bapi[-_]?key\b\s*[:=][^;,}\n]{0,200}?(?:process\.env\.[A-Z0-9_]+|import\.meta\.env\.[A-Z0-9_]+)\s*(?:\?\?|\|\|)\s*["'`]([^"'`]+)["'`]/i,
|
|
1604
|
-
// Ternary fallback: `apiKey = X ? 'literal' : ...` captures the truthy
|
|
1605
|
-
// branch. Same statement/property-scoped bridge as the `||`/`??` arm so a
|
|
1606
|
-
// sibling property's ternary (`{ apiKey: env, region: prod ? 'sg' : 'us' }`)
|
|
1607
|
-
// does not bind 'sg' to apiKey.
|
|
1608
1992
|
/\bapi[-_]?key\b\s*[:=][^;,}\n]{0,200}?\?\s*["'`]([^"'`]+)["'`]\s*:/i,
|
|
1609
1993
|
], platform);
|
|
1610
1994
|
if (inlineApiKey && !isAllowedPlaceholder(inlineApiKey.value)) {
|
|
1611
1995
|
findings.push(finding(`${platform}.secret.inline-api-key`, "warning", "A social.plus API key appears to be hardcoded in source.", relativeFile(root, inlineApiKey.file), "Use the host app's environment/config pattern instead of committing API keys directly into source files. The literal is still committed even when wrapped in an env-fallback (e.g. `defaultValue:`, `??`, `||`, ternary)."));
|
|
1612
1996
|
}
|
|
1613
|
-
// Hardcoded user identity — every benchmark Pure MCP failure mode included
|
|
1614
|
-
// `userId: "current-user"` or similar. The user identity should come from
|
|
1615
|
-
// the host app's auth state, not a literal in source.
|
|
1616
1997
|
const literalUserId = firstLiteralAssignment(sourceContent, [
|
|
1617
1998
|
/\buserId\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
1618
1999
|
/\buser_id\s*[:=]\s*["'`]([^"'`]+)["'`]/i,
|
|
@@ -1622,9 +2003,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1622
2003
|
if (literalUserId && !isAllowedPlaceholder(literalUserId.value)) {
|
|
1623
2004
|
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.)."));
|
|
1624
2005
|
}
|
|
1625
|
-
// AST pass — catches indirect literals via identifier resolution.
|
|
1626
|
-
// Runs for TypeScript/TSX files. Covers: auth.no-literal-user-id,
|
|
1627
|
-
// secret.inline-api-key, and feed.target.literal.
|
|
1628
2006
|
if (platform === "typescript" || platform === "react-native") {
|
|
1629
2007
|
for (const [file, content] of sourceContent) {
|
|
1630
2008
|
if (!file.endsWith(".ts") && !file.endsWith(".tsx"))
|
|
@@ -1633,8 +2011,7 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1633
2011
|
const lang = file.endsWith(".tsx") ? "tsx" : "typescript";
|
|
1634
2012
|
const tree = tryParse(lang, content);
|
|
1635
2013
|
if (!tree)
|
|
1636
|
-
continue;
|
|
1637
|
-
// ── auth.no-literal-user-id via .login({ userId: CONST }) ──
|
|
2014
|
+
continue;
|
|
1638
2015
|
const userIdRule = `${platform}.auth.no-literal-user-id`;
|
|
1639
2016
|
if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
|
|
1640
2017
|
const loginCalls = findCallExpressions(tree, /\.login\b/);
|
|
@@ -1652,17 +2029,14 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1652
2029
|
}
|
|
1653
2030
|
}
|
|
1654
2031
|
}
|
|
1655
|
-
// ── secret.inline-api-key via createClient({ apiKey: CONST }) or createClient(CONST, ...) ──
|
|
1656
2032
|
const secretRule = `${platform}.secret.inline-api-key`;
|
|
1657
2033
|
if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
|
|
1658
2034
|
const createCalls = findCallExpressions(tree, /\bcreateClient\b/);
|
|
1659
2035
|
for (const call of createCalls) {
|
|
1660
|
-
// Object form: createClient({ apiKey: CONST })
|
|
1661
2036
|
const firstArg = call.args[0];
|
|
1662
2037
|
if (!firstArg)
|
|
1663
2038
|
continue;
|
|
1664
2039
|
let apiKeyNode = pickObjectProperty(firstArg, "apiKey");
|
|
1665
|
-
// Positional form: createClient(KEY, region)
|
|
1666
2040
|
if (!apiKeyNode && firstArg.type !== "object") {
|
|
1667
2041
|
apiKeyNode = firstArg;
|
|
1668
2042
|
}
|
|
@@ -1675,7 +2049,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1675
2049
|
}
|
|
1676
2050
|
}
|
|
1677
2051
|
}
|
|
1678
|
-
// ── feed.target.literal via getPosts({ targetId: CONST }) or similar ──
|
|
1679
2052
|
const feedRule = `${platform}.feed.target.literal`;
|
|
1680
2053
|
if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
|
|
1681
2054
|
const feedCalls = findCallExpressions(tree, /\bgetPosts\b|\bgetComments\b|\bcreatePost\b/);
|
|
@@ -1699,7 +2072,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1699
2072
|
}
|
|
1700
2073
|
}
|
|
1701
2074
|
}
|
|
1702
|
-
// AST pass for Kotlin/Android — catches indirect literals via identifier resolution.
|
|
1703
2075
|
if (platform === "android") {
|
|
1704
2076
|
for (const [file, content] of sourceContent) {
|
|
1705
2077
|
if (!file.endsWith(".kt"))
|
|
@@ -1707,8 +2079,7 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1707
2079
|
const rel = relativeFile(root, file);
|
|
1708
2080
|
const tree = tryParse("kotlin", content);
|
|
1709
2081
|
if (!tree)
|
|
1710
|
-
continue;
|
|
1711
|
-
// ── auth.no-literal-user-id via .login(CONST) ──
|
|
2082
|
+
continue;
|
|
1712
2083
|
const userIdRule = `${platform}.auth.no-literal-user-id`;
|
|
1713
2084
|
if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
|
|
1714
2085
|
const loginCalls = findCallExpressions(tree, /\.login$/);
|
|
@@ -1723,7 +2094,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1723
2094
|
}
|
|
1724
2095
|
}
|
|
1725
2096
|
}
|
|
1726
|
-
// ── secret.inline-api-key via .setup(CONST) ──
|
|
1727
2097
|
const secretRule = `${platform}.secret.inline-api-key`;
|
|
1728
2098
|
if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
|
|
1729
2099
|
const setupCalls = findCallExpressions(tree, /\.setup$/);
|
|
@@ -1738,7 +2108,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1738
2108
|
}
|
|
1739
2109
|
}
|
|
1740
2110
|
}
|
|
1741
|
-
// ── feed.target.literal via .targetCommunity(CONST) / .targetUser(CONST) ──
|
|
1742
2111
|
const feedRule = `${platform}.feed.target.literal`;
|
|
1743
2112
|
if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
|
|
1744
2113
|
const feedCalls = findCallExpressions(tree, /targetCommunity$|targetUser$|targetFeed$/);
|
|
@@ -1755,11 +2124,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1755
2124
|
}
|
|
1756
2125
|
}
|
|
1757
2126
|
}
|
|
1758
|
-
// AST pass for Swift/iOS — catches indirect literals via identifier resolution.
|
|
1759
|
-
// Swift SDK calls are label-keyed (`login(userId:…)`, `AmityClient(apiKey:…)`,
|
|
1760
|
-
// `getPosts(targetType:targetId:)`), so the arms resolve the labeled argument.
|
|
1761
|
-
// tryParse returns null when tree-sitter-swift is unavailable or the file is
|
|
1762
|
-
// oversized — the regex passes above remain the detection floor in that case.
|
|
1763
2127
|
if (platform === "ios") {
|
|
1764
2128
|
for (const [file, content] of sourceContent) {
|
|
1765
2129
|
if (!file.endsWith(".swift"))
|
|
@@ -1768,7 +2132,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1768
2132
|
const tree = tryParse("swift", content);
|
|
1769
2133
|
if (!tree)
|
|
1770
2134
|
continue;
|
|
1771
|
-
// ── auth.no-literal-user-id via .login(userId: CONST) ──
|
|
1772
2135
|
const userIdRule = `${platform}.auth.no-literal-user-id`;
|
|
1773
2136
|
if (!findings.some((f) => f.ruleId === userIdRule && f.file === rel)) {
|
|
1774
2137
|
for (const call of findCallExpressions(tree, /\.login$/)) {
|
|
@@ -1782,7 +2145,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1782
2145
|
}
|
|
1783
2146
|
}
|
|
1784
2147
|
}
|
|
1785
|
-
// ── secret.inline-api-key via AmityClient(apiKey: CONST) / .setup(apiKey: CONST) ──
|
|
1786
2148
|
const secretRule = `${platform}.secret.inline-api-key`;
|
|
1787
2149
|
if (!findings.some((f) => f.ruleId === secretRule && f.file === rel)) {
|
|
1788
2150
|
for (const call of findCallExpressions(tree, /(?:^|\.)AmityClient$|\.setup$/)) {
|
|
@@ -1796,8 +2158,6 @@ function validateLiteralGuardrails(root, platform, sourceContent) {
|
|
|
1796
2158
|
}
|
|
1797
2159
|
}
|
|
1798
2160
|
}
|
|
1799
|
-
// ── feed.target.literal via any call carrying a target label (targetId:,
|
|
1800
|
-
// communityId:, channelId:, withCommunityId:, …) resolving to a literal ──
|
|
1801
2161
|
const feedRule = `${platform}.feed.target.literal`;
|
|
1802
2162
|
if (!findings.some((f) => f.ruleId === feedRule && f.file === rel)) {
|
|
1803
2163
|
outer: for (const call of findCallExpressions(tree, /\w/)) {
|
|
@@ -1927,10 +2287,6 @@ async function validateIos(root) {
|
|
|
1927
2287
|
const swiftContent = await readMany(swiftFiles);
|
|
1928
2288
|
const setupFiles = filesMatching(swiftContent, [/AmityClient\s*\(/, /AmityClient\s*\.\s*setup/]);
|
|
1929
2289
|
const loginFiles = filesMatching(swiftContent, [/\.login\s*\(/, /client\.login\s*\(/]);
|
|
1930
|
-
// social.plus push REGISTRATION only (real iOS symbol: registerPushNotification). Do NOT key
|
|
1931
|
-
// on the generic APNs primitive `didRegisterForRemoteNotifications` — that is the host app's own
|
|
1932
|
-
// push, present in any iOS app with notifications, and made the social.plus-push rules false-fire
|
|
1933
|
-
// on brownfield apps whose social.plus integration never included push.
|
|
1934
2290
|
const pushRegistrationFiles = filesMatching(swiftContent, [/registerPushNotification/, /enablePushNotification/]);
|
|
1935
2291
|
const pushUnregisterFiles = filesMatching(swiftContent, [/unregisterPushNotification/, /disablePushNotification/]);
|
|
1936
2292
|
const liveDataFiles = filesMatching(swiftContent, [/AmityCollection/, /AmityObject/, /\.observe\s*\(/, /getPost\s*\(/, /queryPosts\s*\(/]);
|
|
@@ -1950,9 +2306,6 @@ async function validateIos(root) {
|
|
|
1950
2306
|
if (!containsAnyForFiles(swiftContent, setupFiles, [/\bregion\s*:/, /\.SG\b|\.EU\b|\.US\b/])) {
|
|
1951
2307
|
findings.push(finding("ios.setup.region", "warning", "AmityClient initialization was found but no explicit region marker was detected.", relativeFile(root, setupFiles[0]), "Set the region explicitly to match the customer's social.plus console project."));
|
|
1952
2308
|
}
|
|
1953
|
-
// Setup must happen during app startup (AppDelegate.application(_:didFinishLaunchingWithOptions:),
|
|
1954
|
-
// @main App.init, or a UIApplicationDelegate willFinishLaunching), not in
|
|
1955
|
-
// a view/scene lifecycle method that re-runs across screens.
|
|
1956
2309
|
const lifecycleSetup = setupFiles.find((file) => /(viewDidLoad|viewWillAppear|viewWillDisappear|viewDidAppear|viewDidDisappear|scene\s*\(_:willConnectTo)/.test(swiftContent.get(file) ?? ""));
|
|
1957
2310
|
if (lifecycleSetup) {
|
|
1958
2311
|
findings.push(finding("ios.setup.lifecycle", "warning", "AmityClient initialization appears inside a view or scene lifecycle method.", relativeFile(root, lifecycleSetup), "Run AmityClient setup during app process startup (AppDelegate.application(_:didFinishLaunchingWithOptions:) or @main App.init) so it does not re-initialize on screen transitions."));
|
|
@@ -1967,15 +2320,9 @@ async function validateIos(root) {
|
|
|
1967
2320
|
if (pushRegistrationFiles.length > 0 && pushUnregisterFiles.length === 0) {
|
|
1968
2321
|
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."));
|
|
1969
2322
|
}
|
|
1970
|
-
// iOS push permission: if push registration exists, require UNUserNotificationCenter.requestAuthorization
|
|
1971
2323
|
if (pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/requestAuthorization/, /UNUserNotificationCenter/])) {
|
|
1972
2324
|
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."));
|
|
1973
2325
|
}
|
|
1974
|
-
// iOS push payload: if didReceiveRemoteNotification or userNotificationCenter(_:didReceive:) exists,
|
|
1975
|
-
// require push-specific SDK forwarding marker.
|
|
1976
|
-
// Nexus gate: only demand social.plus forwarding when social.plus push is actually set up
|
|
1977
|
-
// (registration present). A host app's generic push payload handler with no social.plus push
|
|
1978
|
-
// registration is not a social.plus concern (brownfield false-positive).
|
|
1979
2326
|
const iosPayloadHandlerFiles = filesMatching(swiftContent, [/didReceiveRemoteNotification/, /userNotificationCenter\s*\(\s*_\s*,\s*didReceive/]);
|
|
1980
2327
|
if (iosPayloadHandlerFiles.length > 0 && pushRegistrationFiles.length > 0 && !containsAny(swiftContent, [/AmityPush/, /handlePushNotification/, /didReceiveAmityNotification/, /EkoPushNotification/, /amityPushMessaging/, /registerDeviceForPush/, /handleAmityPush/])) {
|
|
1981
2328
|
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."));
|
|
@@ -1994,6 +2341,15 @@ async function validateIos(root) {
|
|
|
1994
2341
|
findings.push(...validateComments(root, "ios", swiftContent));
|
|
1995
2342
|
findings.push(...validateModeration(root, "ios", swiftContent));
|
|
1996
2343
|
findings.push(...validateLiveCollectionApiMismatch(root, "ios", swiftContent));
|
|
2344
|
+
findings.push(...validateStories(root, "ios", swiftContent));
|
|
2345
|
+
findings.push(...validateSearch(root, "ios", swiftContent));
|
|
2346
|
+
findings.push(...validateNotificationTray(root, "ios", swiftContent));
|
|
2347
|
+
findings.push(...validateMembershipList(root, "ios", swiftContent));
|
|
2348
|
+
findings.push(...validateEvents(root, "ios", swiftContent));
|
|
2349
|
+
findings.push(...validateBlockedUsers(root, "ios", swiftContent));
|
|
2350
|
+
findings.push(...validateInvitations(root, "ios", swiftContent));
|
|
2351
|
+
findings.push(...validatePollVoteStatusGuard(root, "ios", swiftContent));
|
|
2352
|
+
findings.push(...validateFollowStatusSubscription(root, "ios", swiftContent));
|
|
1997
2353
|
findings.push(...validatePostDataTypeHandled(root, "ios", swiftContent));
|
|
1998
2354
|
findings.push(...validateAvatarFromSdk(root, "ios", swiftContent));
|
|
1999
2355
|
findings.push(...validatePollAnswerDataShape(root, "ios", swiftContent));
|
|
@@ -2022,6 +2378,8 @@ async function validateIos(root) {
|
|
|
2022
2378
|
findings.push(...validateChatMessageOrderExplicit(root, "ios", swiftContent));
|
|
2023
2379
|
findings.push(...validateProfileSocialCounts(root, "ios", swiftContent));
|
|
2024
2380
|
findings.push(...validateChat(root, "ios", swiftContent));
|
|
2381
|
+
findings.push(...validateChatDeprecations(root, "ios", swiftContent));
|
|
2382
|
+
findings.push(...validateLivestreamDeprecation(root, "ios", swiftContent));
|
|
2025
2383
|
findings.push(...(await validateDesignReuse(root, "ios", swiftContent)));
|
|
2026
2384
|
findings.push(...(await validateEnvSecretHygiene(root, "ios", swiftContent)));
|
|
2027
2385
|
return findings;
|
|
@@ -2308,6 +2666,19 @@ function containsAny(contents, patterns) {
|
|
|
2308
2666
|
}
|
|
2309
2667
|
return false;
|
|
2310
2668
|
}
|
|
2669
|
+
function containsAnyStripped(contents, platform, patterns) {
|
|
2670
|
+
for (const [file, content] of contents) {
|
|
2671
|
+
const stripped = commentStripped(file, platform, content);
|
|
2672
|
+
if (patterns.some((pattern) => pattern.test(stripped))) {
|
|
2673
|
+
return true;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return false;
|
|
2677
|
+
}
|
|
2678
|
+
function blankDeadDeclarations(text, namePattern) {
|
|
2679
|
+
const typeToken = "(?:[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*(?:<[^>=;{}\\n]*>)?\\??\\s+)?";
|
|
2680
|
+
return text.replace(new RegExp(`\\b(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:late\\s+)?(?:function|fun|func|const|let|var|val|final)\\s+${typeToken}${namePattern}\\b`, "g"), " ");
|
|
2681
|
+
}
|
|
2311
2682
|
function escapeRegExp(value) {
|
|
2312
2683
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2313
2684
|
}
|
|
@@ -2320,11 +2691,6 @@ function containsAnyForFiles(contents, files, patterns) {
|
|
|
2320
2691
|
function relativeFile(root, file) {
|
|
2321
2692
|
return file ? path.relative(root, file) : undefined;
|
|
2322
2693
|
}
|
|
2323
|
-
/**
|
|
2324
|
-
* Map a source file path + platform to the AST Language type (if supported).
|
|
2325
|
-
* Returns undefined for languages without tree-sitter support (Dart — no npm
|
|
2326
|
-
* grammar compatible with the pinned tree-sitter 0.21 exists; see ARCHITECTURE.md).
|
|
2327
|
-
*/
|
|
2328
2694
|
function astLanguageForFile(filePath, platform) {
|
|
2329
2695
|
const ext = path.extname(filePath).toLowerCase();
|
|
2330
2696
|
if (ext === ".ts")
|
|
@@ -2335,26 +2701,14 @@ function astLanguageForFile(filePath, platform) {
|
|
|
2335
2701
|
return "kotlin";
|
|
2336
2702
|
if (ext === ".swift")
|
|
2337
2703
|
return "swift";
|
|
2338
|
-
// Platforms that use TS/TSX but file doesn't have extension — infer from platform
|
|
2339
2704
|
if ((platform === "typescript" || platform === "react-native") && (ext === ".js" || ext === ".jsx")) {
|
|
2340
2705
|
return "typescript";
|
|
2341
2706
|
}
|
|
2342
2707
|
return undefined;
|
|
2343
2708
|
}
|
|
2344
|
-
// Comment-aware view of a source file for the presence-of-a-bad-literal regex
|
|
2345
|
-
// checks that GATE (channel-target-resolved, inline secrets, literal userId/feed
|
|
2346
|
-
// target). A pattern that appears only in a commented-out or documentation line
|
|
2347
|
-
// must not trip a gate — a hard CI failure on a comment is the worst false positive
|
|
2348
|
-
// a compliance gate can produce. ts/tsx/kotlin/swift use the precise tree-sitter
|
|
2349
|
-
// stripper; Dart (no compatible grammar published) uses the conservative scanner
|
|
2350
|
-
// below. Anything else is returned unchanged.
|
|
2351
2709
|
function commentStripped(filePath, platform, content) {
|
|
2352
2710
|
const astLang = astLanguageForFile(filePath, platform);
|
|
2353
2711
|
if (astLang === "swift") {
|
|
2354
|
-
// Swift is gate-relevant, so it must never degrade to RAW content: when the
|
|
2355
|
-
// tree-sitter pass is a no-op (bindings unavailable, oversized file, or simply
|
|
2356
|
-
// no comments) chain into the conservative scanner. Both strippers only ever
|
|
2357
|
-
// blank comment spans, so the composition stays fail-toward-firing.
|
|
2358
2712
|
const stripped = stripComments("swift", content);
|
|
2359
2713
|
return stripped !== content ? stripped : stripLineAndBlockComments(content);
|
|
2360
2714
|
}
|
|
@@ -2365,15 +2719,6 @@ function commentStripped(filePath, platform, content) {
|
|
|
2365
2719
|
return stripLineAndBlockComments(content);
|
|
2366
2720
|
return content;
|
|
2367
2721
|
}
|
|
2368
|
-
// Conservative comment stripper for languages without a wired tree-sitter grammar
|
|
2369
|
-
// (Swift, Dart). Blanks `//` line comments and `/* */` block comments with spaces,
|
|
2370
|
-
// preserving newlines so offsets/line numbers are unchanged. It tracks single-line
|
|
2371
|
-
// string state ("…" and '…') with escape handling so a `//` inside a string or a URL
|
|
2372
|
-
// ("https://…") is not mistaken for a comment. Critically, it only ever blanks
|
|
2373
|
-
// comment spans — never code or string text — so any mis-classification degrades
|
|
2374
|
-
// toward a residual false-positive (a comment left un-stripped), never a silent
|
|
2375
|
-
// false-negative on a gate. Multi-line/raw strings are not modeled precisely, but the
|
|
2376
|
-
// same fail-toward-firing property holds (worst case: a comment is not stripped).
|
|
2377
2722
|
function stripLineAndBlockComments(source) {
|
|
2378
2723
|
const out = source.split("");
|
|
2379
2724
|
let inString = null;
|
|
@@ -2396,8 +2741,6 @@ function stripLineAndBlockComments(source) {
|
|
|
2396
2741
|
continue;
|
|
2397
2742
|
}
|
|
2398
2743
|
if (inString) {
|
|
2399
|
-
// Escape: skip the next char — but never jump past a newline, so a stray
|
|
2400
|
-
// trailing backslash can't swallow the following line of real code.
|
|
2401
2744
|
if (c === "\\" && next !== "\n") {
|
|
2402
2745
|
i += 2;
|
|
2403
2746
|
continue;
|
|
@@ -2432,14 +2775,6 @@ function stripLineAndBlockComments(source) {
|
|
|
2432
2775
|
}
|
|
2433
2776
|
function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
|
|
2434
2777
|
const findings = [];
|
|
2435
|
-
// TypeScript/React Native: the SDK types referenceType as the string-literal
|
|
2436
|
-
// union Amity.CommentReferenceType ('post' | 'story' | 'content') — there is
|
|
2437
|
-
// no enum to use, and `referenceType: 'post'` is the documented, idiomatic
|
|
2438
|
-
// value (this ruleset's own comment-limit / creation-affordance remediation
|
|
2439
|
-
// strings instruct exactly that). A wrong casing like 'POST' is not assignable
|
|
2440
|
-
// to the union, so tsc already catches it. Flagging the correct string here was
|
|
2441
|
-
// a pure false positive; the rule remains active on android/flutter/ios where a
|
|
2442
|
-
// real AmityCommentReferenceType enum exists.
|
|
2443
2778
|
if (platform === "typescript" || platform === "react-native")
|
|
2444
2779
|
return findings;
|
|
2445
2780
|
const ruleId = `${platform}.comment.reference-type-enum`;
|
|
@@ -2457,7 +2792,7 @@ function validateCommentReferenceTypeEnum(root, platform, sourceContent) {
|
|
|
2457
2792
|
for (const pattern of REFERENCE_TYPE_PATTERNS) {
|
|
2458
2793
|
if (pattern.test(text)) {
|
|
2459
2794
|
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>.`));
|
|
2460
|
-
break;
|
|
2795
|
+
break;
|
|
2461
2796
|
}
|
|
2462
2797
|
}
|
|
2463
2798
|
}
|
|
@@ -2498,11 +2833,6 @@ function validateReactionConfiguredNameUsed(root, platform, sourceContent) {
|
|
|
2498
2833
|
if (/\/\/\s*vise:\s*reaction\s*"[^"]+"\s*matches\s*console\s*config/i.test(text)) {
|
|
2499
2834
|
continue;
|
|
2500
2835
|
}
|
|
2501
|
-
// The reaction NAME is the tenant-specific argument. The TS/RN SDK signature is
|
|
2502
|
-
// addReaction(referenceType, referenceId, reactionName) — the first literal is the
|
|
2503
|
-
// referenceType ('post'/'comment'/'message'/'story'), which is LEGITIMATELY a literal
|
|
2504
|
-
// and must not be mistaken for a hardcoded reaction name. Flag only when a string
|
|
2505
|
-
// literal that is NOT a known reference-type appears in the call.
|
|
2506
2836
|
const REF_TYPE = /^(?:post|comment|message|story|community|user|channel|content)$/i;
|
|
2507
2837
|
const reactionCallRe = /\b(?:addReaction|removeReaction|flagReaction|react)\s*\(([^)]*)\)/gi;
|
|
2508
2838
|
let hardcodesReactionName = false;
|
|
@@ -2525,7 +2855,6 @@ function validateImagePostChildResolutionAwaited(root, platform, sourceContent)
|
|
|
2525
2855
|
const ruleId = platform + '.image-post.child-resolution-awaited';
|
|
2526
2856
|
for (const [filename, text] of sourceContent) {
|
|
2527
2857
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2528
|
-
// Look for createPost and immediate render/setState/display
|
|
2529
2858
|
if (/createPost\s*\(/.test(text) &&
|
|
2530
2859
|
/(?:render|setState|display)\s*\(/.test(text)) {
|
|
2531
2860
|
const hasResolution = /whenComplete/.test(text) ||
|
|
@@ -2582,22 +2911,23 @@ function validateUnreadSubscribedNotCounted(root, platform, sourceContent) {
|
|
|
2582
2911
|
let hasSubscribedStream = false;
|
|
2583
2912
|
let firstManualCountFile = '';
|
|
2584
2913
|
for (const [filename, text] of sourceContent) {
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
/\.
|
|
2588
|
-
|
|
2914
|
+
const checkContent = commentStripped(filename, platform, text);
|
|
2915
|
+
if (/\.filter\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
|
|
2916
|
+
/\.where\s*\(\s*[^)]*!\s*[^)]*isRead\s*[^)]*\)\s*\.\s*length/.test(checkContent) ||
|
|
2917
|
+
/\.count\s*\(\s*[^)]*!\s*[^)]*\.isRead\s*\)/.test(checkContent) ||
|
|
2918
|
+
/unreadCount\s*=\s*[^;]*\bfilter\b/.test(checkContent)) {
|
|
2589
2919
|
hasManualCount = true;
|
|
2590
2920
|
if (!firstManualCountFile) {
|
|
2591
2921
|
firstManualCountFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2592
2922
|
}
|
|
2593
2923
|
}
|
|
2594
|
-
if (/AmityCoreClient\.unreadCount/.test(
|
|
2595
|
-
/\.unreadCount\(\)/.test(
|
|
2596
|
-
/unreadCountObservable/.test(
|
|
2597
|
-
/getUnreadCount/.test(
|
|
2598
|
-
/subscribeUnread/.test(
|
|
2599
|
-
/unreadCount\.observe/.test(
|
|
2600
|
-
/unread\.stream/.test(
|
|
2924
|
+
if (/AmityCoreClient\.unreadCount/.test(checkContent) ||
|
|
2925
|
+
/\.unreadCount\(\)/.test(checkContent) ||
|
|
2926
|
+
/unreadCountObservable/.test(checkContent) ||
|
|
2927
|
+
/getUnreadCount\s*\(/.test(checkContent) ||
|
|
2928
|
+
/subscribeUnread\s*\(/.test(checkContent) ||
|
|
2929
|
+
/unreadCount\.observe/.test(checkContent) ||
|
|
2930
|
+
/unread\.stream/.test(checkContent) ||
|
|
2601
2931
|
/\/\/\s*vise:\s*unread sourced from/.test(text)) {
|
|
2602
2932
|
hasSubscribedStream = true;
|
|
2603
2933
|
}
|
|
@@ -2620,9 +2950,10 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
|
|
|
2620
2950
|
firstPushRegistrationFile = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2621
2951
|
}
|
|
2622
2952
|
}
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2953
|
+
const astLang = astLanguageForFile(filename, platform);
|
|
2954
|
+
const checkContent = astLang ? stripComments(astLang, text) : text;
|
|
2955
|
+
if (/\.notifications\s*\(\s*\)(?:\s*\.\s*\w+\s*\([^)]*\))*\s*\.\s*(?:getSettings|setSettings)\s*[({]/.test(checkContent) ||
|
|
2956
|
+
/\b(?:notificationRepository|notificationManager)\s*\.\s*(?:getSettings|setSettings)\s*[({]/.test(checkContent) ||
|
|
2626
2957
|
/\/\/\s*vise:\s*notification preferences synced via/.test(text)) {
|
|
2627
2958
|
hasPreferences = true;
|
|
2628
2959
|
}
|
|
@@ -2632,31 +2963,14 @@ function validateNotificationsPreferencesConfigured(root, platform, sourceConten
|
|
|
2632
2963
|
}
|
|
2633
2964
|
return findings;
|
|
2634
2965
|
}
|
|
2635
|
-
// The ban-state / role-gated / flagCount-leak rules all flag a MISSING UI guard.
|
|
2636
|
-
// That claim only makes sense for files that render UI. Service and data layers —
|
|
2637
|
-
// repositories, managers, datasources, API clients, stores, interactors — wrap the
|
|
2638
|
-
// SDK call but legitimately leave the guard to the UI layer above them, so firing on
|
|
2639
|
-
// them is a false positive. Observed on the official Amity sample apps, where
|
|
2640
|
-
// PostFlagManager / StoryManager / MembershipListDataSource tripped all three rules
|
|
2641
|
-
// despite the guards living correctly in their View/ViewController callers.
|
|
2642
2966
|
function isNonUiSourceFile(filename) {
|
|
2643
2967
|
const base = path.basename(filename).replace(/\.(kt|swift|dart|tsx?|jsx?)$/i, "");
|
|
2644
|
-
// PascalCase class-named files (Swift, Kotlin, TS): suffix match. Note "Controller"
|
|
2645
|
-
// and "View(Model)" are deliberately absent — iOS ViewControllers/SwiftUI Views DO
|
|
2646
|
-
// render and must keep firing.
|
|
2647
2968
|
if (/(?:Application|Manager|Repository|Repo|Service|DataSource|Datasource|Provider|Client|Store|Mapper|Interactor|UseCase|Bloc|Cubit|Notifier|Mock|Fake|Stub|Test|Tests|Spec)$/.test(base))
|
|
2648
2969
|
return true;
|
|
2649
|
-
// snake_case files (Dart, sometimes RN): same non-UI layers, plus Flutter's BLoC /
|
|
2650
|
-
// Cubit / ChangeNotifier state-management layers. "_page"/"_popup"/"_screen" are UI
|
|
2651
|
-
// and intentionally not matched.
|
|
2652
2970
|
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))
|
|
2653
2971
|
return true;
|
|
2654
|
-
// Bare data-layer file names (store.ts, repository.ts, service.ts, datasource.ts, …) — the same
|
|
2655
|
-
// non-UI layers as the suffix/snake forms above, just without a domain prefix. A file literally
|
|
2656
|
-
// named `store` is a state-management/data layer (e.g. a Zustand/Redux store), not a screen.
|
|
2657
2972
|
if (/^(?:store|stores|repository|repositories|repo|repos|service|services|datasource|datasources|provider|providers|client|clients|interactor|interactors|usecase|usecases|bloc|cubit|notifier|api|mapper)$/i.test(base))
|
|
2658
2973
|
return true;
|
|
2659
|
-
// dotted test/spec helpers (foo.test.ts, foo.spec.ts)
|
|
2660
2974
|
if (/\.(?:test|spec)$/i.test(base))
|
|
2661
2975
|
return true;
|
|
2662
2976
|
return false;
|
|
@@ -2666,38 +2980,17 @@ function validateUserBanStateRespected(root, platform, sourceContent) {
|
|
|
2666
2980
|
const ruleId = platform + '.user.ban-state-respected';
|
|
2667
2981
|
for (const [filename, text] of sourceContent) {
|
|
2668
2982
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2669
|
-
// Skip type declaration files — they declare the method signatures but are not
|
|
2670
|
-
// the implementation that needs the ban-state guard.
|
|
2671
2983
|
if (path.basename(filename).endsWith('.d.ts'))
|
|
2672
2984
|
continue;
|
|
2673
|
-
// Skip non-UI service/data layers — the ban gate belongs in the UI that renders
|
|
2674
|
-
// the interaction button, not in the manager/repository that wraps the SDK call.
|
|
2675
2985
|
if (isNonUiSourceFile(filename))
|
|
2676
2986
|
continue;
|
|
2677
|
-
// Check if interaction methods are used in the file (includes flag actions — banned users
|
|
2678
|
-
// should also not be able to flag content, preventing spurious moderation queue spam).
|
|
2679
|
-
// iOS/Swift: the interaction surface is the COMMENT-STRIPPED file (tree-sitter, scanner
|
|
2680
|
-
// fallback) — a `// TODO: wire createPost here` comment is not an interaction surface and
|
|
2681
|
-
// used to false-fire this rule. The ban-state markers below stay on RAW text on purpose:
|
|
2682
|
-
// the `// vise: ban state checked at` escape hatch lives in a comment.
|
|
2683
2987
|
const interactionSurface = platform === "ios" && filename.endsWith(".swift") ? commentStripped(filename, platform, text) : text;
|
|
2684
2988
|
if (!/\b(createPost|createComment|sendMessage|addReaction|flagComment|flagPost)\b/.test(interactionSurface)) {
|
|
2685
2989
|
continue;
|
|
2686
2990
|
}
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
// when a hook destructures the flag and the screen consumes it directly.
|
|
2691
|
-
const hasBanCheck =
|
|
2692
|
-
// Any ban-STATE boolean: isBanned, isGlobalBan, isGlobalBanned, isUserBanned,
|
|
2693
|
-
// currentUser.isGlobalBan(ned). The `is` prefix anchors this to ban-state flags
|
|
2694
|
-
// and excludes the banUser/bannedUserIds ACTION names. Real SDK ships both
|
|
2695
|
-
// isGlobalBan and isGlobalBanned; agents commonly derive a local `isGlobalBanned`.
|
|
2696
|
-
/\bis\w*Ban(?:ned)?\b/.test(text) ||
|
|
2697
|
-
// camelCase boolean form: `currentUserIsBanned` / `userIsBanned` — typically the ban state
|
|
2698
|
-
// fetched in a parent and passed down as a prop, then used to gate the write. The lowercase
|
|
2699
|
-
// `is`-anchored marker above misses it (capital `Is`, no leading word boundary mid-identifier).
|
|
2700
|
-
/\b\w*[Ii]sBanned\b/.test(text) ||
|
|
2991
|
+
const banUseSurface = text.replace(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|fun|func|const|let|var|val)\s+\w*[Bb]an\w*\b/g, " ");
|
|
2992
|
+
const hasBanCheck = /\bis\w*Ban(?:ned)?\b/.test(banUseSurface) ||
|
|
2993
|
+
/\b\w*[Ii]sBanned\b/.test(banUseSurface) ||
|
|
2701
2994
|
/isCurrentUserBannedFromCommunity/.test(text) ||
|
|
2702
2995
|
/community\.bannedUserIds\.(?:contains|includes)/i.test(text) ||
|
|
2703
2996
|
/channel\.isMuted/.test(text) ||
|
|
@@ -2715,24 +3008,22 @@ function validateFlagCountNotLeaked(root, platform, sourceContent) {
|
|
|
2715
3008
|
const ruleId = platform + '.flag-count.not-leaked-to-non-mods';
|
|
2716
3009
|
for (const [filename, text] of sourceContent) {
|
|
2717
3010
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2718
|
-
// Non-UI service/data layers read flagCount to pass it upward; the role gate
|
|
2719
|
-
// that decides whether to RENDER it lives in the UI layer, so skip them.
|
|
2720
3011
|
if (isNonUiSourceFile(filename))
|
|
2721
3012
|
continue;
|
|
2722
|
-
|
|
2723
|
-
if (!/\bflagCount\b/.test(
|
|
3013
|
+
const stripped = commentStripped(filename, platform, text);
|
|
3014
|
+
if (!/\bflagCount\b/.test(stripped)) {
|
|
2724
3015
|
continue;
|
|
2725
3016
|
}
|
|
2726
|
-
|
|
2727
|
-
const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(
|
|
2728
|
-
/\.hasRole\s*\(.*moderator/i.test(
|
|
2729
|
-
/community\.hasModeratorRole/.test(
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
/member\.isModerator/.test(
|
|
2733
|
-
/roles\.includes\s*\(.*moderator/i.test(
|
|
2734
|
-
/permissions\.(?:contains|includes).*moderation/i.test(
|
|
2735
|
-
/myReactions\.(?:contains|includes).*flag/i.test(
|
|
3017
|
+
const roleUseSurface = blankDeadDeclarations(stripped, "(?:isModerator|isCommunityModerator)");
|
|
3018
|
+
const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(stripped) ||
|
|
3019
|
+
/\.hasRole\s*\(.*moderator/i.test(stripped) ||
|
|
3020
|
+
/community\.hasModeratorRole/.test(stripped) ||
|
|
3021
|
+
/\bisCommunityModerator\b/.test(roleUseSurface) ||
|
|
3022
|
+
/\bisModerator\b/.test(roleUseSurface) ||
|
|
3023
|
+
/member\.isModerator/.test(stripped) ||
|
|
3024
|
+
/roles\.includes\s*\(.*moderator/i.test(stripped) ||
|
|
3025
|
+
/permissions\.(?:contains|includes).*moderation/i.test(stripped) ||
|
|
3026
|
+
/myReactions\.(?:contains|includes).*flag/i.test(stripped) ||
|
|
2736
3027
|
/\/\/\s*vise:\s*flagCount visible only to moderators/i.test(text);
|
|
2737
3028
|
if (!hasRoleCheck) {
|
|
2738
3029
|
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."));
|
|
@@ -2745,52 +3036,37 @@ function validateModerationRoleGatedAction(root, platform, sourceContent) {
|
|
|
2745
3036
|
const ruleId = platform + '.moderation.role-gated-action';
|
|
2746
3037
|
for (const [filename, text] of sourceContent) {
|
|
2747
3038
|
const rel = (typeof filename === 'string' && root) ? (filename.startsWith(root) ? filename.slice(root.length).replace(/^\//, '') : filename) : filename;
|
|
2748
|
-
// Skip non-UI service/data layers for BOTH branches. The ownership gate
|
|
2749
|
-
// (edit/delete) is a UI concern; and on real code (Amity's MembershipListDataSource,
|
|
2750
|
-
// a UITableView data source that wraps muteMembers) the mod-only ban/mute gate also
|
|
2751
|
-
// belongs in the presenting ViewController, not the data layer — firing on the
|
|
2752
|
-
// service reads as a false positive. The SDK enforces ban/mute server-side regardless.
|
|
2753
3039
|
if (isNonUiSourceFile(filename))
|
|
2754
3040
|
continue;
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
// on a moderator role is itself a bug.)
|
|
2759
|
-
const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(text);
|
|
2760
|
-
// Delete/edit: valid for the content AUTHOR (ownership) OR a moderator/admin
|
|
2761
|
-
// — per the SDK, "only post owners and admins can delete posts".
|
|
2762
|
-
const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(text);
|
|
3041
|
+
const stripped = commentStripped(filename, platform, text);
|
|
3042
|
+
const hasModOnlyAction = /\b(banUser|unbanUser|banMembers|muteChannelMember|unmuteChannelMember|muteMembers)\b/.test(stripped);
|
|
3043
|
+
const hasOwnableAction = /\b(deletePost|softDeletePost|hardDeletePost|deleteComment|softDeleteComment|hardDeleteComment|editPost|updateComment)\b/.test(stripped);
|
|
2763
3044
|
if (!hasModOnlyAction && !hasOwnableAction) {
|
|
2764
3045
|
continue;
|
|
2765
3046
|
}
|
|
2766
|
-
|
|
2767
|
-
const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(
|
|
2768
|
-
/\.hasRole\s*\(.*moderator/i.test(
|
|
2769
|
-
/community\.hasModeratorRole/.test(
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
/member\.isModerator/.test(
|
|
2773
|
-
/roles\.includes\s*\(.*moderator/i.test(
|
|
2774
|
-
/permissions\.(?:contains|includes).*moderation/i.test(
|
|
2775
|
-
/\bhasPermission\s*\(/.test(
|
|
3047
|
+
const roleUseSurface = blankDeadDeclarations(stripped, "(?:isModerator|isCommunityModerator)");
|
|
3048
|
+
const hasRoleCheck = /currentUser\.roles\.(?:contains|includes|some).*moderator/i.test(stripped) ||
|
|
3049
|
+
/\.hasRole\s*\(.*moderator/i.test(stripped) ||
|
|
3050
|
+
/community\.hasModeratorRole/.test(stripped) ||
|
|
3051
|
+
/\bisCommunityModerator\b/.test(roleUseSurface) ||
|
|
3052
|
+
/\bisModerator\b/.test(roleUseSurface) ||
|
|
3053
|
+
/member\.isModerator/.test(stripped) ||
|
|
3054
|
+
/roles\.includes\s*\(.*moderator/i.test(stripped) ||
|
|
3055
|
+
/permissions\.(?:contains|includes).*moderation/i.test(stripped) ||
|
|
3056
|
+
/\bhasPermission\s*\(/.test(stripped) ||
|
|
2776
3057
|
/\/\/\s*vise:\s*role check applied/i.test(text);
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
// idiomatic Swift/Kotlin form, e.g. `comment.userId == amity.currentUserId`.
|
|
2785
|
-
/\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(text) ||
|
|
2786
|
-
/\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(text) ||
|
|
3058
|
+
const hasOwnershipCheck = /\bisAuthor\b/.test(stripped) ||
|
|
3059
|
+
/\bisOwner\b/.test(stripped) ||
|
|
3060
|
+
/postedUserId\s*===?=?\s*\w+/.test(stripped) ||
|
|
3061
|
+
/\w+\s*===?=?\s*[\w.]*postedUserId\b/.test(stripped) ||
|
|
3062
|
+
/creator\s*\??\.\s*userId\s*===?=?/.test(stripped) ||
|
|
3063
|
+
/\buserId\s*===?\s*[\w.]*(?:currentUser|currentUserId)\b/.test(stripped) ||
|
|
3064
|
+
/\b(?:currentUser|currentUserId)[\w.]*\s*===?\s*[\w.]*\buserId\b/.test(stripped) ||
|
|
2787
3065
|
/\/\/\s*vise:\s*owner action\b/i.test(text);
|
|
2788
|
-
// Moderator-only actions require a role check.
|
|
2789
3066
|
if (hasModOnlyAction && !hasRoleCheck) {
|
|
2790
3067
|
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(...))."));
|
|
2791
3068
|
}
|
|
2792
3069
|
else if (hasOwnableAction && !hasRoleCheck && !hasOwnershipCheck) {
|
|
2793
|
-
// Delete/edit need EITHER ownership OR a moderator role.
|
|
2794
3070
|
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."));
|
|
2795
3071
|
}
|
|
2796
3072
|
}
|
|
@@ -2808,9 +3084,6 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
|
|
|
2808
3084
|
if (!matches)
|
|
2809
3085
|
continue;
|
|
2810
3086
|
for (const match of matches) {
|
|
2811
|
-
// A standard TEXT post (`data: { text: ... }`, incl. quoted keys for Dart/Swift) legitimately
|
|
2812
|
-
// has no dataType — only CUSTOM data payloads need the dataType tag. Don't mistake the canonical
|
|
2813
|
-
// text-post shape for a custom post (the weak-model bench build hit this with `data: { text }`).
|
|
2814
3087
|
if (/data\s*[:=(]\s*[\{\[]\s*['"]?text['"]?\s*:/i.test(match))
|
|
2815
3088
|
continue;
|
|
2816
3089
|
if (!/dataType\s*[:=\(]/i.test(match)) {
|
|
@@ -2820,11 +3093,6 @@ function validateCustomPostTypeDataTypeDeclared(root, platform, sourceContent) {
|
|
|
2820
3093
|
}
|
|
2821
3094
|
return findings;
|
|
2822
3095
|
}
|
|
2823
|
-
// ── Post data-type check ──────────────────────────────────────────────────────
|
|
2824
|
-
// Fires when a file renders post content but only reads the text field without
|
|
2825
|
-
// branching on the post's data type. getGlobalFeed / getPosts return every
|
|
2826
|
-
// post type (image, video, file, poll, liveStream…); text-only rendering leaves
|
|
2827
|
-
// all non-text posts silently blank.
|
|
2828
3096
|
function validatePostDataTypeHandled(root, platform, sourceContent) {
|
|
2829
3097
|
const findings = [];
|
|
2830
3098
|
const ruleId = `${platform}.feed.post-datatype-handled`;
|
|
@@ -2833,9 +3101,6 @@ function validatePostDataTypeHandled(root, platform, sourceContent) {
|
|
|
2833
3101
|
"react-native": /post\s*\??\.\s*data\s*(?:as\s*\{|\.text\b|\?\.text\b)/,
|
|
2834
3102
|
flutter: /\.getData\s*<|\bAmityTextPost\b|\bAmityPostData\b/,
|
|
2835
3103
|
ios: /\.getData\(\)\s*as\?.*AmityPost\.Data\.TEXT|post\s*\.\s*text\b/,
|
|
2836
|
-
// POST text only — a bare `.getText()` also matches a COMMENT renderer reading
|
|
2837
|
-
// AmityComment.Data.TEXT (comments are text by nature), which is not a post
|
|
2838
|
-
// renderer and must not trip this rule. Scope to AmityPost.Data.TEXT.
|
|
2839
3104
|
android: /\.getData\(\)\s*as\??\s*AmityPost\.Data\.TEXT/,
|
|
2840
3105
|
};
|
|
2841
3106
|
const TYPE_GUARD = {
|
|
@@ -2926,19 +3191,10 @@ function validateRichPostComposerScope(root, platform, sourceContent) {
|
|
|
2926
3191
|
}
|
|
2927
3192
|
return findings;
|
|
2928
3193
|
}
|
|
2929
|
-
// ── Avatar / display-identity from SDK ───────────────────────────────────────
|
|
2930
|
-
// Fires when a file renders community or user display identity using raw string
|
|
2931
|
-
// manipulation (charAt / slice on a name or raw userId) instead of the SDK-
|
|
2932
|
-
// provided avatar.fileUrl / avatarImage / getAvatar(). The SDK resolves the
|
|
2933
|
-
// avatar URL on the linked object; ignoring it shows a generated initial where
|
|
2934
|
-
// the user uploaded a real photo.
|
|
2935
3194
|
function validateAvatarFromSdk(root, platform, sourceContent) {
|
|
2936
3195
|
const findings = [];
|
|
2937
3196
|
const ruleId = `${platform}.community.avatar-from-sdk`;
|
|
2938
3197
|
const INITIAL_PAT = {
|
|
2939
|
-
// slice(0,1) / slice(0,2) are avatar initials; slice(0,8)+ is a userId/name
|
|
2940
|
-
// truncation used as a display fallback (e.g. userId.slice(0, 8)) and must
|
|
2941
|
-
// NOT be flagged as a missing avatar.
|
|
2942
3198
|
typescript: /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
|
|
2943
3199
|
"react-native": /(?:displayName|name|userId|postedUserId)\s*\??\.\s*(?:charAt\s*\(\s*0\s*\)|slice\s*\(\s*0\s*,\s*[12]\s*\))/,
|
|
2944
3200
|
flutter: /(?:displayName|name)\s*\??\.(?:substring\s*\(\s*0\s*,\s*1|characters\.first)/,
|
|
@@ -2960,11 +3216,12 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
|
|
|
2960
3216
|
continue;
|
|
2961
3217
|
if (/\/\/\s*vise:\s*avatar fallback intentional/i.test(content))
|
|
2962
3218
|
continue;
|
|
2963
|
-
|
|
3219
|
+
const checkContent = commentStripped(file, platform, content);
|
|
3220
|
+
if (!CTX_PAT.test(checkContent))
|
|
2964
3221
|
continue;
|
|
2965
|
-
if (!initPat.test(
|
|
3222
|
+
if (!initPat.test(checkContent))
|
|
2966
3223
|
continue;
|
|
2967
|
-
if (avatarPat.test(
|
|
3224
|
+
if (avatarPat.test(checkContent))
|
|
2968
3225
|
continue;
|
|
2969
3226
|
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."));
|
|
2970
3227
|
break;
|
|
@@ -2973,13 +3230,6 @@ function validateAvatarFromSdk(root, platform, sourceContent) {
|
|
|
2973
3230
|
}
|
|
2974
3231
|
function validatePollAnswerDataShape(root, platform, sourceContent) {
|
|
2975
3232
|
const findings = [];
|
|
2976
|
-
// iOS has no poll-answer data-shape footgun: AmityPollAnswer exposes a typed
|
|
2977
|
-
// `public let text: String` (and `image: AmityImageData?`) — there is no
|
|
2978
|
-
// `.data` to mis-shape, and `answer.text` is the documented, correct accessor
|
|
2979
|
-
// (confirmed against the SDK source; two independent agent builds both wrote
|
|
2980
|
-
// it from the docs). The rule used to flag `answer.text` as wrong — a pure
|
|
2981
|
-
// false positive. The TS/RN concern (`answer.data` is a string, don't read
|
|
2982
|
-
// `.data.text`) doesn't translate to typed Swift. Rule de-registered for iOS.
|
|
2983
3233
|
if (platform === "ios")
|
|
2984
3234
|
return findings;
|
|
2985
3235
|
const ruleId = `${platform}.feed.poll-answer-data-shape`;
|
|
@@ -3003,7 +3253,6 @@ function validatePollAnswerDataShape(root, platform, sourceContent) {
|
|
|
3003
3253
|
for (const [file, content] of sourceContent) {
|
|
3004
3254
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3005
3255
|
continue;
|
|
3006
|
-
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
3007
3256
|
if (/\/\/\s*vise:\s*poll-answer-shape intentional/i.test(content))
|
|
3008
3257
|
continue;
|
|
3009
3258
|
const astLang = astLanguageForFile(file, platform);
|
|
@@ -3039,17 +3288,12 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3039
3288
|
const COMMENT_CTX = /referenceType\s*[:=]\s*['"]?post['"]?|\breferenceId\b|\bCommentRepository\b|\bAmityComment\b/;
|
|
3040
3289
|
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.";
|
|
3041
3290
|
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.";
|
|
3042
|
-
// TypeScript / React Native: AST pass — inspect the actual getComments({...})
|
|
3043
|
-
// first argument and fire only when the object literal carries neither
|
|
3044
|
-
// pageSize nor limit. More precise than a text scan: ignores the token
|
|
3045
|
-
// appearing in comments/strings, and won't fire when params are passed as a
|
|
3046
|
-
// variable we can't statically read (conservative — avoids false positives).
|
|
3047
3291
|
if (platform === "typescript" || platform === "react-native") {
|
|
3048
3292
|
for (const [file, content] of sourceContent) {
|
|
3049
3293
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3050
3294
|
continue;
|
|
3051
3295
|
if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
|
|
3052
|
-
continue;
|
|
3296
|
+
continue;
|
|
3053
3297
|
const astLang = astLanguageForFile(file, platform);
|
|
3054
3298
|
if (!astLang)
|
|
3055
3299
|
continue;
|
|
@@ -3064,7 +3308,7 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3064
3308
|
for (const call of calls) {
|
|
3065
3309
|
const objArg = call.args[0];
|
|
3066
3310
|
if (!objArg || objArg.type !== "object")
|
|
3067
|
-
continue;
|
|
3311
|
+
continue;
|
|
3068
3312
|
if (pickObjectProperty(objArg, "pageSize") || pickObjectProperty(objArg, "limit"))
|
|
3069
3313
|
continue;
|
|
3070
3314
|
findings.push(finding(ruleId, "warning", message, relativeFile(root, file), recommendation));
|
|
@@ -3073,16 +3317,13 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3073
3317
|
}
|
|
3074
3318
|
return findings;
|
|
3075
3319
|
}
|
|
3076
|
-
// Flutter / iOS / Android: regex pass (builder chains and option structs do
|
|
3077
|
-
// not fit call-argument inspection). Kotlin runs against comment-stripped
|
|
3078
|
-
// source; Dart/Swift have no grammar and run against raw content.
|
|
3079
3320
|
const getCommentsPat = GETCOMMENTS_PAT[platform] ?? GETCOMMENTS_PAT.typescript;
|
|
3080
3321
|
const limitPat = HAS_LIMIT[platform] ?? HAS_LIMIT.typescript;
|
|
3081
3322
|
for (const [file, content] of sourceContent) {
|
|
3082
3323
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3083
3324
|
continue;
|
|
3084
3325
|
if (/\/\/\s*vise:\s*comment-limit handled/i.test(content))
|
|
3085
|
-
continue;
|
|
3326
|
+
continue;
|
|
3086
3327
|
const astLang = astLanguageForFile(file, platform);
|
|
3087
3328
|
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
3088
3329
|
if (!COMMENT_CTX.test(checkContent))
|
|
@@ -3096,12 +3337,6 @@ function validateCommentsQueryHasLimit(root, platform, sourceContent) {
|
|
|
3096
3337
|
}
|
|
3097
3338
|
return findings;
|
|
3098
3339
|
}
|
|
3099
|
-
// ── Comment creation affordance ──────────────────────────────────────────────
|
|
3100
|
-
// Fires when comments are READ (getComments) but never CREATED (createComment)
|
|
3101
|
-
// anywhere in the codebase. A read-only comment list gives users no way to
|
|
3102
|
-
// participate. Mirrors the moderation-affordance-present precedent: the write
|
|
3103
|
-
// path may live in a sibling file, so detection is codebase-wide and an
|
|
3104
|
-
// attestation escape covers deliberately read-only surfaces.
|
|
3105
3340
|
function validateCommentCreationAffordance(root, platform, sourceContent) {
|
|
3106
3341
|
const findings = [];
|
|
3107
3342
|
const ruleId = `${platform}.comments.creation-affordance-present`;
|
|
@@ -3116,9 +3351,6 @@ function validateCommentCreationAffordance(root, platform, sourceContent) {
|
|
|
3116
3351
|
typescript: /CommentRepository\s*\.\s*createComment\s*\(/,
|
|
3117
3352
|
"react-native": /CommentRepository\s*\.\s*createComment\s*\(/,
|
|
3118
3353
|
flutter: /\.createComment\s*\(/,
|
|
3119
|
-
// Any receiver, not just a var literally named `commentRepository` — agents
|
|
3120
|
-
// idiomatically hold the repo in `repo`/`vm`/etc. (real v8: AmityCommentRepository
|
|
3121
|
-
// .createComment(with: AmityCommentCreateOptions(...))). Mirrors android/flutter.
|
|
3122
3354
|
ios: /AmityCommentCreateOptions\s*\(|\.createComment\s*\(/,
|
|
3123
3355
|
android: /\.createComment\s*\(/,
|
|
3124
3356
|
};
|
|
@@ -3129,7 +3361,6 @@ function validateCommentCreationAffordance(root, platform, sourceContent) {
|
|
|
3129
3361
|
for (const [file, content] of sourceContent) {
|
|
3130
3362
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3131
3363
|
continue;
|
|
3132
|
-
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
3133
3364
|
if (/\/\/\s*vise:\s*comments read-only intentional/i.test(content))
|
|
3134
3365
|
return findings;
|
|
3135
3366
|
const astLang = astLanguageForFile(file, platform);
|
|
@@ -3172,7 +3403,7 @@ function validateCommentThreadUiStates(root, platform, sourceContent) {
|
|
|
3172
3403
|
continue;
|
|
3173
3404
|
const hasLoading = /LoadState\.Loading|loadState\.refresh\s+is\s+LoadState\.Loading|loadState\.append\s+is\s+LoadState\.Loading/.test(checkContent);
|
|
3174
3405
|
const hasError = /LoadState\.Error|loadState\.refresh\s+is\s+LoadState\.Error|retry\s*\(\s*\)/.test(checkContent);
|
|
3175
|
-
const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|
|
|
3406
|
+
const hasEmpty = /itemCount\s*==\s*0|itemCount\s*<=\s*0|No comments|EmptyState|emptyState|\bisEmpty\b/.test(checkContent);
|
|
3176
3407
|
if (!(hasLoading && hasError && hasEmpty)) {
|
|
3177
3408
|
findings.push(finding(ruleId, "warning", "Comment tray renders a paged comment list but does not handle loading, error, and empty states. It can show an empty tray while comments are still loading or hide failures.", relativeFile(root, file), "For collectAsLazyPagingItems(), branch on comments.loadState.refresh/append for Loading and Error, show retry for errors, and show the empty state only when refresh is NotLoading and itemCount == 0. Add // vise: comment-ui-states intentional — <reason> only when a parent component handles these states."));
|
|
3178
3409
|
break;
|
|
@@ -3184,7 +3415,7 @@ function validateChatUnreadVisible(root, platform, sourceContent) {
|
|
|
3184
3415
|
const findings = [];
|
|
3185
3416
|
const ruleId = `${platform}.chat.unread-visible`;
|
|
3186
3417
|
let chatListFile;
|
|
3187
|
-
let
|
|
3418
|
+
let chatListContent;
|
|
3188
3419
|
const channelListPat = {
|
|
3189
3420
|
typescript: /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
|
|
3190
3421
|
"react-native": /\b(?:ChannelRepository|ChatRepository|AmityChannelRepository)\s*\.\s*(?:getChannels|queryChannels)\b|\bgetChannels\s*\(/,
|
|
@@ -3212,16 +3443,15 @@ function validateChatUnreadVisible(root, platform, sourceContent) {
|
|
|
3212
3443
|
for (const [file, content] of sourceContent) {
|
|
3213
3444
|
if (/\/\/\s*vise:\s*chat-unread intentional/i.test(content))
|
|
3214
3445
|
return findings;
|
|
3215
|
-
const
|
|
3216
|
-
const checkContent = astLang ? stripComments(astLang, content) : content;
|
|
3217
|
-
if (unreadSignalPat.test(checkContent)) {
|
|
3218
|
-
hasUnreadSignal = true;
|
|
3219
|
-
}
|
|
3446
|
+
const checkContent = commentStripped(file, platform, content);
|
|
3220
3447
|
if (!chatListFile && queryPat.test(checkContent) && uiPat.test(checkContent)) {
|
|
3221
3448
|
chatListFile = file;
|
|
3449
|
+
chatListContent = checkContent;
|
|
3222
3450
|
}
|
|
3223
3451
|
}
|
|
3224
|
-
if (chatListFile &&
|
|
3452
|
+
if (chatListFile &&
|
|
3453
|
+
chatListContent !== undefined &&
|
|
3454
|
+
!unreadSignalPat.test(blankDeadDeclarations(chatListContent, "(?:unreadCount|totalChannelUnread|subChannelsUnreadCount)"))) {
|
|
3225
3455
|
findings.push(finding(ruleId, "warning", "Chat channel list is present but no SDK unread count is rendered or subscribed. The UI will drop unread badges/counts.", relativeFile(root, chatListFile), "Render unread from the SDK, for example channel.getUnreadCount()/getSubChannelsUnreadCount() for channel rows or AmityCoreClient.observeUserUnread()/getTotalChannelUnread() for a global badge. Add // vise: chat-unread intentional — <reason> only for a deliberately unread-free chat surface."));
|
|
3226
3456
|
}
|
|
3227
3457
|
return findings;
|
|
@@ -3263,11 +3493,11 @@ function validateProfileSocialCounts(root, platform, sourceContent) {
|
|
|
3263
3493
|
const findings = [];
|
|
3264
3494
|
const ruleId = `${platform}.profile.social-counts-from-sdk`;
|
|
3265
3495
|
const sdkCountsPat = {
|
|
3266
|
-
typescript: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(
|
|
3267
|
-
"react-native": /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(
|
|
3268
|
-
flutter: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(
|
|
3269
|
-
ios: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(
|
|
3270
|
-
android: /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(
|
|
3496
|
+
typescript: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
|
|
3497
|
+
"react-native": /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
|
|
3498
|
+
flutter: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
|
|
3499
|
+
ios: /\bfollowerCount\b|\bfollowingCount\b|\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/i,
|
|
3500
|
+
android: /\bgetFollowerCount\s*\(|\bgetFollowingCount\s*\(|\bgetFollowers\s*\(|\bgetFollowings\s*\(/,
|
|
3271
3501
|
};
|
|
3272
3502
|
const placeholderCountsPat = {
|
|
3273
3503
|
typescript: /value\s*=\s*["'][—-]+["']|value\s*=\s*["']0["']|>\s*[—-]+\s*<|>\s*0\s*</,
|
|
@@ -3311,7 +3541,6 @@ function validateCommunityDisplayNameFromSdk(root, platform, sourceContent) {
|
|
|
3311
3541
|
for (const [file, content] of sourceContent) {
|
|
3312
3542
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3313
3543
|
continue;
|
|
3314
|
-
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
3315
3544
|
if (/\/\/\s*vise:\s*community-id display intentional/i.test(content))
|
|
3316
3545
|
continue;
|
|
3317
3546
|
const astLang = astLanguageForFile(file, platform);
|
|
@@ -3334,9 +3563,6 @@ function validateRoomPostFetched(root, platform, sourceContent) {
|
|
|
3334
3563
|
typescript: /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
|
|
3335
3564
|
"react-native": /(?:getRoomInfo\s*\(\s*\)|room)\s*\??\.\s*roomId\b(?!\s*[,;)]|\s*,\s*cb)/,
|
|
3336
3565
|
flutter: /room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
|
|
3337
|
-
// Negative lookbehind for Swift string interpolation `\(room.roomId)`:
|
|
3338
|
-
// that's a segment/identifier key, not a displayed name, and must not fire.
|
|
3339
|
-
// Real display (`Text(room.roomId)`, `label.text = room.roomId`) still matches.
|
|
3340
3566
|
ios: /(?<!\\\()room\s*\??\.\s*roomId\b|getRoomInfo\s*\(\s*\)\s*\?\.?\s*roomId/,
|
|
3341
3567
|
android: /room\s*\??\.\s*getRoomId\s*\(\)|getRoomInfo\s*\(\s*\)\s*\??\.\s*roomId/,
|
|
3342
3568
|
};
|
|
@@ -3353,7 +3579,6 @@ function validateRoomPostFetched(root, platform, sourceContent) {
|
|
|
3353
3579
|
for (const [file, content] of sourceContent) {
|
|
3354
3580
|
if (path.basename(file).endsWith('.d.ts'))
|
|
3355
3581
|
continue;
|
|
3356
|
-
// Escape-hatch is checked on RAW content — stripComments would delete the marker.
|
|
3357
3582
|
if (/\/\/\s*vise:\s*room-display intentional/i.test(content))
|
|
3358
3583
|
continue;
|
|
3359
3584
|
const astLang = astLanguageForFile(file, platform);
|
|
@@ -3388,16 +3613,8 @@ function validateFeedTargetTypeExplicit(root, platform, sourceContent) {
|
|
|
3388
3613
|
let ttm;
|
|
3389
3614
|
let flagged = false;
|
|
3390
3615
|
while ((ttm = g.exec(text)) !== null) {
|
|
3391
|
-
// A literal targetType in a READ query (getPosts/getCommunityFeed params) is correct —
|
|
3392
|
-
// you specify targetType:'community' + targetId to read that feed. Read-query objects
|
|
3393
|
-
// carry feedType/sortBy keys; a createPost payload carries dataType/data. Only the
|
|
3394
|
-
// latter is the reusability concern this rule targets.
|
|
3395
3616
|
const window = text.slice(Math.max(0, ttm.index - 180), ttm.index + 180);
|
|
3396
3617
|
const isReadQuery = /\b(?:feedType|sortBy)\s*:/.test(window) && !/\bdataType\s*:/.test(window);
|
|
3397
|
-
// A literal targetType is the required enum ('community'/'user'); the reusability concern is
|
|
3398
|
-
// the TARGET id. If targetId is bound to a dynamic value (identifier/member/prop, not a
|
|
3399
|
-
// string literal) it is the correct community-feed form — do not flag. Only flag when the
|
|
3400
|
-
// targetId is hardcoded or absent. (Mirrors the read-query carve-out above.)
|
|
3401
3618
|
const targetIdDynamic = /\btargetId\s*:\s*[A-Za-z_$]/.test(window);
|
|
3402
3619
|
if (!isReadQuery && !targetIdDynamic) {
|
|
3403
3620
|
flagged = true;
|